fix: correlate downstream receipts with submit responses

This commit is contained in:
hectorzhao
2026-07-14 16:22:41 +08:00
parent 135b4fd24e
commit 1ce02ef206
12 changed files with 487 additions and 91 deletions
@@ -0,0 +1,11 @@
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "cmppSubmitSequenceId" TEXT;
UPDATE "CmppDownstreamDelivery"
SET
"status" = 'unconfirmed',
"deliveredAt" = NULL,
"lastError" = 'CMPP_DELIVER_RESP Msg_Id=0,仅确认协议收包,未确认业务回执关联'
WHERE "deliveryType" = 'receipt'
AND "status" = 'delivered'
AND COALESCE("ackMessageId", '0') = '0';
+1
View File
@@ -983,6 +983,7 @@ model SmsMessageRecord {
channelId String?
submitId String?
gatewayMessageId String?
cmppSubmitSequenceId String?
status String @default("queued")
submitStatus String?
receiptStatus String?
+38 -1
View File
@@ -551,15 +551,20 @@ describe('SendChainService', () => {
account: '100001',
phoneNumber: '13800000001',
content: 'unreported content',
sequenceId: 1216579149,
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ cmppSubmitSequenceId: '1216579149' }),
});
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }),
});
expect(service['postGatewayControl']).toHaveBeenCalledWith(
'/downstream/receipt',
expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }),
expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE', submitSequenceId: 1216579149 }),
);
expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled();
});
@@ -1484,6 +1489,29 @@ describe('SendChainService', () => {
}));
});
it('does not treat Result=0 with Msg_Id=0 as a business acknowledgement', async () => {
const { service, prisma } = createService();
await service.acknowledgeDownstreamDelivery({
id: 'delivery-1',
connectionId: 'conn-1',
sequenceId: '91',
messageId: '0',
result: 0,
acknowledgedAt: '2026-07-14T07:07:49.336Z',
});
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }),
}));
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'pending', lastError: expect.stringContaining('Msg_Id=0') }),
}));
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'delivered' }),
}));
});
it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => {
const { service, prisma } = createService();
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
@@ -1564,6 +1592,15 @@ describe('SendChainService', () => {
resourceId: 'delivery-1',
}),
});
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
acknowledgedAt: null,
ackResult: null,
ackMessageId: null,
deliveredAt: null,
}),
}));
});
it('supports batch requeue of downstream deliveries', async () => {
+24 -4
View File
@@ -119,7 +119,7 @@ export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentD
acknowledgedAt?: string;
}
export type GatewayDownstreamFailureType = 'send_failed' | 'ack_timeout' | 'ack_rejected' | 'connection_lost';
export type GatewayDownstreamFailureType = 'send_failed' | 'ack_timeout' | 'ack_rejected' | 'ack_invalid' | 'connection_lost';
type GatewayControlDeliveryResult = {
sent?: boolean;
@@ -987,7 +987,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
if (data.result === 0) {
const acknowledgedMessageId = String(data.messageId ?? '').trim();
if (data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0') {
await this.prisma.cmppDownstreamDelivery.updateMany({
where: { id: data.id, status: { not: 'delivered' } },
data: {
@@ -1015,6 +1016,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
connectionId: data.connectionId,
},
});
if (data.result === 0) {
return this.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid');
}
return this.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
}
@@ -1027,7 +1031,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return delivery;
}
const retryCount = (delivery.retryCount ?? 0) + 1;
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'connection_lost';
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost';
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
const finalFailure = !retryAllowed || retryCount >= downstreamMaxRetries();
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
@@ -1265,7 +1269,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
};
await this.prisma.cmppDownstreamDelivery.update({
where: { id: delivery.id },
data: { status: 'pending', retryCount: 0, nextRetryAt: null, ackDeadlineAt: null, lastError: null },
data: {
status: 'pending',
retryCount: 0,
nextRetryAt: null,
sentAt: null,
acknowledgedAt: null,
ackDeadlineAt: null,
ackResult: null,
ackSequenceId: null,
ackMessageId: null,
connectionId: null,
deliveredAt: null,
lastError: null,
},
});
try {
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
@@ -1649,6 +1666,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
unitPrice: billing.unitPrice,
amountCents: billing.amountCents,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
status: 'validating',
},
});
@@ -2140,6 +2158,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
applicationId?: string | null;
messageId: string;
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
},
errorCode: string,
reason: string,
@@ -2181,6 +2200,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
rawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
deliveredAt: deliveredAt.toISOString(),
},
});
@@ -280,6 +280,8 @@
- 下游投递重试已改为指数退避第一版:首次失败后按基础间隔重试,随后按 2 倍递增,并受最大退避上限约束,避免客户长时间离线时平台每分钟机械重试。
- 下游状态必须以客户确认作为终态:Gateway `SendPkt` 成功后只能写 `awaiting_ack`,仅收到匹配连接、`Sequence_Id``Msg_Id``CMPP_DELIVER_RESP.Result=0` 后才能写 `delivered`;超时、非零 Result 和历史未留存 ACK 的记录分别按未确认、拒绝或历史未确认展示,不能再把 TCP 写出冒充客户已收到。
- 企业应用必须分别提供“回执自动重试投递”和“上行短信自动重试投递”开关,默认开启。开关按投递创建时快照保存;关闭只阻止已经写出但未获 ACK/被拒绝后的自动重发,不阻止离线队列在客户首次上线时完成首次投递。手工重投不受开关限制,但必须提示重复业务处理风险并二次确认。
- 客户 Submit 的失败状态回执必须严格晚于对应 `CMPP_SUBMIT_RESP` 写出,且 Deliver 中的业务 `Msg_Id` 必须非 0、与该 SubmitResp 返回的 `Msg_Id` 完全一致;`Result=0``Msg_Id=0` 只能表示客户端协议栈收包,不能标记业务回执已确认。平台必须持久化原 Submit Sequence_Id,使 Gateway 重启或客户重连后的补投仍可重建相同业务 `Msg_Id`
- 运营端允许对 `delivered`(客户端已确认)记录再次手工重投,但单条和批量入口都必须明确提示可能造成下游重复处理;`awaiting_ack` 状态在确认窗口内不得并发重投。
- 已实现客户侧最终 Deliver 推送的第一版能力:Gateway 在下游 Submit 被接受后记录 messageId 到客户连接的内存映射;NestJS 收到最终 receipt/uplink 并入库后调用 Gateway `/downstream/receipt``/downstream/uplink`Gateway 向仍在线的客户 CMPP 连接下发 Deliver Receipt 或普通 Deliver。
- 已实现客户侧 Deliver 持久化第一版能力:NestJS 收到最终 receipt/uplink 后写入 `CmppDownstreamDelivery` 待投递记录;在线推送成功标记 delivered,客户断线或 Gateway 不可达时保留 pending 并记录 retry 信息;客户重新 bind 后 Gateway 按账号拉取 pending 记录补发。
- 已实现普通上行匹配与人工认领第一版:优先按 messageId 精确匹配;无 messageId 时按接入号匹配应用路由;仍无唯一应用时按手机号和最近下发时间窗口匹配;多候选标记 ambiguous 并写入 `SmsUplinkMatchCandidate` 候选,运营端可人工认领候选应用/下发记录;认领后更新上行记录、保留候选审计,并创建真实客户侧上行 Deliver 投递记录。
+2 -1
View File
@@ -3290,4 +3290,5 @@ npm run verify:phase8
| TC-GW-ACK-001 | 客户在线时分别触发一条状态回执和一条上行短信,Gateway `SendPkt` 成功后延迟返回 `CMPP_DELIVER_RESP`。 | 写出后 `CmppDownstreamDelivery.status=awaiting_ack`;只有匹配连接、Sequence_Id、Msg_Id 且 Result=0 后才变为 `delivered`,并保存写出时间、确认时间、ACK 字段和连接 ID。 |
| TC-GW-ACK-002 | 分别返回非零 Result、不返回响应直到超时、重启 Gateway 后让旧 `awaiting_ack` 超时。 | 非零 Result 和超时不会误记为 deliveredGateway 重启后 NestJS 能恢复过期 ACK,按策略进入退避重试或最终 `rejected/unconfirmed`。 |
| TC-GW-ACK-003 | 在企业应用中分别关闭“回执自动重试”和“上行短信自动重试”,各制造一次 ACK 超时,再重新开启并创建新投递。 | 关闭只影响对应类型的新投递策略快照;离线后的首次投递仍会在重连时执行;已写出未确认的记录不自动重发;重新开启后新记录按退避策略重试。 |
| TC-GW-ACK-004 | 对 `unconfirmed/rejected/failed` 记录执行单条和批量手工重投。 | 页面提示重复处理风险并二次确认;真实调用 Gateway;`awaiting_ack/delivered` 不允许重投;重发复用同一业务 Msg_Id。 |
| TC-GW-ACK-004 | 对 `unconfirmed/rejected/failed/delivered` 记录执行单条和批量手工重投。 | 页面提示重复处理风险并二次确认;真实调用 Gateway;`awaiting_ack` 不允许并发重投;重发复用同一业务 Msg_Id。 |
| TC-GW-ACK-005 | 客户 Submit 后由业务校验立即生成失败回执,并覆盖在线即时投递、Gateway 重启后恢复投递;另模拟客户端对 `Msg_Id=0` 返回 Result=0。 | 客户收到的第一个响应包必须是对应 `CMPP_SUBMIT_RESP`,之后 Deliver 的 `Msg_Id` 非 0 且与 SubmitResp 完全一致;重启后根据持久化 Submit Sequence_Id 重建同一 Msg_IdResult=0/Msg_Id=0 不得写为 delivered。 |
+9
View File
@@ -1727,3 +1727,12 @@ git diff --check
- 功能提交 `8c03663f` 已 push 并部署生产。部署前 PostgreSQL 备份为 `/opt/cmpp-platform/backups/cmpp-20260714-142059.sql`(约 65MB),运行源码备份为 `/opt/cmpp-platform/backups/source-20260714-142059.tar.gz`(约 21MB);发布包本地和服务器 SHA-256 均为 `3e969aaf00e828da4167018bc349795413743414bc827d452859f0fd45776cd1`
- 生产 migration `20260714130000_add_downstream_delivery_ack_tracking` 成功应用,37 条 migration 全部齐全;历史 7 条 `delivered` 已按新口径迁移为 `unconfirmed`,204 个企业应用的回执和上行自动重试开关均为默认开启。生产 `.deployed-commit=8c03663f245c12cd5e6aab28ad9eeb68ea60ed57``cmpp-api``cmpp-gateway`、Nginx、MinIO 均 active`12026/17890/8090/3000` 监听,API/Gateway health、Redis、外部首页和运营端登录页均正常,部署后近期日志无新增 error;前端产物已确认包含“回执自动重试”和“客户端已确认”。未擅自发送或重投客户短信。
- 生产验证站点当前为 HTTP,已按发布约束显式设置 `SESSION_COOKIE_SECURE=false`,ACK 超时设置为 30 秒;无 Cookie 和无效 Cookie 访问受保护/会话接口均返回预期 401。服务器凭据文件对存量管理员只记录 `password=unchanged`,不是可用明文密码,因此未继续进行真实登录和开关点击,且已清除本次诊断产生的一次失败计数;登录后的页面交互由用户使用现有账号验收。
## 2026-07-14 下游回执 Msg_Id=0 与 SubmitResp 时序修复
- 生产核查 15:07、15:10 两条余额失败短信:`CmppDownstreamDelivery` 均记录 Result=0,但 ACK Msg_Id 都是 0Gateway 日志中的原 SubmitResp Msg_Id 分别为 `9677414932210841325``9687782471788644609`。这只能证明下游协议栈收到了 Deliver,不能证明下游平台已将状态回执关联到原短信,与下游页面仍显示“未回执”一致。
- 根因为 NestJS 在处理入站 Submit 时同步生成失败回执,而 Gateway 尚未建立 messageId 映射;原查找逻辑在精确消息未命中时回退账号会话,使用 bind 会话的零值 Msg_Id 提前写出 Deliver。上午 11:40 的回执手工重投后才显示,是因为重投时对应 Submit 映射已经存在,能携带正确 Msg_Id。
- 修复为:精确消息未登记时禁止回退账号会话;Gateway 仅在 ConnectResp/SubmitResp 成功写出后冲刷 pending;新建短信记录持久化原 CMPP Submit Sequence_Id,重启恢复时结合平台 MessageId 重建相同 Msg_Id;发送层拒绝任何 Msg_Id=0 的 DeliverNestJS 也不再把 Result=0/Msg_Id=0 标记为 delivered。
- migration `20260714153000_fix_downstream_receipt_message_id` 新增 `SmsMessageRecord.cmppSubmitSequenceId`,并把存量 `deliveryType=receipt/status=delivered/ackMessageId=0` 纠正为 `unconfirmed`,保留 ACK 证据但不冒充业务确认。
- 下游投递记录列表改为固定信息分组:投递类型/时间、企业/应用、消息 ID、状态、重试、最后错误和操作各自保持可读宽度,最后错误不再被挤成竖排。`delivered` 记录开放单条和批量手工重投,继续保留重复业务处理二次确认;`awaiting_ack` 仍禁止并发重投。
- 已新增 TC-GW-ACK-005 和 Gateway 回归:首个返回包必须是 SubmitResp,随后失败回执 Deliver Msg_Id 与 SubmitResp 完全相同;另覆盖精确映射不回退、持久化 Sequence_Id 恢复和 Msg_Id=0 拒绝。API 全量 15 suites、154 项、Gateway 全量 Go 测试、Prisma validate、真实本地 PostgreSQL migration、API build、前端 build 和 `git diff --check` 均通过;前端仅有既有 Vite chunk size warning。经用户授权在应用内浏览器完成一次本地图形验证码登录,使用真实 NestJS API、PostgreSQL 和临时投递记录在 1440×1000 视口验证列表无横向挤压、无 `NaN`、console 无 error/warn`delivered` 行的重投按钮可用且点击确实进入真实后端重投链路;因本地未运行 Gateway,请求按预期变为待重试而非伪造成功。临时投递记录和验收账号已清理。当前未提交、未推送、未部署生产。
+54 -3
View File
@@ -6,6 +6,7 @@ import (
"crypto/md5"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -75,6 +76,7 @@ type DownstreamReceipt struct {
ReceiptStatus string `json:"receiptStatus"`
RawStatus string `json:"rawStatus,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"`
DeliveredAt string `json:"deliveredAt,omitempty"`
}
@@ -220,7 +222,11 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
}
rememberAccount(session)
go s.reportConnection(&session, "connected", "")
response.AfterSend = func(sendErr error) {
if sendErr == nil {
go s.flushPending(defaultString(auth.Account, account), logger)
}
}
logger.Printf(
"cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x%02x account=%s remote=%s",
cmppVersionName(req.Version), uint8(req.Version), uint8(req.Version), account, packet.Conn.Conn.RemoteAddr(),
@@ -319,11 +325,16 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
if current := findSessionByConn(packet.Conn); current != nil && current.report != nil {
go current.report(current, "submit", "")
}
response.AfterSend = func(sendErr error) {
if sendErr != nil {
return
}
go func() {
if _, err := s.flushPending(account, logger); err != nil {
logger.Printf("cmpp inbound event=post_submit_pending_flush_failed account=%s message_id=%s error=%q", account, result.MessageID, err.Error())
}
}()
}
logger.Printf(
"cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s",
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash,
@@ -493,6 +504,7 @@ type pendingDelivery struct {
ID string `json:"id"`
DeliveryType string `json:"deliveryType"`
Payload json.RawMessage `json:"payload"`
CreatedAt time.Time `json:"createdAt"`
}
type pendingFlushResult struct {
@@ -546,7 +558,8 @@ func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (D
}
event.DeliveryID = delivery.ID
event.Account = defaultString(event.Account, account)
return PushReceiptWithResult(event)
allowRecovery := !delivery.CreatedAt.IsZero() && time.Since(delivery.CreatedAt) >= 5*time.Second
return pushReceiptWithResult(event, allowRecovery)
case "uplink":
var event DownstreamUplink
if err := json.Unmarshal(delivery.Payload, &event); err != nil {
@@ -848,7 +861,14 @@ func PushReceipt(event DownstreamReceipt) (bool, error) {
}
func PushReceiptWithResult(event DownstreamReceipt) (DownstreamSendResult, error) {
session := findSession(event.MessageID, event.Account)
return pushReceiptWithResult(event, false)
}
func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (DownstreamSendResult, error) {
session := findReceiptSession(event.MessageID, event.Account)
if session == nil && allowRecovery {
session = recoverReceiptSession(event)
}
if session == nil {
return DownstreamSendResult{}, nil
}
@@ -878,6 +898,34 @@ func PushReceiptWithResult(event DownstreamReceipt) (DownstreamSendResult, error
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(event.MessageID, event.SubmitSequenceID)
return &recovered
}
func PushUplink(event DownstreamUplink) (bool, error) {
result, err := PushUplinkWithResult(event)
return result.Sent, err
@@ -936,8 +984,11 @@ func findSession(messageID string, account string) *downstreamSession {
func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID string) (DownstreamSendResult, error) {
session.mu.Lock()
defer session.mu.Unlock()
sequenceID := <-session.conn.SeqId
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())
tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt)
+110
View File
@@ -226,6 +226,82 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
}
}
func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
account := "100001"
password := "secret-hash"
var mu sync.Mutex
var submit submitRequest
pendingReturned := false
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/gateway/events/inbound/authenticate":
_ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account})
case "/api/gateway/events/inbound/submit":
mu.Lock()
defer mu.Unlock()
if err := json.NewDecoder(r.Body).Decode(&submit); err != nil {
t.Fatalf("decode submit: %v", err)
}
_ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-ORDER"})
case "/api/gateway/events/downstream/pending":
mu.Lock()
defer mu.Unlock()
if submit.SequenceID == 0 || pendingReturned {
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
return
}
payload, _ := json.Marshal(DownstreamReceipt{
Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696",
ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: submit.SequenceID,
})
pendingReturned = true
_ = json.NewEncoder(w).Encode([]pendingDelivery{{
ID: "delivery-order", DeliveryType: "receipt", Payload: payload, CreatedAt: time.Now().UTC(),
}})
case "/api/gateway/events/inbound/connection", "/api/gateway/events/downstream/sent":
w.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected api path: %s", r.URL.Path)
}
}))
defer api.Close()
addr := reserveTCPAddr(t)
go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }()
time.Sleep(300 * time.Millisecond)
client := cmpp.NewClient(cmpp.V20)
defer client.Disconnect()
if err := client.Connect(addr, account, password, 2*time.Second); err != nil {
t.Fatalf("connect CMPP2 inbound: %v", err)
}
content, _ := cmpputils.Utf8ToUcs2("测试回执顺序")
if _, err := client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{
PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1,
ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696",
MsgFmt: 8, MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000",
DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(content)), MsgContent: content,
}); err != nil {
t.Fatalf("send submit: %v", err)
}
first, err := client.RecvAndUnpackPkt(2 * time.Second)
if err != nil {
t.Fatalf("receive first packet: %v", err)
}
submitResponse, ok := first.(*cmpp.Cmpp2SubmitRspPkt)
if !ok || submitResponse.Result != 0 || submitResponse.MsgId == 0 {
t.Fatalf("first packet must be successful SUBMIT_RESP, got %T %+v", first, first)
}
deliver := recvDeliver20(t, client)
if deliver.MsgId != submitResponse.MsgId {
t.Fatalf("receipt Msg_Id=%d does not match SUBMIT_RESP Msg_Id=%d", deliver.MsgId, submitResponse.MsgId)
}
}
func TestInboundServerNegotiatesCMPP2AndUsesAuthenticatedAccountForSubmit(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
@@ -582,6 +658,40 @@ func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) {
}
}
func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
conn := &cmpp.Conn{}
rememberAccount(downstreamSession{
account: "100001", protocol: "cmpp20", conn: conn, mu: &sync.Mutex{}, connectionID: "conn-1",
})
if session := findReceiptSession("MSG-NOT-REMEMBERED", "100001"); session != nil {
t.Fatalf("receipt unexpectedly fell back to account session: %+v", session)
}
recovered := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: 1216579149,
})
if recovered == nil {
t.Fatal("expected persisted submit sequence to recover receipt session")
}
if recovered.gatewayMsgID != messageIDFrom("MSG-NOT-REMEMBERED", 1216579149) || recovered.gatewayMsgID == 0 {
t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID)
}
}
func TestSendDownstreamRejectsZeroMessageID(t *testing.T) {
_, err := sendDownstream(
&downstreamSession{mu: &sync.Mutex{}},
&cmpp.Cmpp2DeliverReqPkt{MsgId: 0},
"delivery-zero",
)
if err == nil || !strings.Contains(err.Error(), "Msg_Id=0") {
t.Fatalf("expected zero Msg_Id rejection, got %v", err)
}
}
func TestDownstreamDeliveryReportsAckTimeout(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
+6 -1
View File
@@ -40,6 +40,7 @@ type Response struct {
*Packet
Packer
SeqId uint32
AfterSend func(error)
}
type Handler interface {
@@ -420,7 +421,11 @@ func (c *conn) serve() {
}
_, err = c.server.Handler.ServeCmpp(r, r.Packet, c.server.ErrorLog)
if err1 := c.finishPacket(r); err1 != nil {
err1 := c.finishPacket(r)
if r.AfterSend != nil {
r.AfterSend(err1)
}
if err1 != nil {
c.server.ErrorLog.Printf(
"send response packet failed remote=%v protocol=%s packet_type=%T seq=%d err_type=%T err=%v",
c.Conn.RemoteAddr(), c.Conn.Typ, r.Packer, r.SeqId, err1, err1,
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
@@ -113,9 +114,10 @@ export function AdminDownstreamDeliveriesPage() {
loadData();
}, [loadData]);
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
const selectableIds = useMemo(
() => records.filter((item) => ['pending', 'failed', 'unconfirmed', 'rejected'].includes(item.status)).map((item) => item.id),
[records],
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
[records, replayableStatuses],
);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const summary = dashboard?.summary;
@@ -123,64 +125,15 @@ export function AdminDownstreamDeliveriesPage() {
const retryBuckets = dashboard?.retryBuckets ?? [];
const topApplications = dashboard?.topApplications ?? [];
const columns: Array<TableColumn<DownstreamDeliveryRecord>> = [
{
key: 'select',
title: '选择',
width: '52px',
align: 'center',
render: (record) => (
<input
type="checkbox"
disabled={!['pending', 'failed', 'unconfirmed', 'rejected'].includes(record.status)}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) =>
event.target.checked
? [...current, record.id]
: current.filter((item) => item !== record.id),
);
}}
aria-label={`选择${record.id}`}
/>
),
},
{ key: 'createdAt', title: '投递时间', width: '180px', render: (record) => record.createdAt },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
{ key: 'type', title: '类型', width: '110px', render: (record) => deliveryTypeLabel[record.deliveryType] ?? record.deliveryType },
{ key: 'messageId', title: '消息 ID', width: '180px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
{ key: 'status', title: '状态', width: '150px', render: (record) => <Tag tone={statusTone[record.status] ?? 'info'}>{statusLabel[record.status] ?? record.status}</Tag> },
{ key: 'retry', title: '重试', width: '90px', align: 'center', render: (record) => record.retryCount },
{ key: 'error', title: '最后错误', render: (record) => record.lastError ?? '-' },
{
key: 'actions',
title: '操作',
width: '170px',
align: 'right',
render: (record) => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={!['pending', 'failed', 'unconfirmed', 'rejected'].includes(record.status)}
icon={<RefreshCw size={14} />}
onClick={() => {
if (!window.confirm('重投可能导致下游业务重复处理,确认继续吗?')) return;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const replayRecord = (record: DownstreamDeliveryRecord) => {
const acknowledgedWarning = record.status === 'delivered' ? '该记录已收到客户端确认,' : '';
if (!window.confirm(`${acknowledgedWarning}重投可能导致下游业务重复处理,确认继续吗?`)) return;
adminApi.requeueDownstreamDelivery(record.id)
.then(() => loadData())
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
}}
size="sm"
variant="secondary"
>
</Button>
</div>
),
},
];
const totalPages = Math.max(1, Math.ceil(total / pageSize));
};
return (
<section className="page-stack admin-sms-task-page report-record-page">
@@ -306,12 +259,12 @@ export function AdminDownstreamDeliveriesPage() {
{typeBreakdown.map((item) => (
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--ack" key={item.deliveryType}>
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
<span>{item.total}</span>
<span>{item.pending}</span>
<span>{item.awaitingAck}</span>
<span>{item.delivered}</span>
<span>{item.unconfirmed + item.rejected}</span>
<span>{item.failed}</span>
<span>{item.total ?? 0}</span>
<span>{item.pending ?? 0}</span>
<span>{item.awaitingAck ?? 0}</span>
<span>{item.delivered ?? 0}</span>
<span>{(item.unconfirmed ?? 0) + (item.rejected ?? 0)}</span>
<span>{item.failed ?? 0}</span>
</div>
))}
</div>
@@ -346,10 +299,10 @@ export function AdminDownstreamDeliveriesPage() {
{topApplications.length > 0 ? topApplications.map((item) => (
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--apps" key={item.applicationId}>
<strong>{item.name}</strong>
<span>{item.pending}</span>
<span>{item.failed + item.unconfirmed + item.rejected}</span>
<span>{item.delivered}</span>
<span>{item.alertCount}</span>
<span>{item.pending ?? 0}</span>
<span>{(item.failed ?? 0) + (item.unconfirmed ?? 0) + (item.rejected ?? 0)}</span>
<span>{item.delivered ?? 0}</span>
<span>{item.alertCount ?? 0}</span>
</div>
)) : (
<div className="downstream-breakdown-table__empty"></div>
@@ -358,11 +311,11 @@ export function AdminDownstreamDeliveriesPage() {
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
{selectedIds.length}
<div className="downstream-delivery-toolbar">
<p>
<strong>{selectedIds.length}</strong>
</p>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<Button
disabled={selectableIds.length === 0}
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
@@ -388,7 +341,70 @@ export function AdminDownstreamDeliveriesPage() {
</Button>
</div>
</div>
<Table columns={columns} data={records} emptyText={loading ? '加载中...' : '暂无下游投递记录'} pagination={false} rowKey="id" />
<div className="downstream-delivery-list" role="table" aria-label="下游投递记录">
<div className="downstream-delivery-list__header" role="row">
<span></span>
<span></span>
<span> / </span>
<span> ID</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{records.length > 0 ? records.map((record) => (
<div className="downstream-delivery-list__row" role="row" key={record.id}>
<div className="downstream-delivery-list__select" role="cell">
<input
type="checkbox"
disabled={!replayableStatuses.has(record.status)}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) => event.target.checked
? [...current, record.id]
: current.filter((item) => item !== record.id));
}}
aria-label={`选择${record.id}`}
/>
</div>
<div className="downstream-delivery-list__meta" role="cell">
<strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong>
<span>{formatDateTime(record.createdAt)}</span>
</div>
<div className="downstream-delivery-list__owner" role="cell">
<strong>{record.tenant?.name ?? '-'}</strong>
<span>{record.application?.name ?? '-'}</span>
</div>
<div className="downstream-delivery-list__message" role="cell">
<strong>{record.messageId ?? '-'}</strong>
</div>
<div role="cell">
<Tag tone={statusTone[record.status] ?? 'info'}>{statusLabel[record.status] ?? record.status}</Tag>
</div>
<div className="downstream-delivery-list__retry" role="cell">
<strong>{record.retryCount}</strong>
<span></span>
</div>
<div className={`downstream-delivery-list__error${record.lastError ? '' : ' is-empty'}`} role="cell" title={record.lastError ?? undefined}>
{record.lastError ?? '无'}
</div>
<div className="downstream-delivery-list__actions" role="cell">
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={!replayableStatuses.has(record.status)}
icon={<RefreshCw size={14} />}
onClick={() => replayRecord(record)}
size="sm"
variant="secondary"
>
</Button>
</div>
</div>
)) : (
<div className="downstream-delivery-list__empty">{loading ? '加载中...' : '暂无下游投递记录'}</div>
)}
</div>
<Pagination
total={total}
page={page}
+133
View File
@@ -7817,6 +7817,139 @@ h3 {
font-size: var(--font-size-sm);
}
.downstream-delivery-toolbar {
align-items: center;
border-bottom: 1px solid var(--color-border);
display: flex;
gap: var(--space-4);
justify-content: space-between;
padding: var(--space-4) var(--space-5);
}
.downstream-delivery-toolbar p {
color: var(--color-text-muted);
line-height: 1.6;
margin: 0;
}
.downstream-delivery-toolbar p strong {
color: var(--color-text-strong);
}
.downstream-delivery-toolbar > div,
.downstream-delivery-list__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--space-2);
}
.downstream-delivery-list {
overflow-x: auto;
}
.downstream-delivery-list__header,
.downstream-delivery-list__row {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: 36px 140px minmax(150px, 1fr) minmax(165px, 1.05fr) 126px 54px minmax(170px, 1.2fr) 136px;
min-width: 1080px;
padding: var(--space-4);
}
.downstream-delivery-list__header {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.downstream-delivery-list__row {
border-top: 1px solid var(--color-border);
min-height: 104px;
}
.downstream-delivery-list__row:first-of-type {
border-top: 0;
}
.downstream-delivery-list__row:hover {
background: color-mix(in srgb, var(--color-selected-soft) 28%, transparent);
}
.downstream-delivery-list__select,
.downstream-delivery-list__retry {
text-align: center;
}
.downstream-delivery-list__meta,
.downstream-delivery-list__owner,
.downstream-delivery-list__retry {
display: grid;
gap: 6px;
}
.downstream-delivery-list__meta strong,
.downstream-delivery-list__owner strong,
.downstream-delivery-list__retry strong {
color: var(--color-text-strong);
}
.downstream-delivery-list__meta span,
.downstream-delivery-list__owner span,
.downstream-delivery-list__retry span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.downstream-delivery-list__message strong {
color: var(--color-text-strong);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: var(--font-size-sm);
overflow-wrap: anywhere;
}
.downstream-delivery-list__error {
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: var(--radius-md);
color: #9a3412;
display: -webkit-box;
line-height: 1.55;
overflow: hidden;
padding: var(--space-3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.downstream-delivery-list__error.is-empty {
background: var(--color-bg-subtle);
border-color: var(--color-border);
color: var(--color-text-subtle);
}
.downstream-delivery-list__actions {
justify-content: flex-end;
}
.downstream-delivery-list__empty {
color: var(--color-text-muted);
padding: 48px var(--space-5);
text-align: center;
}
@media (max-width: 760px) {
.downstream-delivery-toolbar {
align-items: stretch;
flex-direction: column;
}
.downstream-delivery-toolbar > div {
justify-content: flex-end;
}
}
.admin-task-enterprise,
.admin-task-counts,
.admin-task-send-type {