fix: 修复 CMPP 协议字段容量与版本兼容性
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-20 15:49:37 +08:00
parent b24cd7c08d
commit 001d5f2cbd
37 changed files with 1933 additions and 295 deletions
@@ -80,6 +80,10 @@ func registerDownstreamAck(session *downstreamSession, deliveryID string, claimI
}
key := downstreamAckKey(session.conn, sequenceID)
downstreamAckRegistry.Lock()
if downstreamAckRegistry.items[key] != nil {
downstreamAckRegistry.Unlock()
return nil
}
downstreamAckRegistry.items[key] = tracker
tracker.timer = time.AfterFunc(time.Until(deadline), func() {
timedOut := takeDownstreamAck(session.conn, sequenceID)
+29 -19
View File
@@ -15,19 +15,19 @@ import (
// arbitrary account session would acknowledge a message with the wrong Msg_Id.
type DownstreamReceipt struct {
DeliveryID string `json:"deliveryId,omitempty"`
ClaimID string `json:"claimId,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"`
SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"`
SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"`
DeliveredAt string `json:"deliveredAt,omitempty"`
DeliveryID string `json:"deliveryId,omitempty"`
ClaimID string `json:"claimId,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"`
SubmitSequenceID *uint32 `json:"submitSequenceId,omitempty"`
SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"`
DeliveredAt string `json:"deliveredAt,omitempty"`
}
type DownstreamUplink struct {
@@ -108,7 +108,7 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
session = recoverReceiptSession(event)
}
if session == nil {
if event.SubmitSequenceID == 0 {
if event.SubmitSequenceID == nil {
return DownstreamSendResult{
Retryable: false,
ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID",
@@ -158,7 +158,11 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber),
SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff),
}
receiptBytes, err := receipt.Pack()
version := cmpp.V30
if session.protocol == "cmpp20" || session.protocol == "cmpp21" {
version = cmpp.V20
}
receiptBytes, err := receipt.PackVersion(version)
if err != nil {
return DownstreamSendResult{}, err
}
@@ -167,8 +171,8 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
}
func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 {
if event.SubmitSequenceID != 0 {
return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID)
if event.SubmitSequenceID != nil {
return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), *event.SubmitSequenceID)
}
if session == nil {
return 0
@@ -189,7 +193,7 @@ func findReceiptSession(messageID string, account string) *downstreamSession {
}
func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
if event.MessageID == "" || event.SubmitSequenceID == 0 || event.Account == "" {
if event.MessageID == "" || event.SubmitSequenceID == nil || event.Account == "" {
return nil
}
downstreamRegistry.RLock()
@@ -200,7 +204,7 @@ func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
}
recovered := *accountSession
recovered.messageID = event.MessageID
recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID)
recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), *event.SubmitSequenceID)
return &recovered
}
@@ -293,6 +297,12 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID
AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt),
}
tracker := registerDownstreamAck(session, deliveryID, claimID, sequenceID, messageID, ackDeadlineAt)
// A wrapped sequence cannot overwrite another unacknowledged delivery.
if deliveryID != "" && tracker == nil {
result.Retryable = true
result.ReasonCode = "SEQUENCE_IN_USE"
return result, nil
}
if err := session.conn.SendPkt(deliver, sequenceID); err != nil {
removeDownstreamAck(tracker)
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err)
@@ -0,0 +1,146 @@
package inbound
import (
"encoding/json"
cmpp "github.com/bigwhite/gocmpp"
"net"
"sync"
"testing"
"time"
)
func TestZeroSequenceReceiptRecoversAndAcknowledgesOverTCP(t *testing.T) {
for _, version := range []cmpp.Type{cmpp.V20, cmpp.V30} {
t.Run(version.String(), func(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
tcp, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
remote, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
server, client := cmpp.NewConn(remote, version), cmpp.NewConn(tcp, version)
server.SetState(cmpp.CONN_AUTHOK)
client.SetState(cmpp.CONN_AUTHOK)
defer server.Close()
defer client.Close()
acknowledged := make(chan downstreamDeliveryLifecycleEvent, 2)
session := &downstreamSession{account: "qa", conn: server, protocol: version.String(), connectionID: "reconnected", mu: &sync.Mutex{}, deliveryReport: func(e downstreamDeliveryLifecycleEvent) {
if e.Kind == "acknowledged" {
acknowledged <- e
}
}}
// Simulate a fresh connection with no in-memory original Submit mapping.
downstreamRegistry.Lock()
downstreamRegistry.byAccount["qa"] = session
downstreamRegistry.Unlock()
var event DownstreamReceipt
if err = json.Unmarshal([]byte(`{"deliveryId":"qa-zero","account":"qa","messageId":"original","submitSequenceId":0,"receiptStatus":"delivered","phoneNumber":"13800138000"}`), &event); err != nil {
t.Fatal(err)
}
result, err := PushReceiptWithResult(event)
if err != nil || !result.Sent {
t.Fatalf("zero cannot recover: %+v %v", result, err)
}
p, err := client.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
var content string
var seq uint32
var msgID uint64
switch pkt := p.(type) {
case *cmpp.Cmpp2DeliverReqPkt:
content, seq, msgID = pkt.MsgContent, pkt.SeqId, pkt.MsgId
case *cmpp.Cmpp3DeliverReqPkt:
content, seq, msgID = pkt.MsgContent, pkt.SeqId, pkt.MsgId
default:
t.Fatalf("bad packet %T", p)
}
var receipt cmpp.CmppReceiptPkt
if err = receipt.UnpackVersion([]byte(content), version); err != nil {
t.Fatal(err)
}
if receipt.MsgId != messageIDFrom("original", 0) || msgID != receipt.MsgId {
t.Fatalf("wrong original Msg_Id: %d", receipt.MsgId)
}
var response cmpp.Packer = &cmpp.Cmpp3DeliverRspPkt{MsgId: msgID, Result: 0}
if version == cmpp.V20 {
response = &cmpp.Cmpp2DeliverRspPkt{MsgId: msgID, Result: 0}
}
if err = client.SendPkt(response, seq); err != nil {
t.Fatal(err)
}
p, err = server.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
switch pkt := p.(type) {
case *cmpp.Cmpp2DeliverRspPkt:
handleDownstreamAcknowledgement(server, pkt.SeqId, pkt.MsgId, uint32(pkt.Result), nil)
case *cmpp.Cmpp3DeliverRspPkt:
handleDownstreamAcknowledgement(server, pkt.SeqId, pkt.MsgId, pkt.Result, nil)
}
select {
case ack := <-acknowledged:
if ack.Result != 0 || ack.MessageID != receipt.MsgId {
t.Fatal("wrong ACK")
}
case <-time.After(time.Second):
t.Fatal("missing ACK")
}
raw, err := json.Marshal(submitRequest{SequenceID: 0})
if err != nil {
t.Fatal(err)
}
var fields map[string]any
json.Unmarshal(raw, &fields)
if value, ok := fields["sequenceId"]; !ok || value != float64(0) {
t.Fatal("zero omitted from Submit callback")
}
})
}
}
func TestDownstreamAckCollisionDoesNotReplacePendingDelivery(t *testing.T) {
conn := &cmpp.Conn{}
session := &downstreamSession{conn: conn}
for _, sequence := range []uint32{^uint32(0), 0} {
first := registerDownstreamAck(session, "first", "a", sequence, 1, time.Now().Add(time.Minute))
if first == nil {
t.Fatal("first registration failed")
}
if registerDownstreamAck(session, "second", "b", sequence, 2, time.Now().Add(time.Minute)) != nil {
t.Fatal("overwrote pending delivery")
}
if takeDownstreamAck(conn, sequence) != first {
t.Fatal("lost original delivery")
}
}
}
func TestAbsentAndZeroSubmitSequenceRemainDistinct(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
for _, payload := range []string{`{}`, `{"submitSequenceId":null}`, `{"submitSequenceId":0}`} {
event := DownstreamReceipt{Account: "qa", MessageID: "original"}
if err := json.Unmarshal([]byte(payload), &event); err != nil {
t.Fatal(err)
}
result, err := PushReceiptWithResult(event)
if err != nil {
t.Fatal(err)
}
if result.Retryable != (event.SubmitSequenceID != nil) {
t.Fatalf("missing confused with zero: %s %+v", payload, result)
}
}
}
+10 -8
View File
@@ -212,7 +212,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
t.Fatalf("expected receipt deliver, got %+v", deliver)
}
var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(deliver.MsgContent)); err != nil {
if err := receipt.UnpackVersion([]byte(deliver.MsgContent), cmpp.V30); err != nil {
t.Fatalf("unpack pushed receipt: %v", err)
}
if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId {
@@ -535,7 +535,7 @@ func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) {
}
payload, _ := json.Marshal(DownstreamReceipt{
Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696",
ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: submit.SequenceID,
ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: sequencePointer(submit.SequenceID),
})
pendingReturned = true
_ = json.NewEncoder(w).Encode([]pendingDelivery{{
@@ -667,7 +667,7 @@ func TestRecoverableReceiptWaitsForClientConnection(t *testing.T) {
DeliveryID: "delivery-retry",
Account: "100001",
MessageID: "MSG-RETRY",
SubmitSequenceID: 77,
SubmitSequenceID: sequencePointer(77),
ReceiptStatus: "delivered",
})
if err != nil {
@@ -1161,7 +1161,7 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
}
recovered := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: 1216579149,
MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: sequencePointer(1216579149),
})
if recovered == nil {
t.Fatal("expected persisted submit sequence to recover receipt session")
@@ -1170,10 +1170,10 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID)
}
first := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77,
MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: sequencePointer(77),
})
second := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77,
MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: sequencePointer(77),
})
if first == nil || second == nil || first.gatewayMsgID != second.gatewayMsgID || first.gatewayMsgID != messageIDFrom("MSG-GROUP", 77) {
t.Fatalf("multi-destination recovery did not preserve the original Msg_Id: first=%+v second=%+v", first, second)
@@ -1183,10 +1183,10 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) {
session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)}
first := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 101,
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(101),
}, session)
second := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 102,
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(102),
}, session)
if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) {
t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second)
@@ -1324,3 +1324,5 @@ func recvSubmitRsp20(t *testing.T, client *cmpp.Client) *cmpp.Cmpp2SubmitRspPkt
t.Fatal("timed out waiting CMPP2 submit response")
return nil
}
func sequencePointer(n uint32) *uint32 { return &n }
+1 -1
View File
@@ -27,7 +27,7 @@ type submitRequest struct {
Content string `json:"content"`
SrcID string `json:"srcId,omitempty"`
DestID string `json:"destId,omitempty"`
SequenceID uint32 `json:"sequenceId,omitempty"`
SequenceID uint32 `json:"sequenceId"`
RegisteredDelivery uint8 `json:"registeredDelivery"`
RemoteIP string `json:"remoteIp,omitempty"`
LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"`
+7
View File
@@ -16,6 +16,13 @@ import (
// The reader and heartbeat goroutines share one connection lifecycle. Closing
// the connection must wake pending submitters before scheduling pool recovery.
// Caller holds c.mu across sequence selection, wire write and pending registration.
func (c *connection) sequenceAvailable(sequence uint32) bool {
_, submit := c.pending[sequence]
_, heartbeat := c.heartbeatPending[sequence]
return !submit && !heartbeat
}
type connection struct {
channelID string
config queue.UpstreamConfig
+4 -3
View File
@@ -15,6 +15,7 @@ import (
// mobile-originated content uses a separate long-message assembly path.
type deliverPacket struct {
version cmpp.Type
seqID uint32
msgID uint64
destID string
@@ -26,7 +27,7 @@ type deliverPacket struct {
}
func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
return deliverPacket{
return deliverPacket{version: cmpp.V20,
seqID: pkt.SeqId,
msgID: pkt.MsgId,
destID: pkt.DestId,
@@ -39,7 +40,7 @@ func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
}
func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
return deliverPacket{
return deliverPacket{version: cmpp.V30,
seqID: pkt.SeqId,
msgID: pkt.MsgId,
destID: pkt.DestId,
@@ -54,7 +55,7 @@ func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
func (c *connection) handleDeliver(pkt deliverPacket) error {
if pkt.registerDelivery == 1 {
var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(pkt.msgContent)); err != nil {
if err := receipt.UnpackVersion([]byte(pkt.msgContent), pkt.version); err != nil {
log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=parse_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err)
return err
}
+1 -1
View File
@@ -166,7 +166,7 @@ func (c *connection) sendHeartbeat() bool {
return false
}
c.sendMu.Lock()
seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{})
seq, err := c.client.SendReqPktAvailable(&cmpp.CmppActiveTestReqPkt{}, c.sequenceAvailable)
c.sendMu.Unlock()
if err != nil {
c.mu.Unlock()
@@ -0,0 +1,50 @@
package upstream
import (
"cmpp-platform/gateway/internal/queue"
"encoding/json"
cmpp "github.com/bigwhite/gocmpp"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCMPP3ReceiptKeepsUnsignedFieldsAndFullDestination(t *testing.T) {
events := make(chan queue.ReceiptEvent, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var e queue.ReceiptEvent
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
t.Error(err)
}
events <- e
w.WriteHeader(200)
}))
defer api.Close()
receipt := cmpp.CmppReceiptPkt{MsgId: ^uint64(0), Stat: "DELIVRD", DestTerminalId: strings.Repeat("9", 32), SmscSequence: ^uint32(0)}
raw, err := receipt.PackVersion(cmpp.V30)
if err != nil {
t.Fatal(err)
}
conn := &connection{channelID: "qa", apiBaseURL: api.URL, httpClient: api.Client()}
packet := deliverPacketFromCMPP3(&cmpp.Cmpp3DeliverReqPkt{SeqId: ^uint32(0), RegisterDelivery: 1, MsgContent: string(raw)})
if err = conn.handleDeliver(packet); err != nil {
t.Fatal(err)
}
event := <-events
if event.GatewayMessageID != "18446744073709551615" || event.SequenceID != ^uint32(0) || event.PhoneNumber != receipt.DestTerminalId {
t.Fatalf("truncated callback: %+v", event)
}
packet.msgContent = string(raw[:60])
if conn.handleDeliver(packet) == nil {
t.Fatal("legacy 60-byte body silently accepted on CMPP3")
}
}
func TestUpstreamSequenceAvailabilityIncludesHeartbeatAndSubmit(t *testing.T) {
conn := &connection{pending: map[uint32]chan submitPartResponse{0: make(chan submitPartResponse)}, heartbeatPending: map[uint32]time.Time{^uint32(0): time.Now()}}
if conn.sequenceAvailable(0) || conn.sequenceAvailable(^uint32(0)) || !conn.sequenceAvailable(1) {
t.Fatal("wrapped sequence overwrites outstanding request")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) {
channelID := c.channelID
if pkt.registerDelivery == 1 {
var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(pkt.msgContent)); err == nil {
if err := receipt.UnpackVersion([]byte(pkt.msgContent), pkt.version); err == nil {
gatewayMessageID = fmt.Sprint(receipt.MsgId)
phone = strings.TrimSpace(receipt.DestTerminalId)
if cmd, ok := c.commandFor(receipt.MsgId); ok {
+2 -2
View File
@@ -101,7 +101,7 @@ func (p *connectionPool) submit(
return result, publishErr
}
}
if firstSequence == 0 {
if len(segments) == 1 {
firstSequence = seq
}
if firstGatewayMessageID == "" {
@@ -146,7 +146,7 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
}
c.sendMu.Lock()
wireSource = "write_uncertain"
seq, err := client.SendReqPkt(pkt)
seq, err := client.SendReqPktAvailable(pkt, c.sequenceAvailable)
if err == nil {
at := time.Now().UTC()
wireAt = &at