feat: complete cmpp gateway delivery recovery workflows
This commit is contained in:
@@ -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