feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
cmpp "github.com/bigwhite/gocmpp"
|
||||
cmpputils "github.com/bigwhite/gocmpp/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Receipt delivery requires the original Submit mapping. Falling back to an
|
||||
// arbitrary account session would acknowledge a message with the wrong Msg_Id.
|
||||
|
||||
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"`
|
||||
SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"`
|
||||
SubmitGroupMessageID string `json:"submitGroupMessageId,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 DownstreamSendResult struct {
|
||||
Sent bool `json:"sent"`
|
||||
Retryable bool `json:"retryable"`
|
||||
ReasonCode string `json:"reasonCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
ConnectionID string `json:"connectionId,omitempty"`
|
||||
SequenceID string `json:"sequenceId,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
SentAt string `json:"sentAt,omitempty"`
|
||||
AckDeadlineAt string `json:"ackDeadlineAt,omitempty"`
|
||||
}
|
||||
|
||||
type downstreamDeliveryLifecycleEvent struct {
|
||||
Kind string
|
||||
DeliveryID string
|
||||
ConnectionID string
|
||||
SequenceID uint32
|
||||
MessageID uint64
|
||||
Result uint32
|
||||
ObservedAt time.Time
|
||||
AckDeadlineAt time.Time
|
||||
FailureType string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
func (s Server) reportDownstreamDelivery(event downstreamDeliveryLifecycleEvent) {
|
||||
if strings.TrimSpace(event.DeliveryID) == "" {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"id": event.DeliveryID, "connectionId": event.ConnectionID,
|
||||
"sequenceId": strconv.FormatUint(uint64(event.SequenceID), 10),
|
||||
"messageId": strconv.FormatUint(event.MessageID, 10),
|
||||
}
|
||||
switch event.Kind {
|
||||
case "sent":
|
||||
payload["sentAt"] = formatRFC3339Nano(event.ObservedAt)
|
||||
payload["ackDeadlineAt"] = formatRFC3339Nano(event.AckDeadlineAt)
|
||||
_ = s.post(context.Background(), "/gateway/events/downstream/sent", payload, nil)
|
||||
case "acknowledged":
|
||||
payload["result"] = event.Result
|
||||
payload["acknowledgedAt"] = formatRFC3339Nano(event.ObservedAt)
|
||||
_ = s.post(context.Background(), "/gateway/events/downstream/acknowledged", payload, nil)
|
||||
case "failed":
|
||||
payload["failureType"] = event.FailureType
|
||||
payload["errorMessage"] = event.ErrorMessage
|
||||
_ = s.post(context.Background(), "/gateway/events/downstream/failed", payload, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func PushReceipt(event DownstreamReceipt) (bool, error) {
|
||||
result, err := PushReceiptWithResult(event)
|
||||
return result.Sent, err
|
||||
}
|
||||
|
||||
func PushReceiptWithResult(event DownstreamReceipt) (DownstreamSendResult, error) {
|
||||
return pushReceiptWithResult(event, true)
|
||||
}
|
||||
|
||||
func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (DownstreamSendResult, error) {
|
||||
session := findReceiptSession(event.MessageID, event.Account)
|
||||
if session == nil && allowRecovery {
|
||||
session = recoverReceiptSession(event)
|
||||
}
|
||||
if session == nil {
|
||||
if event.SubmitSequenceID == 0 {
|
||||
return DownstreamSendResult{
|
||||
Retryable: false,
|
||||
ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID",
|
||||
ErrorMessage: "历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id,系统已终止重投",
|
||||
}, nil
|
||||
}
|
||||
if strings.TrimSpace(event.MessageID) == "" || strings.TrimSpace(event.Account) == "" {
|
||||
return DownstreamSendResult{
|
||||
Retryable: false,
|
||||
ReasonCode: "INVALID_RECEIPT_PAYLOAD",
|
||||
ErrorMessage: "状态回执缺少平台消息 ID 或客户账号,系统已终止重投",
|
||||
}, nil
|
||||
}
|
||||
return DownstreamSendResult{
|
||||
Retryable: true,
|
||||
ReasonCode: "CLIENT_DISCONNECTED",
|
||||
ErrorMessage: "下游客户端当前未连接,等待自动重试",
|
||||
}, nil
|
||||
}
|
||||
if downstreamSubmitResponsePending(session.conn) {
|
||||
return DownstreamSendResult{
|
||||
Retryable: true,
|
||||
ReasonCode: "SUBMIT_RESPONSE_PENDING",
|
||||
ErrorMessage: "客户 SubmitResp 尚未完成写出,回执已保留并等待响应后投递",
|
||||
}, 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 DownstreamSendResult{}, err
|
||||
}
|
||||
deliver := downstreamDeliverPacket(session, session.gatewayMsgID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes))
|
||||
return sendDownstream(session, deliver, event.DeliveryID)
|
||||
}
|
||||
|
||||
func findReceiptSession(messageID string, account string) *downstreamSession {
|
||||
downstreamRegistry.RLock()
|
||||
defer downstreamRegistry.RUnlock()
|
||||
if messageID != "" {
|
||||
return downstreamRegistry.byMessageID[messageID]
|
||||
}
|
||||
if account != "" {
|
||||
return downstreamRegistry.byAccount[account]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
|
||||
if event.MessageID == "" || event.SubmitSequenceID == 0 || event.Account == "" {
|
||||
return nil
|
||||
}
|
||||
downstreamRegistry.RLock()
|
||||
accountSession := downstreamRegistry.byAccount[event.Account]
|
||||
downstreamRegistry.RUnlock()
|
||||
if accountSession == nil || accountSession.conn == nil {
|
||||
return nil
|
||||
}
|
||||
recovered := *accountSession
|
||||
recovered.messageID = event.MessageID
|
||||
recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID)
|
||||
return &recovered
|
||||
}
|
||||
|
||||
func PushUplink(event DownstreamUplink) (bool, error) {
|
||||
result, err := PushUplinkWithResult(event)
|
||||
return result.Sent, err
|
||||
}
|
||||
|
||||
func PushUplinkWithResult(event DownstreamUplink) (DownstreamSendResult, error) {
|
||||
session := findSession(event.MessageID, event.Account)
|
||||
if session == nil {
|
||||
return DownstreamSendResult{
|
||||
Retryable: true,
|
||||
ReasonCode: "CLIENT_DISCONNECTED",
|
||||
ErrorMessage: "下游客户端当前未连接,等待自动重试",
|
||||
}, nil
|
||||
}
|
||||
content, err := cmpputils.Utf8ToUcs2(event.Content)
|
||||
if err != nil {
|
||||
return DownstreamSendResult{}, err
|
||||
}
|
||||
deliver := downstreamDeliverPacket(
|
||||
session,
|
||||
messageIDFrom(defaultString(event.MessageID, event.Account), uint32(time.Now().UnixNano())),
|
||||
defaultString(event.DestID, session.srcID),
|
||||
event.PhoneNumber,
|
||||
8,
|
||||
0,
|
||||
content,
|
||||
)
|
||||
return sendDownstream(session, deliver, event.DeliveryID)
|
||||
}
|
||||
|
||||
func errorMessageWithCode(message string, code string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
code = strings.TrimSpace(code)
|
||||
if message == "" {
|
||||
message = "gateway did not complete downstream delivery"
|
||||
}
|
||||
if code == "" {
|
||||
return message
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", message, code)
|
||||
}
|
||||
|
||||
func downstreamDeliverPacket(session *downstreamSession, messageID uint64, destID string, sourceTerminalID string, msgFmt uint8, registerDelivery uint8, content string) cmpp.Packer {
|
||||
if session != nil && (session.protocol == "cmpp20" || session.protocol == "cmpp21") {
|
||||
return &cmpp.Cmpp2DeliverReqPkt{
|
||||
MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt,
|
||||
SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery,
|
||||
MsgLength: uint8(len(content)), MsgContent: content,
|
||||
}
|
||||
}
|
||||
return &cmpp.Cmpp3DeliverReqPkt{
|
||||
MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt,
|
||||
SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery,
|
||||
MsgLength: uint8(len(content)), MsgContent: content,
|
||||
}
|
||||
}
|
||||
|
||||
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.Packer, deliveryID string) (DownstreamSendResult, error) {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
messageID := downstreamDeliverMessageID(deliver)
|
||||
if messageID == 0 {
|
||||
return DownstreamSendResult{}, errors.New("refusing downstream CMPP_DELIVER with Msg_Id=0")
|
||||
}
|
||||
sequenceID := <-session.conn.SeqId
|
||||
sentAt := time.Now().UTC()
|
||||
ackDeadlineAt := sentAt.Add(downstreamAckTimeout())
|
||||
result := DownstreamSendResult{
|
||||
ConnectionID: session.connectionID,
|
||||
SequenceID: strconv.FormatUint(uint64(sequenceID), 10),
|
||||
MessageID: strconv.FormatUint(messageID, 10),
|
||||
SentAt: formatRFC3339Nano(sentAt),
|
||||
AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt),
|
||||
}
|
||||
tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt)
|
||||
if err := session.conn.SendPkt(deliver, sequenceID); err != nil {
|
||||
removeDownstreamAck(tracker)
|
||||
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err)
|
||||
if session.report != nil {
|
||||
go session.report(session, "disconnected", err.Error())
|
||||
}
|
||||
forgetDownstream(session)
|
||||
result.Retryable = true
|
||||
result.ReasonCode = "SEND_FAILED"
|
||||
result.ErrorMessage = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "success", "", nil)
|
||||
result.Sent = true
|
||||
if deliveryID != "" && session.deliveryReport != nil {
|
||||
go session.deliveryReport(downstreamDeliveryLifecycleEvent{
|
||||
Kind: "sent", DeliveryID: deliveryID, ConnectionID: session.connectionID,
|
||||
SequenceID: sequenceID, MessageID: messageID, ObservedAt: sentAt, AckDeadlineAt: ackDeadlineAt,
|
||||
})
|
||||
}
|
||||
session.touchPresence("connected", false, true)
|
||||
if session.report != nil {
|
||||
go session.report(session, "deliver", "")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func downstreamDeliverMetadata(deliver cmpp.Packer) (string, string) {
|
||||
switch packet := deliver.(type) {
|
||||
case *cmpp.Cmpp2DeliverReqPkt:
|
||||
if packet.RegisterDelivery == 1 {
|
||||
return "deliver_receipt", packet.SrcTerminalId
|
||||
}
|
||||
return "deliver_uplink", packet.SrcTerminalId
|
||||
case *cmpp.Cmpp3DeliverReqPkt:
|
||||
if packet.RegisterDelivery == 1 {
|
||||
return "deliver_receipt", packet.SrcTerminalId
|
||||
}
|
||||
return "deliver_uplink", packet.SrcTerminalId
|
||||
default:
|
||||
return "deliver", ""
|
||||
}
|
||||
}
|
||||
|
||||
func downstreamDeliverMessageID(deliver cmpp.Packer) uint64 {
|
||||
switch packet := deliver.(type) {
|
||||
case *cmpp.Cmpp2DeliverReqPkt:
|
||||
return packet.MsgId
|
||||
case *cmpp.Cmpp3DeliverReqPkt:
|
||||
return packet.MsgId
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func cmppReceiptStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "delivered":
|
||||
return "DELIVRD"
|
||||
case "unknown":
|
||||
return "UNKNOWN"
|
||||
default:
|
||||
return "UNDELIV"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user