fix: correlate downstream receipts with submit responses
This commit is contained in:
@@ -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';
|
||||||
@@ -983,6 +983,7 @@ model SmsMessageRecord {
|
|||||||
channelId String?
|
channelId String?
|
||||||
submitId String?
|
submitId String?
|
||||||
gatewayMessageId String?
|
gatewayMessageId String?
|
||||||
|
cmppSubmitSequenceId String?
|
||||||
status String @default("queued")
|
status String @default("queued")
|
||||||
submitStatus String?
|
submitStatus String?
|
||||||
receiptStatus String?
|
receiptStatus String?
|
||||||
|
|||||||
@@ -551,15 +551,20 @@ describe('SendChainService', () => {
|
|||||||
account: '100001',
|
account: '100001',
|
||||||
phoneNumber: '13800000001',
|
phoneNumber: '13800000001',
|
||||||
content: 'unreported content',
|
content: 'unreported content',
|
||||||
|
sequenceId: 1216579149,
|
||||||
remoteIp: '127.0.0.1',
|
remoteIp: '127.0.0.1',
|
||||||
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-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({
|
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }),
|
data: expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }),
|
||||||
});
|
});
|
||||||
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
||||||
'/downstream/receipt',
|
'/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();
|
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 () => {
|
it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
||||||
@@ -1564,6 +1592,15 @@ describe('SendChainService', () => {
|
|||||||
resourceId: 'delivery-1',
|
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 () => {
|
it('supports batch requeue of downstream deliveries', async () => {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentD
|
|||||||
acknowledgedAt?: string;
|
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 = {
|
type GatewayControlDeliveryResult = {
|
||||||
sent?: boolean;
|
sent?: boolean;
|
||||||
@@ -987,7 +987,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||||
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
|
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({
|
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||||
where: { id: data.id, status: { not: 'delivered' } },
|
where: { id: data.id, status: { not: 'delivered' } },
|
||||||
data: {
|
data: {
|
||||||
@@ -1015,6 +1016,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
connectionId: data.connectionId,
|
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');
|
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;
|
return delivery;
|
||||||
}
|
}
|
||||||
const retryCount = (delivery.retryCount ?? 0) + 1;
|
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 retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
|
||||||
const finalFailure = !retryAllowed || retryCount >= downstreamMaxRetries();
|
const finalFailure = !retryAllowed || retryCount >= downstreamMaxRetries();
|
||||||
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
|
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
|
||||||
@@ -1265,7 +1269,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
await this.prisma.cmppDownstreamDelivery.update({
|
await this.prisma.cmppDownstreamDelivery.update({
|
||||||
where: { id: delivery.id },
|
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 {
|
try {
|
||||||
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||||
@@ -1649,6 +1666,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
unitPrice: billing.unitPrice,
|
unitPrice: billing.unitPrice,
|
||||||
amountCents: billing.amountCents,
|
amountCents: billing.amountCents,
|
||||||
queuePriority,
|
queuePriority,
|
||||||
|
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||||
status: 'validating',
|
status: 'validating',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2140,6 +2158,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
applicationId?: string | null;
|
applicationId?: string | null;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
cmppSubmitSequenceId?: string | null;
|
||||||
},
|
},
|
||||||
errorCode: string,
|
errorCode: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
@@ -2181,6 +2200,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
rawStatus: 'REJECTD',
|
rawStatus: 'REJECTD',
|
||||||
errorCode,
|
errorCode,
|
||||||
errorMessage: reason,
|
errorMessage: reason,
|
||||||
|
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
|
||||||
deliveredAt: deliveredAt.toISOString(),
|
deliveredAt: deliveredAt.toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -280,6 +280,8 @@
|
|||||||
- 下游投递重试已改为指数退避第一版:首次失败后按基础间隔重试,随后按 2 倍递增,并受最大退避上限约束,避免客户长时间离线时平台每分钟机械重试。
|
- 下游投递重试已改为指数退避第一版:首次失败后按基础间隔重试,随后按 2 倍递增,并受最大退避上限约束,避免客户长时间离线时平台每分钟机械重试。
|
||||||
- 下游状态必须以客户确认作为终态:Gateway `SendPkt` 成功后只能写 `awaiting_ack`,仅收到匹配连接、`Sequence_Id`、`Msg_Id` 且 `CMPP_DELIVER_RESP.Result=0` 后才能写 `delivered`;超时、非零 Result 和历史未留存 ACK 的记录分别按未确认、拒绝或历史未确认展示,不能再把 TCP 写出冒充客户已收到。
|
- 下游状态必须以客户确认作为终态:Gateway `SendPkt` 成功后只能写 `awaiting_ack`,仅收到匹配连接、`Sequence_Id`、`Msg_Id` 且 `CMPP_DELIVER_RESP.Result=0` 后才能写 `delivered`;超时、非零 Result 和历史未留存 ACK 的记录分别按未确认、拒绝或历史未确认展示,不能再把 TCP 写出冒充客户已收到。
|
||||||
- 企业应用必须分别提供“回执自动重试投递”和“上行短信自动重试投递”开关,默认开启。开关按投递创建时快照保存;关闭只阻止已经写出但未获 ACK/被拒绝后的自动重发,不阻止离线队列在客户首次上线时完成首次投递。手工重投不受开关限制,但必须提示重复业务处理风险并二次确认。
|
- 企业应用必须分别提供“回执自动重试投递”和“上行短信自动重试投递”开关,默认开启。开关按投递创建时快照保存;关闭只阻止已经写出但未获 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 推送的第一版能力: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 记录补发。
|
- 已实现客户侧 Deliver 持久化第一版能力:NestJS 收到最终 receipt/uplink 后写入 `CmppDownstreamDelivery` 待投递记录;在线推送成功标记 delivered,客户断线或 Gateway 不可达时保留 pending 并记录 retry 信息;客户重新 bind 后 Gateway 按账号拉取 pending 记录补发。
|
||||||
- 已实现普通上行匹配与人工认领第一版:优先按 messageId 精确匹配;无 messageId 时按接入号匹配应用路由;仍无唯一应用时按手机号和最近下发时间窗口匹配;多候选标记 ambiguous 并写入 `SmsUplinkMatchCandidate` 候选,运营端可人工认领候选应用/下发记录;认领后更新上行记录、保留候选审计,并创建真实客户侧上行 Deliver 投递记录。
|
- 已实现普通上行匹配与人工认领第一版:优先按 messageId 精确匹配;无 messageId 时按接入号匹配应用路由;仍无唯一应用时按手机号和最近下发时间窗口匹配;多候选标记 ambiguous 并写入 `SmsUplinkMatchCandidate` 候选,运营端可人工认领候选应用/下发记录;认领后更新上行记录、保留候选审计,并创建真实客户侧上行 Deliver 投递记录。
|
||||||
|
|||||||
@@ -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-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 和超时不会误记为 delivered;Gateway 重启后 NestJS 能恢复过期 ACK,按策略进入退避重试或最终 `rejected/unconfirmed`。 |
|
| TC-GW-ACK-002 | 分别返回非零 Result、不返回响应直到超时、重启 Gateway 后让旧 `awaiting_ack` 超时。 | 非零 Result 和超时不会误记为 delivered;Gateway 重启后 NestJS 能恢复过期 ACK,按策略进入退避重试或最终 `rejected/unconfirmed`。 |
|
||||||
| TC-GW-ACK-003 | 在企业应用中分别关闭“回执自动重试”和“上行短信自动重试”,各制造一次 ACK 超时,再重新开启并创建新投递。 | 关闭只影响对应类型的新投递策略快照;离线后的首次投递仍会在重连时执行;已写出未确认的记录不自动重发;重新开启后新记录按退避策略重试。 |
|
| 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_Id;Result=0/Msg_Id=0 不得写为 delivered。 |
|
||||||
|
|||||||
@@ -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`。
|
- 功能提交 `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;前端产物已确认包含“回执自动重试”和“客户端已确认”。未擅自发送或重投客户短信。
|
- 生产 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`,不是可用明文密码,因此未继续进行真实登录和开关点击,且已清除本次诊断产生的一次失败计数;登录后的页面交互由用户使用现有账号验收。
|
- 生产验证站点当前为 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 都是 0;Gateway 日志中的原 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 的 Deliver,NestJS 也不再把 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,请求按预期变为待重试而非伪造成功。临时投递记录和验收账号已清理。当前未提交、未推送、未部署生产。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -75,6 +76,7 @@ type DownstreamReceipt struct {
|
|||||||
ReceiptStatus string `json:"receiptStatus"`
|
ReceiptStatus string `json:"receiptStatus"`
|
||||||
RawStatus string `json:"rawStatus,omitempty"`
|
RawStatus string `json:"rawStatus,omitempty"`
|
||||||
ErrorCode string `json:"errorCode,omitempty"`
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
|
SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"`
|
||||||
DeliveredAt string `json:"deliveredAt,omitempty"`
|
DeliveredAt string `json:"deliveredAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +222,11 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
|
|||||||
}
|
}
|
||||||
rememberAccount(session)
|
rememberAccount(session)
|
||||||
go s.reportConnection(&session, "connected", "")
|
go s.reportConnection(&session, "connected", "")
|
||||||
go s.flushPending(defaultString(auth.Account, account), logger)
|
response.AfterSend = func(sendErr error) {
|
||||||
|
if sendErr == nil {
|
||||||
|
go s.flushPending(defaultString(auth.Account, account), logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.Printf(
|
logger.Printf(
|
||||||
"cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x%02x account=%s remote=%s",
|
"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(),
|
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 {
|
if current := findSessionByConn(packet.Conn); current != nil && current.report != nil {
|
||||||
go current.report(current, "submit", "")
|
go current.report(current, "submit", "")
|
||||||
}
|
}
|
||||||
go func() {
|
response.AfterSend = func(sendErr error) {
|
||||||
if _, err := s.flushPending(account, logger); err != nil {
|
if sendErr != nil {
|
||||||
logger.Printf("cmpp inbound event=post_submit_pending_flush_failed account=%s message_id=%s error=%q", account, result.MessageID, err.Error())
|
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(
|
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",
|
"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,
|
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"`
|
ID string `json:"id"`
|
||||||
DeliveryType string `json:"deliveryType"`
|
DeliveryType string `json:"deliveryType"`
|
||||||
Payload json.RawMessage `json:"payload"`
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type pendingFlushResult struct {
|
type pendingFlushResult struct {
|
||||||
@@ -546,7 +558,8 @@ func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (D
|
|||||||
}
|
}
|
||||||
event.DeliveryID = delivery.ID
|
event.DeliveryID = delivery.ID
|
||||||
event.Account = defaultString(event.Account, account)
|
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":
|
case "uplink":
|
||||||
var event DownstreamUplink
|
var event DownstreamUplink
|
||||||
if err := json.Unmarshal(delivery.Payload, &event); err != nil {
|
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) {
|
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 {
|
if session == nil {
|
||||||
return DownstreamSendResult{}, nil
|
return DownstreamSendResult{}, nil
|
||||||
}
|
}
|
||||||
@@ -878,6 +898,34 @@ func PushReceiptWithResult(event DownstreamReceipt) (DownstreamSendResult, error
|
|||||||
return sendDownstream(session, deliver, event.DeliveryID)
|
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) {
|
func PushUplink(event DownstreamUplink) (bool, error) {
|
||||||
result, err := PushUplinkWithResult(event)
|
result, err := PushUplinkWithResult(event)
|
||||||
return result.Sent, err
|
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) {
|
func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID string) (DownstreamSendResult, error) {
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
defer session.mu.Unlock()
|
defer session.mu.Unlock()
|
||||||
sequenceID := <-session.conn.SeqId
|
|
||||||
messageID := downstreamDeliverMessageID(deliver)
|
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()
|
sentAt := time.Now().UTC()
|
||||||
ackDeadlineAt := sentAt.Add(downstreamAckTimeout())
|
ackDeadlineAt := sentAt.Add(downstreamAckTimeout())
|
||||||
tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt)
|
tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt)
|
||||||
|
|||||||
@@ -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) {
|
func TestInboundServerNegotiatesCMPP2AndUsesAuthenticatedAccountForSubmit(t *testing.T) {
|
||||||
resetDownstreamRegistry()
|
resetDownstreamRegistry()
|
||||||
defer 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) {
|
func TestDownstreamDeliveryReportsAckTimeout(t *testing.T) {
|
||||||
resetDownstreamRegistry()
|
resetDownstreamRegistry()
|
||||||
defer resetDownstreamRegistry()
|
defer resetDownstreamRegistry()
|
||||||
|
|||||||
Vendored
+7
-2
@@ -39,7 +39,8 @@ type Packet struct {
|
|||||||
type Response struct {
|
type Response struct {
|
||||||
*Packet
|
*Packet
|
||||||
Packer
|
Packer
|
||||||
SeqId uint32
|
SeqId uint32
|
||||||
|
AfterSend func(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler interface {
|
type Handler interface {
|
||||||
@@ -420,7 +421,11 @@ func (c *conn) serve() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.server.Handler.ServeCmpp(r, r.Packet, c.server.ErrorLog)
|
_, 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(
|
c.server.ErrorLog.Printf(
|
||||||
"send response packet failed remote=%v protocol=%s packet_type=%T seq=%d err_type=%T err=%v",
|
"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,
|
c.Conn.RemoteAddr(), c.Conn.Typ, r.Packer, r.SeqId, err1, err1,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
|
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
|
||||||
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
|
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'> = {
|
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||||
pending: 'warning',
|
pending: 'warning',
|
||||||
@@ -113,9 +114,10 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
|
||||||
const selectableIds = useMemo(
|
const selectableIds = useMemo(
|
||||||
() => records.filter((item) => ['pending', 'failed', 'unconfirmed', 'rejected'].includes(item.status)).map((item) => item.id),
|
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
|
||||||
[records],
|
[records, replayableStatuses],
|
||||||
);
|
);
|
||||||
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
|
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
|
||||||
const summary = dashboard?.summary;
|
const summary = dashboard?.summary;
|
||||||
@@ -123,65 +125,16 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
const retryBuckets = dashboard?.retryBuckets ?? [];
|
const retryBuckets = dashboard?.retryBuckets ?? [];
|
||||||
const topApplications = dashboard?.topApplications ?? [];
|
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;
|
|
||||||
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));
|
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 || '人工重投失败'));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack admin-sms-task-page report-record-page">
|
<section className="page-stack admin-sms-task-page report-record-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
@@ -306,12 +259,12 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
{typeBreakdown.map((item) => (
|
{typeBreakdown.map((item) => (
|
||||||
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--ack" key={item.deliveryType}>
|
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--ack" key={item.deliveryType}>
|
||||||
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
|
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
|
||||||
<span>{item.total}</span>
|
<span>{item.total ?? 0}</span>
|
||||||
<span>{item.pending}</span>
|
<span>{item.pending ?? 0}</span>
|
||||||
<span>{item.awaitingAck}</span>
|
<span>{item.awaitingAck ?? 0}</span>
|
||||||
<span>{item.delivered}</span>
|
<span>{item.delivered ?? 0}</span>
|
||||||
<span>{item.unconfirmed + item.rejected}</span>
|
<span>{(item.unconfirmed ?? 0) + (item.rejected ?? 0)}</span>
|
||||||
<span>{item.failed}</span>
|
<span>{item.failed ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -346,10 +299,10 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
{topApplications.length > 0 ? topApplications.map((item) => (
|
{topApplications.length > 0 ? topApplications.map((item) => (
|
||||||
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--apps" key={item.applicationId}>
|
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--apps" key={item.applicationId}>
|
||||||
<strong>{item.name}</strong>
|
<strong>{item.name}</strong>
|
||||||
<span>{item.pending}</span>
|
<span>{item.pending ?? 0}</span>
|
||||||
<span>{item.failed + item.unconfirmed + item.rejected}</span>
|
<span>{(item.failed ?? 0) + (item.unconfirmed ?? 0) + (item.rejected ?? 0)}</span>
|
||||||
<span>{item.delivered}</span>
|
<span>{item.delivered ?? 0}</span>
|
||||||
<span>{item.alertCount}</span>
|
<span>{item.alertCount ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)) : (
|
)) : (
|
||||||
<div className="downstream-breakdown-table__empty">暂无应用告警数据</div>
|
<div className="downstream-breakdown-table__empty">暂无应用告警数据</div>
|
||||||
@@ -358,11 +311,11 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface admin-task-table-card report-task-table-card">
|
<div className="surface admin-task-table-card report-task-table-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
<div className="downstream-delivery-toolbar">
|
||||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
<p>
|
||||||
已选择 {selectedIds.length} 条,可对未确认或失败记录执行批量重投
|
已选择 <strong>{selectedIds.length}</strong> 条;客户端已确认的记录也允许人工重投,请注意重复处理风险。
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
disabled={selectableIds.length === 0}
|
disabled={selectableIds.length === 0}
|
||||||
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
|
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
|
||||||
@@ -388,7 +341,70 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Pagination
|
||||||
total={total}
|
total={total}
|
||||||
page={page}
|
page={page}
|
||||||
|
|||||||
@@ -7817,6 +7817,139 @@ h3 {
|
|||||||
font-size: var(--font-size-sm);
|
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-enterprise,
|
||||||
.admin-task-counts,
|
.admin-task-counts,
|
||||||
.admin-task-send-type {
|
.admin-task-send-type {
|
||||||
|
|||||||
Reference in New Issue
Block a user