feat: complete cmpp gateway delivery recovery workflows
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const defaultPresenceTTL = 3 * time.Minute
|
||||
|
||||
type PresenceStore interface {
|
||||
TouchAccount(ctx context.Context, snapshot DownstreamPresence) error
|
||||
RemoveAccount(ctx context.Context, account string) error
|
||||
ListAccounts(ctx context.Context) ([]DownstreamPresence, error)
|
||||
}
|
||||
|
||||
type DownstreamPresence struct {
|
||||
Account string `json:"account"`
|
||||
SrcID string `json:"srcId,omitempty"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
GatewayInstanceID string `json:"gatewayInstanceId,omitempty"`
|
||||
State string `json:"state"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastSubmitAt time.Time `json:"lastSubmitAt,omitempty"`
|
||||
LastDeliverAt time.Time `json:"lastDeliverAt,omitempty"`
|
||||
}
|
||||
|
||||
type RedisPresenceStore struct {
|
||||
Client *redis.Client
|
||||
TTL time.Duration
|
||||
Prefix string
|
||||
}
|
||||
|
||||
func NewRedisPresenceStore(redisURL string) (*RedisPresenceStore, error) {
|
||||
if redisURL == "" {
|
||||
redisURL = "redis://127.0.0.1:6379"
|
||||
}
|
||||
options, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedisPresenceStore{
|
||||
Client: redis.NewClient(options),
|
||||
TTL: defaultPresenceTTL,
|
||||
Prefix: "gateway:downstream:presence",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisPresenceStore) TouchAccount(ctx context.Context, snapshot DownstreamPresence) error {
|
||||
if s == nil || s.Client == nil || snapshot.Account == "" {
|
||||
return nil
|
||||
}
|
||||
if snapshot.UpdatedAt.IsZero() {
|
||||
snapshot.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if snapshot.ConnectedAt.IsZero() {
|
||||
snapshot.ConnectedAt = snapshot.UpdatedAt
|
||||
}
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ttl := s.ttl()
|
||||
pipe := s.Client.TxPipeline()
|
||||
pipe.Set(ctx, s.accountKey(snapshot.Account), payload, ttl)
|
||||
pipe.SAdd(ctx, s.accountsKey(), snapshot.Account)
|
||||
pipe.Expire(ctx, s.accountsKey(), ttl*4)
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RedisPresenceStore) RemoveAccount(ctx context.Context, account string) error {
|
||||
if s == nil || s.Client == nil || account == "" {
|
||||
return nil
|
||||
}
|
||||
pipe := s.Client.TxPipeline()
|
||||
pipe.Del(ctx, s.accountKey(account))
|
||||
pipe.SRem(ctx, s.accountsKey(), account)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RedisPresenceStore) ListAccounts(ctx context.Context) ([]DownstreamPresence, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
accounts, err := s.Client.SMembers(ctx, s.accountsKey()).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]DownstreamPresence, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
payload, getErr := s.Client.Get(ctx, s.accountKey(account)).Bytes()
|
||||
if getErr == redis.Nil {
|
||||
_ = s.Client.SRem(ctx, s.accountsKey(), account).Err()
|
||||
continue
|
||||
}
|
||||
if getErr != nil {
|
||||
return nil, getErr
|
||||
}
|
||||
var snapshot DownstreamPresence
|
||||
if err := json.Unmarshal(payload, &snapshot); err != nil {
|
||||
return nil, fmt.Errorf("decode presence %s: %w", account, err)
|
||||
}
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *RedisPresenceStore) ttl() time.Duration {
|
||||
if s != nil && s.TTL > 0 {
|
||||
return s.TTL
|
||||
}
|
||||
return defaultPresenceTTL
|
||||
}
|
||||
|
||||
func (s *RedisPresenceStore) accountKey(account string) string {
|
||||
return fmt.Sprintf("%s:account:%s", s.prefix(), account)
|
||||
}
|
||||
|
||||
func (s *RedisPresenceStore) accountsKey() string {
|
||||
return fmt.Sprintf("%s:accounts", s.prefix())
|
||||
}
|
||||
|
||||
func (s *RedisPresenceStore) prefix() string {
|
||||
if s != nil && s.Prefix != "" {
|
||||
return s.Prefix
|
||||
}
|
||||
return "gateway:downstream:presence"
|
||||
}
|
||||
|
||||
func ListRecoveryCandidates(ctx context.Context, store PresenceStore) ([]DownstreamPresence, error) {
|
||||
candidateMap := map[string]DownstreamPresence{}
|
||||
|
||||
if store != nil {
|
||||
snapshots, err := store.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, snapshot := range snapshots {
|
||||
account := strings.TrimSpace(snapshot.Account)
|
||||
if account == "" {
|
||||
continue
|
||||
}
|
||||
candidateMap[account] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
for _, account := range onlineAccounts() {
|
||||
account = strings.TrimSpace(account)
|
||||
if account == "" {
|
||||
continue
|
||||
}
|
||||
current := candidateMap[account]
|
||||
current.Account = account
|
||||
if strings.TrimSpace(current.State) == "" {
|
||||
current.State = "connected"
|
||||
}
|
||||
if current.UpdatedAt.IsZero() {
|
||||
current.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
candidateMap[account] = current
|
||||
}
|
||||
|
||||
result := make([]DownstreamPresence, 0, len(candidateMap))
|
||||
for _, snapshot := range candidateMap {
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].UpdatedAt.Equal(result[j].UpdatedAt) {
|
||||
return result[i].Account < result[j].Account
|
||||
}
|
||||
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
func TestRedisPresenceStoreTouchListAndRemove(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
store, err := NewRedisPresenceStore("redis://" + mr.Addr())
|
||||
if err != nil {
|
||||
t.Fatalf("new presence store: %v", err)
|
||||
}
|
||||
store.TTL = time.Minute
|
||||
store.Prefix = "test:presence"
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
err = store.TouchAccount(context.Background(), DownstreamPresence{
|
||||
Account: "100001",
|
||||
SrcID: "10690000",
|
||||
RemoteIP: "127.0.0.1",
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "connected",
|
||||
ConnectedAt: now,
|
||||
UpdatedAt: now,
|
||||
LastSubmitAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("touch account: %v", err)
|
||||
}
|
||||
|
||||
accounts, err := store.ListAccounts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list accounts: %v", err)
|
||||
}
|
||||
if len(accounts) != 1 {
|
||||
t.Fatalf("accounts len = %d, want 1", len(accounts))
|
||||
}
|
||||
if accounts[0].Account != "100001" || accounts[0].GatewayInstanceID != "gateway-a" || accounts[0].State != "connected" {
|
||||
t.Fatalf("unexpected presence snapshot: %+v", accounts[0])
|
||||
}
|
||||
|
||||
if err := store.RemoveAccount(context.Background(), "100001"); err != nil {
|
||||
t.Fatalf("remove account: %v", err)
|
||||
}
|
||||
accounts, err = store.ListAccounts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list accounts after remove: %v", err)
|
||||
}
|
||||
if len(accounts) != 0 {
|
||||
t.Fatalf("accounts len after remove = %d, want 0", len(accounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRecoveryCandidatesMergesPresenceAndInMemory(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
store := &memoryPresenceStore{
|
||||
snapshots: map[string]DownstreamPresence{
|
||||
"100001": {
|
||||
Account: "100001",
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "connected",
|
||||
UpdatedAt: time.Now().UTC().Add(-time.Minute),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
downstreamRegistry.Lock()
|
||||
downstreamRegistry.byAccount["100002"] = &downstreamSession{account: "100002"}
|
||||
downstreamRegistry.Unlock()
|
||||
|
||||
candidates, err := ListRecoveryCandidates(context.Background(), store)
|
||||
if err != nil {
|
||||
t.Fatalf("list recovery candidates: %v", err)
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates len = %d, want 2", len(candidates))
|
||||
}
|
||||
|
||||
accounts := map[string]DownstreamPresence{}
|
||||
for _, item := range candidates {
|
||||
accounts[item.Account] = item
|
||||
}
|
||||
if _, ok := accounts["100001"]; !ok {
|
||||
t.Fatal("expected redis presence candidate 100001")
|
||||
}
|
||||
if snapshot, ok := accounts["100002"]; !ok || snapshot.State != "connected" {
|
||||
t.Fatalf("expected in-memory candidate 100002 connected, got %+v", snapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRecoveryLockTTL = 30 * time.Second
|
||||
defaultRecoveryBackoffBase = 30 * time.Second
|
||||
defaultRecoveryBackoffMax = 10 * time.Minute
|
||||
)
|
||||
|
||||
var ErrRecoveryLockLost = errors.New("recovery lock lost")
|
||||
|
||||
type RecoveryStore interface {
|
||||
StartAccountRecovery(ctx context.Context, account string, instanceID string) (RecoveryStartDecision, error)
|
||||
CompleteAccountRecovery(ctx context.Context, status DownstreamRecoveryStatus) error
|
||||
GetAccountRecoveryStatus(ctx context.Context, account string) (DownstreamRecoveryStatus, error)
|
||||
ListRecoveryStatuses(ctx context.Context) ([]DownstreamRecoveryStatus, error)
|
||||
}
|
||||
|
||||
type RecoveryStartDecision struct {
|
||||
Allowed bool
|
||||
SkipReason string
|
||||
Status DownstreamRecoveryStatus
|
||||
}
|
||||
|
||||
type DownstreamRecoveryStatus struct {
|
||||
Account string `json:"account"`
|
||||
GatewayInstanceID string `json:"gatewayInstanceId,omitempty"`
|
||||
State string `json:"state"`
|
||||
LockOwner string `json:"lockOwner,omitempty"`
|
||||
LockToken string `json:"lockToken,omitempty"`
|
||||
LockAcquiredAt time.Time `json:"lockAcquiredAt,omitempty"`
|
||||
LockExpiresAt time.Time `json:"lockExpiresAt,omitempty"`
|
||||
LastAttemptAt time.Time `json:"lastAttemptAt,omitempty"`
|
||||
LastSuccessAt time.Time `json:"lastSuccessAt,omitempty"`
|
||||
LastFailureAt time.Time `json:"lastFailureAt,omitempty"`
|
||||
NextRetryAt time.Time `json:"nextRetryAt,omitempty"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
FailureCategory string `json:"failureCategory,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
LastSkipReason string `json:"lastSkipReason,omitempty"`
|
||||
}
|
||||
|
||||
type RedisRecoveryStore struct {
|
||||
Client *redis.Client
|
||||
Prefix string
|
||||
}
|
||||
|
||||
func NewRedisRecoveryStore(redisURL string) (*RedisRecoveryStore, error) {
|
||||
if redisURL == "" {
|
||||
redisURL = "redis://127.0.0.1:6379"
|
||||
}
|
||||
options, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedisRecoveryStore{
|
||||
Client: redis.NewClient(options),
|
||||
Prefix: "gateway:downstream:recovery",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) StartAccountRecovery(ctx context.Context, account string, instanceID string) (RecoveryStartDecision, error) {
|
||||
if s == nil || s.Client == nil || account == "" {
|
||||
return RecoveryStartDecision{Allowed: true}, nil
|
||||
}
|
||||
status, err := s.getStatus(ctx, account)
|
||||
if err != nil {
|
||||
return RecoveryStartDecision{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !status.NextRetryAt.IsZero() && status.NextRetryAt.After(now) {
|
||||
status.LastSkipReason = "backoff"
|
||||
status.FailureCategory = "backoff"
|
||||
return RecoveryStartDecision{Allowed: false, SkipReason: "backoff", Status: status}, nil
|
||||
}
|
||||
lockToken := newRecoveryLockToken(instanceID)
|
||||
acquired, err := s.Client.SetNX(ctx, s.lockKey(account), lockToken, defaultRecoveryLockTTL).Result()
|
||||
if err != nil {
|
||||
return RecoveryStartDecision{}, err
|
||||
}
|
||||
if !acquired {
|
||||
status.LockToken, _ = s.Client.Get(ctx, s.lockKey(account)).Result()
|
||||
status.LockOwner = recoveryLockOwner(status.LockToken)
|
||||
status.LockExpiresAt = lockExpiresAt(ctx, s.Client, s.lockKey(account), now)
|
||||
status.LastSkipReason = "locked"
|
||||
status.FailureCategory = "lock_contended"
|
||||
return RecoveryStartDecision{Allowed: false, SkipReason: "locked", Status: status}, nil
|
||||
}
|
||||
status.Account = account
|
||||
status.GatewayInstanceID = instanceID
|
||||
status.State = "running"
|
||||
status.LockOwner = instanceID
|
||||
status.LockToken = lockToken
|
||||
status.LockAcquiredAt = now
|
||||
status.LockExpiresAt = now.Add(defaultRecoveryLockTTL)
|
||||
status.LastAttemptAt = now
|
||||
status.LastSkipReason = ""
|
||||
status.FailureCategory = ""
|
||||
if err := s.saveStatus(ctx, status); err != nil {
|
||||
_ = s.deleteLockIfOwned(ctx, account, lockToken)
|
||||
return RecoveryStartDecision{}, err
|
||||
}
|
||||
return RecoveryStartDecision{Allowed: true, Status: status}, nil
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) CompleteAccountRecovery(ctx context.Context, status DownstreamRecoveryStatus) error {
|
||||
if s == nil || s.Client == nil || status.Account == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if status.LastAttemptAt.IsZero() {
|
||||
status.LastAttemptAt = now
|
||||
}
|
||||
if status.LockOwner == "" {
|
||||
status.LockOwner = status.GatewayInstanceID
|
||||
}
|
||||
switch status.State {
|
||||
case "success":
|
||||
status.LastSuccessAt = now
|
||||
status.AttemptCount = 0
|
||||
status.NextRetryAt = time.Time{}
|
||||
status.LastError = ""
|
||||
status.FailureCategory = ""
|
||||
case "waiting_connection", "failed", "partial":
|
||||
status.AttemptCount++
|
||||
if status.State == "failed" {
|
||||
status.LastFailureAt = now
|
||||
}
|
||||
if status.FailureCategory == "" {
|
||||
status.FailureCategory = classifyRecoveryFailure(status)
|
||||
}
|
||||
status.NextRetryAt = now.Add(recoveryBackoffDelay(status.AttemptCount))
|
||||
default:
|
||||
status.State = "unknown"
|
||||
}
|
||||
payload, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.Client.Eval(ctx, completeRecoveryScript, []string{s.lockKey(status.Account), s.statusKey(status.Account), s.accountsKey()}, status.LockToken, payload, int64((24*time.Hour)/time.Millisecond), status.Account).Int()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == 0 {
|
||||
return ErrRecoveryLockLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyRecoveryFailure(status DownstreamRecoveryStatus) string {
|
||||
switch status.State {
|
||||
case "waiting_connection":
|
||||
return "client_disconnected"
|
||||
case "partial":
|
||||
return "partial_delivery_failed"
|
||||
case "failed":
|
||||
if status.LastSkipReason == "backoff" {
|
||||
return "backoff"
|
||||
}
|
||||
if status.LastSkipReason == "locked" {
|
||||
return "lock_contended"
|
||||
}
|
||||
if status.LastError != "" {
|
||||
return "flush_failed"
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) ListRecoveryStatuses(ctx context.Context) ([]DownstreamRecoveryStatus, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
accounts, err := s.Client.SMembers(ctx, s.accountsKey()).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]DownstreamRecoveryStatus, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
status, getErr := s.getStatus(ctx, account)
|
||||
if getErr == redis.Nil {
|
||||
_ = s.Client.SRem(ctx, s.accountsKey(), account).Err()
|
||||
continue
|
||||
}
|
||||
if getErr != nil {
|
||||
return nil, getErr
|
||||
}
|
||||
result = append(result, status)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) GetAccountRecoveryStatus(ctx context.Context, account string) (DownstreamRecoveryStatus, error) {
|
||||
if s == nil || s.Client == nil || account == "" {
|
||||
return DownstreamRecoveryStatus{Account: account}, nil
|
||||
}
|
||||
return s.getStatus(ctx, account)
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) getStatus(ctx context.Context, account string) (DownstreamRecoveryStatus, error) {
|
||||
payload, err := s.Client.Get(ctx, s.statusKey(account)).Bytes()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
return DownstreamRecoveryStatus{Account: account}, nil
|
||||
}
|
||||
return DownstreamRecoveryStatus{}, err
|
||||
}
|
||||
var status DownstreamRecoveryStatus
|
||||
if err := json.Unmarshal(payload, &status); err != nil {
|
||||
return DownstreamRecoveryStatus{}, fmt.Errorf("decode recovery status %s: %w", account, err)
|
||||
}
|
||||
if status.Account == "" {
|
||||
status.Account = account
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) saveStatus(ctx context.Context, status DownstreamRecoveryStatus) error {
|
||||
payload, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pipe := s.Client.TxPipeline()
|
||||
pipe.Set(ctx, s.statusKey(status.Account), payload, 24*time.Hour)
|
||||
pipe.SAdd(ctx, s.accountsKey(), status.Account)
|
||||
pipe.Expire(ctx, s.accountsKey(), 24*time.Hour)
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) deleteLockIfOwned(ctx context.Context, account string, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.Client.Eval(ctx, deleteLockIfOwnedScript, []string{s.lockKey(account)}, token).Result()
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) statusKey(account string) string {
|
||||
return fmt.Sprintf("%s:status:%s", s.prefix(), account)
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) lockKey(account string) string {
|
||||
return fmt.Sprintf("%s:lock:%s", s.prefix(), account)
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) accountsKey() string {
|
||||
return fmt.Sprintf("%s:accounts", s.prefix())
|
||||
}
|
||||
|
||||
func (s *RedisRecoveryStore) prefix() string {
|
||||
if s != nil && s.Prefix != "" {
|
||||
return s.Prefix
|
||||
}
|
||||
return "gateway:downstream:recovery"
|
||||
}
|
||||
|
||||
func recoveryBackoffDelay(attemptCount int) time.Duration {
|
||||
if attemptCount <= 0 {
|
||||
return defaultRecoveryBackoffBase
|
||||
}
|
||||
delay := defaultRecoveryBackoffBase
|
||||
for step := 1; step < attemptCount; step++ {
|
||||
delay *= 2
|
||||
if delay >= defaultRecoveryBackoffMax {
|
||||
return defaultRecoveryBackoffMax
|
||||
}
|
||||
}
|
||||
if delay > defaultRecoveryBackoffMax {
|
||||
return defaultRecoveryBackoffMax
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func newRecoveryLockToken(instanceID string) string {
|
||||
return fmt.Sprintf("%s:%d", instanceID, time.Now().UTC().UnixNano())
|
||||
}
|
||||
|
||||
func recoveryLockOwner(token string) string {
|
||||
for index, char := range token {
|
||||
if char == ':' {
|
||||
return token[:index]
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func lockExpiresAt(ctx context.Context, client *redis.Client, key string, now time.Time) time.Time {
|
||||
ttl, err := client.TTL(ctx, key).Result()
|
||||
if err != nil || ttl <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return now.Add(ttl)
|
||||
}
|
||||
|
||||
const completeRecoveryScript = `
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if current ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
redis.call("SET", KEYS[2], ARGV[2], "PX", ARGV[3])
|
||||
redis.call("SADD", KEYS[3], ARGV[4])
|
||||
redis.call("PEXPIRE", KEYS[3], ARGV[3])
|
||||
redis.call("DEL", KEYS[1])
|
||||
return 1
|
||||
`
|
||||
|
||||
const deleteLockIfOwnedScript = `
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if current == ARGV[1] then
|
||||
return redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`
|
||||
@@ -0,0 +1,101 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
func TestRedisRecoveryStoreBackoffAndStatuses(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
store, err := NewRedisRecoveryStore("redis://" + mr.Addr())
|
||||
if err != nil {
|
||||
t.Fatalf("new recovery store: %v", err)
|
||||
}
|
||||
store.Prefix = "test:recovery"
|
||||
|
||||
decision, err := store.StartAccountRecovery(context.Background(), "100001", "gateway-a")
|
||||
if err != nil {
|
||||
t.Fatalf("start recovery: %v", err)
|
||||
}
|
||||
if !decision.Allowed {
|
||||
t.Fatalf("expected recovery allowed, got %+v", decision)
|
||||
}
|
||||
if decision.Status.LockOwner != "gateway-a" || decision.Status.LockToken == "" || decision.Status.LockExpiresAt.IsZero() {
|
||||
t.Fatalf("missing recovery lock metadata: %+v", decision.Status)
|
||||
}
|
||||
status := decision.Status
|
||||
status.State = "waiting_connection"
|
||||
if err := store.CompleteAccountRecovery(context.Background(), status); err != nil {
|
||||
t.Fatalf("complete recovery: %v", err)
|
||||
}
|
||||
|
||||
statuses, err := store.ListRecoveryStatuses(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list recovery statuses: %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].State != "waiting_connection" || statuses[0].AttemptCount != 1 {
|
||||
t.Fatalf("unexpected statuses: %+v", statuses)
|
||||
}
|
||||
if statuses[0].FailureCategory != "client_disconnected" {
|
||||
t.Fatalf("failure category = %q, want client_disconnected", statuses[0].FailureCategory)
|
||||
}
|
||||
if statuses[0].NextRetryAt.IsZero() {
|
||||
t.Fatalf("expected next retry at after waiting connection: %+v", statuses[0])
|
||||
}
|
||||
|
||||
decision, err = store.StartAccountRecovery(context.Background(), "100001", "gateway-a")
|
||||
if err != nil {
|
||||
t.Fatalf("start recovery second time: %v", err)
|
||||
}
|
||||
if decision.Allowed || decision.SkipReason != "backoff" {
|
||||
t.Fatalf("expected backoff skip, got %+v", decision)
|
||||
}
|
||||
if decision.Status.FailureCategory != "backoff" {
|
||||
t.Fatalf("skip failure category = %q, want backoff", decision.Status.FailureCategory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRecoveryStoreDoesNotReleaseLockOwnedByAnotherGateway(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
store, err := NewRedisRecoveryStore("redis://" + mr.Addr())
|
||||
if err != nil {
|
||||
t.Fatalf("new recovery store: %v", err)
|
||||
}
|
||||
store.Prefix = "test:recovery:takeover"
|
||||
|
||||
first, err := store.StartAccountRecovery(context.Background(), "100001", "gateway-a")
|
||||
if err != nil {
|
||||
t.Fatalf("start first recovery: %v", err)
|
||||
}
|
||||
if !first.Allowed {
|
||||
t.Fatalf("expected first recovery allowed, got %+v", first)
|
||||
}
|
||||
|
||||
mr.FastForward(defaultRecoveryLockTTL + time.Second)
|
||||
second, err := store.StartAccountRecovery(context.Background(), "100001", "gateway-b")
|
||||
if err != nil {
|
||||
t.Fatalf("start second recovery: %v", err)
|
||||
}
|
||||
if !second.Allowed {
|
||||
t.Fatalf("expected second recovery to take over expired lock, got %+v", second)
|
||||
}
|
||||
|
||||
stale := first.Status
|
||||
stale.State = "success"
|
||||
err = store.CompleteAccountRecovery(context.Background(), stale)
|
||||
if !errors.Is(err, ErrRecoveryLockLost) {
|
||||
t.Fatalf("stale completion error = %v, want ErrRecoveryLockLost", err)
|
||||
}
|
||||
|
||||
lockValue, err := store.Client.Get(context.Background(), store.lockKey("100001")).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("load current lock: %v", err)
|
||||
}
|
||||
if lockValue != second.Status.LockToken {
|
||||
t.Fatalf("current lock was changed by stale completion: got %q want %q", lockValue, second.Status.LockToken)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cmpp "github.com/bigwhite/gocmpp"
|
||||
@@ -20,9 +21,13 @@ import (
|
||||
const defaultHTTPTimeout = 10 * time.Second
|
||||
|
||||
type Server struct {
|
||||
Addr string
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Addr string
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
PendingFlushInterval time.Duration
|
||||
PresenceStore PresenceStore
|
||||
RecoveryStore RecoveryStore
|
||||
GatewayInstanceID string
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
@@ -49,6 +54,56 @@ type submitResponse struct {
|
||||
|
||||
type authResponse struct {
|
||||
PasswordCipher string `json:"passwordCipher"`
|
||||
ApplicationID string `json:"applicationId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
type DownstreamReceipt struct {
|
||||
DeliveryID string `json:"deliveryId,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
MessageID string `json:"messageId"`
|
||||
GatewayMessageID string `json:"gatewayMessageId,omitempty"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
ReceiptStatus string `json:"receiptStatus"`
|
||||
RawStatus string `json:"rawStatus,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
DeliveredAt string `json:"deliveredAt,omitempty"`
|
||||
}
|
||||
|
||||
type DownstreamUplink struct {
|
||||
DeliveryID string `json:"deliveryId,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
DestID string `json:"destId"`
|
||||
Content string `json:"content"`
|
||||
ReceivedAt string `json:"receivedAt,omitempty"`
|
||||
}
|
||||
|
||||
type downstreamSession struct {
|
||||
messageID string
|
||||
account string
|
||||
srcID string
|
||||
phoneNumber string
|
||||
gatewayMsgID uint64
|
||||
remoteIP string
|
||||
connectedAt time.Time
|
||||
conn *cmpp.Conn
|
||||
mu *sync.Mutex
|
||||
presence PresenceStore
|
||||
instanceID string
|
||||
}
|
||||
|
||||
var downstreamRegistry = struct {
|
||||
sync.RWMutex
|
||||
byMessageID map[string]*downstreamSession
|
||||
byAccount map[string]*downstreamSession
|
||||
}{
|
||||
byMessageID: make(map[string]*downstreamSession),
|
||||
byAccount: make(map[string]*downstreamSession),
|
||||
}
|
||||
|
||||
func (s Server) ListenAndServe() error {
|
||||
@@ -56,6 +111,9 @@ func (s Server) ListenAndServe() error {
|
||||
if addr == "" {
|
||||
addr = ":17890"
|
||||
}
|
||||
s.logRecoveryCandidates(log.Default())
|
||||
go s.recoverPendingCandidates(log.Default())
|
||||
go s.runPendingFlusher(log.Default())
|
||||
return cmpp.ListenAndServe(addr, cmpp.V30, 30*time.Second, 3, nil,
|
||||
cmpp.HandlerFunc(s.handleLogin),
|
||||
cmpp.HandlerFunc(s.handleSubmit),
|
||||
@@ -83,6 +141,18 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
|
||||
authSource := []byte(req.AuthSrc)
|
||||
authISMG := md5.Sum(bytes.Join([][]byte{{byte(resp.Status)}, authSource, []byte(auth.PasswordCipher)}, nil))
|
||||
resp.AuthIsmg = string(authISMG[:])
|
||||
session := downstreamSession{
|
||||
account: strings.TrimSpace(defaultString(auth.Account, account)),
|
||||
srcID: strings.TrimSpace(auth.Account),
|
||||
remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
|
||||
connectedAt: time.Now().UTC(),
|
||||
conn: packet.Conn,
|
||||
mu: &sync.Mutex{},
|
||||
presence: s.PresenceStore,
|
||||
instanceID: s.gatewayInstanceID(),
|
||||
}
|
||||
rememberAccount(session)
|
||||
go s.flushPending(defaultString(auth.Account, account), logger)
|
||||
logger.Printf("cmpp inbound account=%s login ok remote=%s", account, packet.Conn.Conn.RemoteAddr())
|
||||
return false, nil
|
||||
}
|
||||
@@ -120,6 +190,19 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
|
||||
}
|
||||
resp.MsgId = messageIDFrom(result.MessageID, req.SeqId)
|
||||
resp.Result = 0
|
||||
rememberDownstream(downstreamSession{
|
||||
messageID: result.MessageID,
|
||||
account: account,
|
||||
srcID: strings.TrimSpace(req.SrcId),
|
||||
phoneNumber: phone,
|
||||
gatewayMsgID: resp.MsgId,
|
||||
remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
|
||||
connectedAt: time.Now().UTC(),
|
||||
conn: packet.Conn,
|
||||
mu: &sync.Mutex{},
|
||||
presence: s.PresenceStore,
|
||||
instanceID: s.gatewayInstanceID(),
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -142,6 +225,82 @@ func (s Server) submit(remote net.Addr, payload submitRequest) (submitResponse,
|
||||
return result, err
|
||||
}
|
||||
|
||||
type pendingDeliveryRequest struct {
|
||||
Account string `json:"account"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type pendingDelivery struct {
|
||||
ID string `json:"id"`
|
||||
DeliveryType string `json:"deliveryType"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type pendingFlushResult struct {
|
||||
Account string
|
||||
Deliveries int
|
||||
DeliveredCount int
|
||||
FailedCount int
|
||||
WaitingCount int
|
||||
LastError string
|
||||
}
|
||||
|
||||
func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushResult, error) {
|
||||
result := pendingFlushResult{Account: account}
|
||||
if account == "" {
|
||||
return result, nil
|
||||
}
|
||||
var deliveries []pendingDelivery
|
||||
if err := s.post(context.Background(), "/gateway/events/downstream/pending", pendingDeliveryRequest{Account: account, Limit: 100}, &deliveries); err != nil {
|
||||
logger.Printf("cmpp inbound pending delivery fetch failed account=%s err=%v", account, err)
|
||||
result.LastError = err.Error()
|
||||
return result, err
|
||||
}
|
||||
result.Deliveries = len(deliveries)
|
||||
for _, delivery := range deliveries {
|
||||
delivered, err := s.pushPendingDelivery(account, delivery)
|
||||
if err != nil {
|
||||
result.FailedCount++
|
||||
result.LastError = err.Error()
|
||||
_ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]string{
|
||||
"id": delivery.ID,
|
||||
"errorMessage": err.Error(),
|
||||
}, nil)
|
||||
continue
|
||||
}
|
||||
if delivered {
|
||||
result.DeliveredCount++
|
||||
_ = s.post(context.Background(), "/gateway/events/downstream/delivered", map[string]string{"id": delivery.ID}, nil)
|
||||
continue
|
||||
}
|
||||
result.WaitingCount++
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (bool, error) {
|
||||
switch delivery.DeliveryType {
|
||||
case "receipt":
|
||||
var event DownstreamReceipt
|
||||
if err := json.Unmarshal(delivery.Payload, &event); err != nil {
|
||||
return false, err
|
||||
}
|
||||
event.DeliveryID = delivery.ID
|
||||
event.Account = defaultString(event.Account, account)
|
||||
return PushReceipt(event)
|
||||
case "uplink":
|
||||
var event DownstreamUplink
|
||||
if err := json.Unmarshal(delivery.Payload, &event); err != nil {
|
||||
return false, err
|
||||
}
|
||||
event.DeliveryID = delivery.ID
|
||||
event.Account = defaultString(event.Account, account)
|
||||
return PushUplink(event)
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported downstream delivery type %q", delivery.DeliveryType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Server) post(ctx context.Context, path string, payload any, result any) error {
|
||||
client := s.HTTPClient
|
||||
if client == nil {
|
||||
@@ -210,3 +369,375 @@ func messageIDFrom(value string, seq uint32) uint64 {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rememberDownstream(session downstreamSession) {
|
||||
if session.messageID == "" || session.conn == nil {
|
||||
return
|
||||
}
|
||||
session.touchPresence("connected", true, false)
|
||||
downstreamRegistry.Lock()
|
||||
downstreamRegistry.byMessageID[session.messageID] = &session
|
||||
if session.account != "" {
|
||||
downstreamRegistry.byAccount[session.account] = &session
|
||||
}
|
||||
downstreamRegistry.Unlock()
|
||||
}
|
||||
|
||||
func rememberAccount(session downstreamSession) {
|
||||
if session.account == "" || session.conn == nil {
|
||||
return
|
||||
}
|
||||
session.touchPresence("connected", false, false)
|
||||
downstreamRegistry.Lock()
|
||||
downstreamRegistry.byAccount[session.account] = &session
|
||||
downstreamRegistry.Unlock()
|
||||
}
|
||||
|
||||
func forgetDownstream(session *downstreamSession) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
downstreamRegistry.Lock()
|
||||
if session.messageID != "" {
|
||||
if current := downstreamRegistry.byMessageID[session.messageID]; current == session {
|
||||
delete(downstreamRegistry.byMessageID, session.messageID)
|
||||
}
|
||||
}
|
||||
if session.account != "" {
|
||||
if current := downstreamRegistry.byAccount[session.account]; current == session {
|
||||
delete(downstreamRegistry.byAccount, session.account)
|
||||
}
|
||||
}
|
||||
downstreamRegistry.Unlock()
|
||||
_ = session.removePresence()
|
||||
}
|
||||
|
||||
func (s Server) runPendingFlusher(logger *log.Logger) {
|
||||
ticker := time.NewTicker(s.pendingFlushInterval())
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
s.flushOnlineAccounts(logger)
|
||||
s.recoverPendingCandidates(logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Server) flushOnlineAccounts(logger *log.Logger) {
|
||||
for _, account := range onlineAccounts() {
|
||||
_, _ = s.flushPending(account, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Server) recoverPendingCandidates(logger *log.Logger) {
|
||||
candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore)
|
||||
if err != nil {
|
||||
logger.Printf("cmpp inbound recovery candidate refresh failed err=%v", err)
|
||||
return
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
account := strings.TrimSpace(candidate.Account)
|
||||
if account == "" {
|
||||
continue
|
||||
}
|
||||
activeRecovery := DownstreamRecoveryStatus{
|
||||
Account: account,
|
||||
GatewayInstanceID: s.gatewayInstanceID(),
|
||||
}
|
||||
if s.RecoveryStore != nil {
|
||||
decision, recoveryErr := s.RecoveryStore.StartAccountRecovery(context.Background(), account, s.gatewayInstanceID())
|
||||
if recoveryErr != nil {
|
||||
logger.Printf("cmpp inbound recovery start failed account=%s err=%v", account, recoveryErr)
|
||||
continue
|
||||
}
|
||||
if !decision.Allowed {
|
||||
logger.Printf("cmpp inbound recovery skipped account=%s reason=%s", account, decision.SkipReason)
|
||||
decision.Status.Account = account
|
||||
decision.Status.GatewayInstanceID = s.gatewayInstanceID()
|
||||
decision.Status.State = defaultString(decision.Status.State, "failed")
|
||||
decision.Status.LastSkipReason = defaultString(decision.Status.LastSkipReason, decision.SkipReason)
|
||||
if decision.Status.FailureCategory == "" {
|
||||
decision.Status.FailureCategory = recoveryFailureCategory(decision.Status.State, "", decision.Status.LastSkipReason)
|
||||
}
|
||||
s.syncRecoveryStatus(logger, account, decision.Status)
|
||||
continue
|
||||
}
|
||||
activeRecovery = decision.Status
|
||||
activeRecovery.Account = account
|
||||
activeRecovery.GatewayInstanceID = s.gatewayInstanceID()
|
||||
}
|
||||
result, flushErr := s.flushPending(account, logger)
|
||||
if s.RecoveryStore != nil {
|
||||
status := DownstreamRecoveryStatus{
|
||||
Account: account,
|
||||
GatewayInstanceID: s.gatewayInstanceID(),
|
||||
LockToken: activeRecovery.LockToken,
|
||||
LockOwner: defaultString(activeRecovery.LockOwner, s.gatewayInstanceID()),
|
||||
LockAcquiredAt: activeRecovery.LockAcquiredAt,
|
||||
LockExpiresAt: activeRecovery.LockExpiresAt,
|
||||
LastAttemptAt: activeRecovery.LastAttemptAt,
|
||||
}
|
||||
switch {
|
||||
case flushErr != nil:
|
||||
status.State = "failed"
|
||||
status.LastError = flushErr.Error()
|
||||
status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason)
|
||||
case result.WaitingCount > 0 && result.DeliveredCount == 0 && result.FailedCount == 0:
|
||||
status.State = "waiting_connection"
|
||||
status.LastError = "downstream client is not connected"
|
||||
status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason)
|
||||
case result.FailedCount > 0 && result.DeliveredCount > 0:
|
||||
status.State = "partial"
|
||||
status.LastError = result.LastError
|
||||
status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason)
|
||||
default:
|
||||
status.State = "success"
|
||||
status.LastError = ""
|
||||
status.FailureCategory = ""
|
||||
}
|
||||
if err := s.RecoveryStore.CompleteAccountRecovery(context.Background(), status); err != nil {
|
||||
logger.Printf("cmpp inbound recovery completion failed account=%s err=%v", account, err)
|
||||
if err == ErrRecoveryLockLost {
|
||||
status.State = "failed"
|
||||
status.LastSkipReason = "lock_lost"
|
||||
status.FailureCategory = "lock_lost"
|
||||
s.syncRecoveryStatus(logger, account, status)
|
||||
}
|
||||
} else if persisted, err := s.RecoveryStore.GetAccountRecoveryStatus(context.Background(), account); err != nil {
|
||||
logger.Printf("cmpp inbound recovery status fetch failed account=%s err=%v", account, err)
|
||||
} else {
|
||||
s.syncRecoveryStatus(logger, account, persisted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s Server) syncRecoveryStatus(logger *log.Logger, account string, status DownstreamRecoveryStatus) {
|
||||
if err := s.post(context.Background(), "/gateway/events/downstream/recovery-status", map[string]any{
|
||||
"account": status.Account,
|
||||
"gatewayInstanceId": status.GatewayInstanceID,
|
||||
"state": status.State,
|
||||
"lockOwner": status.LockOwner,
|
||||
"lockExpiresAt": formatRFC3339Nano(status.LockExpiresAt),
|
||||
"lastAttemptAt": formatRFC3339Nano(status.LastAttemptAt),
|
||||
"lastSuccessAt": formatRFC3339Nano(status.LastSuccessAt),
|
||||
"lastFailureAt": formatRFC3339Nano(status.LastFailureAt),
|
||||
"nextRetryAt": formatRFC3339Nano(status.NextRetryAt),
|
||||
"attemptCount": status.AttemptCount,
|
||||
"failureCategory": status.FailureCategory,
|
||||
"lastError": status.LastError,
|
||||
"lastSkipReason": status.LastSkipReason,
|
||||
}, nil); err != nil {
|
||||
logger.Printf("cmpp inbound recovery status sync failed account=%s err=%v", account, err)
|
||||
}
|
||||
}
|
||||
|
||||
func recoveryFailureCategory(state string, lastError string, lastSkipReason string) string {
|
||||
if state == "success" || state == "running" {
|
||||
return ""
|
||||
}
|
||||
if lastSkipReason == "backoff" {
|
||||
return "backoff"
|
||||
}
|
||||
if lastSkipReason == "locked" {
|
||||
return "lock_contended"
|
||||
}
|
||||
if lastSkipReason == "lock_lost" {
|
||||
return "lock_lost"
|
||||
}
|
||||
if state == "waiting_connection" {
|
||||
return "client_disconnected"
|
||||
}
|
||||
if state == "partial" {
|
||||
return "partial_delivery_failed"
|
||||
}
|
||||
if state == "failed" && strings.TrimSpace(lastError) != "" {
|
||||
return "flush_failed"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func onlineAccounts() []string {
|
||||
downstreamRegistry.RLock()
|
||||
defer downstreamRegistry.RUnlock()
|
||||
accounts := make([]string, 0, len(downstreamRegistry.byAccount))
|
||||
for account := range downstreamRegistry.byAccount {
|
||||
if strings.TrimSpace(account) != "" {
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
func PushReceipt(event DownstreamReceipt) (bool, error) {
|
||||
session := findSession(event.MessageID, event.Account)
|
||||
if session == nil {
|
||||
return false, nil
|
||||
}
|
||||
stat := strings.TrimSpace(event.RawStatus)
|
||||
if stat == "" {
|
||||
stat = cmppReceiptStatus(event.ReceiptStatus)
|
||||
}
|
||||
when := time.Now()
|
||||
if event.DeliveredAt != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, event.DeliveredAt); err == nil {
|
||||
when = parsed
|
||||
}
|
||||
}
|
||||
receipt := &cmpp.CmppReceiptPkt{
|
||||
MsgId: session.gatewayMsgID,
|
||||
Stat: stat,
|
||||
SubmitTime: when.Format("0601021504"),
|
||||
DoneTime: when.Format("0601021504"),
|
||||
DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber),
|
||||
SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff),
|
||||
}
|
||||
receiptBytes, err := receipt.Pack()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
deliver := &cmpp.Cmpp3DeliverReqPkt{
|
||||
MsgId: session.gatewayMsgID,
|
||||
DestId: session.srcID,
|
||||
ServiceId: "cmpp",
|
||||
MsgFmt: 0,
|
||||
SrcTerminalId: defaultString(event.PhoneNumber, session.phoneNumber),
|
||||
RegisterDelivery: 1,
|
||||
MsgLength: uint8(cmpp.CmppReceiptPktLen),
|
||||
MsgContent: string(receiptBytes),
|
||||
}
|
||||
return sendDownstream(session, deliver)
|
||||
}
|
||||
|
||||
func PushUplink(event DownstreamUplink) (bool, error) {
|
||||
session := findSession(event.MessageID, event.Account)
|
||||
if session == nil {
|
||||
return false, nil
|
||||
}
|
||||
content, err := cmpputils.Utf8ToUcs2(event.Content)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
deliver := &cmpp.Cmpp3DeliverReqPkt{
|
||||
MsgId: messageIDFrom(defaultString(event.MessageID, event.Account), uint32(time.Now().UnixNano())),
|
||||
DestId: defaultString(event.DestID, session.srcID),
|
||||
ServiceId: "cmpp",
|
||||
MsgFmt: 8,
|
||||
SrcTerminalId: event.PhoneNumber,
|
||||
RegisterDelivery: 0,
|
||||
MsgLength: uint8(len(content)),
|
||||
MsgContent: content,
|
||||
}
|
||||
return sendDownstream(session, deliver)
|
||||
}
|
||||
|
||||
func findSession(messageID string, account string) *downstreamSession {
|
||||
downstreamRegistry.RLock()
|
||||
defer downstreamRegistry.RUnlock()
|
||||
if messageID != "" {
|
||||
if session := downstreamRegistry.byMessageID[messageID]; session != nil {
|
||||
return session
|
||||
}
|
||||
}
|
||||
if account != "" {
|
||||
return downstreamRegistry.byAccount[account]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendDownstream(session *downstreamSession, deliver *cmpp.Cmpp3DeliverReqPkt) (bool, error) {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
if err := session.conn.SendPkt(deliver, <-session.conn.SeqId); err != nil {
|
||||
forgetDownstream(session)
|
||||
return false, err
|
||||
}
|
||||
session.touchPresence("connected", false, true)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s Server) pendingFlushInterval() time.Duration {
|
||||
if s.PendingFlushInterval > 0 {
|
||||
return s.PendingFlushInterval
|
||||
}
|
||||
return time.Minute
|
||||
}
|
||||
|
||||
func (s Server) logRecoveryCandidates(logger *log.Logger) {
|
||||
candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore)
|
||||
if err != nil {
|
||||
logger.Printf("cmpp inbound recovery candidates load failed err=%v", err)
|
||||
return
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
logger.Printf("cmpp inbound recovery candidates loaded count=0")
|
||||
return
|
||||
}
|
||||
accounts := make([]string, 0, len(candidates))
|
||||
for _, item := range candidates {
|
||||
if strings.TrimSpace(item.Account) != "" {
|
||||
accounts = append(accounts, item.Account)
|
||||
}
|
||||
}
|
||||
logger.Printf("cmpp inbound recovery candidates loaded count=%d accounts=%s", len(candidates), strings.Join(accounts, ","))
|
||||
}
|
||||
|
||||
func cmppReceiptStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "delivered":
|
||||
return "DELIVRD"
|
||||
case "unknown":
|
||||
return "UNKNOWN"
|
||||
default:
|
||||
return "UNDELIV"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formatRFC3339Nano(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func (s Server) gatewayInstanceID() string {
|
||||
if strings.TrimSpace(s.GatewayInstanceID) != "" {
|
||||
return strings.TrimSpace(s.GatewayInstanceID)
|
||||
}
|
||||
return "gateway-1"
|
||||
}
|
||||
|
||||
func (session downstreamSession) touchPresence(state string, includeSubmit bool, includeDeliver bool) {
|
||||
if session.presence == nil || strings.TrimSpace(session.account) == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
snapshot := DownstreamPresence{
|
||||
Account: strings.TrimSpace(session.account),
|
||||
SrcID: strings.TrimSpace(session.srcID),
|
||||
RemoteIP: strings.TrimSpace(session.remoteIP),
|
||||
GatewayInstanceID: strings.TrimSpace(session.instanceID),
|
||||
State: defaultString(strings.TrimSpace(state), "connected"),
|
||||
ConnectedAt: session.connectedAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if includeSubmit {
|
||||
snapshot.LastSubmitAt = now
|
||||
}
|
||||
if includeDeliver {
|
||||
snapshot.LastDeliverAt = now
|
||||
}
|
||||
_ = session.presence.TouchAccount(context.Background(), snapshot)
|
||||
}
|
||||
|
||||
func (session downstreamSession) removePresence() error {
|
||||
if session.presence == nil || strings.TrimSpace(session.account) == "" {
|
||||
return nil
|
||||
}
|
||||
return session.presence.RemoveAccount(context.Background(), strings.TrimSpace(session.account))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +15,63 @@ import (
|
||||
cmpputils "github.com/bigwhite/gocmpp/utils"
|
||||
)
|
||||
|
||||
type memoryPresenceStore struct {
|
||||
snapshots map[string]DownstreamPresence
|
||||
removed []string
|
||||
}
|
||||
|
||||
type memoryRecoveryStore struct {
|
||||
decisions map[string]RecoveryStartDecision
|
||||
completed []DownstreamRecoveryStatus
|
||||
}
|
||||
|
||||
func (m *memoryPresenceStore) TouchAccount(_ context.Context, snapshot DownstreamPresence) error {
|
||||
if m.snapshots == nil {
|
||||
m.snapshots = map[string]DownstreamPresence{}
|
||||
}
|
||||
m.snapshots[snapshot.Account] = snapshot
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryPresenceStore) RemoveAccount(_ context.Context, account string) error {
|
||||
delete(m.snapshots, account)
|
||||
m.removed = append(m.removed, account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryPresenceStore) ListAccounts(_ context.Context) ([]DownstreamPresence, error) {
|
||||
result := make([]DownstreamPresence, 0, len(m.snapshots))
|
||||
for _, snapshot := range m.snapshots {
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *memoryRecoveryStore) StartAccountRecovery(_ context.Context, account string, _ string) (RecoveryStartDecision, error) {
|
||||
if decision, ok := m.decisions[account]; ok {
|
||||
return decision, nil
|
||||
}
|
||||
return RecoveryStartDecision{Allowed: true}, nil
|
||||
}
|
||||
|
||||
func (m *memoryRecoveryStore) CompleteAccountRecovery(_ context.Context, status DownstreamRecoveryStatus) error {
|
||||
m.completed = append(m.completed, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryRecoveryStore) ListRecoveryStatuses(_ context.Context) ([]DownstreamRecoveryStatus, error) {
|
||||
return append([]DownstreamRecoveryStatus(nil), m.completed...), nil
|
||||
}
|
||||
|
||||
func (m *memoryRecoveryStore) GetAccountRecoveryStatus(_ context.Context, account string) (DownstreamRecoveryStatus, error) {
|
||||
for _, item := range m.completed {
|
||||
if item.Account == account {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return DownstreamRecoveryStatus{Account: account}, nil
|
||||
}
|
||||
|
||||
func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
||||
account := "100001"
|
||||
password := "secret-hash"
|
||||
@@ -29,6 +89,8 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
||||
t.Fatalf("decode submit: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-1"})
|
||||
case "/api/gateway/events/downstream/pending":
|
||||
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
|
||||
default:
|
||||
t.Fatalf("unexpected api path: %s", r.URL.Path)
|
||||
}
|
||||
@@ -76,6 +138,26 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
||||
if rsp.Result != 0 || rsp.MsgId == 0 {
|
||||
t.Fatalf("unexpected submit response: %+v", rsp)
|
||||
}
|
||||
delivered, err := PushReceipt(DownstreamReceipt{
|
||||
MessageID: "MSG-1",
|
||||
PhoneNumber: "13500002696",
|
||||
ReceiptStatus: "delivered",
|
||||
DeliveredAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
if err != nil || !delivered {
|
||||
t.Fatalf("push receipt delivered=%v err=%v", delivered, err)
|
||||
}
|
||||
deliver := recvDeliver(t, client)
|
||||
if deliver.RegisterDelivery != 1 {
|
||||
t.Fatalf("expected receipt deliver, got %+v", deliver)
|
||||
}
|
||||
var receipt cmpp.CmppReceiptPkt
|
||||
if err := receipt.Unpack([]byte(deliver.MsgContent)); err != nil {
|
||||
t.Fatalf("unpack pushed receipt: %v", err)
|
||||
}
|
||||
if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13500002696" {
|
||||
t.Fatalf("unexpected pushed receipt: %+v", receipt)
|
||||
}
|
||||
if gotAuth.Account != account || gotAuth.AuthSource == "" || gotAuth.RemoteIP == "" {
|
||||
t.Fatalf("unexpected auth payload: %+v", gotAuth)
|
||||
}
|
||||
@@ -84,6 +166,183 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushOnlineAccountsFetchesPendingDeliveries(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
account := "100001"
|
||||
calls := 0
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/gateway/events/downstream/pending":
|
||||
calls++
|
||||
var payload pendingDeliveryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode pending request: %v", err)
|
||||
}
|
||||
if payload.Account != account {
|
||||
t.Fatalf("unexpected account: %+v", payload)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
|
||||
default:
|
||||
t.Fatalf("unexpected api path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
downstreamRegistry.Lock()
|
||||
downstreamRegistry.byAccount[account] = &downstreamSession{account: account}
|
||||
downstreamRegistry.Unlock()
|
||||
|
||||
server := Server{APIBaseURL: api.URL + "/api"}
|
||||
server.flushOnlineAccounts(log.Default())
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("pending fetch calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverPendingCandidatesFetchesPresenceAccounts(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
account := "100009"
|
||||
calls := 0
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/gateway/events/downstream/pending":
|
||||
calls++
|
||||
var payload pendingDeliveryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode pending request: %v", err)
|
||||
}
|
||||
if payload.Account != account {
|
||||
t.Fatalf("unexpected account: %+v", payload)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
|
||||
default:
|
||||
t.Fatalf("unexpected api path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
store := &memoryPresenceStore{
|
||||
snapshots: map[string]DownstreamPresence{
|
||||
account: {
|
||||
Account: account,
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "connected",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := Server{APIBaseURL: api.URL + "/api", PresenceStore: store}
|
||||
server.recoverPendingCandidates(log.Default())
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("recovery pending fetch calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverPendingCandidatesWritesWaitingConnectionStatus(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
account := "100010"
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/gateway/events/downstream/pending":
|
||||
_ = json.NewEncoder(w).Encode([]pendingDelivery{{
|
||||
ID: "delivery-1",
|
||||
DeliveryType: "receipt",
|
||||
Payload: json.RawMessage(`{"messageId":"MSG-404","phoneNumber":"13800000001","receiptStatus":"delivered"}`),
|
||||
}})
|
||||
case "/api/gateway/events/downstream/recovery-status":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Fatalf("unexpected api path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
recovery := &memoryRecoveryStore{}
|
||||
store := &memoryPresenceStore{
|
||||
snapshots: map[string]DownstreamPresence{
|
||||
account: {
|
||||
Account: account,
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "connected",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := Server{APIBaseURL: api.URL + "/api", PresenceStore: store, RecoveryStore: recovery}
|
||||
server.recoverPendingCandidates(log.Default())
|
||||
|
||||
if len(recovery.completed) != 1 {
|
||||
t.Fatalf("completed recovery statuses = %d, want 1", len(recovery.completed))
|
||||
}
|
||||
if recovery.completed[0].State != "waiting_connection" {
|
||||
t.Fatalf("unexpected recovery status: %+v", recovery.completed[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberAndForgetAccountUpdatesPresenceStore(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
store := &memoryPresenceStore{}
|
||||
session := downstreamSession{
|
||||
account: "100001",
|
||||
srcID: "10690000",
|
||||
remoteIP: "127.0.0.1",
|
||||
connectedAt: time.Now().UTC(),
|
||||
conn: &cmpp.Conn{},
|
||||
mu: &sync.Mutex{},
|
||||
presence: store,
|
||||
instanceID: "gateway-a",
|
||||
}
|
||||
|
||||
rememberAccount(session)
|
||||
snapshot, ok := store.snapshots["100001"]
|
||||
if !ok {
|
||||
t.Fatal("expected presence snapshot to be stored")
|
||||
}
|
||||
if snapshot.Account != "100001" || snapshot.GatewayInstanceID != "gateway-a" || snapshot.State != "connected" {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
|
||||
forgetDownstream(downstreamRegistry.byAccount["100001"])
|
||||
if len(store.removed) != 1 || store.removed[0] != "100001" {
|
||||
t.Fatalf("unexpected removed accounts: %+v", store.removed)
|
||||
}
|
||||
}
|
||||
|
||||
func recvDeliver(t *testing.T, client *cmpp.Client) *cmpp.Cmpp3DeliverReqPkt {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if deliver, ok := packet.(*cmpp.Cmpp3DeliverReqPkt); ok {
|
||||
return deliver
|
||||
}
|
||||
}
|
||||
t.Fatal("timed out waiting deliver request")
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetDownstreamRegistry() {
|
||||
downstreamRegistry.Lock()
|
||||
defer downstreamRegistry.Unlock()
|
||||
downstreamRegistry.byAccount = make(map[string]*downstreamSession)
|
||||
downstreamRegistry.byMessageID = make(map[string]*downstreamSession)
|
||||
}
|
||||
|
||||
func reserveTCPAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
|
||||
@@ -25,19 +25,20 @@ type Envelope struct {
|
||||
|
||||
type SubmitCommand struct {
|
||||
Envelope
|
||||
TenantID string `json:"tenantId"`
|
||||
ApplicationID string `json:"applicationId"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
SubmitID string `json:"submitId"`
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
Content string `json:"content"`
|
||||
Signature string `json:"signature"`
|
||||
TemplateID string `json:"templateId"`
|
||||
BillingUnits int `json:"billingUnits"`
|
||||
QueuePriority string `json:"queuePriority"`
|
||||
Route Route `json:"route"`
|
||||
CMPP CMPP `json:"cmpp"`
|
||||
Retry Retry `json:"retry"`
|
||||
TenantID string `json:"tenantId"`
|
||||
ApplicationID string `json:"applicationId"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
SubmitID string `json:"submitId"`
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
Content string `json:"content"`
|
||||
Signature string `json:"signature"`
|
||||
TemplateID string `json:"templateId"`
|
||||
BillingUnits int `json:"billingUnits"`
|
||||
QueuePriority string `json:"queuePriority"`
|
||||
Route Route `json:"route"`
|
||||
CMPP CMPP `json:"cmpp"`
|
||||
Upstream UpstreamConfig `json:"upstream"`
|
||||
Retry Retry `json:"retry"`
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
@@ -57,6 +58,16 @@ type CMPP struct {
|
||||
FeeType string `json:"feeType,omitempty"`
|
||||
}
|
||||
|
||||
type UpstreamConfig struct {
|
||||
GatewayHost string `json:"gatewayHost"`
|
||||
GatewayPort int `json:"gatewayPort"`
|
||||
Account string `json:"account"`
|
||||
PasswordCipher string `json:"passwordCipher"`
|
||||
CMPPVersion string `json:"cmppVersion"`
|
||||
DesiredConnections int `json:"desiredConnections,omitempty"`
|
||||
WindowSize int `json:"windowSize,omitempty"`
|
||||
}
|
||||
|
||||
type Retry struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
@@ -70,12 +81,25 @@ type SubmitResult struct {
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
SubmittedAt time.Time `json:"submittedAt"`
|
||||
Segments []SubmitSegmentResult `json:"segments,omitempty"`
|
||||
}
|
||||
|
||||
type SubmitSegmentResult struct {
|
||||
SegmentTotal int `json:"segmentTotal"`
|
||||
SegmentIndex int `json:"segmentIndex"`
|
||||
SequenceID uint32 `json:"sequenceId"`
|
||||
GatewayMessageID string `json:"gatewayMessageId"`
|
||||
SubmitStatus string `json:"submitStatus"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
SubmittedAt time.Time `json:"submittedAt"`
|
||||
}
|
||||
|
||||
type ReceiptEvent struct {
|
||||
Envelope
|
||||
SequenceID uint32 `json:"sequenceId"`
|
||||
GatewayMessageID string `json:"gatewayMessageId"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
ReceiptStatus string `json:"receiptStatus"`
|
||||
RawStatus string `json:"rawStatus"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
|
||||
@@ -34,6 +34,15 @@ func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) {
|
||||
"registeredDelivery": 1,
|
||||
"msgFmt": 8
|
||||
},
|
||||
"upstream": {
|
||||
"gatewayHost": "127.0.0.1",
|
||||
"gatewayPort": 17890,
|
||||
"account": "account-a",
|
||||
"passwordCipher": "secret",
|
||||
"cmppVersion": "3.0",
|
||||
"desiredConnections": 2,
|
||||
"windowSize": 16
|
||||
},
|
||||
"retry": {
|
||||
"attempt": 0,
|
||||
"maxAttempts": 3
|
||||
@@ -47,4 +56,10 @@ func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) {
|
||||
if command.QueuePriority != "priority" {
|
||||
t.Fatalf("QueuePriority = %q, want priority", command.QueuePriority)
|
||||
}
|
||||
if command.Upstream.GatewayHost != "127.0.0.1" || command.Upstream.Account != "account-a" {
|
||||
t.Fatalf("unexpected upstream config: %+v", command.Upstream)
|
||||
}
|
||||
if command.Upstream.DesiredConnections != 2 || command.Upstream.WindowSize != 16 {
|
||||
t.Fatalf("unexpected upstream window config: %+v", command.Upstream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,13 @@ func NewSubmitCommand(index int) queue.SubmitCommand {
|
||||
FeeCode: "0",
|
||||
FeeType: "01",
|
||||
},
|
||||
Upstream: queue.UpstreamConfig{
|
||||
GatewayHost: "127.0.0.1",
|
||||
GatewayPort: 17890,
|
||||
Account: "cmpp-account-spike",
|
||||
PasswordCipher: "secret-spike",
|
||||
CMPPVersion: "3.0",
|
||||
},
|
||||
Retry: queue.Retry{Attempt: 0, MaxAttempts: 3},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
package submitworker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
"cmpp-platform/gateway/internal/upstream"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultStream = "gateway.submit.commands"
|
||||
defaultGroup = "cmpp-gateway"
|
||||
defaultConsumer = "gateway-1"
|
||||
defaultMinIdle = 30 * time.Second
|
||||
defaultMaxFails = 3
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
Redis *redis.Client
|
||||
Upstream *upstream.Manager
|
||||
Submit func(context.Context, queue.SubmitCommand) (queue.SubmitResult, error)
|
||||
ReportDeadLetter func(context.Context, DeadLetterEvent) error
|
||||
Stream string
|
||||
Group string
|
||||
Consumer string
|
||||
Block time.Duration
|
||||
Count int64
|
||||
MinIdle time.Duration
|
||||
MaxFailures int
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
type DeadLetterEvent struct {
|
||||
StreamMessageID string `json:"streamMessageId"`
|
||||
TraceID string `json:"traceId,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
ChannelID string `json:"channelId,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
SubmitID string `json:"submitId,omitempty"`
|
||||
FailureCode string `json:"failureCode"`
|
||||
FailureMessage string `json:"failureMessage"`
|
||||
Attempts int `json:"attempts"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
CommandPayload map[string]interface{} `json:"commandPayload,omitempty"`
|
||||
RawPayload string `json:"rawPayload,omitempty"`
|
||||
DeadLetteredAt time.Time `json:"deadLetteredAt"`
|
||||
}
|
||||
|
||||
func New(redisURL string, manager *upstream.Manager) (*Worker, error) {
|
||||
client, err := redisClient(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Worker{Redis: client, Upstream: manager}, nil
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
if w.Redis == nil {
|
||||
return fmt.Errorf("redis client is required")
|
||||
}
|
||||
if w.Upstream == nil {
|
||||
return fmt.Errorf("upstream manager is required")
|
||||
}
|
||||
for {
|
||||
if err := w.ensureGroup(ctx); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
w.logf("gateway submit worker ensure group failed: %v", err)
|
||||
sleep(ctx, 3*time.Second)
|
||||
continue
|
||||
}
|
||||
if err := w.recoverPending(ctx); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
w.logf("gateway submit worker pending recovery failed: %v", err)
|
||||
sleep(ctx, time.Second)
|
||||
continue
|
||||
}
|
||||
if err := w.consumeOnce(ctx); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
w.logf("gateway submit worker consume failed: %v", err)
|
||||
sleep(ctx, time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) ensureGroup(ctx context.Context) error {
|
||||
err := w.Redis.XGroupCreateMkStream(ctx, w.stream(), w.group(), "0").Err()
|
||||
if err == nil || strings.Contains(err.Error(), "BUSYGROUP") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) consumeOnce(ctx context.Context) error {
|
||||
streams, err := w.Redis.XReadGroup(ctx, &redis.XReadGroupArgs{
|
||||
Group: w.group(),
|
||||
Consumer: w.consumer(),
|
||||
Streams: []string{w.stream(), ">"},
|
||||
Count: w.count(),
|
||||
Block: w.block(),
|
||||
}).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stream := range streams {
|
||||
if err := w.processMessages(ctx, stream.Messages); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) recoverPending(ctx context.Context) error {
|
||||
start := "0-0"
|
||||
for {
|
||||
messages, next, err := w.Redis.XAutoClaim(ctx, &redis.XAutoClaimArgs{
|
||||
Stream: w.stream(),
|
||||
Group: w.group(),
|
||||
Consumer: w.consumer(),
|
||||
MinIdle: w.minIdle(),
|
||||
Start: start,
|
||||
Count: w.count(),
|
||||
}).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
w.logf("gateway submit worker reclaimed %d pending message(s)", len(messages))
|
||||
if err := w.processMessages(ctx, messages); err != nil {
|
||||
return err
|
||||
}
|
||||
start = next
|
||||
if next == "0-0" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) processMessages(ctx context.Context, messages []redis.XMessage) error {
|
||||
for _, message := range messages {
|
||||
if err := w.processMessage(ctx, message); err != nil {
|
||||
w.logf("gateway submit worker message %s failed: %v", message.ID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) processMessage(ctx context.Context, message redis.XMessage) error {
|
||||
command, err := CommandFromStreamValues(message.Values)
|
||||
if err != nil {
|
||||
return w.deadLetterMalformedMessage(ctx, message, err)
|
||||
}
|
||||
if err := w.handleCommand(ctx, command); err != nil {
|
||||
attempts, attemptsErr := w.incrementFailureAttempt(ctx, message.ID)
|
||||
if attemptsErr != nil {
|
||||
w.logf("gateway submit worker increment failure %s failed: %v", message.ID, attemptsErr)
|
||||
}
|
||||
if attempts >= w.maxFailures() {
|
||||
if reportErr := w.deadLetterCommand(ctx, message, command, attempts, err); reportErr != nil {
|
||||
return reportErr
|
||||
}
|
||||
return w.ackAndClearFailure(ctx, message.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return w.ackAndClearFailure(ctx, message.ID)
|
||||
}
|
||||
|
||||
func (w *Worker) handleCommand(ctx context.Context, command queue.SubmitCommand) error {
|
||||
submit := w.Submit
|
||||
if submit == nil {
|
||||
if w.Upstream == nil {
|
||||
return fmt.Errorf("upstream manager is required")
|
||||
}
|
||||
submit = w.Upstream.Submit
|
||||
}
|
||||
result, err := submit(ctx, command)
|
||||
if err != nil && (result.SubmitStatus == "" || result.SubmitStatus == "accepted") {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CommandFromStreamValues(values map[string]interface{}) (queue.SubmitCommand, error) {
|
||||
raw, ok := values["data"]
|
||||
if !ok {
|
||||
return queue.SubmitCommand{}, fmt.Errorf("stream data field is required")
|
||||
}
|
||||
var data string
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
data = value
|
||||
case []byte:
|
||||
data = string(value)
|
||||
default:
|
||||
data = fmt.Sprint(value)
|
||||
}
|
||||
var command queue.SubmitCommand
|
||||
if err := json.Unmarshal([]byte(data), &command); err != nil {
|
||||
return queue.SubmitCommand{}, err
|
||||
}
|
||||
if command.MessageType != queue.MessageTypeSubmitCommand {
|
||||
return queue.SubmitCommand{}, fmt.Errorf("unsupported messageType %q", command.MessageType)
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (w *Worker) deadLetterMalformedMessage(ctx context.Context, message redis.XMessage, cause error) error {
|
||||
event := DeadLetterEvent{
|
||||
StreamMessageID: message.ID,
|
||||
FailureCode: "INVALID_COMMAND_PAYLOAD",
|
||||
FailureMessage: cause.Error(),
|
||||
Attempts: 1,
|
||||
MaxAttempts: 1,
|
||||
RawPayload: extractRawPayload(message.Values),
|
||||
DeadLetteredAt: time.Now().UTC(),
|
||||
}
|
||||
if err := w.reportDeadLetter(ctx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.ackAndClearFailure(ctx, message.ID)
|
||||
}
|
||||
|
||||
func (w *Worker) deadLetterCommand(ctx context.Context, message redis.XMessage, command queue.SubmitCommand, attempts int, cause error) error {
|
||||
payload, payloadErr := commandPayload(command)
|
||||
if payloadErr != nil {
|
||||
w.logf("gateway submit worker marshal dead-letter command %s failed: %v", message.ID, payloadErr)
|
||||
}
|
||||
return w.reportDeadLetter(ctx, DeadLetterEvent{
|
||||
StreamMessageID: message.ID,
|
||||
TraceID: command.TraceID,
|
||||
MessageID: command.MessageID,
|
||||
ChannelID: command.ChannelID,
|
||||
TenantID: command.TenantID,
|
||||
ApplicationID: command.ApplicationID,
|
||||
SubmitID: command.SubmitID,
|
||||
FailureCode: "SUBMIT_PROCESSING_FAILED",
|
||||
FailureMessage: cause.Error(),
|
||||
Attempts: attempts,
|
||||
MaxAttempts: w.maxFailures(),
|
||||
CommandPayload: payload,
|
||||
RawPayload: extractRawPayload(message.Values),
|
||||
DeadLetteredAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Worker) reportDeadLetter(ctx context.Context, event DeadLetterEvent) error {
|
||||
if w.ReportDeadLetter != nil {
|
||||
return w.ReportDeadLetter(ctx, event)
|
||||
}
|
||||
if w.APIBaseURL == "" {
|
||||
return fmt.Errorf("gateway submit dead-letter reporter is not configured")
|
||||
}
|
||||
client := w.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
strings.TrimRight(w.APIBaseURL, "/")+"/gateway/events/dead-letter",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("dead-letter endpoint returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redisClient(redisURL string) (*redis.Client, error) {
|
||||
if redisURL == "" {
|
||||
redisURL = "redis://127.0.0.1:6379"
|
||||
}
|
||||
options, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return redis.NewClient(options), nil
|
||||
}
|
||||
|
||||
func (w *Worker) incrementFailureAttempt(ctx context.Context, messageID string) (int, error) {
|
||||
value, err := w.Redis.HIncrBy(ctx, w.failureAttemptsKey(), messageID, 1).Result()
|
||||
return int(value), err
|
||||
}
|
||||
|
||||
func (w *Worker) ackAndClearFailure(ctx context.Context, messageID string) error {
|
||||
if err := w.Redis.XAck(ctx, w.stream(), w.group(), messageID).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Redis.HDel(ctx, w.failureAttemptsKey(), messageID).Err(); err != nil {
|
||||
w.logf("gateway submit worker clear failure %s failed: %v", messageID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) stream() string {
|
||||
if w.Stream != "" {
|
||||
return w.Stream
|
||||
}
|
||||
return defaultStream
|
||||
}
|
||||
|
||||
func (w *Worker) group() string {
|
||||
if w.Group != "" {
|
||||
return w.Group
|
||||
}
|
||||
return defaultGroup
|
||||
}
|
||||
|
||||
func (w *Worker) consumer() string {
|
||||
if w.Consumer != "" {
|
||||
return w.Consumer
|
||||
}
|
||||
return defaultConsumer
|
||||
}
|
||||
|
||||
func (w *Worker) block() time.Duration {
|
||||
if w.Block > 0 {
|
||||
return w.Block
|
||||
}
|
||||
return 5 * time.Second
|
||||
}
|
||||
|
||||
func (w *Worker) count() int64 {
|
||||
if w.Count > 0 {
|
||||
return w.Count
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
func (w *Worker) minIdle() time.Duration {
|
||||
if w.MinIdle > 0 {
|
||||
return w.MinIdle
|
||||
}
|
||||
return defaultMinIdle
|
||||
}
|
||||
|
||||
func (w *Worker) maxFailures() int {
|
||||
if w.MaxFailures > 0 {
|
||||
return w.MaxFailures
|
||||
}
|
||||
return defaultMaxFails
|
||||
}
|
||||
|
||||
func (w *Worker) failureAttemptsKey() string {
|
||||
return w.stream() + ":failure-attempts"
|
||||
}
|
||||
|
||||
func (w *Worker) logf(format string, args ...interface{}) {
|
||||
if w.Logger != nil {
|
||||
w.Logger.Printf(format, args...)
|
||||
return
|
||||
}
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, duration time.Duration) {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
func extractRawPayload(values map[string]interface{}) string {
|
||||
raw, ok := values["data"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return value
|
||||
case []byte:
|
||||
return string(value)
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func commandPayload(command queue.SubmitCommand) (map[string]interface{}, error) {
|
||||
data, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package submitworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestCommandFromStreamValuesParsesSubmitCommand(t *testing.T) {
|
||||
command, err := CommandFromStreamValues(map[string]interface{}{
|
||||
"messageType": "SubmitCommand",
|
||||
"data": `{
|
||||
"schemaVersion": "v1",
|
||||
"messageType": "SubmitCommand",
|
||||
"traceId": "trace-worker-0001",
|
||||
"messageId": "msg-worker-0001",
|
||||
"channelId": "channel-1",
|
||||
"createdAt": "2026-07-07T10:00:00Z",
|
||||
"tenantId": "tenant-1",
|
||||
"applicationId": "app-1",
|
||||
"submitId": "submit-1",
|
||||
"phoneNumber": "13800138000",
|
||||
"content": "hello",
|
||||
"signature": "测试",
|
||||
"templateId": "tpl-1",
|
||||
"billingUnits": 1,
|
||||
"queuePriority": "normal",
|
||||
"route": {
|
||||
"channelCode": "CMPP-A",
|
||||
"cmppAccountCode": "account-a",
|
||||
"priority": 0,
|
||||
"rateLimitPerSecond": 100
|
||||
},
|
||||
"cmpp": {
|
||||
"serviceId": "SMS",
|
||||
"srcId": "10690000",
|
||||
"registeredDelivery": 1,
|
||||
"msgFmt": 8
|
||||
},
|
||||
"upstream": {
|
||||
"gatewayHost": "127.0.0.1",
|
||||
"gatewayPort": 17890,
|
||||
"account": "account-a",
|
||||
"passwordCipher": "secret",
|
||||
"cmppVersion": "3.0"
|
||||
},
|
||||
"retry": {
|
||||
"attempt": 0,
|
||||
"maxAttempts": 1
|
||||
}
|
||||
}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse stream command: %v", err)
|
||||
}
|
||||
if command.MessageID != "msg-worker-0001" || command.Upstream.Account != "account-a" {
|
||||
t.Fatalf("unexpected command: %+v", command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFromStreamValuesRejectsMissingData(t *testing.T) {
|
||||
_, err := CommandFromStreamValues(map[string]interface{}{"messageType": "SubmitCommand"})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing data error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageUsesInjectedSubmit(t *testing.T) {
|
||||
var got queue.SubmitCommand
|
||||
worker := &Worker{
|
||||
Submit: func(_ context.Context, command queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
got = command
|
||||
return queue.SubmitResult{SubmitStatus: "accepted"}, nil
|
||||
},
|
||||
}
|
||||
if err := worker.handleCommand(context.Background(), queue.SubmitCommand{
|
||||
Envelope: queue.Envelope{MessageID: "msg-worker-0002"},
|
||||
SubmitID: "submit-2",
|
||||
PhoneNumber: "13800138000",
|
||||
Content: "hello",
|
||||
Upstream: queue.UpstreamConfig{GatewayHost: "127.0.0.1", GatewayPort: 17890, Account: "account-a", PasswordCipher: "secret", CMPPVersion: "3.0"},
|
||||
CMPP: queue.CMPP{ServiceID: "SMS", SrcID: "10690000", RegisteredDelivery: 1, MsgFmt: 8},
|
||||
Route: queue.Route{ChannelCode: "CMPP-A"},
|
||||
Retry: queue.Retry{Attempt: 0, MaxAttempts: 1},
|
||||
ApplicationID: "app-1",
|
||||
TenantID: "tenant-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("handleCommand returned error: %v", err)
|
||||
}
|
||||
if got.MessageID != "msg-worker-0002" || got.SubmitID != "submit-2" {
|
||||
t.Fatalf("unexpected command: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinIdleDefaultsToThirtySeconds(t *testing.T) {
|
||||
worker := &Worker{}
|
||||
if got := worker.minIdle(); got != defaultMinIdle {
|
||||
t.Fatalf("minIdle = %v, want %v", got, defaultMinIdle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessageDeadLettersAfterMaxFailures(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
reported := []DeadLetterEvent{}
|
||||
worker := &Worker{
|
||||
Redis: client,
|
||||
Stream: "gateway.submit.commands",
|
||||
Group: "cmpp-gateway",
|
||||
MaxFailures: 2,
|
||||
Submit: func(_ context.Context, command queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
return queue.SubmitResult{}, context.DeadlineExceeded
|
||||
},
|
||||
ReportDeadLetter: func(_ context.Context, event DeadLetterEvent) error {
|
||||
reported = append(reported, event)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := worker.ensureGroup(ctx); err != nil {
|
||||
t.Fatalf("ensureGroup: %v", err)
|
||||
}
|
||||
message := redis.XMessage{
|
||||
ID: "1710000000000-0",
|
||||
Values: map[string]interface{}{
|
||||
"messageType": "SubmitCommand",
|
||||
"data": `{
|
||||
"schemaVersion": "v1",
|
||||
"messageType": "SubmitCommand",
|
||||
"traceId": "trace-worker-0003",
|
||||
"messageId": "msg-worker-0003",
|
||||
"channelId": "channel-1",
|
||||
"createdAt": "2026-07-08T10:00:00Z",
|
||||
"tenantId": "tenant-1",
|
||||
"applicationId": "app-1",
|
||||
"submitId": "submit-3",
|
||||
"phoneNumber": "13800138000",
|
||||
"content": "hello",
|
||||
"signature": "测试",
|
||||
"templateId": "tpl-1",
|
||||
"billingUnits": 1,
|
||||
"queuePriority": "normal",
|
||||
"route": { "channelCode": "CMPP-A", "cmppAccountCode": "account-a", "priority": 0 },
|
||||
"cmpp": { "serviceId": "SMS", "srcId": "10690000", "registeredDelivery": 1, "msgFmt": 8 },
|
||||
"upstream": { "gatewayHost": "127.0.0.1", "gatewayPort": 17890, "account": "account-a", "passwordCipher": "secret", "cmppVersion": "3.0" },
|
||||
"retry": { "attempt": 0, "maxAttempts": 1 }
|
||||
}`,
|
||||
},
|
||||
}
|
||||
if err := client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: worker.stream(),
|
||||
ID: message.ID,
|
||||
Values: message.Values,
|
||||
}).Err(); err != nil {
|
||||
t.Fatalf("xadd: %v", err)
|
||||
}
|
||||
|
||||
if err := worker.processMessage(ctx, message); err == nil {
|
||||
t.Fatal("expected first failure")
|
||||
}
|
||||
if len(reported) != 0 {
|
||||
t.Fatalf("unexpected dead letters on first failure: %+v", reported)
|
||||
}
|
||||
|
||||
if err := worker.processMessage(ctx, message); err != nil {
|
||||
t.Fatalf("second failure should dead-letter and ack, got %v", err)
|
||||
}
|
||||
if len(reported) != 1 {
|
||||
t.Fatalf("dead letters = %d, want 1", len(reported))
|
||||
}
|
||||
if reported[0].FailureCode != "SUBMIT_PROCESSING_FAILED" || reported[0].Attempts != 2 {
|
||||
t.Fatalf("unexpected dead letter: %+v", reported[0])
|
||||
}
|
||||
if client.HGet(ctx, worker.failureAttemptsKey(), message.ID).Err() != redis.Nil {
|
||||
t.Fatalf("failure attempt key was not cleared")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
|
||||
conn := &connection{
|
||||
pending: make(map[uint32]chan submitPartResponse),
|
||||
}
|
||||
waiter := make(chan submitPartResponse, 1)
|
||||
conn.pending[7] = waiter
|
||||
|
||||
loss := errors.New("socket closed")
|
||||
conn.handleConnectionLoss(loss)
|
||||
|
||||
select {
|
||||
case result := <-waiter:
|
||||
if !errors.Is(result.err, loss) {
|
||||
t.Fatalf("pending waiter err = %v, want %v", result.err, loss)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected pending waiter to be notified")
|
||||
}
|
||||
|
||||
if !conn.closed {
|
||||
t.Fatal("expected connection to be marked closed")
|
||||
}
|
||||
if len(conn.pending) != 0 {
|
||||
t.Fatalf("expected pending map to be reset, got %d entries", len(conn.pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryReadTimeoutDetection(t *testing.T) {
|
||||
if !isTemporaryReadTimeout(fakeNetError{timeout: true}) {
|
||||
t.Fatal("expected timeout error to be treated as temporary")
|
||||
}
|
||||
if isTemporaryReadTimeout(errors.New("eof")) {
|
||||
t.Fatal("did not expect non-timeout error to be treated as temporary")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeNetError struct {
|
||||
timeout bool
|
||||
}
|
||||
|
||||
func (f fakeNetError) Error() string { return "network error" }
|
||||
func (f fakeNetError) Timeout() bool { return f.timeout }
|
||||
func (f fakeNetError) Temporary() bool { return f.timeout }
|
||||
@@ -0,0 +1,153 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSingleMessageBytes = 140
|
||||
maxMultipartPayloadBytes = 134
|
||||
concatUDHLength = 6
|
||||
maxMultipartSegments = 255
|
||||
)
|
||||
|
||||
type submitPart struct {
|
||||
PkTotal uint8
|
||||
PkNumber uint8
|
||||
TpUdhi uint8
|
||||
MsgContent string
|
||||
}
|
||||
|
||||
type longUplinkAssembly struct {
|
||||
msgFmt uint8
|
||||
total uint8
|
||||
parts map[uint8]string
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
func splitSubmitContent(format int, content string) ([]submitPart, error) {
|
||||
encoded, err := encodeContent(format, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(encoded) <= maxSingleMessageBytes {
|
||||
return []submitPart{{
|
||||
PkTotal: 1,
|
||||
PkNumber: 1,
|
||||
TpUdhi: 0,
|
||||
MsgContent: encoded,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
chunks, err := splitEncodedContent(format, content, maxMultipartPayloadBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(chunks) > maxMultipartSegments {
|
||||
return nil, fmt.Errorf("message requires %d segments, maximum is %d", len(chunks), maxMultipartSegments)
|
||||
}
|
||||
|
||||
ref := uint8(time.Now().UnixNano())
|
||||
parts := make([]submitPart, 0, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
total := uint8(len(chunks))
|
||||
number := uint8(i + 1)
|
||||
udh := []byte{0x05, 0x00, 0x03, ref, total, number}
|
||||
content := append(udh, []byte(chunk)...)
|
||||
parts = append(parts, submitPart{
|
||||
PkTotal: total,
|
||||
PkNumber: number,
|
||||
TpUdhi: 1,
|
||||
MsgContent: string(content),
|
||||
})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func splitEncodedContent(format int, content string, limit int) ([]string, error) {
|
||||
var chunks []string
|
||||
var current strings.Builder
|
||||
currentLen := 0
|
||||
for _, r := range content {
|
||||
encoded, err := encodeContent(format, string(r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(encoded) > limit {
|
||||
return nil, fmt.Errorf("single character exceeds segment payload limit")
|
||||
}
|
||||
if currentLen > 0 && currentLen+len(encoded) > limit {
|
||||
chunks = append(chunks, current.String())
|
||||
current.Reset()
|
||||
currentLen = 0
|
||||
}
|
||||
current.WriteString(encoded)
|
||||
currentLen += len(encoded)
|
||||
}
|
||||
if currentLen > 0 {
|
||||
chunks = append(chunks, current.String())
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
chunks = append(chunks, "")
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func parseConcatSegment(content string) (ref uint8, total uint8, number uint8, payload string, ok bool) {
|
||||
raw := []byte(content)
|
||||
if len(raw) < concatUDHLength {
|
||||
return 0, 0, 0, "", false
|
||||
}
|
||||
if raw[0] != 0x05 || raw[1] != 0x00 || raw[2] != 0x03 {
|
||||
return 0, 0, 0, "", false
|
||||
}
|
||||
ref = raw[3]
|
||||
total = raw[4]
|
||||
number = raw[5]
|
||||
if total == 0 || number == 0 || number > total {
|
||||
return 0, 0, 0, "", false
|
||||
}
|
||||
return ref, total, number, string(raw[concatUDHLength:]), true
|
||||
}
|
||||
|
||||
func assembleLongUplink(assemblies map[string]*longUplinkAssembly, key string, msgFmt uint8, total uint8, number uint8, payload string) (string, bool, error) {
|
||||
assembly := assemblies[key]
|
||||
if assembly == nil || assembly.total != total || assembly.msgFmt != msgFmt {
|
||||
assembly = &longUplinkAssembly{
|
||||
msgFmt: msgFmt,
|
||||
total: total,
|
||||
parts: make(map[uint8]string, int(total)),
|
||||
}
|
||||
assemblies[key] = assembly
|
||||
}
|
||||
assembly.parts[number] = payload
|
||||
assembly.updatedAt = time.Now()
|
||||
if len(assembly.parts) < int(total) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
var raw strings.Builder
|
||||
for i := uint8(1); i <= total; i++ {
|
||||
part, ok := assembly.parts[i]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
raw.WriteString(part)
|
||||
}
|
||||
delete(assemblies, key)
|
||||
content, err := decodeContent(msgFmt, raw.String())
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return content, true, nil
|
||||
}
|
||||
|
||||
func pruneLongUplinkAssemblies(assemblies map[string]*longUplinkAssembly, now time.Time, ttl time.Duration) {
|
||||
for key, assembly := range assemblies {
|
||||
if now.Sub(assembly.updatedAt) > ttl {
|
||||
delete(assemblies, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitSubmitContentUCS2LongMessage(t *testing.T) {
|
||||
content := strings.Repeat("测试", 40)
|
||||
|
||||
parts, err := splitSubmitContent(8, content)
|
||||
if err != nil {
|
||||
t.Fatalf("splitSubmitContent returned error: %v", err)
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("expected multipart content, got %d part", len(parts))
|
||||
}
|
||||
total := uint8(len(parts))
|
||||
ref := []byte(parts[0].MsgContent)[3]
|
||||
for i, part := range parts {
|
||||
if part.PkTotal != total {
|
||||
t.Fatalf("part %d PkTotal = %d, want %d", i, part.PkTotal, total)
|
||||
}
|
||||
if part.PkNumber != uint8(i+1) {
|
||||
t.Fatalf("part %d PkNumber = %d, want %d", i, part.PkNumber, i+1)
|
||||
}
|
||||
if part.TpUdhi != 1 {
|
||||
t.Fatalf("part %d TpUdhi = %d, want 1", i, part.TpUdhi)
|
||||
}
|
||||
raw := []byte(part.MsgContent)
|
||||
if len(raw) > maxSingleMessageBytes {
|
||||
t.Fatalf("part %d length = %d, want <= %d", i, len(raw), maxSingleMessageBytes)
|
||||
}
|
||||
if raw[0] != 0x05 || raw[1] != 0x00 || raw[2] != 0x03 {
|
||||
t.Fatalf("part %d missing standard concat UDH: %v", i, raw[:concatUDHLength])
|
||||
}
|
||||
if raw[3] != ref || raw[4] != total || raw[5] != uint8(i+1) {
|
||||
t.Fatalf("part %d UDH = %v, ref=%d total=%d number=%d", i, raw[:concatUDHLength], ref, total, i+1)
|
||||
}
|
||||
if len(raw[concatUDHLength:])%2 != 0 {
|
||||
t.Fatalf("part %d UCS2 payload length must be even, got %d", i, len(raw[concatUDHLength:]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSubmitContentSingleShortMessage(t *testing.T) {
|
||||
parts, err := splitSubmitContent(15, "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("splitSubmitContent returned error: %v", err)
|
||||
}
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("expected one part, got %d", len(parts))
|
||||
}
|
||||
if parts[0].PkTotal != 1 || parts[0].PkNumber != 1 || parts[0].TpUdhi != 0 {
|
||||
t.Fatalf("unexpected single part metadata: %+v", parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleLongUplinkOutOfOrder(t *testing.T) {
|
||||
content := strings.Repeat("上行", 40)
|
||||
parts, err := splitSubmitContent(8, content)
|
||||
if err != nil {
|
||||
t.Fatalf("splitSubmitContent returned error: %v", err)
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("expected multipart content, got %d part", len(parts))
|
||||
}
|
||||
|
||||
assemblies := map[string]*longUplinkAssembly{}
|
||||
key := "channel:phone:dest:ref"
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
_, total, number, payload, ok := parseConcatSegment(parts[i].MsgContent)
|
||||
if !ok {
|
||||
t.Fatalf("part %d did not parse as concat segment", i)
|
||||
}
|
||||
assembled, complete, err := assembleLongUplink(assemblies, key, 8, total, number, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleLongUplink returned error: %v", err)
|
||||
}
|
||||
if i > 0 && complete {
|
||||
t.Fatalf("assembly completed before all parts arrived")
|
||||
}
|
||||
if i == 0 {
|
||||
if !complete {
|
||||
t.Fatalf("assembly did not complete after all parts arrived")
|
||||
}
|
||||
if assembled != content {
|
||||
t.Fatalf("assembled content mismatch: got %q want %q", assembled, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(assemblies) != 0 {
|
||||
t.Fatalf("expected completed assembly to be removed, got %d", len(assemblies))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
|
||||
cmpp "github.com/bigwhite/gocmpp"
|
||||
cmpputils "github.com/bigwhite/gocmpp/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConnectTimeout = 5 * time.Second
|
||||
defaultSubmitTimeout = 10 * time.Second
|
||||
defaultHTTPTimeout = 10 * time.Second
|
||||
defaultWindowSize = 16
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[string]*connectionPool
|
||||
}
|
||||
|
||||
func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
if err := validateSubmitCommand(cmd); err != nil {
|
||||
result := submitResult(cmd, 0, "", "rejected", "INVALID_COMMAND", err.Error())
|
||||
if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil {
|
||||
return result, postErr
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
pool, err := m.connectionFor(cmd)
|
||||
if err != nil {
|
||||
result := submitResult(cmd, 0, "", "rejected", "CONNECT_FAILED", err.Error())
|
||||
if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil {
|
||||
return result, postErr
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
result, err := pool.submit(ctx, cmd)
|
||||
if err != nil {
|
||||
if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil {
|
||||
return result, postErr
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
if err := m.post(ctx, "/gateway/events/submit-result", result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.HTTPClient == nil {
|
||||
m.HTTPClient = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
if m.conns == nil {
|
||||
m.conns = make(map[string]*connectionPool)
|
||||
}
|
||||
|
||||
pool := m.conns[cmd.ChannelID]
|
||||
if pool == nil || !pool.matches(cmd.Upstream) {
|
||||
if pool != nil {
|
||||
pool.close()
|
||||
}
|
||||
pool = &connectionPool{
|
||||
channelID: cmd.ChannelID,
|
||||
config: normalizeUpstreamConfig(cmd.Upstream),
|
||||
apiBaseURL: m.APIBaseURL,
|
||||
httpClient: m.HTTPClient,
|
||||
}
|
||||
m.conns[cmd.ChannelID] = pool
|
||||
}
|
||||
if err := pool.ensureConnected(); err != nil {
|
||||
delete(m.conns, cmd.ChannelID)
|
||||
return nil, err
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func (m *Manager) post(ctx context.Context, path string, payload any) error {
|
||||
client := m.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
return postJSON(ctx, client, m.APIBaseURL, path, payload)
|
||||
}
|
||||
|
||||
type connectionPool struct {
|
||||
channelID string
|
||||
config queue.UpstreamConfig
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
conns []*connection
|
||||
next int
|
||||
}
|
||||
|
||||
func (p *connectionPool) matches(config queue.UpstreamConfig) bool {
|
||||
return p.config == normalizeUpstreamConfig(config)
|
||||
}
|
||||
|
||||
func (p *connectionPool) ensureConnected() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
desired := p.config.DesiredConnections
|
||||
if desired <= 0 {
|
||||
desired = 1
|
||||
}
|
||||
for len(p.conns) < desired {
|
||||
index := len(p.conns)
|
||||
conn := &connection{
|
||||
channelID: p.channelID,
|
||||
config: p.config,
|
||||
index: index,
|
||||
apiBaseURL: p.apiBaseURL,
|
||||
httpClient: p.httpClient,
|
||||
window: make(chan struct{}, p.config.WindowSize),
|
||||
pending: make(map[uint32]chan submitPartResponse),
|
||||
tracker: make(map[uint64]queue.SubmitCommand),
|
||||
longUplink: make(map[string]*longUplinkAssembly),
|
||||
}
|
||||
if err := conn.ensureConnected(); err != nil {
|
||||
conn.close()
|
||||
p.closeLocked()
|
||||
return err
|
||||
}
|
||||
p.conns = append(p.conns, conn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *connectionPool) submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content)
|
||||
if err != nil {
|
||||
result := submitResult(cmd, 0, "", "rejected", "ENCODE_FAILED", err.Error())
|
||||
return result, err
|
||||
}
|
||||
|
||||
var firstSequence uint32
|
||||
var firstGatewayMessageID string
|
||||
segments := make([]queue.SubmitSegmentResult, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
conn, release, err := p.acquireConnection(ctx)
|
||||
if err != nil {
|
||||
result := submitResult(cmd, 0, "", "timeout", "WINDOW_TIMEOUT", err.Error())
|
||||
result.Segments = segments
|
||||
return result, err
|
||||
}
|
||||
seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part)
|
||||
release()
|
||||
segments = append(segments, submitSegmentResult(part, seq, gatewayMessageID, result))
|
||||
if firstSequence == 0 {
|
||||
firstSequence = seq
|
||||
}
|
||||
if firstGatewayMessageID == "" {
|
||||
firstGatewayMessageID = gatewayMessageID
|
||||
}
|
||||
if err != nil {
|
||||
result.Segments = segments
|
||||
return result, err
|
||||
}
|
||||
if result.SubmitStatus != "accepted" {
|
||||
result.Segments = segments
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
result := submitResult(cmd, firstSequence, firstGatewayMessageID, "accepted", "", "")
|
||||
result.Segments = segments
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *connectionPool) acquireConnection(ctx context.Context) (*connection, func(), error) {
|
||||
waitCtx, cancel := context.WithTimeout(ctx, defaultSubmitTimeout)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if conn, release := p.tryAcquireConnection(); conn != nil {
|
||||
if err := conn.ensureConnected(); err != nil {
|
||||
release()
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return nil, nil, waitCtx.Err()
|
||||
case <-ticker.C:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return conn, release, nil
|
||||
}
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return nil, nil, waitCtx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *connectionPool) tryAcquireConnection() (*connection, func()) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.conns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for i := 0; i < len(p.conns); i++ {
|
||||
index := (p.next + i) % len(p.conns)
|
||||
conn := p.conns[index]
|
||||
if conn.tryAcquireWindow() {
|
||||
p.next = (index + 1) % len(p.conns)
|
||||
return conn, conn.releaseWindow
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *connectionPool) close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.closeLocked()
|
||||
}
|
||||
|
||||
func (p *connectionPool) closeLocked() {
|
||||
for _, conn := range p.conns {
|
||||
conn.close()
|
||||
}
|
||||
p.conns = nil
|
||||
}
|
||||
|
||||
type connection struct {
|
||||
channelID string
|
||||
config queue.UpstreamConfig
|
||||
index int
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
sendMu sync.Mutex
|
||||
client *cmpp.Client
|
||||
window chan struct{}
|
||||
pending map[uint32]chan submitPartResponse
|
||||
tracker map[uint64]queue.SubmitCommand
|
||||
longUplink map[string]*longUplinkAssembly
|
||||
readOnce sync.Once
|
||||
closed bool
|
||||
}
|
||||
|
||||
type submitPartResponse struct {
|
||||
rsp *cmpp.Cmpp3SubmitRspPkt
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *connection) matches(config queue.UpstreamConfig) bool {
|
||||
return c.config == normalizeUpstreamConfig(config)
|
||||
}
|
||||
|
||||
func (c *connection) ensureConnected() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client != nil && !c.closed {
|
||||
return nil
|
||||
}
|
||||
client := cmpp.NewClient(protocolVersion(c.config.CMPPVersion))
|
||||
addr := fmt.Sprintf("%s:%d", c.config.GatewayHost, c.config.GatewayPort)
|
||||
if err := client.Connect(addr, c.config.Account, c.config.PasswordCipher, defaultConnectTimeout); err != nil {
|
||||
client.Disconnect()
|
||||
return err
|
||||
}
|
||||
c.client = client
|
||||
c.closed = false
|
||||
go c.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, part submitPart) (uint32, string, queue.SubmitResult, error) {
|
||||
rspCh := make(chan submitPartResponse, 1)
|
||||
pkt := &cmpp.Cmpp3SubmitReqPkt{
|
||||
PkTotal: part.PkTotal,
|
||||
PkNumber: part.PkNumber,
|
||||
TpUdhi: part.TpUdhi,
|
||||
RegisteredDelivery: uint8(cmd.CMPP.RegisteredDelivery),
|
||||
MsgLevel: 1,
|
||||
ServiceId: cmd.CMPP.ServiceID,
|
||||
FeeUserType: uint8(defaultInt(cmd.CMPP.FeeUserType, 2)),
|
||||
FeeTerminalId: cmd.PhoneNumber,
|
||||
MsgFmt: uint8(cmd.CMPP.MsgFmt),
|
||||
MsgSrc: c.config.Account,
|
||||
FeeType: defaultString(cmd.CMPP.FeeType, "02"),
|
||||
FeeCode: defaultString(cmd.CMPP.FeeCode, "0"),
|
||||
SrcId: cmd.CMPP.SrcID,
|
||||
DestUsrTl: 1,
|
||||
DestTerminalId: []string{cmd.PhoneNumber},
|
||||
MsgLength: uint8(len(part.MsgContent)),
|
||||
MsgContent: part.MsgContent,
|
||||
}
|
||||
|
||||
c.sendMu.Lock()
|
||||
seq, err := c.client.SendReqPkt(pkt)
|
||||
c.sendMu.Unlock()
|
||||
if err != nil {
|
||||
c.close()
|
||||
result := submitResult(cmd, 0, "", "timeout", "SEND_FAILED", err.Error())
|
||||
return 0, "", result, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.pending[seq] = rspCh
|
||||
c.mu.Unlock()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, seq)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(ctx, defaultSubmitTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
result := submitResult(cmd, seq, "", "timeout", "SUBMIT_TIMEOUT", waitCtx.Err().Error())
|
||||
return seq, "", result, waitCtx.Err()
|
||||
case rsp := <-rspCh:
|
||||
if rsp.err != nil {
|
||||
result := submitResult(cmd, seq, "", "timeout", "CONNECTION_LOST", rsp.err.Error())
|
||||
return seq, "", result, rsp.err
|
||||
}
|
||||
if rsp.rsp == nil {
|
||||
err := fmt.Errorf("submit response is empty")
|
||||
result := submitResult(cmd, seq, "", "timeout", "EMPTY_SUBMIT_RESPONSE", err.Error())
|
||||
return seq, "", result, err
|
||||
}
|
||||
gatewayMessageID := fmt.Sprint(rsp.rsp.MsgId)
|
||||
status := "accepted"
|
||||
errorCode := ""
|
||||
errorMessage := ""
|
||||
if rsp.rsp.Result != 0 {
|
||||
status = "rejected"
|
||||
errorCode = fmt.Sprint(rsp.rsp.Result)
|
||||
errorMessage = fmt.Sprintf("upstream submit rejected with result %d", rsp.rsp.Result)
|
||||
}
|
||||
if rsp.rsp.Result == 0 {
|
||||
c.mu.Lock()
|
||||
c.tracker[rsp.rsp.MsgId] = cmd
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return seq, gatewayMessageID, submitResult(cmd, seq, gatewayMessageID, status, errorCode, errorMessage), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connection) tryAcquireWindow() bool {
|
||||
if c.window == nil {
|
||||
c.window = make(chan struct{}, defaultWindowSize)
|
||||
}
|
||||
select {
|
||||
case c.window <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connection) releaseWindow() {
|
||||
if c.window == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-c.window:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connection) readLoop() {
|
||||
for {
|
||||
pkt, err := c.client.RecvAndUnpackPkt(time.Second)
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
closed := c.closed
|
||||
c.mu.Unlock()
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
if isTemporaryReadTimeout(err) {
|
||||
continue
|
||||
}
|
||||
c.handleConnectionLoss(err)
|
||||
continue
|
||||
}
|
||||
switch p := pkt.(type) {
|
||||
case *cmpp.Cmpp3SubmitRspPkt:
|
||||
c.mu.Lock()
|
||||
ch := c.pending[p.SeqId]
|
||||
c.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- submitPartResponse{rsp: p}
|
||||
}
|
||||
case *cmpp.Cmpp3DeliverReqPkt:
|
||||
c.handleDeliver(p)
|
||||
case *cmpp.CmppActiveTestReqPkt:
|
||||
_ = c.client.SendRspPkt(&cmpp.CmppActiveTestRspPkt{}, p.SeqId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connection) handleDeliver(pkt *cmpp.Cmpp3DeliverReqPkt) {
|
||||
_ = c.client.SendRspPkt(&cmpp.Cmpp3DeliverRspPkt{MsgId: pkt.MsgId, Result: 0}, pkt.SeqId)
|
||||
|
||||
if pkt.RegisterDelivery == 1 {
|
||||
var receipt cmpp.CmppReceiptPkt
|
||||
if err := receipt.Unpack([]byte(pkt.MsgContent)); err != nil {
|
||||
return
|
||||
}
|
||||
cmd, ok := c.commandFor(receipt.MsgId)
|
||||
if !ok {
|
||||
cmd, ok = c.commandFor(pkt.MsgId)
|
||||
}
|
||||
traceID := fmt.Sprintf("receipt-%d", receipt.MsgId)
|
||||
messageID := fmt.Sprintf("receipt-%d", receipt.MsgId)
|
||||
channelID := c.channelID
|
||||
if ok {
|
||||
traceID = cmd.TraceID
|
||||
messageID = cmd.MessageID
|
||||
channelID = cmd.ChannelID
|
||||
}
|
||||
event := queue.ReceiptEvent{
|
||||
Envelope: queue.Envelope{
|
||||
SchemaVersion: queue.SchemaVersion,
|
||||
MessageType: queue.MessageTypeReceiptEvent,
|
||||
TraceID: traceID,
|
||||
MessageID: messageID,
|
||||
ChannelID: channelID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
SequenceID: pkt.SeqId,
|
||||
GatewayMessageID: fmt.Sprint(receipt.MsgId),
|
||||
PhoneNumber: strings.TrimSpace(receipt.DestTerminalId),
|
||||
ReceiptStatus: receiptStatus(receipt.Stat),
|
||||
RawStatus: strings.TrimSpace(receipt.Stat),
|
||||
DeliveredAt: time.Now().UTC(),
|
||||
}
|
||||
_ = postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/receipt", event)
|
||||
return
|
||||
}
|
||||
|
||||
content, complete, err := c.decodeUplinkContent(pkt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !complete {
|
||||
return
|
||||
}
|
||||
cmd, _ := c.commandFor(pkt.MsgId)
|
||||
event := queue.UplinkEvent{
|
||||
Envelope: queue.Envelope{
|
||||
SchemaVersion: queue.SchemaVersion,
|
||||
MessageType: queue.MessageTypeUplinkEvent,
|
||||
TraceID: cmd.TraceID,
|
||||
MessageID: cmd.MessageID,
|
||||
ChannelID: c.channelID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
SequenceID: pkt.SeqId,
|
||||
PhoneNumber: strings.TrimSpace(pkt.SrcTerminalId),
|
||||
DestID: strings.TrimSpace(pkt.DestId),
|
||||
Content: content,
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
}
|
||||
_ = postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/uplink", event)
|
||||
}
|
||||
|
||||
func (c *connection) decodeUplinkContent(pkt *cmpp.Cmpp3DeliverReqPkt) (string, bool, error) {
|
||||
if pkt.TpUdhi != 1 {
|
||||
content, err := decodeContent(pkt.MsgFmt, pkt.MsgContent)
|
||||
return content, true, err
|
||||
}
|
||||
ref, total, number, payload, ok := parseConcatSegment(pkt.MsgContent)
|
||||
if !ok {
|
||||
content, err := decodeContent(pkt.MsgFmt, pkt.MsgContent)
|
||||
return content, true, err
|
||||
}
|
||||
key := fmt.Sprintf("%s:%s:%s:%d:%d", c.channelID, strings.TrimSpace(pkt.SrcTerminalId), strings.TrimSpace(pkt.DestId), ref, total)
|
||||
c.mu.Lock()
|
||||
if c.longUplink == nil {
|
||||
c.longUplink = make(map[string]*longUplinkAssembly)
|
||||
}
|
||||
pruneLongUplinkAssemblies(c.longUplink, time.Now(), 10*time.Minute)
|
||||
content, complete, err := assembleLongUplink(c.longUplink, key, pkt.MsgFmt, total, number, payload)
|
||||
c.mu.Unlock()
|
||||
return content, complete, err
|
||||
}
|
||||
|
||||
func (c *connection) commandFor(gatewayMsgID uint64) (queue.SubmitCommand, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cmd, ok := c.tracker[gatewayMsgID]
|
||||
return cmd, ok
|
||||
}
|
||||
|
||||
func (c *connection) close() {
|
||||
c.handleConnectionLoss(fmt.Errorf("connection closed"))
|
||||
}
|
||||
|
||||
func (c *connection) handleConnectionLoss(err error) {
|
||||
c.mu.Lock()
|
||||
if c.closed && c.client == nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
pending := c.pending
|
||||
c.pending = make(map[uint32]chan submitPartResponse)
|
||||
if c.client != nil {
|
||||
c.client.Disconnect()
|
||||
c.client = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, ch := range pending {
|
||||
select {
|
||||
case ch <- submitPartResponse{err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func submitResult(cmd queue.SubmitCommand, sequenceID uint32, gatewayMessageID string, status string, code string, message string) queue.SubmitResult {
|
||||
if gatewayMessageID == "" {
|
||||
gatewayMessageID = fmt.Sprintf("GW-%s-%d", cmd.SubmitID, time.Now().UnixNano())
|
||||
}
|
||||
return queue.SubmitResult{
|
||||
Envelope: queue.Envelope{
|
||||
SchemaVersion: queue.SchemaVersion,
|
||||
MessageType: queue.MessageTypeSubmitResult,
|
||||
TraceID: cmd.TraceID,
|
||||
MessageID: cmd.MessageID,
|
||||
ChannelID: cmd.ChannelID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
SequenceID: sequenceID,
|
||||
GatewayMessageID: gatewayMessageID,
|
||||
SubmitStatus: status,
|
||||
ErrorCode: code,
|
||||
ErrorMessage: message,
|
||||
SubmittedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func submitSegmentResult(part submitPart, sequenceID uint32, gatewayMessageID string, result queue.SubmitResult) queue.SubmitSegmentResult {
|
||||
if gatewayMessageID == "" {
|
||||
gatewayMessageID = result.GatewayMessageID
|
||||
}
|
||||
return queue.SubmitSegmentResult{
|
||||
SegmentTotal: int(part.PkTotal),
|
||||
SegmentIndex: int(part.PkNumber),
|
||||
SequenceID: sequenceID,
|
||||
GatewayMessageID: gatewayMessageID,
|
||||
SubmitStatus: result.SubmitStatus,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
SubmittedAt: result.SubmittedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validateSubmitCommand(cmd queue.SubmitCommand) error {
|
||||
if cmd.MessageType != queue.MessageTypeSubmitCommand {
|
||||
return fmt.Errorf("unsupported messageType %q", cmd.MessageType)
|
||||
}
|
||||
if cmd.MessageID == "" || cmd.ChannelID == "" || cmd.SubmitID == "" {
|
||||
return fmt.Errorf("messageId, channelId and submitId are required")
|
||||
}
|
||||
if cmd.Upstream.GatewayHost == "" || cmd.Upstream.GatewayPort <= 0 {
|
||||
return fmt.Errorf("upstream gatewayHost and gatewayPort are required")
|
||||
}
|
||||
if cmd.Upstream.Account == "" || cmd.Upstream.PasswordCipher == "" {
|
||||
return fmt.Errorf("upstream account and passwordCipher are required")
|
||||
}
|
||||
if len(cmd.PhoneNumber) == 0 || len(cmd.Content) == 0 {
|
||||
return fmt.Errorf("phoneNumber and content are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig {
|
||||
if config.DesiredConnections <= 0 {
|
||||
config.DesiredConnections = 1
|
||||
}
|
||||
if config.WindowSize <= 0 {
|
||||
config.WindowSize = defaultWindowSize
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func isTemporaryReadTimeout(err error) bool {
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && netErr.Timeout()
|
||||
}
|
||||
|
||||
func postJSON(ctx context.Context, client *http.Client, apiBaseURL string, path string, payload any) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := strings.TrimRight(apiBaseURL, "/")
|
||||
if base == "" {
|
||||
base = "http://127.0.0.1:3000/api"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.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 encodeContent(format int, content string) (string, error) {
|
||||
switch format {
|
||||
case 8:
|
||||
return cmpputils.Utf8ToUcs2(content)
|
||||
case 15:
|
||||
return cmpputils.Utf8ToGB18030(content)
|
||||
default:
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
func decodeContent(format uint8, content string) (string, error) {
|
||||
switch format {
|
||||
case 8:
|
||||
return cmpputils.Ucs2ToUtf8(content)
|
||||
case 15:
|
||||
return cmpputils.GB18030ToUtf8(content)
|
||||
default:
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
func protocolVersion(version string) cmpp.Type {
|
||||
if strings.HasPrefix(version, "2") {
|
||||
return cmpp.V20
|
||||
}
|
||||
return cmpp.V30
|
||||
}
|
||||
|
||||
func receiptStatus(stat string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(stat)) {
|
||||
case "DELIVRD":
|
||||
return "delivered"
|
||||
case "":
|
||||
return "unknown"
|
||||
default:
|
||||
return "undelivered"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultInt(value int, fallback int) int {
|
||||
if value == 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
)
|
||||
|
||||
func TestConnectionPoolAcquiresAcrossConnections(t *testing.T) {
|
||||
pool := &connectionPool{
|
||||
conns: []*connection{
|
||||
{window: make(chan struct{}, 1)},
|
||||
{window: make(chan struct{}, 1)},
|
||||
},
|
||||
}
|
||||
|
||||
first, releaseFirst := pool.tryAcquireConnection()
|
||||
if first == nil {
|
||||
t.Fatalf("expected first connection")
|
||||
}
|
||||
second, releaseSecond := pool.tryAcquireConnection()
|
||||
if second == nil {
|
||||
t.Fatalf("expected second connection")
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("expected pool to use another connection when the first window is full")
|
||||
}
|
||||
third, _ := pool.tryAcquireConnection()
|
||||
if third != nil {
|
||||
t.Fatalf("expected nil connection while all windows are full")
|
||||
}
|
||||
|
||||
releaseFirst()
|
||||
reacquired, releaseReacquired := pool.tryAcquireConnection()
|
||||
if reacquired == nil {
|
||||
t.Fatalf("expected a connection after releasing a window")
|
||||
}
|
||||
releaseReacquired()
|
||||
releaseSecond()
|
||||
}
|
||||
|
||||
func TestNormalizeUpstreamConfigDefaults(t *testing.T) {
|
||||
config := normalizeUpstreamConfig(queueUpstreamConfigForTest())
|
||||
if config.DesiredConnections != 1 {
|
||||
t.Fatalf("DesiredConnections = %d, want 1", config.DesiredConnections)
|
||||
}
|
||||
if config.WindowSize != defaultWindowSize {
|
||||
t.Fatalf("WindowSize = %d, want %d", config.WindowSize, defaultWindowSize)
|
||||
}
|
||||
}
|
||||
|
||||
func queueUpstreamConfigForTest() queue.UpstreamConfig {
|
||||
return queue.UpstreamConfig{
|
||||
GatewayHost: "127.0.0.1",
|
||||
GatewayPort: 17890,
|
||||
Account: "account",
|
||||
PasswordCipher: "secret",
|
||||
CMPPVersion: "3.0",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user