From f08a73e19129f5249a5a9e0035c7ab63b80422d4 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Tue, 14 Jul 2026 11:19:24 +0800 Subject: [PATCH] fix: accept partially reported CMPP signatures --- api/src/send-chain/send-chain.service.spec.ts | 49 +++++++++++++++++++ api/src/send-chain/send-chain.service.ts | 9 ++-- .../first-version-development-requirements.md | 2 +- docs/system-functional-test-cases.md | 15 +++++- docs/testing-progress.md | 7 +++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 7a408c5..f11915d 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -596,6 +596,55 @@ describe('SendChainService', () => { where: { id: 'task-1' }, data: expect.objectContaining({ status: 'pending_review', riskTaskId: 'review-task-1', auditStatus: 'pending' }), }); + expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ + where: { + applicationId: 'app-1', + name: '【签名】', + auditStatus: 'approved', + }, + orderBy: { updatedAt: 'desc' }, + }); + expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); + }); + + it('accepts an approved bracketed signature when only part of its channels are reported', async () => { + const { service, prisma, riskReview } = createService(); + prisma.smsApplication.findFirst.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'active', + interfaceEnabled: true, + templateMismatchMode: 'manual_review', + customerUnitPrice: 3, + queuePriority: 'normal', + ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], + tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, + }); + prisma.smsTemplate.findFirst.mockResolvedValue(null); + prisma.smsSignature.findFirst.mockResolvedValue({ + id: 'sig-1', + name: '【航天信息信诺网】', + auditStatus: 'approved', + reportStatus: 'reporting', + }); + + await expect(service.submitInboundMessage({ + account: '100001', + phoneNumber: '18821203795', + content: '【航天信息信诺网】您本次操作的验证码是171102,有效时10分钟。', + remoteIp: '127.0.0.1', + })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + + expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ + where: { + applicationId: 'app-1', + name: '【航天信息信诺网】', + auditStatus: 'approved', + }, + orderBy: { updatedAt: 'desc' }, + }); + expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith(expect.objectContaining({ signatureId: 'sig-1' })); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 029d8fd..ace2973 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1569,7 +1569,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } else if (!template && application.templateMismatchMode === 'manual_review') { const signature = await this.resolveInboundSignatureCandidate(application.id, data.content); if (!signature) { - await reject('SIGNATURE', '短信内容未识别到已审核且已报备的签名'); + await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); } else { const risk = await this.riskReview.evaluateTask({ tenantId: application.tenantId, @@ -2012,14 +2012,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } private resolveInboundSignatureCandidate(applicationId: string, content: string) { - const match = content.match(/^【([^】]+)】/); - if (!match?.[1]) return null; + const match = content.match(/^【[^】]+】/); + if (!match?.[0]) return null; return this.prisma.smsSignature.findFirst({ where: { applicationId, - name: match[1], + name: match[0], auditStatus: 'approved', - reportStatus: 'approved', }, orderBy: { updatedAt: 'desc' }, }); diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 2822b8d..29ee214 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -147,7 +147,7 @@ 12. 企业应用“不符合模板的短信”配置为 `manual_review` 时,合法的 CMPP Submit 在模板不匹配后进入人工审核;配置为 `reject` 时仍直接拒绝并返回 `REJECTD` Deliver Receipt,其他模式不得被人工审核聚合逻辑误接管。 13. CMPP 模板不匹配审核支持短窗口内容指纹聚合:只有同一企业应用、同一 CMPP 账号、规范化后内容 SHA-256 完全一致且位于同一时间窗口的短信才能合并为一个审核任务。默认窗口 10 秒,可通过 `CMPP_TEMPLATE_REVIEW_WINDOW_MS` 调整。 14. 聚合审核不合并短信记录、计费或回执:每个手机号仍有独立 `SmsMessageRecord/messageId/sequenceId`。审核通过后逐条进入真实路由和上游提交;审核驳回后逐条释放冻结并产生客户侧 `REJECTD` 回执。 -15. 人工审核只覆盖模板不匹配;签名必须能从短信前缀识别且已审核/报备通过。签名不合法、风控直接拒绝或余额不足不得因内容聚合而绕过。 +15. 人工审核只覆盖模板不匹配;签名必须以完整中文中括号前缀 `【签名】` 识别,并使用包含中括号的完整名称匹配签名库。入站候选签名只要求 `auditStatus=approved`,不得以全局 `reportStatus` 提前拒绝;报备通过状态必须在后续路由和最终提交前按具体通道校验。签名不合法、风控直接拒绝或余额不足不得因内容聚合而绕过。 16. 发送入队必须按短信应用的队列等级分流到优先队列或普通队列;同等条件下优先队列消息必须先于普通队列消息被 Send Worker 消费并提交 Gateway。 17. 优先队列只能改变待发送消息的调度顺序,不得绕过企业/应用状态、签名模板审核、通道报备、余额/授信、黑名单、风控、通道组路由、通道限速和 Gateway 连接可用性校验。 18. 同一队列内部按创建时间、任务顺序和手机号拆分顺序保持 FIFO 或可解释的稳定排序;优先队列插队时必须可在 trace 或任务日志中追踪队列等级和入队时间。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index f2f940e..1f3ccc3 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1139,7 +1139,20 @@ - 不同应用、账号、内容指纹或窗口的短信不合并。 - 应用 B 不进入人工审核,继续逐条产生 `REJECTD` Deliver Receipt。 - 审核通过后每条成员独立进入路由、提交和计费;审核驳回后每条成员独立释放冻结并向客户下发 `REJECTD`。 - - 签名无法识别/未报备、风控直接拒绝或余额不足时不进入聚合审核。 + - 签名无法识别、未审核通过、风控直接拒绝或余额不足时不进入聚合审核;签名报备状态由后续具体通道路由校验。 + +### TC-SEND-039A CMPP 完整括号签名与部分通道报备放行 + +- 优先级:P0 +- 前置条件:应用配置 `templateMismatchMode=manual_review`;签名库名称为完整的 `【航天信息信诺网】`,`auditStatus=approved`、全局 `reportStatus=reporting`;主通道签名任务 approved、备用通道签名任务 pending。 +- 步骤: + 1. 通过真实 CMPP 入站提交 `【航天信息信诺网】您本次操作的验证码是171102,有效时间10分钟。`。 + 2. 查看入站签名查询、人工审核聚合记录和失败回执。 + 3. 审核通过后查看短信路由与 Gateway SubmitCommand。 +- 预期结果: + - 入站签名使用完整的 `【航天信息信诺网】` 查询,不剥离中括号,不以无括号名称查询。 + - 全局 `reportStatus=reporting` 不在入站阶段触发 `SIGNATURE` 拒绝,短信进入真实人工审核聚合链路且不产生签名失败回执。 + - 审核通过后只允许选择签名任务为 approved 的主通道,不能选择 pending 的备用通道;最终提交前继续执行同一通道级校验。 ### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 74d503d..a2a7ee0 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1699,3 +1699,10 @@ git diff --check - 本地真实 PostgreSQL 已应用 `20260714100000_add_report_record_source_entry`,并通过编译后的 NestJS 服务对现有应用、签名、模板执行真实组合查询。API 全量测试 13 suites、141 项通过;Prisma validate、API build、前端 build、Gateway 测试和 `git diff --check` 通过。应用内浏览器确认真实鉴权跳转、页面标题、非空 DOM、无框架错误覆盖及 console 无 error/warn;因图形验证码未获授权代解,登录后页面点击验收未执行。 - 功能提交 `64216712` 与历史入口修正提交 `70991478` 均已 push,生产最终运行代码为 `709914787196072b66d293ee9290d7cd41b46990`。首次备份为 `/opt/cmpp-platform/backups/cmpp-20260714-095958.sql` 和 `source-20260714-095958.tar.gz`,最终修正部署前备份为 `/opt/cmpp-platform/backups/cmpp-20260714-100658.sql` 和 `source-20260714-100658.tar.gz`;发布包本地与服务器 SHA-256 一致。 - 生产 36 条 migration 全部应用,四项服务 active,`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 200,部署后 journal 无新 error。真实报备记录 API 返回 16 条且通道名称缺失数为 0;7 条系统记录为 `system`,9 条旧人工记录为 `legacy`。用生产现有数据调用独立组合查询,企业应用、模板、签名和引流信息分组均各返回 1 个精确匹配结果;企业黑名单当前为 0 条,未注入演示数据。 + +## 2026-07-14 CMPP 入站完整括号签名与部分通道放行修复 + +- 生产只读核查手机号 `18821203795` 的最近一次提交:Gateway 已接收入站,但 NestJS 在路由前以 `SIGNATURE / 短信内容未识别到已审核且已报备的签名` 拒绝,未生成通道提交记录。实际短信前缀和签名库名称均为完整的 `【航天信息信诺网】`;签名审核已通过、全局报备状态为 reporting,主通道任务 approved、备用通道任务 pending。 +- 根因是入站签名解析正则取捕获组后剥离了中括号,却用无括号名称查询保存完整括号的签名库;同时查询错误要求全局 `SmsSignature.reportStatus=approved`,与“部分通道通过即可发送、路由只选通过通道”的既定规则冲突。 +- 修复为从短信开头提取完整 `【签名】` 并原样查询,仅在入站候选阶段校验 `auditStatus=approved`。全局 `reportStatus` 不再作为入口门禁,具体通道的 `ChannelSignatureReportTask(reportType=signature).status=approved` 仍由路由和最终提交二次校验。 +- 新增真实故障形态回归用例:完整括号签名、审核通过、全局 reporting 时可进入模板不匹配人工审核聚合,并验证查询不再携带全局报备条件、不产生签名失败回执。定向 API 测试 1 suite、41 项通过;API 全量 13 suites、142 项通过,API build、前端 build、Gateway 全量 Go 测试和 `git diff --check` 通过,前端仅有既有 Vite chunk size warning。生产部署结果待本批次完成后补录。