diff --git a/api/prisma/migrations/20260709143000_add_application_interface_switch/migration.sql b/api/prisma/migrations/20260709143000_add_application_interface_switch/migration.sql new file mode 100644 index 0000000..994632d --- /dev/null +++ b/api/prisma/migrations/20260709143000_add_application_interface_switch/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "SmsApplication" +ADD COLUMN "interfaceEnabled" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "interfaceType" TEXT NOT NULL DEFAULT 'cmpp20'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d7d0c52..b2bfd67 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -344,6 +344,8 @@ model SmsApplication { cmppAccount String @unique cmppEnterpriseCode String secretHash String + interfaceEnabled Boolean @default(true) + interfaceType String @default("cmpp20") cmppMaxConnections Int @default(1) cmppWindowSize Int @default(16) dailyLimit Int? diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index b7a43b8..845c301 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -63,7 +63,7 @@ function createPrismaMock() { findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }), }, smsApplication: { - findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }), + findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, customerUnitPrice: 3, queuePriority: 'normal' }), findMany: jest.fn().mockResolvedValue([{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }]), findFirst: jest.fn().mockResolvedValue({ id: 'app-1', @@ -71,6 +71,7 @@ function createPrismaMock() { cmppAccount: '100001', secretHash: 'secret-hash', status: 'active', + interfaceEnabled: true, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, @@ -414,6 +415,74 @@ describe('SendChainService', () => { ).rejects.toThrow('企业认证未通过,不能发送短信'); }); + it('blocks sending when application interface is disabled', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findUnique.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + status: 'active', + interfaceEnabled: false, + customerUnitPrice: 3, + queuePriority: 'normal', + }); + + await expect( + service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: 'hello', + phones: ['13800000001'], + }), + ).rejects.toThrow('短信应用接口未开通,不能发送短信'); + + expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); + }); + + it('rejects Gateway authentication when application interface is disabled', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findFirst.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + secretHash: 'secret-hash', + status: 'active', + interfaceEnabled: false, + ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], + tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, + }); + + await expect(service.authenticateInboundApplication({ + account: '100001', + password: 'secret-hash', + remoteIp: '127.0.0.1', + })).rejects.toThrow('CMPP interface is disabled for this application'); + }); + + it('rejects Gateway submit when application interface is disabled', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findFirst.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + secretHash: 'secret-hash', + status: 'active', + interfaceEnabled: false, + queuePriority: 'normal', + ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], + tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, + }); + + await expect(service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + remoteIp: '127.0.0.1', + })).rejects.toThrow('CMPP interface is disabled for this application'); + + expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); + }); + it('previews imported phone files with duplicate, invalid, blacklist, and variable errors', async () => { const { service, prisma } = createService(); prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000003' }]); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 56df3c7..e8298ba 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1396,6 +1396,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!application || application.status !== 'active' || application.tenant.status !== 'active') { throw new BadRequestException('CMPP account is invalid or disabled'); } + if (!application.interfaceEnabled) { + throw new BadRequestException('CMPP interface is disabled for this application'); + } if (application.tenant.certificationStatus !== 'approved') { throw new BadRequestException('Enterprise certification is not approved'); } @@ -1419,6 +1422,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!application || application.status !== 'active' || application.tenant.status !== 'active') { throw new BadRequestException('CMPP account is invalid or disabled'); } + if (!application.interfaceEnabled) { + throw new BadRequestException('CMPP interface is disabled for this application'); + } if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { throw new BadRequestException('CMPP source IP is not in application allowlist'); } @@ -1787,6 +1793,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!application || application.tenantId !== tenantId || application.status !== 'active') { throw new BadRequestException('短信应用不存在或已停用'); } + if (!application.interfaceEnabled) { + throw new BadRequestException('短信应用接口未开通,不能发送短信'); + } if (!templateId) { return; } diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index d5328e4..8a5d40f 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -74,7 +74,7 @@ export class AdminSmsConfigController { @Post('enterprise-templates') createTemplate(@Body() body: CreateSmsTemplateDto) { - return this.smsConfig.createTemplate(body); + return this.smsConfig.createTemplate(body, { initialAuditStatus: 'approved' }); } @Put('enterprise-templates/:id') diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 1b19474..8ea27fe 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -12,6 +12,8 @@ function createPrismaMock() { cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 2, cmppWindowSize: 32, + interfaceEnabled: true, + interfaceType: 'cmpp20', queuePriority: 'normal', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }], @@ -25,6 +27,8 @@ function createPrismaMock() { cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 2, cmppWindowSize: 32, + interfaceEnabled: true, + interfaceType: 'cmpp20', queuePriority: 'normal', secretHash: '0123456789abcdef', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, @@ -180,8 +184,11 @@ describe('SmsConfigService', () => { passwordCipher: '0123456789abcdef', gatewayHost: '127.0.0.1', gatewayPort: 17890, + interfaceEnabled: true, + interfaceType: 'cmpp20', maxConnections: 2, windowSize: 32, + protocolVersion: 'CMPP2.0', })); }); @@ -198,6 +205,8 @@ describe('SmsConfigService', () => { passwordCipher: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, + interfaceEnabled: false, + interfaceType: 'cmpp20', queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'], })).resolves.toEqual(expect.objectContaining({ id: 'app-new' })); @@ -211,6 +220,8 @@ describe('SmsConfigService', () => { secretHash: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, + interfaceEnabled: false, + interfaceType: 'cmpp20', queuePriority: 'priority', ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, }), @@ -230,6 +241,19 @@ describe('SmsConfigService', () => { expect(prisma.smsApplication.create).not.toHaveBeenCalled(); }); + it('rejects unavailable enterprise application interface types', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.createApplication({ + tenantId: 'tenant-1', + name: 'HTTP应用', + interfaceType: 'http', + })).rejects.toThrow('interfaceType only supports cmpp20'); + + expect(prisma.smsApplication.create).not.toHaveBeenCalled(); + }); + it('rejects duplicate or invalid CMPP application accounts', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); @@ -420,6 +444,34 @@ describe('SmsConfigService', () => { })); }); + it('creates admin enterprise templates as approved when requested', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.createTemplate({ + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '运营添加模板', + content: '您的验证码为${code}', + variables: [{ name: 'code', example: '123456', required: true }], + }, { initialAuditStatus: 'approved' })).resolves.toEqual(expect.objectContaining({ id: 'tpl-new' })); + + expect(prisma.smsTemplate.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '运营添加模板', + auditStatus: 'approved', + variables: { + create: [{ name: 'code', example: '123456', required: true }], + }, + }), + include: { variables: true, application: true, tenant: true, signature: true }, + })); + }); + it('updates enterprise templates and rebuilds template variables', async () => { const prisma = createPrismaMock(); const tx = { diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 40af254..3c36978 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -11,6 +11,8 @@ export interface CreateSmsApplicationDto { cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; + interfaceEnabled?: boolean; + interfaceType?: string; cmppMaxConnections?: number; cmppWindowSize?: number; dailyLimit?: number; @@ -64,6 +66,10 @@ export interface CreateSmsTemplateDto { variables?: Array<{ name: string; example?: string; required?: boolean }>; } +export interface CreateSmsTemplateOptions { + initialAuditStatus?: string; +} + export type UpdateSmsTemplateDto = Partial> & { auditStatus?: string; }; @@ -93,6 +99,8 @@ export interface ApplicationListQuery { const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const; type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number]; +const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const; +type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number]; @Injectable() export class SmsConfigService { @@ -158,6 +166,7 @@ export class SmsConfigService { async createApplication(data: CreateSmsApplicationDto) { const secret = normalizeApplicationPassword(data.passwordCipher); const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); + const interfaceType = normalizeApplicationInterfaceType(data.interfaceType); const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount(); const cmppEnterpriseCode = await this.resolveCmppEnterpriseCode(data.cmppEnterpriseCode, data.tenantId); return this.prisma.smsApplication.create({ @@ -169,6 +178,8 @@ export class SmsConfigService { cmppAccount, cmppEnterpriseCode, secretHash: secret, + interfaceEnabled: data.interfaceEnabled ?? true, + interfaceType, cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), dailyLimit: data.dailyLimit, @@ -198,6 +209,9 @@ export class SmsConfigService { const cmppEnterpriseCode = data.cmppEnterpriseCode === undefined ? undefined : normalizeEnterpriseCode(data.cmppEnterpriseCode); + const interfaceType = data.interfaceType === undefined + ? undefined + : normalizeApplicationInterfaceType(data.interfaceType); const secretHash = data.passwordCipher === undefined ? undefined : normalizeApplicationPassword(data.passwordCipher); @@ -215,6 +229,8 @@ export class SmsConfigService { cmppAccount, cmppEnterpriseCode, secretHash, + interfaceEnabled: data.interfaceEnabled, + interfaceType, cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), dailyLimit: data.dailyLimit, @@ -373,10 +389,12 @@ export class SmsConfigService { account: application.cmppAccount, passwordCipher: application.secretHash, srcId: channel?.srcId ?? '', + interfaceEnabled: application.interfaceEnabled, + interfaceType: application.interfaceType, maxConnections: application.cmppMaxConnections, heartbeatSeconds: 30, windowSize: application.cmppWindowSize, - protocolVersion: channel?.cmppVersion ?? '3.0', + protocolVersion: application.interfaceType === 'cmpp20' ? 'CMPP2.0' : application.interfaceType, }; } @@ -542,7 +560,7 @@ export class SmsConfigService { }); } - createTemplate(data: CreateSmsTemplateDto) { + createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { return this.prisma.smsTemplate.create({ data: { tenantId: data.tenantId, @@ -551,6 +569,7 @@ export class SmsConfigService { name: data.name, content: data.content, category: data.category, + auditStatus: options.initialAuditStatus, billingUnits: estimateBillingUnits(data.content), variables: { create: (data.variables ?? inferTemplateVariables(data.content)).map((variable: TemplateVariableInput) => ({ @@ -833,6 +852,14 @@ function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePrio return queuePriority as ApplicationQueuePriority; } +function normalizeApplicationInterfaceType(value?: string): ApplicationInterfaceType { + const interfaceType = value ?? 'cmpp20'; + if (!APPLICATION_INTERFACE_TYPES.includes(interfaceType as ApplicationInterfaceType)) { + throw new BadRequestException('interfaceType only supports cmpp20; HTTP interface is not available yet'); + } + return interfaceType as ApplicationInterfaceType; +} + function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) { if (value === undefined || value === null) { return fallback; diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 465972c..bc05e68 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -110,6 +110,8 @@ 10. 短信应用接口密码 `passwordCipher` 新建时默认随机生成 16 位 UUID 片段,运营端可手工修改;编辑时留空不覆盖原密码。 11. 应用 `AppID` 是平台内部应用标识,用于页面展示、复制参数和工单定位,不作为 CMPP bind/login 认证参数。 12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections` 和客户提交窗口 `cmppWindowSize`;这两个字段是平台运行配置,不是 CMPP 协议字段,也不是 gocmpp 库参数。 +13. 短信应用必须恢复设计基线中的“短信接口”开关,字段为 `interfaceEnabled`,默认开通;关闭后客户端/API 发送链路、客户侧 CMPP Gateway bind/login 和 submit 都必须被真实后端拒绝,不允许只在前端隐藏入口。 +14. 短信应用必须恢复设计基线中的“接口类型”配置,当前第一版仅允许 `CMPP2.0`,字段为 `interfaceType=cmpp20`;HTTP 接口在页面中展示为暂不可选,后端也必须拒绝 `http` 等未实现类型。 ### 4.3 签名与引流信息 @@ -123,6 +125,7 @@ 1. 客户端创建短信模板,填写模板名称、短信内容、变量、应用、签名。 2. 系统校验敏感词、字数、变量格式和签名匹配。 3. 运营端短信模板审核可通过或驳回模板。 +4. 运营端在企业模板管理中代企业添加的短信模板,保存后应直接置为已通过 `approved`;客户端自行创建并提交的模板仍按审核流程处理。 4. 审核通过的模板才允许在短信发送中选择。 ### 4.5 短信发送 @@ -207,7 +210,7 @@ #### 4.8.2 下游客户 CMPP 接入能力 1. Gateway 必须监听生产 CMPP 端口 `17890`,作为平台侧 CMPP Server 接收企业客户系统连接;该端口不是 HTTP 健康检查或控制接口。 -2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括客户侧企业代码、账号、密码、CMPP 版本、源 IP 白名单、应用状态、企业状态、连接数上限;客户侧企业代码必须来自 `SmsApplication.cmppEnterpriseCode`。 +2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括客户侧企业代码、账号、密码、CMPP 版本、源 IP 白名单、短信接口开关、应用状态、企业状态、连接数上限;客户侧企业代码必须来自 `SmsApplication.cmppEnterpriseCode`,且 `interfaceEnabled=false` 时必须拒绝鉴权和后续 submit。 3. 客户端应用的 IP 白名单必须对 CMPP 下游连接生效;未命中白名单、应用停用、企业停用、密码错误、超过连接数上限时必须拒绝连接并记录系统日志。 4. Gateway 必须维护应用级下游连接状态,回写 applicationId、tenantId、connectionId、currentConnections、desiredConnections、lastHeartbeatAt、lastError,运营端企业应用列表和连接详情必须来自这些真实状态。 5. Gateway 必须实现下游 ActiveTest、Terminate、异常断开处理;断开后连接数和状态必须及时回写。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 5578f32..f681397 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -412,6 +412,7 @@ 4. 对另一条待审核模板执行驳回并填写原因。 - 预期结果: - 搜索条件由真实后端 API 处理,结果只包含匹配记录。 + - 运营端企业模板管理新增的短信模板在审核页状态直接为 approved,不进入待审核。 - 通过后模板状态变为 approved,驳回后模板状态变为 rejected。 - 审核记录、操作者、审核时间和驳回原因可追溯。 - 客户端模板列表同步展示最新状态。 @@ -487,12 +488,14 @@ 1. 打开运营端企业应用管理,点击新增短信应用。 2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。 3. 选择企业后进入应用参数表单。 - 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。 + 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、短信接口开关、接口类型、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。 5. 刷新列表并打开编辑页。 - 预期结果: - 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。 - 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。 - - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections`、`cmppWindowSize` 和通道组绑定。 + - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`interfaceEnabled`、`interfaceType=cmpp20`、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections`、`cmppWindowSize` 和通道组绑定。 + - “短信接口”开关刷新后仍来自真实数据库;关闭后该应用不能通过客户端/API 发送,也不能通过 Gateway bind/login 或 submit。 + - “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。 - `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。 - `cmppEnterpriseCode` 来自应用自身配置,不透传上游通道企业代码;`passwordCipher` 为 16 位,编辑留空不覆盖原密码。 - 后端真实保存应用队列等级和 CMPP 参数,刷新列表、编辑页和 CMPP 参数弹窗后仍显示正确。 @@ -999,10 +1002,10 @@ 4. 查询 NestJS 数据库和运营端短信记录。 - 预期结果: - 17890 是真实 CMPP Server 监听,不是 HTTP 端口。 - - bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态和 IP 白名单。 - - 密码错误、应用停用、企业停用、IP 不在白名单时 connect/login 被拒绝。 + - bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态、短信接口开关和 IP 白名单。 + - 密码错误、应用停用、企业停用、短信接口关闭、IP 不在白名单时 connect/login 被拒绝。 - submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。 - - submit 内容不匹配审核模板、余额不足、无可用通道时返回明确失败,不得伪造成功。 + - submit 内容不匹配审核模板、余额不足、短信接口关闭、无可用通道时返回明确失败,不得伪造成功。 ### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环 @@ -2997,6 +3000,7 @@ npm run verify:phase8 | 用例 | 细化执行点 | 必查断言 | | --- | --- | --- | | TC-ADMIN-014 | 按客户名称、应用名称、模板内容、审核编号、审核状态分别搜索模板审核列表。 | 每次搜索均发起 API 请求;结果只包含匹配数据;清空条件后恢复默认列表;跨租户/不存在关键字无误展示。 | +| TC-ADMIN-014 | 运营端企业模板管理新增短信模板后进入短信模板审核页查看。 | 新增模板状态直接为 approved;通过/驳回按钮禁用;客户端自行提交的模板仍可保持 pending 审核流。 | | TC-ADMIN-014 | 对待审核模板分别执行通过、驳回。 | 审核状态更新;审核记录包含审核人、时间、原因;客户端模板列表同步;驳回模板不能发送。 | | TC-ADMIN-015 | 运营端查看企业认证详情,核对主体信息、执照附件、对公账户验证、联系人。 | 详情字段来自 certification API;附件 id/URL 可追溯;通过/驳回同步 Tenant.certificationStatus;驳回后客户可重提。 | | TC-ADMIN-016 | 复制通道,随后查询新通道详情、报备字段、签名报备材料。 | 新通道 code/id 唯一;CMPP 参数、限速、报备字段、材料被复制;源通道不受影响;复制日志包含 sourceChannelId 和 newChannelId。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 4c73c54..40872a5 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -12,6 +12,14 @@ - 2026-07-09 追加:运营端短信记录“发送详情 - 通道发送与回执”的“回执码”必须展示通道原始回执码 `SmsReceiptRecord.rawStatus`,用于和上游平台核对;该字段不再回退展示平台映射状态 `receiptStatus` 或提交状态。 - 2026-07-09 追加:生产验证 `17317959177` 在 DB 中已有 `rawStatus=DELIVRD`,但页面仍显示空。排查确认页面调用 `/admin/send/messages` 时生产返回缺少 `submitRecords/receiptRecords` 明细,而 `/admin/operations/messages` 返回完整通道发送与回执记录;短信记录页已切换到完整明细接口,并将 UTC ISO 时间按 `Asia/Shanghai` 展示,避免 16 点多页面显示 8 点多。 +## 2026-07-09 企业应用短信接口开关和接口类型 + +- 按设计基线恢复企业应用添加/编辑页“短信接口 开通/关闭”和“接口类型 CMPP/HTTP”;当前第一版只允许 CMPP2.0,HTTP 在页面中禁用,后端拒绝未实现的 `interfaceType=http`。 +- Prisma `SmsApplication` 新增 `interfaceEnabled`、`interfaceType` 持久化字段;企业应用创建、编辑、列表和 CMPP 参数接口均返回真实数据库值。 +- 发送链路新增硬校验:`interfaceEnabled=false` 时客户端/API 发送、Gateway bind/login 鉴权和 Gateway submit 入站都会拒绝,不进入任务、计费或 Gateway 上游提交。 +- 测试口径同步:`TC-ADMIN-018A` 覆盖表单保存、CMPP2.0 only、HTTP 禁用和接口关闭后的发送/Gateway 拒绝;`TC-GW-006` 覆盖 Gateway 鉴权与 submit 对接口开关的校验。 +- 已执行:`npm --prefix api run prisma:generate`、`npm --prefix api test -- sms-config.service.spec.ts send-chain.service.spec.ts`、`npm --prefix api run build`、`npm run build`、`git diff --check`、`npm --prefix api run prisma:migrate:deploy`。 + ## 2026-07-09 通道复制默认停用与真实连接池状态回写修复 - 生产验证发现当前 3 个赛邮行业通道中有 2 个由首个 active 通道复制而来;数据库 `CmppConnectionState` 曾显示 3 条通道都为 `connected/currentConnections=1`,但生产机 `ss/netstat` 与上游平台都只能看到 1 条真实 TCP 长连接。 @@ -291,6 +299,7 @@ npm run test:gateway - `GET /api/admin/channels/:id/connection-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询连接日志;保留 `/link-logs` 兼容旧前端。 - 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。 - 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。 +- 运营端企业模板管理新增短信模板时,后端直接写入 `auditStatus=approved`;短信模板审核页展示为“已通过”,客户端自行提交模板仍保留审核流。 - 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。 - 前端新增 `/api` Vite 代理和 `src/api/adminApi.ts`,通道管理、模板审核、企业认证审核应调用真实 API;API 不可用时页面应展示错误态或空态,静态兜底不能作为验收通过依据。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index afb3ffd..53fe4bb 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -606,6 +606,8 @@ export type EnterpriseApplication = { templateMismatchMode?: string | null; cmppAccount?: string | null; cmppEnterpriseCode?: string | null; + interfaceEnabled?: boolean | null; + interfaceType?: 'cmpp20' | string | null; cmppMaxConnections?: number | null; cmppWindowSize?: number | null; ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; @@ -651,6 +653,8 @@ export type ApplicationCmppParams = { account: string; passwordCipher: string; srcId: string; + interfaceEnabled?: boolean; + interfaceType?: string; maxConnections: number; heartbeatSeconds: number; windowSize: number; @@ -802,9 +806,9 @@ export const adminApi = { request(withQuery('/admin/enterprise-applications', query)), getEnterpriseApplication: (id: string) => request(`/admin/enterprise-applications/${id}`), - createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => + createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => request('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), - updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => + updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => request(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeApplicationStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-applications/${id}/status`, { diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index b85b442..d2941c4 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -22,6 +22,8 @@ type SmsApp = { type CmppParams = { host: string; port: number; + interfaceEnabled: boolean; + interfaceType: string; enterpriseCode: string; account: string; password: string; @@ -122,10 +124,14 @@ const connectionStateMeta: Record
+
短信接口{interfaceEnabled ? '开通' : '关闭'}
+
接口类型{interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}
CMPP网关地址{host}
CMPP网关端口{port}
企业代码{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}
@@ -457,8 +467,8 @@ function mapApplication(application: EnterpriseApplication): SmsApp { sentToday: application.sentToday ?? 0, deliveryRate: application.deliveryRate ?? 0, unitPrice: (application.customerUnitPrice ?? 0) / 100, - cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected', - cmppParams: { host: '', port: 0, enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' }, + cmppStatus: application.interfaceEnabled === false ? 'inactive' : application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected', + cmppParams: { host: '', port: 0, interfaceEnabled: application.interfaceEnabled !== false, interfaceType: application.interfaceType ?? 'cmpp20', enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP2.0' }, cmppConnections: connections, }; } diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 501ef3c..36354fb 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -6,6 +6,7 @@ import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui'; type Carrier = 'mobile' | 'unicom' | 'telecom'; type QueuePriority = 'normal' | 'priority'; +type InterfaceType = 'cmpp20'; const carrierMeta: Record = { mobile: { label: '移动', description: '移动号码只会进入移动通道组' }, @@ -25,6 +26,8 @@ export function AdminSmsApplicationFormPage() { const [cmppAccount, setCmppAccount] = useState(''); const [cmppEnterpriseCode, setCmppEnterpriseCode] = useState(''); const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword()); + const [interfaceEnabled, setInterfaceEnabled] = useState(true); + const [interfaceType, setInterfaceType] = useState('cmpp20'); const [cmppMaxConnections, setCmppMaxConnections] = useState('1'); const [cmppWindowSize, setCmppWindowSize] = useState('16'); const [phoneDailyLimit, setPhoneDailyLimit] = useState('10'); @@ -83,6 +86,8 @@ export function AdminSmsApplicationFormPage() { setCmppAccount(application.cmppAccount ?? ''); setCmppEnterpriseCode(application.cmppEnterpriseCode ?? application.tenant?.code ?? ''); setPasswordCipher(''); + setInterfaceEnabled(application.interfaceEnabled !== false); + setInterfaceType('cmpp20'); setCmppMaxConnections(String(application.cmppMaxConnections ?? 1)); setCmppWindowSize(String(application.cmppWindowSize ?? 16)); setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : ''); @@ -123,6 +128,8 @@ export function AdminSmsApplicationFormPage() { cmppAccount: cmppAccount.trim() || undefined, cmppEnterpriseCode: cmppEnterpriseCode.trim() || undefined, passwordCipher: passwordCipher.trim() || undefined, + interfaceEnabled, + interfaceType, cmppMaxConnections: Number(cmppMaxConnections) || 1, cmppWindowSize: Number(cmppWindowSize) || 16, maxPhonesPerTask: Number(phoneDailyLimit) || undefined, @@ -182,18 +189,6 @@ export function AdminSmsApplicationFormPage() { setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} /> setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} /> setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} /> - setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> - setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} /> - setPasswordCipher(event.target.value)} - placeholder="16 位接口密码" - suffix={} - value={passwordCipher} - /> - setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} /> - setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
发送队列
@@ -223,6 +218,47 @@ export function AdminSmsApplicationFormPage() { required value={mismatchPolicy} /> +
+ + +
+
+

接口配置

+

短信接口关闭后,客户 CMPP 鉴权和发送接口都会被真实后端拒绝。

+
+
+
+ 短信接口 + +
+
+ 接口类型 +
+ + +
+
+ setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> + setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} /> + setPasswordCipher(event.target.value)} + placeholder="16 位接口密码" + suffix={} + value={passwordCipher} + /> + setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} /> + setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} /> setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
diff --git a/src/styles/global.css b/src/styles/global.css index 5b10266..cafb8d4 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1259,6 +1259,11 @@ h3 { gap: var(--space-2); } +.radio-row label.is-disabled { + color: var(--color-text-muted); + cursor: not-allowed; +} + .radio-row input[type="radio"] { accent-color: var(--color-selected); height: 16px;