feat: add application interface controls
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SmsApplication"
|
||||
ADD COLUMN "interfaceEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "interfaceType" TEXT NOT NULL DEFAULT 'cmpp20';
|
||||
@@ -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?
|
||||
|
||||
@@ -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' }]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<Omit<CreateSmsTemplateDto, 'tenantId'>> & {
|
||||
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;
|
||||
|
||||
@@ -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、异常断开处理;断开后连接数和状态必须及时回写。
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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 不可用时页面应展示错误态或空态,静态兜底不能作为验收通过依据。
|
||||
|
||||
|
||||
+6
-2
@@ -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<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
request<EnterpriseApplication>(`/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<EnterpriseApplication>('/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<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
||||
|
||||
@@ -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<CmppConnection['state'], { label: string; tone
|
||||
|
||||
function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
|
||||
const cmppParams = params ?? app.cmppParams;
|
||||
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
|
||||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||||
return [
|
||||
`应用名称: ${app.name}`,
|
||||
`企业名称: ${app.enterprise}`,
|
||||
`AppID: ${app.appId}`,
|
||||
`短信接口: ${interfaceEnabled ? '开通' : '关闭'}`,
|
||||
`接口类型: ${interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}`,
|
||||
`CMPP网关地址: ${'gatewayHost' in cmppParams ? cmppParams.gatewayHost : cmppParams.host}`,
|
||||
`CMPP网关端口: ${'gatewayPort' in cmppParams ? cmppParams.gatewayPort : cmppParams.port}`,
|
||||
`企业代码: ${cmppParams.enterpriseCode}`,
|
||||
@@ -146,6 +152,8 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
|
||||
const port = params?.gatewayPort ?? app.cmppParams.port;
|
||||
const password = params?.passwordCipher ?? app.cmppParams.password;
|
||||
const srcId = params?.srcId ?? app.cmppParams.accessNumber;
|
||||
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
|
||||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||||
|
||||
async function copyParams() {
|
||||
await navigator.clipboard.writeText(paramsText);
|
||||
@@ -168,6 +176,8 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
|
||||
>
|
||||
<div className="cmpp-param-detail">
|
||||
<div className="cmpp-param-grid">
|
||||
<div><span>短信接口</span><strong>{interfaceEnabled ? '开通' : '关闭'}</strong></div>
|
||||
<div><span>接口类型</span><strong>{interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}</strong></div>
|
||||
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
|
||||
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
|
||||
<div><span>企业代码</span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Carrier, { label: string; description: string }> = {
|
||||
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<InterfaceType>('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() {
|
||||
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
|
||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
||||
<Input label="企业代码" onChange={(event) => setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} />
|
||||
<Input
|
||||
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
||||
label="接口密码"
|
||||
onChange={(event) => setPasswordCipher(event.target.value)}
|
||||
placeholder="16 位接口密码"
|
||||
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
|
||||
value={passwordCipher}
|
||||
/>
|
||||
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
||||
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>发送队列</span>
|
||||
<div className="radio-row">
|
||||
@@ -223,6 +218,47 @@ export function AdminSmsApplicationFormPage() {
|
||||
required
|
||||
value={mismatchPolicy}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>接口配置</h3>
|
||||
<p>短信接口关闭后,客户 CMPP 鉴权和发送接口都会被真实后端拒绝。</p>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>短信接口</span>
|
||||
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
|
||||
<span />
|
||||
{interfaceEnabled ? '开通' : '关闭'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>接口类型</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />
|
||||
CMPP2.0
|
||||
</label>
|
||||
<label className="is-disabled">
|
||||
<input disabled type="radio" />
|
||||
HTTP接口(暂不可选)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
||||
<Input label="企业代码" onChange={(event) => setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} />
|
||||
<Input
|
||||
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
||||
label="接口密码"
|
||||
onChange={(event) => setPasswordCipher(event.target.value)}
|
||||
placeholder="16 位接口密码"
|
||||
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
|
||||
value={passwordCipher}
|
||||
/>
|
||||
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
||||
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
|
||||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user