fix: track live downstream cmpp connections

This commit is contained in:
hectorzhao
2026-07-11 19:16:52 +08:00
parent 76e0417ccf
commit 2fea15ef25
21 changed files with 477 additions and 141 deletions
@@ -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;
+29
View File
@@ -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
@@ -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);
+2 -2
View File
@@ -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 {}
@@ -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 });
+30 -9
View File
@@ -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',
}),
});
});
+79 -28
View File
@@ -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;
}
@@ -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、异常断开处理;断开后连接数和状态必须及时回写。
+1
View File
@@ -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 完整闭环
+14
View File
@@ -1559,6 +1559,13 @@ git diff --check
- 已部署生产验证:`cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 activeAPI/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 均为 activeAPI/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;待全量测试与生产验证完成后补充部署结果。
+57 -1
View File
@@ -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
}
+18
View File
@@ -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)
}
+22 -8
View File
@@ -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<ApplicationConnectionsResponse>(`/admin/enterprise-applications/${applicationId}/connections`),
disconnectApplicationConnection: (applicationId: string, connectionId: string, reason?: string) =>
request<CmppConnectionState>(`/admin/enterprise-applications/${applicationId}/connections/${connectionId}`, {
method: 'DELETE',
body: JSON.stringify({ reason }),
}),
getApplicationCmppParams: (applicationId: string) =>
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
listChannels: () => request<AdminChannel[]>('/admin/channels'),
+9 -3
View File
@@ -24,7 +24,9 @@ const typeOptions = [
export function AdminDrainageFieldsPage() {
const [fields, setFields] = useState<DrainageField[]>([]);
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() {
<div className="surface admin-drainage-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
<div className="admin-system-toolbar__actions">
<Button icon={<Search size={16} />} onClick={() => { setAppliedKeyword(keyword.trim()); setAppliedType(type); }}></Button>
<Button onClick={() => { setKeyword(''); setType('all'); setAppliedKeyword(''); setAppliedType('all'); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={18} />} onClick={() => setCreating(true)}></Button>
</div>
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type SmsApp = {
@@ -197,11 +197,9 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
function CmppConnectionModal({
app,
onClose,
onDeleteConnection,
}: {
app: SmsApp;
onClose: () => void;
onDeleteConnection: (connectionId: string) => void;
}) {
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
@@ -216,7 +214,7 @@ function CmppConnectionModal({
<div className="cmpp-connection-detail">
<div className="cmpp-connection-summary">
<div><span></span><strong>{activeConnections}</strong></div>
<div><span></span><strong>{Math.max(activeConnections, app.cmppConnections.length)}</strong></div>
<div><span></span><strong>{app.cmppParams.maxConnections}</strong></div>
<div><span>AppID</span><strong>{app.appId}</strong></div>
<div><span></span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
</div>
@@ -231,15 +229,6 @@ function CmppConnectionModal({
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
{
key: 'actions',
title: '操作',
align: 'right',
width: '120px',
render: (record: CmppConnection) => (
<Button icon={<Trash2 size={14} />} onClick={() => onDeleteConnection(record.id)} size="sm" variant="danger"></Button>
),
},
]}
data={app.cmppConnections}
emptyText="暂无CMPP连接"
@@ -254,6 +243,7 @@ export function AdminEnterpriseApplicationsPage() {
const navigate = useNavigate();
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
@@ -268,9 +258,9 @@ export function AdminEnterpriseApplicationsPage() {
| null
>(null);
async function loadSmsApps() {
async function loadSmsApps(keyword = appliedEnterpriseKeyword) {
try {
const applications = await adminApi.listEnterpriseApplications({ keyword: enterpriseKeyword });
const applications = await adminApi.listEnterpriseApplications({ keyword });
setSmsApps(applications.map(mapApplication));
setError('');
} catch (err) {
@@ -281,7 +271,7 @@ export function AdminEnterpriseApplicationsPage() {
useEffect(() => {
void loadSmsApps();
}, [enterpriseKeyword]);
}, [appliedEnterpriseKeyword]);
async function openAddModal() {
setAddModalOpen(true);
@@ -328,22 +318,14 @@ export function AdminEnterpriseApplicationsPage() {
setConfirmAction(null);
}
async function deleteConnection(appId: string, connectionId: string) {
await adminApi.disconnectApplicationConnection(appId, connectionId, '运营端断开企业应用 CMPP 连接');
const data = await adminApi.listApplicationConnections(appId);
const nextApp = mapApplication({ ...data.application, cmppConnections: data.connections, cmppStatus: data.summary.status as EnterpriseApplication['cmppStatus'] });
setConnectionApp(nextApp);
await loadSmsApps();
}
async function openParams(app: SmsApp) {
setParamsApp(app);
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
}
const filteredSmsApps = useMemo(
() => smsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)),
[enterpriseKeyword, smsApps],
() => smsApps.filter((item) => !appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword)),
[appliedEnterpriseKeyword, smsApps],
);
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
@@ -408,7 +390,10 @@ export function AdminEnterpriseApplicationsPage() {
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Button onClick={() => setEnterpriseKeyword('')} variant="ghost"></Button>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => setAppliedEnterpriseKeyword(enterpriseKeyword.trim())}></Button>
<Button onClick={() => { setEnterpriseKeyword(''); setAppliedEnterpriseKeyword(''); }} variant="ghost"></Button>
</div>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
@@ -446,7 +431,6 @@ export function AdminEnterpriseApplicationsPage() {
<CmppConnectionModal
app={connectionApp}
onClose={() => setConnectionApp(null)}
onDeleteConnection={(connectionId) => { void deleteConnection(connectionApp.id, connectionId); }}
/>
) : null}
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
@@ -467,22 +451,22 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
deliveryRate: application.deliveryRate ?? 0,
unitPrice: (application.customerUnitPrice ?? 0) / 100,
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' },
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: application.cmppMaxConnections ?? 1, heartbeatSeconds: 30, windowSize: application.cmppWindowSize ?? 16, protocolVersion: 'CMPP2.0' },
cmppConnections: connections,
};
}
function mapConnection(connection: CmppConnectionState): CmppConnection {
const isOpen = connection.status === 'connected' && connection.currentConnections > 0;
function mapConnection(connection: CmppDownstreamConnection): CmppConnection {
const isOpen = connection.status === 'connected';
return {
id: connection.connectionId,
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
bindType: 'transceiver',
clientIp: String(connection.channel?.gatewayHost ?? ''),
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
establishedAt: formatDateTime(connection.lastConnectedAt),
clientIp: String(connection.remoteIp ?? ''),
sourceAddr: connection.enterpriseCode,
establishedAt: formatDateTime(connection.connectedAt),
lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt),
lastSubmitAt: formatDateTime(connection.updatedAt),
pendingWindow: connection.currentConnections,
lastSubmitAt: formatDateTime(connection.lastSubmitAt),
pendingWindow: 0,
};
}
@@ -491,19 +491,21 @@ export function AdminEnterpriseSignaturesPage() {
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData() {
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, signatureKeyword: appliedSignatureKeyword }) {
try {
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignatures({ keyword: [enterpriseKeyword, signatureKeyword].filter(Boolean).join(' ') }),
adminApi.listEnterpriseSignatures({ keyword: [filters.enterpriseKeyword, filters.signatureKeyword].filter(Boolean).join(' ') }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
]);
@@ -523,9 +525,9 @@ export function AdminEnterpriseSignaturesPage() {
const filteredSignatures = useMemo(() => signatures.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
&& (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword));
}), [enterpriseKeyword, signatureKeyword, signatures]);
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || application.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword));
}), [appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const currentPage = Math.min(page, totalPages);
@@ -533,7 +535,7 @@ export function AdminEnterpriseSignaturesPage() {
useEffect(() => {
setPage(1);
}, [enterpriseKeyword, filteredSignatures.length, signatureKeyword]);
}, [appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
@@ -695,8 +697,22 @@ export function AdminEnterpriseSignaturesPage() {
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="签名/应用" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setSignatureKeyword(''); void loadData(); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}></Button>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), signatureKeyword: signatureKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
void loadData(filters);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', signatureKeyword: '' };
setEnterpriseKeyword('');
setSignatureKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedSignatureKeyword('');
void loadData(filters);
}} variant="ghost"></Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -250,18 +250,20 @@ export function AdminEnterpriseTemplatesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [error, setError] = useState('');
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [templateKeyword, setTemplateKeyword] = useState('');
const [appliedTemplateKeyword, setAppliedTemplateKeyword] = useState('');
const [tenants, setTenants] = useState<TenantOption[]>([]);
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() {
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="模板/应用/内容" onChange={(event) => setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={<Search size={16} />} value={templateKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setTemplateKeyword(''); void loadData(); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}></Button>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), templateKeyword: templateKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedTemplateKeyword(filters.templateKeyword);
void loadData(filters);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', templateKeyword: '' };
setEnterpriseKeyword('');
setTemplateKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedTemplateKeyword('');
void loadData(filters);
}} variant="ghost"></Button>
</div>
</div>
<div className="surface section-stack">
<Tabs
+40 -20
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { Database, ListFilter, Plus, RotateCcw, Search } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type PhoneSegment = DictionaryItem & {
prefix?: string;
@@ -42,20 +43,12 @@ export function AdminPhoneSegmentsPage() {
const [rulePage, setRulePage] = useState(1);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
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<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
{ 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<Array<TableColumn<CarrierRule>>>(() => [
@@ -119,28 +125,42 @@ export function AdminPhoneSegmentsPage() {
], []);
return (
<section className="page-stack admin-system-page">
<section className="page-stack admin-system-page phone-segment-workbench">
<div className="page-heading">
<div>
<Breadcrumb items={['系统管理', '手机号段库']} />
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-system-toolbar phone-segment-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
{activeTab === 'segments' ? '新增号段' : '新增规则'}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="phone-segment-overview" aria-label="号段数据概览">
<section>
<span><Database size={20} /></span>
<div><strong>{segmentTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
<section>
<span><ListFilter size={20} /></span>
<div><strong>{ruleTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
</div>
<div className="surface phone-segment-query">
<Input label="关键词" onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '手机号段、运营商、省份或城市' : '运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<div className="phone-segment-query__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button icon={<RotateCcw size={16} />} onClick={reset} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-system-table-card">
<Tabs
className="phone-segment-tabs"
className="phone-segment-workbench__tabs"
onChange={(value) => {
setActiveTab(value as 'segments' | 'rules');
setRulePage(1);
}}
value={activeTab}
items={[
+24 -5
View File
@@ -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<OperationLogItem[]>([]);
@@ -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<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{formatDateTime(record.time)}</span> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
@@ -89,13 +104,13 @@ export function AdminSystemLogsPage() {
<div className="system-log-filters">
<Input
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、操作人、动作、资源ID或详情"
prefix={<Search size={16} />}
value={keyword}
/>
<Select
onChange={(event) => { setLevel(event.target.value); setPage(1); }}
onChange={(event) => setLevel(event.target.value)}
options={[
{ label: '全部级别', value: 'all' },
{ label: '信息', value: 'info' },
@@ -105,7 +120,11 @@ export function AdminSystemLogsPage() {
]}
value={level}
/>
<Select onChange={(event) => { setModule(event.target.value); setPage(1); }} options={moduleOptions} value={module} />
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
<div className="system-log-filters__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button onClick={reset} variant="ghost"></Button>
</div>
</div>
<div className="system-log-range">
+7 -2
View File
@@ -63,6 +63,7 @@ export function AdminUsersPage() {
const [users, setUsers] = useState<ManagedUser[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<UserForm>(emptyForm);
@@ -84,12 +85,12 @@ export function AdminUsersPage() {
}, []);
const filteredUsers = useMemo(() => {
const value = keyword.trim().toLowerCase();
const value = appliedKeyword.trim().toLowerCase();
return users.filter((user) => {
const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase();
return !value || target.includes(value);
});
}, [keyword, users]);
}, [appliedKeyword, users]);
function openCreate() {
setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' });
@@ -199,6 +200,10 @@ export function AdminUsersPage() {
<div className="surface admin-system-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
<div className="admin-system-toolbar__actions">
<Button icon={<Search size={16} />} onClick={() => setAppliedKeyword(keyword.trim())}></Button>
<Button onClick={() => { setKeyword(''); setAppliedKeyword(''); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={16} />} onClick={openCreate}></Button>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
+37
View File
@@ -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;