diff --git a/api/prisma/migrations/20260711193000_add_cmpp_downstream_connections/migration.sql b/api/prisma/migrations/20260711193000_add_cmpp_downstream_connections/migration.sql new file mode 100644 index 0000000..578accf --- /dev/null +++ b/api/prisma/migrations/20260711193000_add_cmpp_downstream_connections/migration.sql @@ -0,0 +1,29 @@ +CREATE TABLE "CmppDownstreamConnection" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "account" TEXT NOT NULL, + "enterpriseCode" TEXT NOT NULL, + "connectionId" TEXT NOT NULL, + "remoteIp" TEXT, + "protocol" TEXT, + "status" TEXT NOT NULL DEFAULT 'connected', + "connectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastHeartbeatAt" TIMESTAMP(3), + "lastSubmitAt" TIMESTAMP(3), + "lastDeliverAt" TIMESTAMP(3), + "disconnectedAt" TIMESTAMP(3), + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CmppDownstreamConnection_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "CmppDownstreamConnection_connectionId_key" ON "CmppDownstreamConnection"("connectionId"); +CREATE INDEX "CmppDownstreamConnection_applicationId_status_updatedAt_idx" ON "CmppDownstreamConnection"("applicationId", "status", "updatedAt"); +CREATE INDEX "CmppDownstreamConnection_tenantId_status_updatedAt_idx" ON "CmppDownstreamConnection"("tenantId", "status", "updatedAt"); +CREATE INDEX "CmppDownstreamConnection_account_status_idx" ON "CmppDownstreamConnection"("account", "status"); + +ALTER TABLE "CmppDownstreamConnection" ADD CONSTRAINT "CmppDownstreamConnection_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "CmppDownstreamConnection" ADD CONSTRAINT "CmppDownstreamConnection_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index af57bc6..6da1a44 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -40,6 +40,7 @@ model Tenant { smsUplinkMessages SmsUplinkMessage[] smsUplinkMatchCandidates SmsUplinkMatchCandidate[] cmppDownstreamDeliveries CmppDownstreamDelivery[] + cmppDownstreamConnections CmppDownstreamConnection[] cmppConnectionStates CmppConnectionState[] gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[] gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] @@ -371,6 +372,7 @@ model SmsApplication { uplinkMessages SmsUplinkMessage[] uplinkMatchCandidates SmsUplinkMatchCandidate[] downstreamDeliveries CmppDownstreamDelivery[] + downstreamConnections CmppDownstreamConnection[] connectionStates CmppConnectionState[] gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[] gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] @@ -550,6 +552,33 @@ model CmppConnectionState { @@index([channelId, status]) } +model CmppDownstreamConnection { + id String @id @default(cuid()) + tenantId String + applicationId String + account String + enterpriseCode String + connectionId String @unique + remoteIp String? + protocol String? + status String @default("connected") + connectedAt DateTime @default(now()) + lastHeartbeatAt DateTime? + lastSubmitAt DateTime? + lastDeliverAt DateTime? + disconnectedAt DateTime? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + application SmsApplication @relation(fields: [applicationId], references: [id]) + + @@index([applicationId, status, updatedAt]) + @@index([tenantId, status, updatedAt]) + @@index([account, status]) +} + model SmsChannelGroup { id String @id @default(cuid()) code String @unique diff --git a/api/src/send-chain/gateway-events.controller.ts b/api/src/send-chain/gateway-events.controller.ts index df3aa31..3cb98be 100644 --- a/api/src/send-chain/gateway-events.controller.ts +++ b/api/src/send-chain/gateway-events.controller.ts @@ -11,11 +11,15 @@ import { GatewayUplinkEventDto, SendChainService, } from './send-chain.service'; +import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service'; @ApiTags('gateway-events') @Controller('gateway/events') export class GatewayEventsController { - constructor(private readonly sendChain: SendChainService) {} + constructor( + private readonly sendChain: SendChainService, + private readonly smsConfig: SmsConfigService, + ) {} @Post('submit-result') submitResult(@Body() body: GatewaySubmitResultDto) { @@ -47,6 +51,11 @@ export class GatewayEventsController { return this.sendChain.submitInboundMessage(body); } + @Post('inbound/connection') + inboundConnection(@Body() body: GatewayDownstreamConnectionEventDto) { + return this.smsConfig.recordDownstreamConnectionEvent(body); + } + @Post('downstream/pending') pendingDownstream(@Body() body: GatewayPendingDeliveryQueryDto) { return this.sendChain.listPendingDownstreamDeliveries(body); diff --git a/api/src/send-chain/send-chain.module.ts b/api/src/send-chain/send-chain.module.ts index 14e0976..87f4ec1 100644 --- a/api/src/send-chain/send-chain.module.ts +++ b/api/src/send-chain/send-chain.module.ts @@ -2,16 +2,16 @@ import { Module } from '@nestjs/common'; import { BillingModule } from '../billing/billing.module'; import { PrismaModule } from '../prisma/prisma.module'; import { RiskReviewModule } from '../risk-review/risk-review.module'; +import { SmsConfigModule } from '../sms-config/sms-config.module'; import { AdminSendChainController } from './admin-send-chain.controller'; import { ClientSendChainController } from './client-send-chain.controller'; import { GatewayEventsController } from './gateway-events.controller'; import { SendChainService } from './send-chain.service'; @Module({ - imports: [PrismaModule, BillingModule, RiskReviewModule], + imports: [PrismaModule, BillingModule, RiskReviewModule, SmsConfigModule], controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController], providers: [SendChainService], exports: [SendChainService], }) export class SendChainModule {} - diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index 8a5d40f..79eaf3a 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service'; @@ -42,16 +42,6 @@ export class AdminSmsConfigController { return this.smsConfig.getApplicationCmppParams(applicationId); } - @Post('enterprise-applications/:id/connections/:connectionId/disconnect') - disconnectApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) { - return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body); - } - - @Delete('enterprise-applications/:id/connections/:connectionId') - deleteApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) { - return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body); - } - @Get('enterprise-signatures') listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) { return this.smsConfig.listSignatures({ tenantId, keyword, status }); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 8ea27fe..15d6e5f 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -99,6 +99,13 @@ function createPrismaMock() { findFirst: jest.fn().mockResolvedValue({ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })), }, + cmppDownstreamConnection: { + findMany: jest.fn().mockResolvedValue([{ id: 'downstream-1', applicationId: 'app-1', tenantId: 'tenant-1', account: '100001', enterpriseCode: 'APP-EC', connectionId: 'gateway-1-1', status: 'connected', connectedAt: new Date(), lastHeartbeatAt: new Date() }]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, smsChannel: { findFirst: jest.fn().mockResolvedValue({ id: 'channel-1', @@ -167,7 +174,7 @@ describe('SmsConfigService', () => { queuePriority: 'normal', sentToday: 2, deliveryRate: 50, - cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })], + cmppConnections: [expect.objectContaining({ connectionId: 'gateway-1-1', account: '100001' })], }), ]); }); @@ -354,21 +361,35 @@ describe('SmsConfigService', () => { await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found'); }); - it('disconnects application CMPP connections and writes operation logs', async () => { + it('records Gateway downstream CMPP connection and heartbeat events against the real application account', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' }); + await service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-1-1', + status: 'connected', + remoteIp: '127.0.0.1', + protocol: 'cmpp30', + observedAt: '2026-07-11T11:00:00.000Z', + }); - expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ - where: { id: 'conn-state-1' }, - data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }), + expect(prisma.cmppDownstreamConnection.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + applicationId: 'app-1', + tenantId: 'tenant-1', + account: '100001', + enterpriseCode: 'APP-EC', + connectionId: 'gateway-1-1', + status: 'connected', + remoteIp: '127.0.0.1', + }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ - action: 'cmpp_connection.disconnected', - resource: 'cmpp_connection', - resourceId: 'channel-1:conn-a', + action: 'cmpp_downstream_connection.connected', + resource: 'cmpp_downstream_connection', + resourceId: 'gateway-1-1', }), }); }); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 3c36978..fdd44e7 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -97,10 +97,22 @@ export interface ApplicationListQuery { includeConnections?: boolean; } +export interface GatewayDownstreamConnectionEventDto { + account: string; + connectionId: string; + status: 'connected' | 'heartbeat' | 'submit' | 'deliver' | 'disconnected'; + remoteIp?: string; + protocol?: string; + connectedAt?: string; + observedAt?: string; + errorMessage?: string; +} + 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]; +const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000; @Injectable() export class SmsConfigService { @@ -108,6 +120,9 @@ export class SmsConfigService { async listApplications(queryOrTenantId?: string | ApplicationListQuery) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; + if (query.includeConnections) { + await this.markTimedOutDownstreamConnections(); + } return this.prisma.smsApplication.findMany({ where: { tenantId: query.tenantId, @@ -128,9 +143,8 @@ export class SmsConfigService { return applications; } const applicationIds = applications.map((application) => application.id); - const connections = await this.prisma.cmppConnectionState.findMany({ + const connections = await this.prisma.cmppDownstreamConnection.findMany({ where: { applicationId: { in: applicationIds } }, - include: { channel: true }, orderBy: { updatedAt: 'desc' }, take: 500, }); @@ -348,9 +362,9 @@ export class SmsConfigService { if (!application) { throw new NotFoundException('Application not found'); } - const connections = await this.prisma.cmppConnectionState.findMany({ + await this.markTimedOutDownstreamConnections(); + const connections = await this.prisma.cmppDownstreamConnection.findMany({ where: { applicationId }, - include: { channel: true }, orderBy: { updatedAt: 'desc' }, take: 100, }); @@ -358,8 +372,8 @@ export class SmsConfigService { application, connections, summary: { - desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0), - currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0), + desiredConnections: application.cmppMaxConnections, + currentConnections: connections.filter((connection) => connection.status === 'connected').length, status: normalizeApplicationCmppStatus(connections, application.status), }, }; @@ -431,31 +445,57 @@ export class SmsConfigService { return normalizeEnterpriseCode(tenant.code); } - async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) { - const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); - if (!application) { - throw new NotFoundException('Application not found'); - } - const connection = await this.prisma.cmppConnectionState.findFirst({ - where: { applicationId, connectionId }, + async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) { + const application = await this.prisma.smsApplication.findUnique({ + where: { cmppAccount: data.account }, + select: { id: true, tenantId: true, cmppEnterpriseCode: true }, }); - if (!connection) { - throw new NotFoundException('Connection not found'); + if (!application) { + throw new BadRequestException('CMPP account does not reference an application'); } - const updated = await this.prisma.cmppConnectionState.update({ - where: { id: connection.id }, + const observedAt = parseGatewayDate(data.observedAt) ?? new Date(); + const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt; + const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } }); + const status = data.status === 'disconnected' ? 'disconnected' : 'connected'; + const payload = { + tenantId: application.tenantId, + applicationId: application.id, + account: data.account, + enterpriseCode: application.cmppEnterpriseCode, + remoteIp: data.remoteIp, + protocol: data.protocol, + status, + connectedAt: existing?.connectedAt ?? connectedAt, + lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt, + lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt, + lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt, + disconnectedAt: data.status === 'disconnected' ? observedAt : null, + lastError: data.status === 'disconnected' ? data.errorMessage ?? existing?.lastError ?? null : null, + }; + const connection = existing + ? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload }) + : await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } }); + await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, { + applicationId: application.id, + account: data.account, + remoteIp: data.remoteIp, + protocol: data.protocol, + status: connection.status, + }); + return connection; + } + + async markTimedOutDownstreamConnections(now = new Date()) { + const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS); + const cutoff = new Date(now.getTime() - timeoutMs); + return this.prisma.cmppDownstreamConnection.updateMany({ + where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } }, data: { - status: 'disconnected', - currentConnections: 0, - lastDisconnectedAt: new Date(), - lastError: data.reason, + status: 'heartbeat_timeout', + disconnectedAt: now, + lastError: `CMPP heartbeat timeout after ${Math.round(timeoutMs / 1000)} seconds`, }, }); - await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, { - applicationId, - reason: data.reason, - }); - return updated; } listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) { @@ -871,11 +911,11 @@ function getPositiveInteger(value: number | undefined, fallback: number, fieldNa return normalized; } -function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) { +function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) { if (applicationStatus !== 'active') { return 'inactive'; } - if (connections.some((connection) => connection.status === 'connected' && connection.currentConnections > 0)) { + if (connections.some((connection) => connection.status === 'connected')) { return 'connected'; } if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) { @@ -883,3 +923,14 @@ function normalizeApplicationCmppStatus(connections: Array<{ status: string; cur } return 'disconnected'; } + +function getPositiveIntegerEnv(name: string, fallback: number) { + const value = Number(process.env[name] ?? fallback); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function parseGatewayDate(value?: string) { + if (!value) return undefined; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index cd0b2ab..6b58496 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -211,6 +211,7 @@ 1. Gateway 必须监听生产 CMPP 端口 `17890`,作为平台侧 CMPP Server 接收企业客户系统连接;该端口不是 HTTP 健康检查或控制接口。 2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括客户侧企业代码、账号、密码、CMPP 版本、源 IP 白名单、短信接口开关、应用状态、企业状态、连接数上限;客户侧企业代码必须来自 `SmsApplication.cmppEnterpriseCode`,且 `interfaceEnabled=false` 时必须拒绝新的鉴权。已完成 bind 的连接若随后被停用,其后续参数合法 Submit 必须按业务失败记录并通过 Deliver Receipt 回执。Gateway 必须根据 CONNECT `Version` 为每条 TCP 连接独立协商 CMPP2.0/2.1/3.0 解包与响应类型,不得用固定 CMPP3.0 结构解析 CMPP2.0 Submit。 +3. 企业应用列表中的 CMPP 连接数和连接状态必须表示客户连接到 Gateway `17890` 的下游 CMPP 会话,不得复用上游 `SmsChannel` 连接状态。Gateway 必须在 bind、CMPP `ACTIVE_TEST`、Submit、Deliver 写入失败等事件时回写应用级连接记录;列表只将最近心跳未超时的会话计为正常连接。超过心跳阈值的会话必须标记为心跳超时/断开,并保留远端 IP、协议版本、建立时间、最后心跳、最后 Submit、最后 Deliver 与断开原因供运营查询。 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 5951754..93c4ce8 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1013,6 +1013,7 @@ - 已鉴权且参数合法的 Submit 必须先返回成功 SubmitResp 和平台 Msg_Id;内容不匹配审核模板、签名/报备未通过、余额不足、应用在 bind 后停用、无可用通道、上游 Submit 最终失败时,均须真实创建短信记录、`SmsReceiptRecord` 和 `CmppDownstreamDelivery`,并向客户下发 `undelivered/REJECTD` Deliver Receipt,不得仅以 SubmitResp 失败替代回执。 - 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` 超过阈值后转为心跳超时/断开,不能继续显示为正常连接。 ### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 9a5f817..f12c5c2 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1559,6 +1559,13 @@ git diff --check - 已部署生产验证:`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 均通过。真实认证 API 返回四类待审明细并与总数一致;手机号段第 2 页返回 25 条、总数 516217、`page=2/pageSize=25`,证明页面分页不再依赖 cursor 猜测总页数。 - 浏览器自动化在登录页连接阶段超时,未使用 CAPTCHA 绕过或修改生产数据;登录后页面视觉验收需在下一轮以人工登录或可用浏览器会话补充截图。其余项目以源码、真实 API 和构建结果验收,不能将该未完成的视觉截图记录成已完成。 +## 2026-07-11 运营端查询控件与手机号段库重做 + +- 企业应用、企业签名、企业模板管理页的查询与重置统一为通用 `Button` 操作组:查询提交当前条件到真实 NestJS 列表 API,重置清空条件后重新加载真实列表,不再依赖输入即筛选或状态更新竞态。 +- 系统管理的用户管理、手机号段库、报备字段库、系统日志均增加查询和重置;用户与报备字段使用已加载真实数据的显式筛选,系统日志使用已提交筛选条件请求真实日志 API。 +- 手机号段库重做为概览、关键词筛选、号段/运营商规则双视图和统一分页工作台。号段和规则均使用 PostgreSQL 返回的 `total/page/pageSize`,不使用静态数组或 cursor 猜测总页数。 +- 已执行前端 `npm run build` 和 `git diff --check`;已部署生产验证,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 通过。生产源码和已构建静态资源均包含新的查询操作组与手机号段工作台样式;手机号段 API 继续返回真实 `total/page/pageSize`。 + ## 2026-07-11 CMPP 业务失败回执闭环 - 修复下游 CMPP 入站的审计缺口:客户已完成 bind、账号可识别且手机号参数合法后,NestJS 会先创建真实 `SmsBatchTask`、`SmsApiRequest` 和 `SmsMessageRecord`,再执行模板、签名/报备、风控和余额校验;不再因模板未报备等业务失败而直接丢弃客户 Submit。 @@ -1566,3 +1573,10 @@ git diff --check - 同一回执策略覆盖最终通道签名报备失败、无可用路由,以及上游 Submit rejected/timeout 在补发耗尽后的终态失败;失败记录、错误码和错误原因均可在运营端真实短信记录链路查询。 - Gateway 在客户 Submit 成功并建立 messageId-连接映射后立即冲刷该账号 pending 下游投递,避免 API 先创建失败回执时只能等待周期补投。 - 已执行 `npm --prefix api test -- --runInBand send-chain.service.spec.ts`(34 项通过)、`npm --prefix api run build`、`go test ./...`(Gateway 全量通过)。待本轮全量 API/前端构建及生产验证完成后补充最终部署结果。 + +## 2026-07-11 企业应用下游 CMPP 连接状态修复 + +- 修复企业应用列表误用上游 `CmppConnectionState` 的问题。新增 PostgreSQL `CmppDownstreamConnection`,一条记录对应一个已鉴权的客户 CMPP TCP bind 会话,按应用保存账号、企业代码、客户端 IP、协议版本、建立时间、最近心跳、最近 Submit、最近 Deliver、断开时间和错误原因。 +- Gateway 在客户 bind 成功、`ACTIVE_TEST`、Submit 和下游 Deliver 写入成功/失败时通过真实 NestJS API 回写连接事件;运营端只以该表的 `connected` 会话统计当前连接数,不再把上游通道连接数显示为企业客户连接数。 +- API 列表/详情查询会将最近心跳超过 `CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS`(默认 90 秒)的会话标记为 `heartbeat_timeout`;运营端展示真实客户端 IP、企业代码与最近心跳。移除了不能真正关闭 TCP 连接的运营端“删除连接”伪操作。 +- 已执行 `sms-config.service.spec.ts`(17 项通过)、Gateway inbound 集成测试(bind 回写连接事件)和 API/前端 build;待全量测试与生产验证完成后补充部署结果。 diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index aae9b66..5b009c0 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -86,6 +86,17 @@ type DownstreamUplink struct { ReceivedAt string `json:"receivedAt,omitempty"` } +type downstreamConnectionEvent struct { + Account string `json:"account"` + ConnectionID string `json:"connectionId"` + Status string `json:"status"` + RemoteIP string `json:"remoteIp,omitempty"` + Protocol string `json:"protocol,omitempty"` + ConnectedAt string `json:"connectedAt,omitempty"` + ObservedAt string `json:"observedAt,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + type downstreamSession struct { messageID string account string @@ -96,10 +107,12 @@ type downstreamSession struct { gatewayMsgID uint64 remoteIP string connectedAt time.Time + connectionID string conn *cmpp.Conn mu *sync.Mutex presence PresenceStore instanceID string + report func(*downstreamSession, string, string) } var downstreamRegistry = struct { @@ -124,6 +137,7 @@ func (s Server) ListenAndServe() error { return cmpp.ListenAndServe(addr, cmpp.V30, 30*time.Second, 3, s.LogWriter, cmpp.HandlerFunc(s.handleLogin), cmpp.HandlerFunc(s.handleSubmit), + cmpp.HandlerFunc(s.handleActivity), ) } @@ -148,19 +162,23 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed] } setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version) + now := time.Now().UTC() session := downstreamSession{ account: strings.TrimSpace(defaultString(auth.Account, account)), enterpriseCode: strings.TrimSpace(auth.EnterpriseCode), protocol: cmppVersionName(req.Version), srcID: strings.TrimSpace(auth.Account), remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()), - connectedAt: time.Now().UTC(), + connectedAt: now, + connectionID: fmt.Sprintf("%s-%d", s.gatewayInstanceID(), now.UnixNano()), conn: packet.Conn, mu: &sync.Mutex{}, presence: s.PresenceStore, instanceID: s.gatewayInstanceID(), + report: s.reportConnection, } rememberAccount(session) + go s.reportConnection(&session, "connected", "") go s.flushPending(defaultString(auth.Account, account), logger) logger.Printf( "cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x%02x account=%s remote=%s", @@ -249,11 +267,16 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge gatewayMsgID: gatewayMsgID, remoteIP: remoteIP(remote), connectedAt: time.Now().UTC(), + connectionID: session.connectionID, conn: packet.Conn, mu: &sync.Mutex{}, presence: s.PresenceStore, instanceID: s.gatewayInstanceID(), + report: session.report, }) + if current := findSessionByConn(packet.Conn); current != nil && current.report != nil { + go current.report(current, "submit", "") + } go func() { if _, err := s.flushPending(account, logger); err != nil { logger.Printf("cmpp inbound event=post_submit_pending_flush_failed account=%s message_id=%s error=%q", account, result.MessageID, err.Error()) @@ -266,6 +289,33 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge return false, nil } +func (s Server) handleActivity(_ *cmpp.Response, packet *cmpp.Packet, _ *log.Logger) (bool, error) { + session := findSessionByConn(packet.Conn) + if session == nil || session.report == nil { + return true, nil + } + switch packet.Packer.(type) { + case *cmpp.CmppActiveTestReqPkt, *cmpp.CmppActiveTestRspPkt: + go session.report(session, "heartbeat", "") + } + return true, nil +} + +func (s Server) reportConnection(session *downstreamSession, status string, errorMessage string) { + if session == nil || strings.TrimSpace(session.account) == "" || strings.TrimSpace(session.connectionID) == "" { + return + } + event := downstreamConnectionEvent{ + Account: session.account, ConnectionID: session.connectionID, Status: status, + RemoteIP: session.remoteIP, Protocol: session.protocol, + ConnectedAt: formatRFC3339Nano(session.connectedAt), ObservedAt: formatRFC3339Nano(time.Now()), + ErrorMessage: errorMessage, + } + if err := s.post(context.Background(), "/gateway/events/inbound/connection", event, nil); err != nil { + log.Printf("cmpp inbound connection state callback failed account=%s connection_id=%s status=%s err=%v", session.account, session.connectionID, status, err) + } +} + type inboundSubmitPacket struct { protocol string pkTotal uint8 @@ -804,10 +854,16 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer) (bool, erro session.mu.Lock() defer session.mu.Unlock() if err := session.conn.SendPkt(deliver, <-session.conn.SeqId); err != nil { + if session.report != nil { + go session.report(session, "disconnected", err.Error()) + } forgetDownstream(session) return false, err } session.touchPresence("connected", false, true) + if session.report != nil { + go session.report(session, "deliver", "") + } return true, nil } diff --git a/gateway/internal/inbound/server_test.go b/gateway/internal/inbound/server_test.go index 0af4e48..54a6425 100644 --- a/gateway/internal/inbound/server_test.go +++ b/gateway/internal/inbound/server_test.go @@ -99,6 +99,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { password := "secret-hash" var gotAuth authRequest var gotSubmit submitRequest + connectionEvents := make(chan downstreamConnectionEvent, 8) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/inbound/authenticate": @@ -113,6 +114,13 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { _ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-1"}) case "/api/gateway/events/downstream/pending": _ = json.NewEncoder(w).Encode([]pendingDelivery{}) + case "/api/gateway/events/inbound/connection": + var event downstreamConnectionEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + t.Fatalf("decode connection event: %v", err) + } + connectionEvents <- event + w.WriteHeader(http.StatusOK) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } @@ -130,6 +138,14 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { if err := client.Connect(addr, account, password, 2*time.Second); err != nil { t.Fatalf("connect inbound cmpp: %v", err) } + select { + case event := <-connectionEvents: + if event.Status != "connected" || event.Account != account || event.ConnectionID == "" || event.RemoteIP == "" || event.Protocol != "cmpp30" { + t.Fatalf("unexpected connection event: %+v", event) + } + case <-time.After(2 * time.Second): + t.Fatal("expected downstream connected callback") + } content, err := cmpputils.Utf8ToUcs2("测试入站") if err != nil { @@ -207,6 +223,8 @@ func TestInboundServerNegotiatesCMPP2AndUsesAuthenticatedAccountForSubmit(t *tes _ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-CMPP2"}) case "/api/gateway/events/downstream/pending": _ = json.NewEncoder(w).Encode([]pendingDelivery{}) + case "/api/gateway/events/inbound/connection": + w.WriteHeader(http.StatusOK) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 68d6f28..353978d 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -264,7 +264,7 @@ export type ClientSmsApplication = { sentToday?: number; deliveryRate?: number; cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; - cmppConnections?: CmppConnectionState[]; + cmppConnections?: CmppDownstreamConnection[]; }; export type ClientSmsSignature = { @@ -634,7 +634,26 @@ export type EnterpriseApplication = { sentToday?: number; deliveryRate?: number; cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; - cmppConnections?: CmppConnectionState[]; + cmppConnections?: CmppDownstreamConnection[]; +}; + +export type CmppDownstreamConnection = { + id: string; + tenantId: string; + applicationId: string; + account: string; + enterpriseCode: string; + connectionId: string; + remoteIp?: string | null; + protocol?: string | null; + status: string; + connectedAt: string; + lastHeartbeatAt?: string | null; + lastSubmitAt?: string | null; + lastDeliverAt?: string | null; + disconnectedAt?: string | null; + lastError?: string | null; + updatedAt: string; }; export type CmppConnectionState = { @@ -657,7 +676,7 @@ export type CmppConnectionState = { export type ApplicationConnectionsResponse = { application: EnterpriseApplication; - connections: CmppConnectionState[]; + connections: CmppDownstreamConnection[]; summary: { desiredConnections: number; currentConnections: number; status: string }; }; @@ -838,11 +857,6 @@ export const adminApi = { }), listApplicationConnections: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/connections`), - disconnectApplicationConnection: (applicationId: string, connectionId: string, reason?: string) => - request(`/admin/enterprise-applications/${applicationId}/connections/${connectionId}`, { - method: 'DELETE', - body: JSON.stringify({ reason }), - }), getApplicationCmppParams: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), listChannels: () => request('/admin/channels'), diff --git a/src/apps/admin/AdminDrainageFieldsPage.tsx b/src/apps/admin/AdminDrainageFieldsPage.tsx index 281d8ed..f1bfe5e 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.tsx @@ -24,7 +24,9 @@ const typeOptions = [ export function AdminDrainageFieldsPage() { const [fields, setFields] = useState([]); const [keyword, setKeyword] = useState(''); + const [appliedKeyword, setAppliedKeyword] = useState(''); const [type, setType] = useState('all'); + const [appliedType, setAppliedType] = useState('all'); const [creating, setCreating] = useState(false); const [code, setCode] = useState(''); const [name, setName] = useState(''); @@ -47,11 +49,11 @@ export function AdminDrainageFieldsPage() { const filteredFields = useMemo( () => fields.filter((field) => { - const matchesKeyword = !keyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(keyword)); - const matchesType = type === 'all' || field.fieldType === type; + const matchesKeyword = !appliedKeyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(appliedKeyword)); + const matchesType = appliedType === 'all' || field.fieldType === appliedType; return matchesKeyword && matchesType; }), - [fields, keyword, type], + [appliedKeyword, appliedType, fields], ); function createField() { @@ -88,6 +90,10 @@ export function AdminDrainageFieldsPage() {
setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={} value={keyword} /> setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={} value={enterpriseKeyword} /> setSignatureKeyword(event.target.value)} placeholder="请输入签名或应用名称" prefix={} value={signatureKeyword} /> - - +
+ + +
{error ?

{error}

: null} diff --git a/src/apps/admin/AdminEnterpriseTemplatesPage.tsx b/src/apps/admin/AdminEnterpriseTemplatesPage.tsx index 6791f2b..6990a54 100644 --- a/src/apps/admin/AdminEnterpriseTemplatesPage.tsx +++ b/src/apps/admin/AdminEnterpriseTemplatesPage.tsx @@ -250,18 +250,20 @@ export function AdminEnterpriseTemplatesPage() { const [applications, setApplications] = useState([]); const [deleteTarget, setDeleteTarget] = useState(null); const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); + const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState(''); const [error, setError] = useState(''); const [signatureItems, setSignatureItems] = useState([]); const [templateModal, setTemplateModal] = useState(null); const [templatePreview, setTemplatePreview] = useState(null); const [templates, setTemplates] = useState([]); const [templateKeyword, setTemplateKeyword] = useState(''); + const [appliedTemplateKeyword, setAppliedTemplateKeyword] = useState(''); const [tenants, setTenants] = useState([]); - async function loadData() { + async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, templateKeyword: appliedTemplateKeyword }) { try { const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([ - adminApi.listEnterpriseTemplates({ keyword: [enterpriseKeyword, templateKeyword].filter(Boolean).join(' ') }), + adminApi.listEnterpriseTemplates({ keyword: [filters.enterpriseKeyword, filters.templateKeyword].filter(Boolean).join(' ') }), adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listEnterpriseSignatures(), @@ -283,9 +285,9 @@ export function AdminEnterpriseTemplatesPage() { const filteredTemplates = useMemo(() => templates.filter((item) => { const enterprise = item.tenant?.name ?? item.tenantId; const application = item.application?.name ?? ''; - return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword)) - && (!templateKeyword || item.name.includes(templateKeyword) || item.content.includes(templateKeyword) || application.includes(templateKeyword)); - }), [enterpriseKeyword, templateKeyword, templates]); + return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword)) + && (!appliedTemplateKeyword || item.name.includes(appliedTemplateKeyword) || item.content.includes(appliedTemplateKeyword) || application.includes(appliedTemplateKeyword)); + }), [appliedEnterpriseKeyword, appliedTemplateKeyword, templates]); async function saveTemplate(state: TemplateFormState) { const existing = templateModal && templateModal !== 'new' ? templateModal : null; @@ -363,8 +365,22 @@ export function AdminEnterpriseTemplatesPage() {
setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={} value={enterpriseKeyword} /> setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={} value={templateKeyword} /> - - +
+ + +
{ - const timer = window.setTimeout(() => { - setSegmentQuery(keyword.trim()); - setPage(1); - }, 300); - return () => window.clearTimeout(timer); - }, [keyword]); - useEffect(() => { let cancelled = false; setLoading(true); Promise.all([ adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, page, pageSize }), - adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }), + adminApi.listPhoneCarrierRules({ keyword: segmentQuery || undefined, page: rulePage, pageSize }), ]) .then(([segmentPage, ruleResponse]) => { if (cancelled) return; @@ -74,7 +67,7 @@ export function AdminPhoneSegmentsPage() { return () => { cancelled = true; }; - }, [activeTab, page, reloadKey, rulePage, segmentQuery]); + }, [page, reloadKey, rulePage, segmentQuery]); const segmentTotalPages = Math.max(1, Math.ceil(segmentTotal / pageSize)); const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize)); @@ -103,12 +96,25 @@ export function AdminPhoneSegmentsPage() { .catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败')); } + function query() { + setSegmentQuery(keyword.trim()); + setPage(1); + setRulePage(1); + } + + function reset() { + setKeyword(''); + setSegmentQuery(''); + setPage(1); + setRulePage(1); + } + const columns = useMemo>>(() => [ { key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => {record.prefix} }, { key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' }, { key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' }, { key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' }, - { key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' }, + { key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) }, ], []); const ruleColumns = useMemo>>(() => [ @@ -119,28 +125,42 @@ export function AdminPhoneSegmentsPage() { ], []); return ( -
+

手机号段库

-
- {error ?

{error}

: null} - -
- setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={} value={keyword} />
+ {error ?

{error}

: null} + +
+
+ +
{segmentTotal.toLocaleString('zh-CN')}

已收录手机号段

+
+
+ +
{ruleTotal.toLocaleString('zh-CN')}

运营商识别规则

+
+
+ +
+ setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '手机号段、运营商、省份或城市' : '运营商、正则或备注'} prefix={} value={keyword} /> +
+ + +
+
{ setActiveTab(value as 'segments' | 'rules'); - setRulePage(1); }} value={activeTab} items={[ diff --git a/src/apps/admin/AdminSystemLogsPage.tsx b/src/apps/admin/AdminSystemLogsPage.tsx index 1136fe9..1f73593 100644 --- a/src/apps/admin/AdminSystemLogsPage.tsx +++ b/src/apps/admin/AdminSystemLogsPage.tsx @@ -25,6 +25,7 @@ export function AdminSystemLogsPage() { const [level, setLevel] = useState('all'); const [module, setModule] = useState('all'); const [range, setRange] = useState('today'); + const [filters, setFilters] = useState({ keyword: '', level: 'all', module: 'all', range: 'today' }); const [page, setPage] = useState(1); const pageSize = 5; const [logs, setLogs] = useState([]); @@ -33,7 +34,7 @@ export function AdminSystemLogsPage() { const [error, setError] = useState(''); useEffect(() => { - adminApi.listSystemLogs({ keyword, level, module, range, page, pageSize }) + adminApi.listSystemLogs({ ...filters, page, pageSize }) .then((data) => { setLogs(data.items); setModules(data.modules); @@ -45,7 +46,7 @@ export function AdminSystemLogsPage() { setTotal(0); setError(err instanceof Error ? err.message : '系统日志加载失败'); }); - }, [keyword, level, module, range, page]); + }, [filters, page]); const moduleOptions = useMemo(() => { return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))]; @@ -54,6 +55,20 @@ export function AdminSystemLogsPage() { const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); + function query() { + setPage(1); + setFilters({ keyword: keyword.trim(), level, module, range }); + } + + function reset() { + setKeyword(''); + setLevel('all'); + setModule('all'); + setRange('today'); + setPage(1); + setFilters({ keyword: '', level: 'all', module: 'all', range: 'today' }); + } + const columns = useMemo>>(() => [ { key: 'time', title: '时间', width: '180px', render: (record) => {formatDateTime(record.time)} }, { key: 'level', title: '级别', width: '120px', render: (record) => {levelLabelMap[record.level]} }, @@ -89,13 +104,13 @@ export function AdminSystemLogsPage() {
{ setKeyword(event.target.value); setPage(1); }} + onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、操作人、动作、资源ID或详情" prefix={} value={keyword} /> { setModule(event.target.value); setPage(1); }} options={moduleOptions} value={module} /> + setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={} value={keyword} /> +
+ + +
{error ?
{error}
: null} diff --git a/src/styles/global.css b/src/styles/global.css index a04f539..defe539 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2843,6 +2843,18 @@ h3 { grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.2fr) auto; } +.admin-split-filter__actions { + display: flex; + gap: var(--space-3); +} + +.admin-system-toolbar__actions, +.system-log-filters__actions { + align-items: center; + display: flex; + gap: var(--space-3); +} + .admin-application-filter { grid-template-columns: minmax(320px, 460px) auto; } @@ -8874,6 +8886,31 @@ h3 { align-items: center; } +.phone-segment-workbench { + max-width: 1440px; +} + +.phone-segment-query { + align-items: end; + display: grid; + gap: var(--space-5); + grid-template-columns: minmax(300px, 520px) auto; +} + +.phone-segment-query__actions { + display: flex; + gap: var(--space-3); +} + +.phone-segment-workbench__tabs { + display: grid; + gap: var(--space-5); +} + +.phone-segment-workbench__tabs .ui-tabs__list { + width: fit-content; +} + .admin-system-table-card { overflow: hidden; padding: 0;