diff --git a/api/src/send-chain/downstream-receipt-targets.ts b/api/src/send-chain/downstream-receipt-targets.ts index d00acde..543e21b 100644 --- a/api/src/send-chain/downstream-receipt-targets.ts +++ b/api/src/send-chain/downstream-receipt-targets.ts @@ -21,6 +21,7 @@ export type DownstreamDeliveryQueueRequest = { receiptDedupeKey?: string; queueHttpWebhook?: boolean; queueCmppDelivery?: boolean; + allowBusinessRejectionCmppDelivery?: boolean; propagateHttpQueueError?: boolean; }; @@ -80,6 +81,7 @@ export async function queueFinalReceiptDeliveries( message: FinalReceiptMessage; payload: Record; segmentPayloads?: Record>; + allowBusinessRejectionCmppDelivery?: boolean; propagateHttpQueueError?: boolean; }, ) { @@ -125,6 +127,7 @@ export async function queueFinalReceiptDeliveries( : `receipt:${message.id}:segment:${target.segmentIndex}`, queueHttpWebhook: false, queueCmppDelivery: true, + allowBusinessRejectionCmppDelivery: data.allowBusinessRejectionCmppDelivery, }); } return { queued: true, cmppTargetCount: targets.length }; diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index a26495f..8978004 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -1205,7 +1205,7 @@ describe('SendChainService', () => { }); }); - it('rejects new submissions synchronously when the application interface was disabled after bind', async () => { + it('accepts Submit after bind and emits an auditable REJECTD receipt when the interface was disabled', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', @@ -1222,6 +1222,7 @@ describe('SendChainService', () => { id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', + status: 'active', interfaceEnabled: false, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, @@ -1232,12 +1233,34 @@ describe('SendChainService', () => { account: '100001', phoneNumber: '13800000001', content: 'hello', + sequenceId: 701, remoteIp: '127.0.0.1', - })).rejects.toThrow('CMPP account is disabled for new submissions'); + })).resolves.toEqual(expect.objectContaining({ + accepted: true, + messageRecordId: 'record-1', + status: 'accepted', + })); - expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); - expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); - expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'record-1' }, + data: expect.objectContaining({ + status: 'failed', + receiptStatus: 'undelivered', + receiptRawStatus: 'REJECTD', + errorCode: 'INTERFACE', + }), + })); + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }), + })); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }), + })); + expect(service['postGatewayControl']).toHaveBeenCalledWith( + '/downstream/receipt', + expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }), + ); }); it('queues an HTTP webhook but not CMPP delivery for an HTTP-only application', async () => { @@ -2376,23 +2399,44 @@ describe('SendChainService', () => { } }); - it('rejects a disabled application before the merged Inbox statement can insert', async () => { + it('persists fast-path Submit before evaluating application or tenant business state', async () => { const previous = process.env.CMPP_INBOUND_FAST_PATH_ENABLED; process.env.CMPP_INBOUND_FAST_PATH_ENABLED = 'true'; try { const { service, prisma } = createService(); - prisma.$queryRaw.mockResolvedValueOnce([{ - validationError: 'CMPP account is disabled for new submissions', - payloadHash: null, - response: null, - }]); + prisma.$queryRaw.mockImplementationOnce((query) => { + const payloadHash = query.values.find((value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)); + return Promise.resolve([{ + validationError: null, + payloadHash, + response: { + accepted: true, + tenantId: 'tenant-1', + applicationId: 'app-1', + taskId: '', + messageId: 'MSG-disabled-after-bind', + messageRecordId: '', + status: 'accepted_pending', + phoneCount: 1, + messages: [], + }, + }]); + }); await expect(service.submitInboundMessage({ requestId: 'cmpp-inbound:disabled', account: '100001', phoneNumber: '13800000001', content: 'hello', - })).rejects.toThrow('CMPP account is disabled for new submissions'); + })).resolves.toEqual(expect.objectContaining({ + accepted: true, + status: 'accepted_pending', + })); + const sql = prisma.$queryRaw.mock.calls[0][0].strings.join(' '); + expect(sql).not.toContain("application.status <> 'active'"); + expect(sql).not.toContain('NOT application."interfaceEnabled"'); + expect(sql).toContain('CMPP source IP is not in application allowlist'); + expect(sql).toContain('CMPP Src_Id must equal the access number assigned to this application'); expect(prisma.cmppInboundSubmissionInbox.findUnique).not.toHaveBeenCalled(); } finally { if (previous == null) delete process.env.CMPP_INBOUND_FAST_PATH_ENABLED; @@ -3928,7 +3972,7 @@ describe('SendChainService', () => { })); }); - it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => { + it('allows a disabling application to reconnect for receipt draining and audits later Submit as REJECTD', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', @@ -3943,6 +3987,16 @@ describe('SendChainService', () => { ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); + prisma.smsApplication.findUnique.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'deleted', + interfaceEnabled: false, + downstreamReceiptRetryEnabled: true, + downstreamUplinkRetryEnabled: true, + httpConfig: { enabled: false }, + }); await expect(service.authenticateInboundApplication({ account: '100001', @@ -3955,9 +4009,20 @@ describe('SendChainService', () => { account: '100001', phoneNumber: '13800000001', content: 'hello', + sequenceId: 702, remoteIp: '127.0.0.1', - })).rejects.toThrow('disabled for new submissions'); - expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); + })).resolves.toEqual(expect.objectContaining({ accepted: true, status: 'accepted' })); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }), + })); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }), + })); + expect(service['postGatewayControl']).toHaveBeenCalledWith( + '/downstream/receipt', + expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }), + ); }); it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => { diff --git a/api/src/send-chain/send-downstream-delivery.service.ts b/api/src/send-chain/send-downstream-delivery.service.ts index aed170e..e1f4c4d 100644 --- a/api/src/send-chain/send-downstream-delivery.service.ts +++ b/api/src/send-chain/send-downstream-delivery.service.ts @@ -210,6 +210,7 @@ export class SendDownstreamDeliveryService { }, }); const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling'; + const cmppDeliveryAllowed = deliveryAllowed || data.allowBusinessRejectionCmppDelivery === true; if (deliveryAllowed && data.queueHttpWebhook !== false) { try { await this.openApi?.queueWebhookEvent({ @@ -229,7 +230,9 @@ export class SendDownstreamDeliveryService { if (data.queueCmppDelivery === false) { return null; } - if (application?.interfaceEnabled !== true) { + if (!application?.cmppAccount || ( + application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true + )) { return null; } const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; @@ -249,11 +252,11 @@ export class SendDownstreamDeliveryService { dedupeKey, deliveryType: data.deliveryType, payload, - retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink' + retryEnabled: cmppDeliveryAllowed && (data.deliveryType === 'uplink' ? application?.downstreamUplinkRetryEnabled ?? true : application?.downstreamReceiptRetryEnabled ?? true), - status: deliveryAllowed ? 'pending' : 'abandoned', - lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送', + status: cmppDeliveryAllowed ? 'pending' : 'abandoned', + lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送', }, }); } catch (error) { @@ -278,7 +281,7 @@ export class SendDownstreamDeliveryService { } throw error; } - if (!deliveryAllowed) { + if (!cmppDeliveryAllowed) { return delivery; } const claimId = `api-direct:${process.pid}:${randomUUID()}`; @@ -478,6 +481,7 @@ export class SendDownstreamDeliveryService { (request) => this.facade.queueAndTryDownstreamDelivery(request), { message, + allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE', payload: { messageId: message.messageId, gatewayMessageId: `PLATFORM:${message.messageId}`, diff --git a/api/src/send-chain/send-inbound-entry.service.ts b/api/src/send-chain/send-inbound-entry.service.ts index 7570a54..a3869da 100644 --- a/api/src/send-chain/send-inbound-entry.service.ts +++ b/api/src/send-chain/send-inbound-entry.service.ts @@ -233,9 +233,6 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { if (!application) { throw new BadRequestException('CMPP account is invalid'); } - if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) { - throw new BadRequestException('CMPP account is disabled for new submissions'); - } if (data.longMessage) { if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { throw new BadRequestException('CMPP source IP is not in application allowlist'); @@ -360,10 +357,10 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { const remoteIp = data.remoteIp?.replace(/^::ffff:/, '').trim() || null; const submittedSrcId = data.srcId?.trim() ?? ''; - // One indexed statement must both validate the current application state and create the - // durable Inbox row. Keeping those operations in one database snapshot prevents a disable - // racing between a separate SELECT and INSERT, while the unique request key remains the - // authoritative idempotency boundary. + // One indexed statement validates protocol-level constraints and creates the durable Inbox + // row. Application/tenant/interface state is deliberately evaluated by the workflow worker: + // an already-authenticated connection must receive a successful SubmitResp first, followed + // by an auditable REJECTD receipt if the business resource was disabled after bind. const rows = await this.prisma.$queryRaw(Prisma.sql` WITH application AS ( SELECT app.id, @@ -383,10 +380,6 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { ), validation AS ( SELECT application.*, CASE - WHEN application.status <> 'active' - OR application."tenantStatus" <> 'active' - OR NOT application."interfaceEnabled" - THEN 'CMPP account is disabled for new submissions' WHEN ${remoteIp}::text IS NOT NULL AND EXISTS ( SELECT 1 FROM "SmsApplicationIpAllowlist" allowlist diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index d045acf..52dcfd5 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1229,6 +1229,7 @@ - Submit 应用身份使用 bind 已鉴权账号;`MsgSrc` 使用应用级企业代码并独立校验。企业代码与登录账号不同时仍能正确定位应用,企业代码不匹配时返回失败。 - 鉴权失败、IP 白名单不符、任一目标手机号等协议参数不合法时返回非零 SubmitResp,且整包不创建短信记录;禁止多号码 Submit 返回成功后只保存或发送首号码。 - 已鉴权且参数合法的 Submit 必须先返回成功 SubmitResp 和平台 Msg_Id;内容不匹配审核模板、签名/报备未通过、余额不足、应用在 bind 后停用、无可用通道、上游 Submit 最终失败时,均须真实创建短信记录、`SmsReceiptRecord` 和 `CmppDownstreamDelivery`,并向客户下发 `undelivered/REJECTD` Deliver Receipt,不得仅以 SubmitResp 失败替代回执。 + - 应用、企业或CMPP接口在bind后停用/删除时,仅本次已受理Submit产生的`ACCOUNT/INTERFACE`平台失败回执可绕过当前业务启用状态投向原CMPP会话;不得因此允许新bind、供应商Submit、普通HTTP消息转CMPP回执或其他停用资源继续发送。 - Gateway 对每次 submit 记录 `submit_received` 和 `submit_accepted`/`submit_rejected`;日志可按账号、IP、sequenceId、号码和 messageId 定位,拒绝时包含 NestJS 真实业务原因和 CMPP result,但不包含明文短信正文。 - CMPP 包在进入 handler 前因长度、命令字、读包或 Unpack 失败时,Gateway 记录 `read/unpack packet failed`、远端地址、协议模式、错误类型和原始错误,不得静默断开。 - 企业应用列表和连接详情展示真实下游 CMPP 会话:bind 后当前连接数加一,显示客户 IP、企业代码、CMPP 版本、连接建立时间与最后心跳;连接持续未响应 `ACTIVE_TEST` 超过阈值后转为心跳超时/断开,不能继续显示为正常连接。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 27b33ca..8d8cb49 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3995,3 +3995,11 @@ git diff --check - 企业应用`inactive/deleted`、企业`inactive/deleted`四次新连接均返回CMPP Bind状态3并断开,未创建短信记录。bind后再停用的严格复验同样拦截发送;此前两次跨机器时间未校准的尝试虽送达并计费,但无法证明配置变更早于Submit,明确作废且不用于判断。严格复验暴露`P0-SEND-AUDIT-001`:应用或企业停用后,参数合法Submit返回非零SubmitResp,且测试号码`13800389700/13800389800`均没有`SmsMessageRecord`、失败回执或下游投递;这与`TC-SEND-037`要求“已鉴权合法Submit先返回成功并留痕,再以REJECTD业务失败”冲突。当前单元测试反而断言同步拒绝且不落库,说明需求、测试和实现口径已漂移,需先确认权威语义再修复,不能把“拦截生效”冒充完整失败审计闭环。 - 通道行为通过:仅`LGST-M-P`停用时,`MSG-94d30de8-78cb-48a4-bdbf-e6ef6dd2fb93`、`MSG-22f26498-0289-4b79-aa0c-a1fa54bb0b99`自动改走`LGST-M-B`并delivered;移动主备同时停用时两条均`failed/ROUTE`、供应商Submit=0、账单=0。确定性故障模拟下,`MSG-c28b231f-2489-41fc-9d0f-6223bc8307bd`和`MSG-a65283b6-9349-4838-a12d-fee2e5ee07ff`先在主通道结果码8/rejected,再创建带`retryOfSubmitRecordId`的备用accepted提交并最终delivered,每条只计费325。 - 最终恢复回读:企业/应用active、接口开启、模板模式direct_send、应用单价325、余额1824051;`【LG压测】`审核和汇总报备approved,6/6通道报备approved;六通道active、端口17900、连接6/6,普通模拟器已恢复;每应用两条指定白名单及phase5/phase6六条临时运营商号段规则全部保留,临时频控规则0。Inbox仅completed、Submit Outbox仅published,命令/结果/协议日志Stream均pending0/lag0,业务BullMQ wait/active/delayed/failed/prioritized均0,遗留`gateway.submit.queue` wait仍为84119且未增长;数据库未授予锁0、idle transaction0,9项服务active,本轮窗口核心服务error级journal为0。 + +## 2026-08-25 P0-SEND-AUDIT-001 本地修复与提交前验证 + +- 根因确认有两段:普通路径在应用查询后、快速路径在Inbox合并SQL内,都把应用/企业/接口状态作为SubmitResp同步拒绝条件,导致已鉴权合法Submit在短信记录前退出;即使后续业务状态机生成`ACCOUNT/INTERFACE`失败回执,下游队列仍会因当前`interfaceEnabled=false`直接返回。两处行为共同造成测试号码没有`SmsMessageRecord`、`SmsReceiptRecord`和`CmppDownstreamDelivery`。 +- 最小修复保留新bind的应用/企业/接口拒绝、账号存在性、IP白名单、`Src_Id`和协议参数校验;已鉴权合法Submit先进入原有持久化流程,普通路径立即、快速路径由Worker按最新状态生成`failed/ACCOUNT`或`failed/INTERFACE`、`undelivered/REJECTD`。仅这两类平台业务失败回执携带窄范围许可,即使资源随后停用/删除或接口关闭,也可创建下游投递并尝试送到原CMPP会话;普通HTTP消息、上行和其他停用资源投递语义未放宽。 +- 单元回归覆盖接口bind后关闭、应用处于disabling且回执入队时已deleted/interface关闭、快速路径Inbox SQL不再提前检查业务状态,同时确认IP白名单与`Src_Id`校验仍保留;既有“接口关闭时新bind拒绝”和“HTTP-only应用不产生CMPP投递”用例继续通过。 +- 提交前门禁:API全量45套526项通过;非增量TypeScript正式构建通过且未要求改写用户保留的`tsbuildinfo`;Gateway `go test ./... -count=1`和`go vet ./...`通过;`git diff --check`通过。无数据库迁移、无新增环境变量,部署脚本和环境变量校验无需修改;回滚只需revert本轮代码/文档提交并重新构建。 +- 本轮按用户授权仅在本地修复和验证,没有部署到测试环境,也没有执行新的短信发送或压力测试;测试环境仍运行旧部署标记`b5005f21d5092b7e2759efdce0ebd02798e4552f+test.phase6.tenant-microbatch.utc.keepalive.connection-race`,恢复资产仍为`/opt/cmpp-platform-backups/phase6-connection-race-20260825T095448Z`。因此P0已由代码和自动化测试验证,但尚未在测试环境重新做真实CMPP闭环验收;预生产/生产未访问,多Gateway P2未实施,未push远端。