diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index b743a4b..af5983f 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -84,7 +84,7 @@ function createPrismaMock() { tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved', - signature: { auditStatus: 'approved', reportStatus: 'approved' }, + signature: { auditStatus: 'approved', reportStatus: 'reporting' }, }), findFirst: jest.fn().mockResolvedValue({ id: 'tpl-1', @@ -92,11 +92,11 @@ function createPrismaMock() { applicationId: 'app-1', content: 'hello', auditStatus: 'approved', - signature: { auditStatus: 'approved', reportStatus: 'approved' }, + signature: { auditStatus: 'approved', reportStatus: 'reporting' }, }), }, smsSignature: { - findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'approved' }), + findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }), }, smsSendTask: { findUnique: jest.fn().mockResolvedValue(null), @@ -154,6 +154,7 @@ function createPrismaMock() { }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }), + findMany: jest.fn().mockImplementation(({ where }) => Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId })))), }, smsReceiptRecord: { create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), @@ -720,6 +721,27 @@ describe('SendChainService', () => { expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything()); }); + it('routes a partially reported signature only through its approved backup channel', async () => { + const { service, prisma } = createService(); + const baseRoute = await prisma.channelRouteRule.findFirst(); + const primary = baseRoute.group.items[0].channel; + const backup = { ...primary, id: 'channel-backup', code: 'CMPP-B' }; + prisma.channelRouteRule.findFirst.mockResolvedValue({ + ...baseRoute, + group: { ...baseRoute.group, items: [ + { ...baseRoute.group.items[0], channelId: primary.id, priority: 1, channel: primary }, + { ...baseRoute.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup }, + ] }, + }); + prisma.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: backup.id }]); + const gatewayAdd = jest.fn().mockResolvedValue(undefined); + service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); + service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); + + await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id })); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) })); + }); + it('persists identified carrier and province before a route lookup fails', async () => { const { service, prisma } = createService(); prisma.channelRouteRule.findFirst.mockResolvedValueOnce(null); @@ -1047,7 +1069,7 @@ describe('SendChainService', () => { const gatewayAdd = jest.fn().mockResolvedValue(undefined); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); - prisma.channelSignatureReportTask.findFirst.mockResolvedValue(null); + prisma.channelSignatureReportTask.findMany.mockResolvedValue([]); prisma.smsMessageRecord.findUnique.mockResolvedValue({ id: 'record-1', tenantId: 'tenant-1', @@ -1067,7 +1089,7 @@ describe('SendChainService', () => { }); await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual( - expect.objectContaining({ submitted: false, status: 'failed', reason: '短信签名未在最终通道报备通过' }), + expect.objectContaining({ submitted: false, status: 'failed', reason: '无已报备通过且在线的可用通道' }), ); expect(gatewayAdd).not.toHaveBeenCalled(); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 90984f4..1bd0401 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1626,8 +1626,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { await reject('TEMPLATE', '短信模板尚未审核通过'); } else if (!template.signature || template.signature.auditStatus !== 'approved') { await reject('SIGNATURE', '短信签名尚未审核通过'); - } else if (template.signature.reportStatus !== 'approved') { - await reject('REPORT', '短信签名尚未报备通过'); } else { const risk = await this.riskReview.evaluateTask({ tenantId: application.tenantId, @@ -1861,7 +1859,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } private async selectChannelForMessage( - message: { id: string; tenantId: string; applicationId?: string | null; phoneNumber: string }, + message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; phoneNumber: string; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, ): Promise { if (!message.applicationId) { @@ -1875,8 +1873,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier); const excluded = new Set(options.excludeChannelIds ?? []); + const signatureId = await this.resolveMessageSignatureId(message); + if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道'); + const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({ + where: { signatureId, status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } }, + select: { channelId: true }, + }); + const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId)); const items = route.group.items.filter((item) => !excluded.has(item.channelId) + && approvedChannelIds.has(item.channelId) && normalizeCarrier(item.carrier) === carrier && isCarrierCompatible(item.channel.carrier, carrier), ); @@ -1884,7 +1890,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const nationalCandidates = items.filter((item) => isNationalChannel(item)); const selected = [...provinceCandidates, ...nationalCandidates].find((item) => this.isChannelSendAvailable(item.channel)); if (!selected) { - throw new NotFoundException('无可用在线通道'); + throw new NotFoundException('无已报备通过且在线的可用通道'); } return { channel: selected.channel, @@ -2111,8 +2117,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') { throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); } - if (!template.signature || template.signature.auditStatus !== 'approved' || template.signature.reportStatus !== 'approved') { - throw new BadRequestException('短信签名未审核通过或通道报备未通过'); + if (!template.signature || template.signature.auditStatus !== 'approved') { + throw new BadRequestException('短信签名未审核通过'); } } @@ -2236,14 +2242,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }, channelId: string, ) { - let signatureId = message.template?.signature?.id ?? message.signature?.id ?? null; - if (!signatureId && message.templateId) { - const template = await this.prisma.smsTemplate.findUnique({ - where: { id: message.templateId }, - include: { signature: true }, - }); - signatureId = template?.signature?.id ?? null; - } + const signatureId = await this.resolveMessageSignatureId(message); if (!signatureId) { throw new BadRequestException('短信签名未配置,不能提交到通道'); } @@ -2256,6 +2255,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } } + private async resolveMessageSignatureId(message: { templateId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { + const direct = message.template?.signature?.id ?? message.signature?.id ?? null; + if (direct || !message.templateId) return direct; + const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } }); + return template?.signature?.id ?? null; + } + private async waitForChannelRateLimit(channelId: string, tps: number) { const redis = this.getRedis(); for (;;) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index d79bf67..9513c08 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -177,6 +177,7 @@ 21. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。 22. 通道组补发时间上限由运营端配置,交互为“小时 + 分钟”,默认 12 小时 0 分钟,最小 1 分钟,最大不得超过 72 小时;真实发送链路按分钟级上限判断是否继续补发。本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。 23. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。 +24. 签名全局 `reportStatus` 仅用于运营汇总展示,不得作为发送的一票否决条件。签名部分通道报备通过时允许发送,但路由候选必须只包含该签名 `ChannelSignatureReportTask.status=approved` 的通道;主通道未通过而备用通道通过时允许选择备用通道。所有在线候选通道均未报备通过时拒绝发送,补发切换通道时必须重新执行相同校验。 ### 4.7 通道签名报备 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index d842747..2a4be1c 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -330,6 +330,21 @@ - 新增移动通道后移动汇总立即变为部分通过/报备中,分母包含新增通道,不能继续误显示全部通过。 - 发送时仍校验最终路由通道对应任务为 approved,不以企业签名列表汇总标签代替通道级校验。 +### TC-ADMIN-005C 部分通道报备通过时的发送路由 + +- 优先级:P0 +- 前置条件:签名和模板审核通过;应用通道组包含主、备用两个在线通道;签名全局报备状态为 reporting,主通道任务 pending,备用通道任务 approved。 +- 步骤: + 1. 使用该签名发送一条短信。 + 2. 查看短信记录、路由通道和 Gateway SubmitCommand。 + 3. 将备用通道任务也改为 pending,再次发送。 + 4. 将主通道任务改为 approved,模拟主通道提交失败并触发补发。 +- 预期结果: + - 第一次发送不因全局 reportStatus=reporting 被提前拒绝,只选择已报备通过的备用通道。 + - 两个候选通道均未通过时发送失败,原因明确为无已报备通过且在线的可用通道,不进入 Gateway。 + - 补发重新按新候选通道的任务状态筛选,不能切换到未报备通过通道。 + - 最终提交前仍执行通道级二次校验,避免路由后状态变化导致错误发送。 + ### TC-ADMIN-006 报备回执导入通过 - 优先级:P0 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 78e301c..76641e5 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1636,3 +1636,10 @@ git diff --check - 2026-07-12 追加:报备状态改为通道任务唯一事实来源。新增统一批量状态变更 API,企业签名按应用当前通道组展示具体通道矩阵,通道详情修改当前任务,报备任务页人工修正任务;三个入口统一更新/创建 `ChannelSignatureReportTask`、写 `ChannelSignatureReportRecord`,并重算三网汇总和 `SmsSignature.reportStatus`。回执导入不再直接覆盖全局状态,同样调用汇总算法;新增但无任务的目标通道按未报备计入汇总分母。 - 已执行相关定向测试 2 suites、46 项及 API 全量测试 13 suites、135 项,API build、前端 build、`git diff --check` 均通过;前端仅有既有 Vite chunk size warning。 - 已提交并 push `eab05958` 后部署生产,部署前完成 PostgreSQL 与运行源码备份;migration `20260712170000_normalize_report_field_types` 成功应用。生产 `.deployed-commit=eab05958`,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 200。新统一状态接口对空 items 返回预期 400,证明路由已注册。生产当前 `DrainageField/ChannelReportField/ChannelSignatureReportTask/ChannelSignatureReportRecord` 均为 0,未为验收注入虚假字段、任务或人工状态;运营人员可从企业签名通道矩阵对真实目标通道首次设置状态并创建任务。 + +## 2026-07-13 部分通道报备通过的发送路由 + +- 修复发送链路先以 `SmsSignature.reportStatus=approved` 一票否决、导致部分通道通过仍无法发送的问题。全局状态改为运营汇总展示;签名审核仍必须通过。 +- 首次发送和失败补发均在路由阶段解析真实签名,只保留对应 `ChannelSignatureReportTask.status=approved` 的候选通道,再结合运营商、省份、优先级、通道状态和实时连接选择通道。主通道未报备而备用通道已报备时允许走备用通道;没有已报备通过且在线的候选通道时明确失败。 +- Gateway 提交前继续保留最终通道报备任务二次校验,覆盖路由完成后任务状态发生变化的竞态。 +- 已执行 `send-chain.service.spec.ts` 40 项通过,覆盖全局 reporting 可发、主通道未通过而备用通道通过时选备用,以及所有候选均未通过时拒绝;API build 和前端 build 通过。