diff --git a/.env.example b/.env.example index 6e23f7a..7cebeea 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ DATABASE_URL=postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_URL=redis://127.0.0.1:6379 +HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters API_ENABLE_SEND_WORKER=true API_SEND_WORKER_CONCURRENCY=50 ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000 diff --git a/api/prisma/migrations/20260716110000_add_http_open_api/migration.sql b/api/prisma/migrations/20260716110000_add_http_open_api/migration.sql new file mode 100644 index 0000000..e51f604 --- /dev/null +++ b/api/prisma/migrations/20260716110000_add_http_open_api/migration.sql @@ -0,0 +1,165 @@ +CREATE TABLE "SmsApplicationHttpConfig" ( + "id" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "sendEnabled" BOOLEAN NOT NULL DEFAULT false, + "messageQueryEnabled" BOOLEAN NOT NULL DEFAULT false, + "receiptWebhookEnabled" BOOLEAN NOT NULL DEFAULT false, + "uplinkWebhookEnabled" BOOLEAN NOT NULL DEFAULT false, + "uplinkQueryEnabled" BOOLEAN NOT NULL DEFAULT false, + "credentialSelfServiceEnabled" BOOLEAN NOT NULL DEFAULT false, + "qpsLimit" INTEGER NOT NULL DEFAULT 10, + "timestampToleranceSeconds" INTEGER NOT NULL DEFAULT 300, + "maxCredentialCount" INTEGER NOT NULL DEFAULT 2, + "uplinkRetentionDays" INTEGER NOT NULL DEFAULT 90, + "maxQueryRangeDays" INTEGER NOT NULL DEFAULT 31, + "maxPageSize" INTEGER NOT NULL DEFAULT 100, + "receiptDeliveryMode" TEXT NOT NULL DEFAULT 'cmpp', + "uplinkDeliveryMode" TEXT NOT NULL DEFAULT 'cmpp', + "webhookRetryEnabled" BOOLEAN NOT NULL DEFAULT true, + "webhookMaxAttempts" INTEGER NOT NULL DEFAULT 7, + "webhookTimeoutSeconds" INTEGER NOT NULL DEFAULT 10, + "requireHttps" BOOLEAN NOT NULL DEFAULT true, + "allowClientManualRetry" BOOLEAN NOT NULL DEFAULT true, + "allowClientTest" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SmsApplicationHttpConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "SmsApplicationHttpIpAllowlist" ( + "id" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "ipCidr" TEXT NOT NULL, + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SmsApplicationHttpIpAllowlist_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "HttpApiCredential" ( + "id" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "accessKey" TEXT NOT NULL, + "secretEncrypted" TEXT NOT NULL, + "secretLast4" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "expiresAt" TIMESTAMP(3), + "lastUsedAt" TIMESTAMP(3), + "lastUsedIp" TEXT, + "createdById" TEXT, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "HttpApiCredential_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "OpenApiRequest" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "requestId" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "bodyHash" TEXT NOT NULL, + "clientMessageId" TEXT, + "messageRecordId" TEXT, + "httpStatus" INTEGER, + "businessCode" TEXT, + "responseBody" JSONB, + "sourceIp" TEXT, + "userAgent" TEXT, + "durationMs" INTEGER, + "status" TEXT NOT NULL DEFAULT 'processing', + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "OpenApiRequest_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "HttpWebhookEndpoint" ( + "id" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "url" TEXT NOT NULL, + "secretEncrypted" TEXT NOT NULL, + "secretLast4" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "lastTestAt" TIMESTAMP(3), + "lastTestStatus" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "HttpWebhookEndpoint_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "HttpWebhookEvent" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "messageRecordId" TEXT, + "messageId" TEXT, + "uplinkMessageId" TEXT, + "payload" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "HttpWebhookEvent_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "HttpWebhookDelivery" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "endpointId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "nextRetryAt" TIMESTAMP(3), + "lastHttpStatus" INTEGER, + "lastError" TEXT, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "HttpWebhookDelivery_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "HttpWebhookAttempt" ( + "id" TEXT NOT NULL, + "deliveryId" TEXT NOT NULL, + "attemptNo" INTEGER NOT NULL, + "requestHeaders" JSONB, + "responseStatus" INTEGER, + "responseSummary" TEXT, + "errorMessage" TEXT, + "durationMs" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "HttpWebhookAttempt_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "SmsMessageRecord" ADD COLUMN "clientMessageId" TEXT; + +CREATE UNIQUE INDEX "SmsApplicationHttpConfig_applicationId_key" ON "SmsApplicationHttpConfig"("applicationId"); +CREATE UNIQUE INDEX "SmsApplicationHttpIpAllowlist_applicationId_ipCidr_key" ON "SmsApplicationHttpIpAllowlist"("applicationId", "ipCidr"); +CREATE UNIQUE INDEX "HttpApiCredential_accessKey_key" ON "HttpApiCredential"("accessKey"); +CREATE INDEX "HttpApiCredential_applicationId_status_idx" ON "HttpApiCredential"("applicationId", "status"); +CREATE UNIQUE INDEX "OpenApiRequest_requestId_key" ON "OpenApiRequest"("requestId"); +CREATE UNIQUE INDEX "OpenApiRequest_applicationId_idempotencyKey_key" ON "OpenApiRequest"("applicationId", "idempotencyKey"); +CREATE INDEX "OpenApiRequest_applicationId_createdAt_idx" ON "OpenApiRequest"("applicationId", "createdAt"); +CREATE UNIQUE INDEX "HttpWebhookEndpoint_applicationId_eventType_key" ON "HttpWebhookEndpoint"("applicationId", "eventType"); +CREATE UNIQUE INDEX "HttpWebhookEvent_eventId_key" ON "HttpWebhookEvent"("eventId"); +CREATE INDEX "HttpWebhookEvent_applicationId_createdAt_idx" ON "HttpWebhookEvent"("applicationId", "createdAt"); +CREATE UNIQUE INDEX "HttpWebhookDelivery_eventId_endpointId_key" ON "HttpWebhookDelivery"("eventId", "endpointId"); +CREATE INDEX "HttpWebhookDelivery_status_nextRetryAt_idx" ON "HttpWebhookDelivery"("status", "nextRetryAt"); +CREATE UNIQUE INDEX "HttpWebhookAttempt_deliveryId_attemptNo_key" ON "HttpWebhookAttempt"("deliveryId", "attemptNo"); +CREATE UNIQUE INDEX "SmsMessageRecord_applicationId_clientMessageId_key" ON "SmsMessageRecord"("applicationId", "clientMessageId"); + +ALTER TABLE "SmsApplicationHttpConfig" ADD CONSTRAINT "SmsApplicationHttpConfig_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SmsApplicationHttpIpAllowlist" ADD CONSTRAINT "SmsApplicationHttpIpAllowlist_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "HttpApiCredential" ADD CONSTRAINT "HttpApiCredential_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OpenApiRequest" ADD CONSTRAINT "OpenApiRequest_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "OpenApiRequest" ADD CONSTRAINT "OpenApiRequest_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OpenApiRequest" ADD CONSTRAINT "OpenApiRequest_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "HttpApiCredential"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "HttpWebhookEndpoint" ADD CONSTRAINT "HttpWebhookEndpoint_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "HttpWebhookEvent" ADD CONSTRAINT "HttpWebhookEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "HttpWebhookEvent" ADD CONSTRAINT "HttpWebhookEvent_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "HttpWebhookDelivery" ADD CONSTRAINT "HttpWebhookDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "HttpWebhookEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "HttpWebhookDelivery" ADD CONSTRAINT "HttpWebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "HttpWebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "HttpWebhookAttempt" ADD CONSTRAINT "HttpWebhookAttempt_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "HttpWebhookDelivery"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 50d5afd..22ebc5b 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -45,6 +45,8 @@ model Tenant { cmppConnectionStates CmppConnectionState[] gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[] gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] + openApiRequests OpenApiRequest[] + httpWebhookEvents HttpWebhookEvent[] } model EnterpriseCertification { @@ -391,6 +393,12 @@ model SmsApplication { connectionStates CmppConnectionState[] gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[] gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] + httpConfig SmsApplicationHttpConfig? + httpIpAllowlist SmsApplicationHttpIpAllowlist[] + httpApiCredentials HttpApiCredential[] + openApiRequests OpenApiRequest[] + httpWebhookEndpoints HttpWebhookEndpoint[] + httpWebhookEvents HttpWebhookEvent[] @@index([tenantId, status]) } @@ -407,6 +415,174 @@ model SmsApplicationIpAllowlist { @@unique([applicationId, ipCidr]) } +model SmsApplicationHttpConfig { + id String @id @default(cuid()) + applicationId String @unique + enabled Boolean @default(false) + sendEnabled Boolean @default(false) + messageQueryEnabled Boolean @default(false) + receiptWebhookEnabled Boolean @default(false) + uplinkWebhookEnabled Boolean @default(false) + uplinkQueryEnabled Boolean @default(false) + credentialSelfServiceEnabled Boolean @default(false) + qpsLimit Int @default(10) + timestampToleranceSeconds Int @default(300) + maxCredentialCount Int @default(2) + uplinkRetentionDays Int @default(90) + maxQueryRangeDays Int @default(31) + maxPageSize Int @default(100) + receiptDeliveryMode String @default("cmpp") + uplinkDeliveryMode String @default("cmpp") + webhookRetryEnabled Boolean @default(true) + webhookMaxAttempts Int @default(7) + webhookTimeoutSeconds Int @default(10) + requireHttps Boolean @default(true) + allowClientManualRetry Boolean @default(true) + allowClientTest Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) +} + +model SmsApplicationHttpIpAllowlist { + id String @id @default(cuid()) + applicationId String + ipCidr String + remark String? + createdAt DateTime @default(now()) + + application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + @@unique([applicationId, ipCidr]) +} + +model HttpApiCredential { + id String @id @default(cuid()) + applicationId String + name String + accessKey String @unique + secretEncrypted String + secretLast4 String + status String @default("active") + expiresAt DateTime? + lastUsedAt DateTime? + lastUsedIp String? + createdById String? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + requests OpenApiRequest[] + + @@index([applicationId, status]) +} + +model OpenApiRequest { + id String @id @default(cuid()) + tenantId String + applicationId String + credentialId String + requestId String @unique + idempotencyKey String + bodyHash String + clientMessageId String? + messageRecordId String? + httpStatus Int? + businessCode String? + responseBody Json? + sourceIp String? + userAgent String? + durationMs Int? + status String @default("processing") + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + credential HttpApiCredential @relation(fields: [credentialId], references: [id]) + + @@unique([applicationId, idempotencyKey]) + @@index([applicationId, createdAt]) +} + +model HttpWebhookEndpoint { + id String @id @default(cuid()) + applicationId String + eventType String + url String + secretEncrypted String + secretLast4 String + status String @default("active") + lastTestAt DateTime? + lastTestStatus String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + deliveries HttpWebhookDelivery[] + + @@unique([applicationId, eventType]) +} + +model HttpWebhookEvent { + id String @id @default(cuid()) + eventId String @unique + tenantId String + applicationId String + eventType String + messageRecordId String? + messageId String? + uplinkMessageId String? + payload Json + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id]) + application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + deliveries HttpWebhookDelivery[] + + @@index([applicationId, createdAt]) +} + +model HttpWebhookDelivery { + id String @id @default(cuid()) + eventId String + endpointId String + status String @default("pending") + attemptCount Int @default(0) + nextRetryAt DateTime? + lastHttpStatus Int? + lastError String? + deliveredAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + event HttpWebhookEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) + endpoint HttpWebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade) + attempts HttpWebhookAttempt[] + + @@unique([eventId, endpointId]) + @@index([status, nextRetryAt]) +} + +model HttpWebhookAttempt { + id String @id @default(cuid()) + deliveryId String + attemptNo Int + requestHeaders Json? + responseStatus Int? + responseSummary String? + errorMessage String? + durationMs Int? + createdAt DateTime @default(now()) + + delivery HttpWebhookDelivery @relation(fields: [deliveryId], references: [id], onDelete: Cascade) + + @@unique([deliveryId, attemptNo]) +} + model SmsSignature { id String @id @default(cuid()) tenantId String @@ -1135,6 +1311,7 @@ model SmsMessageRecord { drainageInfoId String? reviewTaskId String? messageId String @unique + clientMessageId String? phoneNumber String carrier String? province String? @@ -1181,6 +1358,7 @@ model SmsMessageRecord { @@index([phoneNumber]) @@index([gatewayMessageId]) @@index([drainageInfoId, queuedAt]) + @@unique([applicationId, clientMessageId]) } model CmppSubmitSession { diff --git a/api/src/app.module.ts b/api/src/app.module.ts index f9c57ca..a270466 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -11,6 +11,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module'; import { FilesModule } from './files/files.module'; import { HealthController } from './health.controller'; import { OperationsModule } from './operations/operations.module'; +import { OpenApiModule } from './open-api/open-api.module'; import { PrismaModule } from './prisma/prisma.module'; import { RiskReviewModule } from './risk-review/risk-review.module'; import { ReportsModule } from './reports/reports.module'; @@ -42,6 +43,7 @@ import { UsersModule } from './users/users.module'; ReportMaterialsModule, SendChainModule, OperationsModule, + OpenApiModule, ], controllers: [HealthController], providers: [RequestContextMiddleware, SessionValidationMiddleware], diff --git a/api/src/main.ts b/api/src/main.ts index 4d481e3..fe0aeb2 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -2,9 +2,10 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; +import { OpenApiModule } from './open-api/open-api.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { rawBody: true }); app.setGlobalPrefix('api'); const swaggerConfig = new DocumentBuilder() @@ -15,6 +16,14 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, swaggerConfig); SwaggerModule.setup('api/docs', app, document); + const clientDocument = SwaggerModule.createDocument(app, new DocumentBuilder() + .setTitle('CMPP短信平台 HTTP 客户接口') + .setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口') + .setVersion('1.0.0') + .build(), { include: [OpenApiModule] }); + clientDocument.paths = Object.fromEntries(Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/'))); + SwaggerModule.setup('api/client-docs', app, clientDocument); + const port = Number(process.env.API_PORT ?? 3000); await app.listen(port); } diff --git a/api/src/open-api/admin-open-api.controller.ts b/api/src/open-api/admin-open-api.controller.ts new file mode 100644 index 0000000..abe226e --- /dev/null +++ b/api/src/open-api/admin-open-api.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { HttpConfigInput, OpenApiService } from './open-api.service'; + +@ApiTags('admin-http-open-api') +@Controller('admin/enterprise-applications/:applicationId/http-api') +export class AdminOpenApiController { + constructor(private readonly service: OpenApiService) {} + + @Get() getConfig(@Param('applicationId') applicationId: string) { return this.service.getConfig(applicationId); } + @Put() @RequireRecentAuthentication() updateConfig(@Param('applicationId') applicationId: string, @Body() body: HttpConfigInput) { return this.service.updateConfig(applicationId, body); } + @Get('credentials') listCredentials(@Param('applicationId') applicationId: string) { return this.service.listCredentials(applicationId); } + @Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }) { return this.service.createCredential(applicationId, body); } + @Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string) { return this.service.revokeCredential(applicationId, credentialId); } + @Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string) { return this.service.getWebhookEndpoints(applicationId); } + @Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body); } + @Get('requests') listRequests(@Param('applicationId') applicationId: string) { return this.service.listRequestLogs(applicationId); } + @Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string) { return this.service.listWebhookDeliveries(applicationId); } + @Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId); } +} diff --git a/api/src/open-api/client-open-api.controller.ts b/api/src/open-api/client-open-api.controller.ts new file mode 100644 index 0000000..015a2d1 --- /dev/null +++ b/api/src/open-api/client-open-api.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { TenantId } from '../common/tenant-id.decorator'; +import { OpenApiService } from './open-api.service'; + +@ApiTags('client-http-open-api-management') +@Controller('client/applications/:applicationId/http-api') +export class ClientOpenApiController { + constructor(private readonly service: OpenApiService) {} + + @Get() getConfig(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getConfig(applicationId, tenantId); } + @Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listCredentials(applicationId, tenantId); } + @Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @TenantId() tenantId?: string) { return this.service.createCredential(applicationId, body, tenantId, true); } + @Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @TenantId() tenantId?: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); } + @Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); } + @Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @TenantId() tenantId?: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); } + @Get('requests') listRequests(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listRequestLogs(applicationId, tenantId); } + @Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); } + @Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @TenantId() tenantId?: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); } +} diff --git a/api/src/open-api/open-api-auth.guard.ts b/api/src/open-api/open-api-auth.guard.ts new file mode 100644 index 0000000..daf4e58 --- /dev/null +++ b/api/src/open-api/open-api-auth.guard.ts @@ -0,0 +1,109 @@ +import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import { isIP } from 'node:net'; +import IORedis from 'ioredis'; +import { PrismaService } from '../prisma/prisma.service'; +import { decryptSecret } from './open-api.crypto'; +import type { OpenApiRequestLike } from './open-api.types'; + +@Injectable() +export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy { + private redis?: IORedis; + + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const accessKey = header(request, 'x-app-key'); + const timestampText = header(request, 'x-timestamp'); + const nonce = header(request, 'x-nonce'); + const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, ''); + if (!accessKey || !timestampText || !nonce || !suppliedSignature) { + throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' }); + } + if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) { + throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' }); + } + const credential = await this.prisma.httpApiCredential.findUnique({ + where: { accessKey }, + include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } }, + }); + if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) { + throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' }); + } + const config = credential.application.httpConfig; + if (!config?.enabled || credential.application.status !== 'active') { + throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' }); + } + const timestamp = Number(timestampText); + if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) { + throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' }); + } + const sourceIp = requestIp(request); + if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) { + throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' }); + } + const path = (request.originalUrl ?? request.url ?? '').split('?')[0]; + const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex'); + const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n'); + const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex'); + const expectedBuffer = Buffer.from(expected, 'hex'); + const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0); + if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) { + throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' }); + } + const redis = this.getRedis(); + const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX'); + if (nonceAccepted !== 'OK') { + throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' }); + } + const second = Math.floor(Date.now() / 1000); + const qpsKey = `openapi:qps:${credential.applicationId}:${second}`; + const currentQps = await redis.incr(qpsKey); + if (currentQps === 1) await redis.expire(qpsKey, 2); + if (currentQps > config.qpsLimit) { + throw new HttpException({ code: 'QPS_LIMIT_EXCEEDED', message: 'HTTP接口QPS超限' }, HttpStatus.TOO_MANY_REQUESTS); + } + request.openApiAuth = { + application: credential.application, + config, + credentialId: credential.id, + accessKey, + sourceIp, + }; + await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } }); + return true; + } + + onModuleDestroy() { this.redis?.disconnect(); } + + private getRedis() { + this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 }); + return this.redis; + } +} + +function header(request: OpenApiRequestLike, name: string) { + const value = request.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +function requestIp(request: OpenApiRequestLike) { + const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim(); + return (forwarded ?? request.socket?.remoteAddress)?.replace(/^::ffff:/, ''); +} + +function ipMatches(ip: string, rule: string) { + const normalized = rule.trim(); + if (!normalized.includes('/')) return ip === normalized; + const [network, bitsText] = normalized.split('/'); + if (isIP(ip) !== 4 || isIP(network) !== 4) return false; + const bits = Number(bitsText); + if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false; + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (ipv4(ip) & mask) === (ipv4(network) & mask); +} + +function ipv4(value: string) { + return value.split('.').reduce((result, part) => ((result << 8) | Number(part)) >>> 0, 0); +} diff --git a/api/src/open-api/open-api-exception.filter.ts b/api/src/open-api/open-api-exception.filter.ts new file mode 100644 index 0000000..4e8471f --- /dev/null +++ b/api/src/open-api/open-api-exception.filter.ts @@ -0,0 +1,20 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; + +@Catch() +export class OpenApiExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const response = host.switchToHttp().getResponse<{ status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } } }>(); + const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + const value = exception instanceof HttpException ? exception.getResponse() : {}; + const object = typeof value === 'object' && value ? value as Record : {}; + const rawMessage = object.message ?? (exception instanceof Error ? exception.message : 'Internal server error'); + const detail = Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage); + response.status(status).type('application/problem+json').send({ + type: `https://cmpp-platform.local/problems/${String(object.code ?? 'REQUEST_FAILED').toLowerCase()}`, + title: String(object.error ?? HttpStatus[status] ?? 'Request failed'), + status, + code: String(object.code ?? 'REQUEST_FAILED'), + detail, + }); + } +} diff --git a/api/src/open-api/open-api.controller.ts b/api/src/open-api/open-api.controller.ts new file mode 100644 index 0000000..2c377b8 --- /dev/null +++ b/api/src/open-api/open-api.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common'; +import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { createHash } from 'node:crypto'; +import { OpenApiAuthGuard } from './open-api-auth.guard'; +import { OpenApiService } from './open-api.service'; +import type { OpenApiRequestLike } from './open-api.types'; +import { OpenApiExceptionFilter } from './open-api-exception.filter'; + +@ApiTags('client-open-api-v1') +@ApiHeader({ name: 'X-App-Key', required: true }) +@ApiHeader({ name: 'X-Timestamp', required: true }) +@ApiHeader({ name: 'X-Nonce', required: true }) +@ApiHeader({ name: 'X-Signature', required: true }) +@UseGuards(OpenApiAuthGuard) +@UseFilters(OpenApiExceptionFilter) +@Controller('openapi/v1/sms') +export class OpenApiController { + constructor(private readonly service: OpenApiService) {} + + @Post('messages') + @HttpCode(202) + @ApiHeader({ name: 'Idempotency-Key', required: true }) + @ApiOperation({ summary: '发送单条短信' }) + sendMessage(@Req() request: OpenApiRequestLike, @Body() body: { mobile?: string; content?: string; templateId?: string; clientMessageId?: string }, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) { + return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent }); + } + + @Get('messages/:messageId') + @ApiOperation({ summary: '查询短信状态' }) + getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) { + return this.service.getMessage(request.openApiAuth!, messageId); + } + + @Get('uplinks') + @ApiOperation({ summary: '游标分页查询上行短信' }) + listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record) { + return this.service.listUplinks(request.openApiAuth!, query); + } + + @Get('uplinks/:uplinkId') + @ApiOperation({ summary: '查询上行短信详情' }) + getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) { + return this.service.getUplink(request.openApiAuth!, uplinkId); + } +} diff --git a/api/src/open-api/open-api.crypto.ts b/api/src/open-api/open-api.crypto.ts new file mode 100644 index 0000000..30f24f8 --- /dev/null +++ b/api/src/open-api/open-api.crypto.ts @@ -0,0 +1,24 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +function encryptionKey() { + const masterKey = process.env.HTTP_API_MASTER_KEY; + if (!masterKey || masterKey.length < 32) { + throw new Error('HTTP_API_MASTER_KEY must be configured with at least 32 characters'); + } + return createHash('sha256').update(masterKey).digest(); +} + +export function encryptSecret(value: string) { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv); + const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + return `${iv.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}.${ciphertext.toString('base64url')}`; +} + +export function decryptSecret(value: string) { + const [iv, tag, ciphertext] = value.split('.'); + if (!iv || !tag || !ciphertext) throw new Error('Invalid encrypted secret'); + const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(iv, 'base64url')); + decipher.setAuthTag(Buffer.from(tag, 'base64url')); + return Buffer.concat([decipher.update(Buffer.from(ciphertext, 'base64url')), decipher.final()]).toString('utf8'); +} diff --git a/api/src/open-api/open-api.module.ts b/api/src/open-api/open-api.module.ts new file mode 100644 index 0000000..7da2733 --- /dev/null +++ b/api/src/open-api/open-api.module.ts @@ -0,0 +1,16 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { SendChainModule } from '../send-chain/send-chain.module'; +import { AdminOpenApiController } from './admin-open-api.controller'; +import { ClientOpenApiController } from './client-open-api.controller'; +import { OpenApiAuthGuard } from './open-api-auth.guard'; +import { OpenApiController } from './open-api.controller'; +import { OpenApiService } from './open-api.service'; + +@Module({ + imports: [PrismaModule, forwardRef(() => SendChainModule)], + controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController], + providers: [OpenApiService, OpenApiAuthGuard], + exports: [OpenApiService], +}) +export class OpenApiModule {} diff --git a/api/src/open-api/open-api.service.spec.ts b/api/src/open-api/open-api.service.spec.ts new file mode 100644 index 0000000..2fff11e --- /dev/null +++ b/api/src/open-api/open-api.service.spec.ts @@ -0,0 +1,86 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { decryptSecret, encryptSecret } from './open-api.crypto'; +import { OpenApiService } from './open-api.service'; + +describe('OpenApiService', () => { + beforeAll(() => { process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters'; }); + + it('encrypts secrets with authenticated encryption', () => { + const encrypted = encryptSecret('customer-secret'); + expect(encrypted).not.toContain('customer-secret'); + expect(decryptSecret(encrypted)).toBe('customer-secret'); + }); + + it('replays a completed request for the same idempotency key and body', async () => { + const prisma = { + openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) }, + }; + const sendChain = { createBatchTask: jest.fn() }; + const service = new OpenApiService(prisma as never, sendChain as never); + const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' }); + expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' }); + expect(sendChain.createBatchTask).not.toHaveBeenCalled(); + }); + + it('rejects reuse of an idempotency key with a different body', async () => { + const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } }; + const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn() } as never); + await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException); + }); + + it('replays the same persisted business rejection', async () => { + const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } }; + const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn() } as never); + await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 }); + }); + + it('uses the real send chain and persists the accepted response', async () => { + const prisma = { + openApiRequest: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), + update: jest.fn().mockResolvedValue({}), + }, + smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, + }; + const sendChain = { createBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) }; + const service = new OpenApiService(prisma as never, sendChain as never); + const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' }); + expect(sendChain.createBatchTask).toHaveBeenCalledWith(expect.objectContaining({ sourceType: 'api', phones: ['18821203795'], clientMessageId: 'client-1' })); + expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' })); + expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) })); + }); + + it('persists a 422 result when the real send chain rejects the business request', async () => { + const prisma = { + openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) }, + smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, + }; + const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never); + await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 }); + expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) })); + }); + + it('creates an HTTP webhook event only for an enabled HTTP delivery mode', async () => { + const prisma = { + smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) }, + httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) }, + httpWebhookEvent: { create: jest.fn().mockResolvedValue({ id: 'event-row-1' }) }, + httpWebhookDelivery: { create: jest.fn().mockResolvedValue({ id: 'delivery-1' }) }, + }; + const service = new OpenApiService(prisma as never, {} as never); + await service.queueWebhookEvent({ tenantId: 'tenant-1', applicationId: 'app-1', eventType: 'receipt', messageId: 'MSG-1', payload: { receiptStatus: 'delivered' } }); + expect(prisma.httpWebhookEvent.create).toHaveBeenCalled(); + expect(prisma.httpWebhookDelivery.create).toHaveBeenCalledWith({ data: { eventId: 'event-row-1', endpointId: 'endpoint-1' } }); + }); +}); + +function auth() { + return { + application: { id: 'app-1', tenantId: 'tenant-1', status: 'active' }, + config: { sendEnabled: true }, + credentialId: 'credential-1', + accessKey: 'ak_test', + sourceIp: '203.0.113.10', + }; +} diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts new file mode 100644 index 0000000..1677a40 --- /dev/null +++ b/api/src/open-api/open-api.service.ts @@ -0,0 +1,481 @@ +import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, UnprocessableEntityException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Queue, Worker } from 'bullmq'; +import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { PrismaService } from '../prisma/prisma.service'; +import { SendChainService } from '../send-chain/send-chain.service'; +import { decryptSecret, encryptSecret } from './open-api.crypto'; +import type { OpenApiAuthContext } from './open-api.types'; + +const WEBHOOK_QUEUE = 'http-webhook-delivery'; +const DELIVERY_MODES = ['cmpp', 'http', 'both', 'none'] as const; +const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400]; + +export type HttpConfigInput = { + enabled?: boolean; + sendEnabled?: boolean; + messageQueryEnabled?: boolean; + receiptWebhookEnabled?: boolean; + uplinkWebhookEnabled?: boolean; + uplinkQueryEnabled?: boolean; + credentialSelfServiceEnabled?: boolean; + qpsLimit?: number; + timestampToleranceSeconds?: number; + maxCredentialCount?: number; + uplinkRetentionDays?: number; + maxQueryRangeDays?: number; + maxPageSize?: number; + receiptDeliveryMode?: string; + uplinkDeliveryMode?: string; + webhookRetryEnabled?: boolean; + webhookMaxAttempts?: number; + webhookTimeoutSeconds?: number; + requireHttps?: boolean; + allowClientManualRetry?: boolean; + allowClientTest?: boolean; + ipAllowlist?: string[]; +}; + +@Injectable() +export class OpenApiService implements OnModuleInit, OnModuleDestroy { + private queue?: Queue<{ deliveryId: string }>; + private worker?: Worker<{ deliveryId: string }>; + + constructor(private readonly prisma: PrismaService, @Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService) {} + + onModuleInit() { + const connection = bullmqConnection(); + this.queue = new Queue(WEBHOOK_QUEUE, { connection }); + this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 }); + } + + async onModuleDestroy() { + await this.worker?.close(); + await this.queue?.close(); + } + + async getConfig(applicationId: string, tenantId?: string) { + const application = await this.requireApplication(applicationId, tenantId); + return { + applicationId, + applicationName: application.name, + config: application.httpConfig, + ipAllowlist: application.httpIpAllowlist.map((item) => item.ipCidr), + }; + } + + async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) { + await this.requireApplication(applicationId, tenantId); + const data = normalizeConfig(input); + const ipAllowlist = normalizeIpAllowlist(input.ipAllowlist); + const [config] = await this.prisma.$transaction([ + this.prisma.smsApplicationHttpConfig.upsert({ + where: { applicationId }, + create: { applicationId, ...data }, + update: data, + }), + this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }), + ...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []), + ]); + return { applicationId, config, ipAllowlist }; + } + + async listCredentials(applicationId: string, tenantId?: string) { + await this.requireApplication(applicationId, tenantId); + return this.prisma.httpApiCredential.findMany({ + where: { applicationId }, + select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, lastUsedAt: true, lastUsedIp: true, createdAt: true, revokedAt: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async createCredential(applicationId: string, data: { name?: string; expiresAt?: string; createdById?: string }, tenantId?: string, selfService = false) { + const application = await this.requireApplication(applicationId, tenantId); + const config = application.httpConfig; + if (!config?.enabled) throw new BadRequestException('请先开通该应用的HTTP接口'); + if (selfService && !config.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理'); + const activeCount = await this.prisma.httpApiCredential.count({ where: { applicationId, status: 'active' } }); + if (activeCount >= config.maxCredentialCount) throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount} 个`); + const secret = randomBytes(32).toString('base64url'); + const credential = await this.prisma.httpApiCredential.create({ + data: { + applicationId, + name: String(data.name ?? '默认凭据').trim().slice(0, 100) || '默认凭据', + accessKey: `ak_${randomBytes(18).toString('base64url')}`, + secretEncrypted: encryptSecret(secret), + secretLast4: secret.slice(-4), + expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined, + createdById: data.createdById, + }, + select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, createdAt: true }, + }); + return { ...credential, secret, secretShownOnce: true }; + } + + async revokeCredential(applicationId: string, credentialId: string, tenantId?: string) { + const application = await this.requireApplication(applicationId, tenantId); + if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理'); + const result = await this.prisma.httpApiCredential.updateMany({ + where: { id: credentialId, applicationId, status: 'active' }, + data: { status: 'revoked', revokedAt: new Date() }, + }); + if (result.count !== 1) throw new NotFoundException('有效访问凭据不存在'); + return { id: credentialId, status: 'revoked' }; + } + + async getWebhookEndpoints(applicationId: string, tenantId?: string) { + await this.requireApplication(applicationId, tenantId); + return this.prisma.httpWebhookEndpoint.findMany({ + where: { applicationId }, + select: { id: true, eventType: true, url: true, secretLast4: true, status: true, lastTestAt: true, lastTestStatus: true, updatedAt: true }, + orderBy: { eventType: 'asc' }, + }); + } + + async upsertWebhookEndpoint(applicationId: string, eventType: string, data: { url: string; rotateSecret?: boolean; status?: string }, tenantId?: string) { + const application = await this.requireApplication(applicationId, tenantId); + if (!['receipt', 'uplink'].includes(eventType)) throw new BadRequestException('eventType only supports receipt or uplink'); + const url = await validateWebhookUrl(data.url, application.httpConfig?.requireHttps ?? true); + const existing = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId, eventType } } }); + const secret = !existing || data.rotateSecret ? randomBytes(32).toString('base64url') : undefined; + const endpoint = await this.prisma.httpWebhookEndpoint.upsert({ + where: { applicationId_eventType: { applicationId, eventType } }, + create: { applicationId, eventType, url, status: data.status ?? 'active', secretEncrypted: encryptSecret(secret!), secretLast4: secret!.slice(-4) }, + update: { url, status: data.status ?? existing?.status ?? 'active', ...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}) }, + select: { id: true, eventType: true, url: true, secretLast4: true, status: true, updatedAt: true }, + }); + return { ...endpoint, ...(secret ? { secret, secretShownOnce: true } : {}) }; + } + + async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; templateId?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) { + if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' }); + const mobile = String(input.mobile ?? '').trim(); + const content = String(input.content ?? ''); + if (!/^1[3-9]\d{9}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' }); + if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' }); + const idempotencyKey = String(meta.idempotencyKey ?? '').trim(); + if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'Idempotency-Key 必填且长度为8至128位' }); + const existing = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } }); + if (existing) { + if (existing.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' }); + if (existing.status === 'completed' && existing.responseBody) return existing.responseBody; + if (existing.status === 'failed' && existing.responseBody && existing.httpStatus) throw new HttpException(existing.responseBody as Record, existing.httpStatus); + throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); + } + if (input.clientMessageId) { + const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({ where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId }, select: { messageId: true } }); + if (duplicateClientMessage) throw new ConflictException({ code: 'CLIENT_MESSAGE_ID_CONFLICT', message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}` }); + } + const requestId = `req_${randomUUID()}`; + const startedAt = Date.now(); + let request; + try { + request = await this.prisma.openApiRequest.create({ + data: { tenantId: auth.application.tenantId, applicationId: auth.application.id, credentialId: auth.credentialId, requestId, idempotencyKey, bodyHash: meta.bodyHash, clientMessageId: input.clientMessageId, sourceIp: auth.sourceIp, userAgent: meta.userAgent }, + }); + } catch (error) { + if ((error as { code?: string }).code === 'P2002') { + const raced = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } }); + if (raced?.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' }); + if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody; + if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus) throw new HttpException(raced.responseBody as Record, raced.httpStatus); + throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); + } + throw error; + } + try { + const task = await this.sendChain.createBatchTask({ + tenantId: auth.application.tenantId, + applicationId: auth.application.id, + templateId: input.templateId, + content, + phones: [mobile], + sourceType: 'api', + sourceIp: auth.sourceIp, + userAgent: meta.userAgent, + clientMessageId: input.clientMessageId, + }); + const message = task.messages?.[0]; + if (task.status === 'rejected' || message?.status === 'rejected') { + throw new UnprocessableEntityException({ code: 'SEND_REJECTED', message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验' }); + } + const response = { code: 'ACCEPTED', requestId, messageId: message?.messageId, clientMessageId: input.clientMessageId ?? null, status: message?.status ?? task.status, acceptedAt: new Date().toISOString() }; + await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'completed', httpStatus: 202, businessCode: 'ACCEPTED', responseBody: response, messageRecordId: message?.id, durationMs: Date.now() - startedAt, completedAt: new Date() } }); + return response; + } catch (error) { + let outwardError = error; + if (error instanceof HttpException && error.getStatus() === 400) { + const response = error.getResponse(); + const message = typeof response === 'object' && response && 'message' in response ? (response as { message: unknown }).message : error.message; + outwardError = new UnprocessableEntityException({ code: 'SEND_REJECTED', message }); + } + const failure = normalizeOpenApiFailure(outwardError); + await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date() } }); + throw outwardError; + } + } + + async getMessage(auth: OpenApiAuthContext, messageId: string) { + if (!auth.config.messageQueryEnabled) throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' }); + const message = await this.prisma.smsMessageRecord.findFirst({ + where: { applicationId: auth.application.id, OR: [{ messageId }, { clientMessageId: messageId }] }, + select: { messageId: true, clientMessageId: true, phoneNumber: true, status: true, submitStatus: true, receiptStatus: true, errorCode: true, errorMessage: true, queuedAt: true, submittedAt: true, deliveredAt: true, updatedAt: true }, + }); + if (!message) throw new NotFoundException({ code: 'MESSAGE_NOT_FOUND', message: '短信记录不存在' }); + return message; + } + + async listUplinks(auth: OpenApiAuthContext, query: Record) { + if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); + const endTime = query.endTime ? new Date(query.endTime) : new Date(); + const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000); + if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' }); + if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) throw new BadRequestException({ code: 'TIME_RANGE_TOO_LARGE', message: `单次查询不能超过${auth.config.maxQueryRangeDays}天` }); + const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize); + const cursor = decodeCursor(query.cursor); + const rows = await this.prisma.smsUplinkMessage.findMany({ + where: { + applicationId: auth.application.id, + matchStatus: 'matched', + receivedAt: { gte: startTime, lte: endTime }, + phoneNumber: query.mobile, + destId: query.accessNumber, + content: query.keyword ? { contains: query.keyword } : undefined, + ...(cursor ? { OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }] } : {}), + }, + select: { id: true, messageId: true, phoneNumber: true, destId: true, content: true, matchStatus: true, matchReason: true, receivedAt: true }, + orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }], + take: limit + 1, + }); + const hasMore = rows.length > limit; + const items = rows.slice(0, limit); + const last = items.at(-1); + return { items, nextCursor: hasMore && last ? encodeCursor(last.receivedAt, last.id) : null }; + } + + async getUplink(auth: OpenApiAuthContext, uplinkId: string) { + if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); + const row = await this.prisma.smsUplinkMessage.findFirst({ where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' } }); + if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' }); + return row; + } + + async queueWebhookEvent(data: { tenantId: string; applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; uplinkMessageId?: string | null; eventType: 'receipt' | 'uplink'; payload: Record }) { + if (!data.applicationId) return null; + const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } }); + const config = application?.httpConfig; + const mode = data.eventType === 'receipt' ? config?.receiptDeliveryMode : config?.uplinkDeliveryMode; + const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled; + if (!config?.enabled || !enabled || !['http', 'both'].includes(mode ?? '')) return null; + const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } } }); + if (!endpoint || endpoint.status !== 'active') return null; + const event = await this.prisma.httpWebhookEvent.create({ + data: { eventId: `evt_${randomUUID()}`, tenantId: data.tenantId, applicationId: data.applicationId, eventType: data.eventType, messageRecordId: data.messageRecordId, messageId: data.messageId, uplinkMessageId: data.uplinkMessageId, payload: data.payload as Prisma.InputJsonValue }, + }); + const delivery = await this.prisma.httpWebhookDelivery.create({ data: { eventId: event.id, endpointId: endpoint.id } }); + await this.queue?.add('deliver', { deliveryId: delivery.id }, { jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 }); + return delivery; + } + + async listRequestLogs(applicationId: string, tenantId?: string) { + await this.requireApplication(applicationId, tenantId); + return this.prisma.openApiRequest.findMany({ where: { applicationId }, select: { id: true, requestId: true, clientMessageId: true, sourceIp: true, httpStatus: true, businessCode: true, status: true, durationMs: true, createdAt: true, completedAt: true }, orderBy: { createdAt: 'desc' }, take: 100 }); + } + + async listWebhookDeliveries(applicationId: string, tenantId?: string) { + await this.requireApplication(applicationId, tenantId); + return this.prisma.httpWebhookDelivery.findMany({ where: { event: { applicationId } }, include: { event: true, endpoint: { select: { eventType: true, url: true } }, attempts: { orderBy: { attemptNo: 'desc' }, take: 5 } }, orderBy: { createdAt: 'desc' }, take: 100 }); + } + + async retryWebhookDelivery(applicationId: string, deliveryId: string, tenantId?: string) { + const application = await this.requireApplication(applicationId, tenantId); + if (tenantId && !application.httpConfig?.allowClientManualRetry) throw new ForbiddenException('该应用未开通客户端手动重投'); + const delivery = await this.prisma.httpWebhookDelivery.findFirst({ where: { id: deliveryId, event: { applicationId } } }); + if (!delivery) throw new NotFoundException('Webhook投递记录不存在'); + await this.prisma.httpWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'pending', nextRetryAt: null, lastError: null } }); + await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 }); + return { id: deliveryId, status: 'pending' }; + } + + private async deliverWebhook(deliveryId: string) { + const delivery = await this.prisma.httpWebhookDelivery.findUnique({ where: { id: deliveryId }, include: { event: true, endpoint: true } }); + if (!delivery || delivery.status === 'delivered') return; + const config = await this.prisma.smsApplicationHttpConfig.findUnique({ where: { applicationId: delivery.event.applicationId } }); + if (!config) return; + const attemptNo = delivery.attemptCount + 1; + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = JSON.stringify({ eventId: delivery.event.eventId, eventType: delivery.event.eventType, occurredAt: delivery.event.createdAt.toISOString(), data: delivery.event.payload }); + const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted)).update(`${timestamp}\n${body}`).digest('hex'); + const startedAt = Date.now(); + let responseStatus: number | undefined; + let responseSummary: string | undefined; + let errorMessage: string | undefined; + try { + const response = await postWebhook(delivery.endpoint.url, body, { + 'content-type': 'application/json', + 'x-event-id': delivery.event.eventId, + 'x-event-type': delivery.event.eventType, + 'x-timestamp': timestamp, + 'x-signature': `sha256=${signature}`, + }, config.webhookTimeoutSeconds * 1000, config.requireHttps); + responseStatus = response.status; + responseSummary = response.body; + } catch (error) { errorMessage = error instanceof Error ? error.message : 'Webhook request failed'; } + const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300; + const retryable = errorMessage !== undefined || responseStatus === 408 || responseStatus === 429 || (responseStatus !== undefined && responseStatus >= 500); + await this.prisma.httpWebhookAttempt.create({ data: { deliveryId, attemptNo, responseStatus, responseSummary, errorMessage, durationMs: Date.now() - startedAt, requestHeaders: { 'x-event-id': delivery.event.eventId, 'x-event-type': delivery.event.eventType, 'x-timestamp': timestamp, 'x-signature': 'sha256=***' } } }); + if (success) { + await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } }); + return; + } + const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length); + if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) { + const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!; + const nextRetryAt = new Date(Date.now() + delaySeconds * 1000); + await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'retrying', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt } }); + await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:${attemptNo + 1}`, delay: delaySeconds * 1000, removeOnComplete: 1000, removeOnFail: 1000 }); + return; + } + await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'failed', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt: null } }); + } + + private async requireApplication(applicationId: string, tenantId?: string) { + const application = await this.prisma.smsApplication.findFirst({ where: { id: applicationId, tenantId }, include: { httpConfig: true, httpIpAllowlist: true } }); + if (!application) throw new NotFoundException('企业应用不存在'); + return application; + } +} + +function normalizeOpenApiFailure(error: unknown) { + if (error instanceof HttpException) { + const value = error.getResponse(); + const object = typeof value === 'object' && value ? value as Record : {}; + const rawMessage = object.message ?? error.message; + return { + httpStatus: error.getStatus(), + code: String(object.code ?? 'SEND_REJECTED'), + responseBody: { code: String(object.code ?? 'SEND_REJECTED'), message: Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage) } as Prisma.InputJsonValue, + }; + } + return { httpStatus: 500, code: 'INTERNAL_ERROR', responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue }; +} + +function normalizeConfig(input: HttpConfigInput) { + for (const mode of [input.receiptDeliveryMode, input.uplinkDeliveryMode]) { + if (mode !== undefined && !DELIVERY_MODES.includes(mode as typeof DELIVERY_MODES[number])) throw new BadRequestException('投递模式仅支持 cmpp、http、both、none'); + } + return { + enabled: input.enabled, + sendEnabled: input.sendEnabled, + messageQueryEnabled: input.messageQueryEnabled, + receiptWebhookEnabled: input.receiptWebhookEnabled, + uplinkWebhookEnabled: input.uplinkWebhookEnabled, + uplinkQueryEnabled: input.uplinkQueryEnabled, + credentialSelfServiceEnabled: input.credentialSelfServiceEnabled, + qpsLimit: bounded(input.qpsLimit, 1, 1000, 'QPS'), + timestampToleranceSeconds: bounded(input.timestampToleranceSeconds, 60, 900, '时间戳容差'), + maxCredentialCount: bounded(input.maxCredentialCount, 1, 10, '凭据数'), + uplinkRetentionDays: bounded(input.uplinkRetentionDays, 1, 365, '上行保留天数'), + maxQueryRangeDays: bounded(input.maxQueryRangeDays, 1, 90, '查询跨度'), + maxPageSize: bounded(input.maxPageSize, 10, 500, '分页上限'), + receiptDeliveryMode: input.receiptDeliveryMode, + uplinkDeliveryMode: input.uplinkDeliveryMode, + webhookRetryEnabled: input.webhookRetryEnabled, + webhookMaxAttempts: bounded(input.webhookMaxAttempts, 1, 7, '回调重试次数'), + webhookTimeoutSeconds: bounded(input.webhookTimeoutSeconds, 1, 30, '回调超时'), + requireHttps: input.requireHttps, + allowClientManualRetry: input.allowClientManualRetry, + allowClientTest: input.allowClientTest, + }; +} + +function bounded(value: number | undefined, min: number, max: number, label: string) { + if (value === undefined) return undefined; + if (!Number.isInteger(value) || value < min || value > max) throw new BadRequestException(`${label}必须在${min}至${max}之间`); + return value; +} + +function normalizeIpAllowlist(values?: string[]) { + return [...new Set((values ?? []).map((item) => item.trim()).filter(Boolean).map((item) => { + const [ip, prefix] = item.split('/'); + const version = isIP(ip); + if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`); + if (prefix !== undefined) { + const bits = Number(prefix); + const max = version === 4 ? 32 : 128; + if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException(`CIDR格式非法:${item}`); + } + return item; + }))]; +} + +async function validateWebhookUrl(value: string, requireHttps: boolean) { + return (await resolveWebhookTarget(value, requireHttps)).url.toString(); +} + +async function resolveWebhookTarget(value: string, requireHttps: boolean) { + let url: URL; + try { url = new URL(String(value ?? '').trim()); } catch { throw new BadRequestException('Webhook URL格式非法'); } + if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS'); + if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS'); + if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码'); + const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }); + if (addresses.some(({ address }) => isPrivateAddress(address))) throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址'); + const selected = addresses[0]; + if (!selected) throw new BadRequestException('Webhook域名未解析到可用地址'); + return { url, address: selected.address, family: isIP(selected.address) }; +} + +async function postWebhook(urlText: string, body: string, headers: Record, timeoutMs: number, requireHttps: boolean) { + const target = await resolveWebhookTarget(urlText, requireHttps); + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const requestFn = target.url.protocol === 'https:' ? httpsRequest : httpRequest; + const request = requestFn(target.url, { + method: 'POST', + headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) }, + lookup: (_hostname, _options, callback) => callback(null, target.address, target.family), + }, (response) => { + const chunks: Buffer[] = []; + let size = 0; + response.on('data', (chunk: Buffer) => { + if (size < 1000) { + const buffer = Buffer.from(chunk); + chunks.push(buffer.subarray(0, 1000 - size)); + size += buffer.length; + } + }); + response.on('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + }); + request.setTimeout(timeoutMs, () => request.destroy(new Error('Webhook request timed out'))); + request.on('error', reject); + request.end(body); + }); +} + +function isPrivateAddress(address: string) { + const normalized = address.replace(/^::ffff:/, ''); + if (normalized === '::1' || normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) return true; + if (isIP(normalized) !== 4) return false; + const [a, b] = normalized.split('.').map(Number); + return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127); +} + +function encodeCursor(receivedAt: Date, id: string) { return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url'); } +function decodeCursor(value?: string) { + if (!value) return null; + try { + const [date, id] = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as [string, string]; + const receivedAt = new Date(date); + if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error(); + return { receivedAt, id }; + } catch { throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' }); } +} + +function bullmqConnection() { + const url = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); + return { host: url.hostname, port: Number(url.port || 6379), username: url.username || undefined, password: url.password || undefined, db: Number(url.pathname.slice(1) || 0), maxRetriesPerRequest: null as null }; +} diff --git a/api/src/open-api/open-api.types.ts b/api/src/open-api/open-api.types.ts new file mode 100644 index 0000000..fd12777 --- /dev/null +++ b/api/src/open-api/open-api.types.ts @@ -0,0 +1,20 @@ +import type { SmsApplication, SmsApplicationHttpConfig } from '@prisma/client'; + +export type OpenApiAuthContext = { + application: SmsApplication; + config: SmsApplicationHttpConfig; + credentialId: string; + accessKey: string; + sourceIp?: string; +}; + +export type OpenApiRequestLike = { + method: string; + originalUrl?: string; + url?: string; + body?: unknown; + rawBody?: Buffer; + headers: Record; + socket?: { remoteAddress?: string }; + openApiAuth?: OpenApiAuthContext; +}; diff --git a/api/src/operations/client-operations.controller.ts b/api/src/operations/client-operations.controller.ts index faf1bbb..c3f954f 100644 --- a/api/src/operations/client-operations.controller.ts +++ b/api/src/operations/client-operations.controller.ts @@ -31,8 +31,8 @@ export class ClientOperationsController { } @Get('uplink-messages') - listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) { - return this.operations.listUplinkMessages({ tenantId, channelId }); + listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string) { + return this.operations.listUplinkMessages({ tenantId, channelId, applicationId, phoneNumber, keyword, startTime, endTime }); } @Get('dashboard') diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index ad59ff6..05be7ef 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -230,7 +230,7 @@ describe('OperationsService', () => { await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' }); expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({ - where: { tenantId: 'tenant-1', channelId: 'channel-1' }, + where: { tenantId: 'tenant-1', channelId: 'channel-1', applicationId: undefined, phoneNumber: undefined, content: undefined, receivedAt: undefined }, include: { tenant: true, application: true, @@ -246,6 +246,7 @@ describe('OperationsService', () => { }, }, orderBy: { receivedAt: 'desc' }, + take: 500, }); }); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 97d4607..3135743 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -96,9 +96,16 @@ export class OperationsService { }); } - listUplinkMessages(query: { tenantId?: string; channelId?: string }) { + listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) { return this.prisma.smsUplinkMessage.findMany({ - where: { tenantId: query.tenantId, channelId: query.channelId }, + where: { + tenantId: query.tenantId, + channelId: query.channelId, + applicationId: query.applicationId, + phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, + content: query.keyword ? { contains: query.keyword } : undefined, + receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined, + }, include: { tenant: true, application: true, @@ -114,6 +121,7 @@ export class OperationsService { }, }, orderBy: { receivedAt: 'desc' }, + take: 500, }); } diff --git a/api/src/send-chain/send-chain.module.ts b/api/src/send-chain/send-chain.module.ts index f4e1ed8..a364b1b 100644 --- a/api/src/send-chain/send-chain.module.ts +++ b/api/src/send-chain/send-chain.module.ts @@ -3,13 +3,14 @@ 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 { OpenApiModule } from '../open-api/open-api.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, forwardRef(() => RiskReviewModule), SmsConfigModule], + imports: [PrismaModule, BillingModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)], controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController], providers: [SendChainService], exports: [SendChainService], diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 2b86f89..7b475fb 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Queue, Worker } from 'bullmq'; import IORedis from 'ioredis'; @@ -9,6 +9,7 @@ import { setTimeout as sleep } from 'node:timers/promises'; import { BillingService } from '../billing/billing.service'; import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; +import { OpenApiService } from '../open-api/open-api.service'; export interface CreateBatchTaskDto { tenantId: string; @@ -24,6 +25,7 @@ export interface CreateBatchTaskDto { sourceIp?: string; userAgent?: string; sourceType?: 'client' | 'api' | 'cmpp'; + clientMessageId?: string; } export interface GatewayInboundAuthDto { @@ -258,6 +260,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { private readonly prisma: PrismaService, private readonly billing: BillingService, private readonly riskReview: RiskReviewService, + @Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService, ) {} onModuleInit() { @@ -379,6 +382,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { signatureId: messageClassification.signatureId, drainageInfoId: messageClassification.drainageInfoId, messageId: `MSG-${randomUUID()}`, + clientMessageId: data.clientMessageId, phoneNumber: phone, content: data.content, billingUnits: billing.billingUnitsPerMessage, @@ -965,6 +969,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { destId: data.destId, content: data.content, receivedAt: record.receivedAt.toISOString(), + uplinkMessageId: record.id, }, }); } @@ -1571,8 +1576,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, - select: { cmppAccount: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true }, + select: { cmppAccount: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, httpConfig: true }, }); + try { + await this.openApi?.queueWebhookEvent({ + tenantId: data.tenantId, + applicationId: data.applicationId, + messageRecordId: data.messageRecordId, + messageId: data.messageId, + uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined, + eventType: data.deliveryType, + payload: data.payload, + }); + } catch (error) { + this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`); + } + const deliveryMode = data.deliveryType === 'receipt' + ? application?.httpConfig?.receiptDeliveryMode ?? 'cmpp' + : application?.httpConfig?.uplinkDeliveryMode ?? 'cmpp'; + if (!['cmpp', 'both'].includes(deliveryMode)) { + return null; + } const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; const delivery = await this.prisma.cmppDownstreamDelivery.create({ data: { diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts index af303c5..86affe4 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -12,6 +12,7 @@ import { SmsConfigService, UpdateSmsTemplateDto, UpdateSmsDrainageInfoDto, + UpdateSmsSignatureDto, } from './sms-config.service'; @ApiTags('client-sms-config') @@ -36,12 +37,12 @@ export class ClientSmsConfigController { @Get('applications/:id/report-fields') getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) { - return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, reportType)); + return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType)); } @Get('report-fields/common') getCommonReportFields(@Query('reportType') reportType: 'signature' | 'drainage' = 'drainage') { - return this.smsConfig.getApplicationReportFields(undefined, reportType); + return this.smsConfig.getClientApplicationReportFields(undefined, reportType); } @Post('applications/:id/secret/reset') @@ -58,12 +59,24 @@ export class ClientSmsConfigController { @Get('signatures') listSignatures(@TenantId() tenantId?: string) { - return this.smsConfig.listSignatures(tenantId); + return this.smsConfig.listClientSignatures(tenantId); + } + + @Get('signatures-workspace') + getSignatureWorkspace(@TenantId() tenantId?: string) { + return this.smsConfig.getClientSignatureWorkspace(tenantId); } @Post('signatures') - createSignature(@Body() body: CreateSmsSignatureDto) { - return this.smsConfig.createSignature(body); + async createSignature(@Body() body: CreateSmsSignatureDto, @TenantId() tenantId?: string) { + const signature = await this.smsConfig.createSignature({ ...body, tenantId: tenantId ?? body.tenantId }); + return this.smsConfig.getClientSignatureView(signature.id, tenantId ?? body.tenantId); + } + + @Put('signatures/:id') + async updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto, @TenantId() tenantId?: string) { + await this.smsConfig.updateClientSignature(signatureId, body, tenantId); + return this.smsConfig.getClientSignatureView(signatureId, tenantId); } @Post('signatures/:id/materials') @@ -73,32 +86,38 @@ export class ClientSmsConfigController { @Get('drainage-infos') listDrainageInfos(@TenantId() tenantId?: string) { - return this.smsConfig.listDrainageInfos({ tenantId }); + return this.smsConfig.listClientDrainageInfos(tenantId); } @Post('signatures/:id/drainage-infos') - createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) { - return this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId); + async createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) { + const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId); + return this.smsConfig.getClientDrainageInfoView(item.id, tenantId); } @Put('drainage-infos/:id') - updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) { - return this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId); + async updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) { + await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId); + return this.smsConfig.getClientDrainageInfoView(itemId, tenantId); } @Post('drainage-infos/:id/status') - changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) { - return this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId); + async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) { + await this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId); + if (body.status === 'deleted') return { id: itemId, status: 'deleted' }; + return this.smsConfig.getClientDrainageInfoView(itemId, tenantId); } @Post('signatures/:id/submit') - submitSignature(@Param('id') signatureId: string) { - return this.smsConfig.submitSignature(signatureId); + async submitSignature(@Param('id') signatureId: string, @TenantId() tenantId?: string) { + await this.smsConfig.submitSignature(signatureId, tenantId); + return this.smsConfig.getClientSignatureView(signatureId, tenantId); } @Post('signatures/:id/status') - changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) { - return this.smsConfig.changeSignatureStatus(signatureId, body); + async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) { + await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId); + return this.smsConfig.getClientSignatureView(signatureId, tenantId); } @Get('templates') diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 6d2c79b..96e875b 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -65,6 +65,7 @@ function createPrismaMock() { count: jest.fn().mockResolvedValue(0), }, smsSignature: { + groupBy: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([{ id: 'sig-1', tenantId: 'tenant-1', @@ -224,7 +225,7 @@ describe('SmsConfigService', () => { ]); expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }), - include: { tenant: true, ipAllowlist: true }, + include: { tenant: true, ipAllowlist: true, httpConfig: true }, })); expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take'); expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({ @@ -661,6 +662,86 @@ describe('SmsConfigService', () => { ]); }); + it('removes channel sources from client report-field responses', async () => { + const prisma = createPrismaMock(); + prisma.channelRouteRule.findMany.mockResolvedValue([{ + id: 'route-1', priority: 10, + group: { + id: 'group-1', name: '内部通道组', + items: [{ channel: { id: 'channel-secret', code: 'SECRET-CH', name: '内部通道', reportFields: [{ status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }], + }, + }] as never); + const service = new SmsConfigService(prisma as never); + + const fields = await service.getClientApplicationReportFields('app-1', 'signature'); + + expect(fields).toEqual([expect.objectContaining({ id: 'field-1', code: 'license', required: true })]); + expect(JSON.stringify(fields)).not.toContain('channel-secret'); + expect(JSON.stringify(fields)).not.toContain('内部通道'); + expect(fields[0]).not.toHaveProperty('channels'); + }); + + it('returns client signatures without report tasks, channels, or internal requirement snapshots', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.findMany.mockResolvedValue([{ + id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: '通知', + auditStatus: 'rejected', rejectReason: '请补充资料', + drainageInfo: { signatureReportValues: { license: 'file-1' }, reportRequirements: [{ channelId: 'channel-secret', channelName: '内部通道' }] }, + createdAt: new Date('2026-07-16T01:00:00Z'), updatedAt: new Date('2026-07-16T02:00:00Z'), + application: { id: 'app-1', name: '应用A', status: 'active' }, + materials: [{ id: 'material-1', fileObjectId: 'file-1', materialType: 'license', title: '营业执照', description: null, createdAt: new Date() }], + drainageItems: [{ id: 'drainage-1', siteName: '官网', url: 'https://example.com', remark: null, reportValues: { owner: '企业A' }, auditStatus: 'pending', rejectReason: null, submittedAt: new Date(), reviewedAt: null, createdAt: new Date(), updatedAt: new Date() }], + _count: { reportMaterials: 2 }, + reportTasks: [{ channelId: 'channel-secret' }], + }] as never); + const service = new SmsConfigService(prisma as never); + + const result = await service.listClientSignatures('tenant-1'); + const serialized = JSON.stringify(result); + + expect(result[0]).toEqual(expect.objectContaining({ + id: 'sig-1', + submittedMaterialCount: 3, + reportValues: { license: 'file-1' }, + drainageInfo: { links: [expect.objectContaining({ id: 'drainage-1', siteName: '官网' })] }, + })); + expect(serialized).not.toContain('channel-secret'); + expect(serialized).not.toContain('内部通道'); + expect(result[0]).not.toHaveProperty('reportTasks'); + expect(result[0]).not.toHaveProperty('reportStatus'); + }); + + it('returns real client signature workspace counts from database grouping', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.findMany.mockResolvedValue([]); + prisma.smsSignature.groupBy.mockResolvedValue([ + { auditStatus: 'pending', _count: { _all: 2 } }, + { auditStatus: 'approved', _count: { _all: 5 } }, + { auditStatus: 'rejected', _count: { _all: 1 } }, + ] as never); + const service = new SmsConfigService(prisma as never); + + await expect(service.getClientSignatureWorkspace('tenant-1')).resolves.toEqual({ + items: [], + summary: { total: 8, pending: 2, approved: 5, rejected: 1, draft: 0 }, + }); + expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith(expect.objectContaining({ + where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } }, + })); + }); + + it('selects client drainage information without internal tasks or channels', async () => { + const prisma = createPrismaMock(); + prisma.smsDrainageInfo.findMany.mockResolvedValue([{ id: 'drainage-1', siteName: '官网' }] as never); + const service = new SmsConfigService(prisma as never); + + await expect(service.listClientDrainageInfos('tenant-1')).resolves.toEqual([{ id: 'drainage-1', siteName: '官网' }]); + const query = prisma.smsDrainageInfo.findMany.mock.calls[0][0]; + expect(query.where).toEqual({ id: undefined, tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }); + expect(query.select).not.toHaveProperty('reportTasks'); + expect(JSON.stringify(query.select)).not.toContain('channel'); + }); + it('requires common signature fields even when a signature is not bound to an application', async () => { const prisma = createPrismaMock(); prisma.commonReportField.findMany.mockResolvedValue([{ diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 14b0be6..a7e48e9 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -180,6 +180,7 @@ export class SmsConfigService { include: { tenant: true, ipAllowlist: true, + httpConfig: true, }, orderBy: { createdAt: 'desc' }, }); @@ -221,6 +222,7 @@ export class SmsConfigService { include: { tenant: true, ipAllowlist: true, + httpConfig: true, }, }); if (!application || (tenantId && application.tenantId !== tenantId)) { @@ -350,6 +352,11 @@ export class SmsConfigService { return Array.from(merged.values()); } + async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') { + const fields = await this.getApplicationReportFields(applicationId, reportType); + return fields.map(({ channels: _channels, commonReportTypes: _commonReportTypes, ...field }) => field); + } + async createApplication(data: CreateSmsApplicationDto) { const secret = normalizeApplicationPassword(data.passwordCipher); const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); @@ -808,6 +815,128 @@ export class SmsConfigService { }); } + async listClientSignatures(tenantId?: string, signatureId?: string) { + const signatures = await this.prisma.smsSignature.findMany({ + where: { id: signatureId, tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, + select: { + id: true, + tenantId: true, + applicationId: true, + name: true, + purpose: true, + auditStatus: true, + rejectReason: true, + drainageInfo: true, + createdAt: true, + updatedAt: true, + application: { select: { id: true, name: true, status: true } }, + materials: { + select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true }, + }, + drainageItems: { + where: { auditStatus: { not: 'deleted' } }, + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + siteName: true, + url: true, + remark: true, + reportValues: true, + auditStatus: true, + rejectReason: true, + submittedAt: true, + reviewedAt: true, + createdAt: true, + updatedAt: true, + }, + }, + _count: { select: { reportMaterials: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + return signatures.map((signature) => { + const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; + return { + id: signature.id, + tenantId: signature.tenantId, + applicationId: signature.applicationId, + name: signature.name, + purpose: signature.purpose, + auditStatus: signature.auditStatus, + rejectReason: signature.rejectReason, + createdAt: signature.createdAt, + updatedAt: signature.updatedAt, + application: signature.application, + materials: signature.materials, + submittedMaterialCount: signature.materials.length + signature._count.reportMaterials, + reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {}, + drainageInfo: { + links: signature.drainageItems.map((item) => ({ + ...item, + reportValues: isRecord(item.reportValues) ? item.reportValues : {}, + })), + }, + }; + }); + } + + async getClientSignatureView(signatureId: string, tenantId?: string) { + const [signature] = await this.listClientSignatures(tenantId, signatureId); + if (!signature) throw new NotFoundException('Signature not found'); + return signature; + } + + async getClientSignatureWorkspace(tenantId?: string) { + const [items, statusCounts] = await Promise.all([ + this.listClientSignatures(tenantId), + this.prisma.smsSignature.groupBy({ + by: ['auditStatus'], + where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, + _count: { _all: true }, + }), + ]); + const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 }; + for (const item of statusCounts) { + const count = item._count._all; + summary.total += count; + if (item.auditStatus in summary && item.auditStatus !== 'total') { + summary[item.auditStatus as keyof Omit] = count; + } + } + return { items, summary }; + } + + async listClientDrainageInfos(tenantId?: string, itemId?: string) { + return this.prisma.smsDrainageInfo.findMany({ + where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } }, + select: { + id: true, + tenantId: true, + signatureId: true, + applicationId: true, + siteName: true, + url: true, + remark: true, + reportValues: true, + auditStatus: true, + rejectReason: true, + submittedAt: true, + reviewedAt: true, + createdAt: true, + updatedAt: true, + signature: { select: { id: true, name: true, auditStatus: true } }, + application: { select: { id: true, name: true, status: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async getClientDrainageInfoView(itemId: string, tenantId?: string) { + const [item] = await this.listClientDrainageInfos(tenantId, itemId); + if (!item) throw new NotFoundException('Drainage info not found'); + return item; + } + async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { await this.validateSignatureReportValues(data.applicationId, data.drainageInfo); const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); @@ -835,9 +964,9 @@ export class SmsConfigService { return signature; } - async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) { + async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) { + if (!signature || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } await this.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo); @@ -852,6 +981,7 @@ export class SmsConfigService { name: data.name, purpose: data.purpose, auditStatus: data.auditStatus, + rejectReason: data.auditStatus === 'pending' ? null : undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, materialVersion: { increment: 1 }, pendingReport: true, @@ -863,6 +993,24 @@ export class SmsConfigService { return updated; } + async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { + const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found'); + if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { + throw new BadRequestException('当前审核状态不允许修改签名'); + } + const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId); + await this.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_signature', + targetId: signatureId, + action: 'client_update_submit', + statusBefore: current.auditStatus, + statusAfter: 'pending', + }); + return updated; + } + listDrainageInfos(query: DrainageInfoListQuery = {}) { return this.prisma.smsDrainageInfo.findMany({ where: { @@ -1098,9 +1246,9 @@ export class SmsConfigService { }); } - async submitSignature(signatureId: string) { + async submitSignature(signatureId: string, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) { + if (!signature || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } @@ -1285,9 +1433,9 @@ export class SmsConfigService { return this.reviewTemplate(templateId, 'rejected', 'reject', data); } - async changeSignatureStatus(signatureId: string, data: StatusChangeDto) { + async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) { + if (!signature || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } const status = data.status ?? 'deleted'; diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index ef612d5..0864de6 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -420,8 +420,9 @@ ### 5.8 客户端签名管理 - 支持新增、编辑、提交审核、上传证明材料。 -- 支持维护引流信息。 -- 支持查看通道报备状态。 +- “签名与引流信息”采用签名父级、引流信息子级的可展开工作台,提供真实后端统计、签名/应用/状态筛选、审核状态、已交资料数、驳回修改说明及新增、修改、删除操作。 +- 签名审核通过后支持维护短信中使用的网站、应用页面等引流信息;新增或修改后进入真实审核流程,客户端只展示“待提交、资料审核中、审核通过、需修改”等客户可理解的状态。 +- 客户端页面和 `/client` API 均不得暴露内部通道、通道组、路由规则、运营商报备汇总、报备任务及内部资料要求快照。动态审核字段接口只返回字段名称、类型、必填规则等客户填报所需信息;通道级报备状态仅限运营端查看。 ### 5.9 客户端账户计费 @@ -457,6 +458,7 @@ - 企业短信模板列表必须采用自适应布局,在常用桌面及平板视口下无需水平滚动即可看到编辑、删除等操作。 - 运营端“企业模板管理”以响应式列表行展示模板、归属、内容、状态和更新时间;预览、编辑、删除操作在常用视口内始终可见,不得依赖水平滚动。 - 运营端“企业签名管理”的移动、联通、电信报备状态必须将状态文字与通过数/总数允许分行展示;报备详情、报备状态、编辑、删除四个操作按钮在空间不足时按每行两个排列,不得将按钮文字挤成单字换行。 +- 运营端企业签名的状态颜色必须按统一业务语义展示:全部目标通道通过为绿色、部分通过为蓝色、审核中/报备中/资料待补充为橙色、审核或报备失败为红色、未提交/未报备/不适用为灰色;卡片总体色先遵循签名审核状态,再汇总真实通道报备状态,不能把“报备中”和“部分通过”混成同一种颜色。 ### 5.12 运营端审核 @@ -507,7 +509,7 @@ - 企业黑名单:企业应用级号码拦截,同一企业不同短信应用的黑名单互不影响。 - 全局黑名单:平台维度号码拦截。 - 敏感词管理:发送前和审核时命中提示或拦截。 -- 手机号段库:用于运营商识别和路由;列表使用服务端游标分页和服务端搜索,不查询或展示全库总条数。 +- 手机号段库:用于运营商识别和路由;号段与运营商区分规则使用真实服务端分页、搜索和总数。通用 Tab 位于页面标题下、搜索条件上;每个 Tab 只显示本类统计数字,不同时展示另一类统计。 - 引流信息字段库:用于签名/报备资料结构化采集。 - 企业应用级黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。 - 企业黑名单必须绑定到具体短信应用,支持按企业、应用、手机号、入库原因、状态搜索;发送预览、风控和发送链路只能拦截当前应用的 active 黑名单号码,不得把同企业其他应用的黑名单串用;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。 @@ -1435,6 +1437,7 @@ - 单次登录绝对时长为 12 小时,无论是否持续操作均不得自动续期;到期必须使用账号、密码和图形验证码完整登录。修改密码、禁用/删除用户、角色变化和管理员强制下线必须通过 `sessionVersion` 和 Redis 会话立即撤销现有会话。 - 用户、权限、企业状态、应用密钥、通道配置、路由、报备状态和资金调整等敏感操作要求最近 30 分钟内验证过当前密码。超时后由后端返回 `RECENT_AUTHENTICATION_REQUIRED`,前端验证当前密码后自动重试原操作;不能只依赖前端弹窗判断。 - 主动退出、空闲锁定、密码解锁、敏感操作再认证和会话创建均需写真实系统日志;多标签页使用浏览器消息同步锁定、解锁和退出。普通网络错误、400、403 业务拒绝或 5xx 不得被误判为自动退出。 +- 在同一浏览器已有运营端或客户端会话时,另一个入口的账号、密码、角色或验证码登录失败只属于本次登录尝试,不得清理、广播退出或跳转已有会话;只有现有会话自身的 401 失效响应才触发退出处理。 ### 2. 用户类型和企业关联 @@ -1487,3 +1490,28 @@ 4. 创建批次时按每条资料所属企业应用的当前生效路由规则展开所有通道;一个签名走多个通道时,必须为每个通道创建或重置独立报备任务并生成一份该通道的 `.xlsx`。无生效路由、通道未配置字段或缺少通道必填资料时,该资料继续保留在待报备池,任务进入“资料待补充”,不得伪装为已完成。 5. 通道“配置签名报备字段”和“配置引流信息字段”弹窗使用字段池,按资料类型分别配置。每列包含标准字段、通道导出表头、列顺序、必填、说明、列宽、文本转换、缺省值以及图片宽高;导出表头和列顺序必须严格使用通道配置,不受导入表格原始名称和顺序影响。 6. 通道导出文件必须为 WPS/Excel 可打开的 `.xlsx`,图片直接内嵌到对应单元格区域,而不是仅写 MinIO URL 或本地路径。批次保留所选材料版本快照、通道文件、行号和通道任务关联,可从最近批次直接下载每个通道文件。 +## HTTP 客户接口第一版 + +### 管理端企业应用配置 + +- CMPP 与 HTTP 是两套可独立开通的接入能力,不再把 HTTP 作为 `interfaceType` 的互斥选项。运营端在企业应用“接口配置”中维护 HTTP 总开关,以及单条发送、短信状态查询、回执 Webhook、上行 Webhook、上行查询、客户端凭据自助管理等子能力。 +- HTTP 配置独立维护 IP/CIDR 白名单、应用级 QPS、签名时间容差、最多有效凭据数、上行保留/查询范围/分页上限、Webhook 超时和最多尝试次数、生产 HTTPS 约束、客户手工重投权限。 +- 回执和上行分别配置 `cmpp/http/both/none` 投递模式。Gateway 产生的回执或上行必须先写入现有真实短信记录,再按模式投递;HTTP 回调不得取代或伪造 Gateway、回执匹配和上行认领链路。 +- HTTP 访问密钥和 Webhook 签名密钥使用 `HTTP_API_MASTER_KEY` 派生的 AES-256-GCM 密钥加密保存。Secret 只在创建或轮换当次返回,后续运营端和客户端仅显示末四位;允许同时保留多个有效凭据以完成无停机轮换。 + +### 客户接口与安全约束 + +- 第一版提供 `POST /api/openapi/v1/sms/messages` 单条发送、`GET /api/openapi/v1/sms/messages/{messageId}` 状态查询、`GET /api/openapi/v1/sms/uplinks` 上行游标查询和 `GET /api/openapi/v1/sms/uplinks/{uplinkId}` 上行详情。 +- 身份只由 `X-App-Key` 对应凭据确定,不接受请求体中的企业或应用身份。签名原文为 `METHOD + "\n" + PATH + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + SHA256(rawBody)`,使用 Secret 执行 HMAC-SHA256。 +- 时间戳默认允许正负 5 分钟;签名成功后使用 Redis `SET NX EX` 防 nonce 重放,并按应用在 Redis 执行秒级 QPS 限制。IP 白名单与 CMPP 白名单相互独立。 +- 单发必须提供 8~128 位 `Idempotency-Key`。PostgreSQL 对 `(applicationId, idempotencyKey)` 建唯一约束并保存请求体哈希和响应快照:相同内容重放原响应,不同内容返回 409;`clientMessageId` 在应用内唯一。 +- 单发复用现有 `SendChainService`,必须经过真实企业/应用、签名、模板、风控、余额、计费、通道路由和 Redis 队列链路;接收成功返回 202,不代表运营商提交或终端到达成功。 +- 上行查询只返回已匹配或人工认领到当前应用的记录,默认最近 24 小时,单次范围和分页上限由应用配置控制;未匹配和歧义上行不得泄露给任一客户。 +- 客户错误使用 `application/problem+json` 和稳定业务码。客户 Swagger 只包含四个 `/openapi/v1` 接口,不得包含 admin、client 管理或 gateway 内部接口。 + +### HTTP Webhook 与客户端页面 + +- 状态回执和上行回调分别配置 HTTPS URL 与独立事件类型,共享该端点的签名密钥;每个事件生成唯一 `eventId`。回调请求用 `TIMESTAMP + "\n" + rawBody` 执行 HMAC-SHA256,客户必须按 `eventId` 幂等。 +- Webhook 禁止重定向,并在保存和每次投递前解析域名,拒绝环回、私网、链路本地、共享地址和元数据地址。2xx 成功;网络错误、408、429、5xx 可按立即、1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时重试;其他 4xx 直接终结。 +- PostgreSQL 分别保存 Webhook 事件、投递状态和每次尝试摘要;客户和运营人员可查询,授权后可手工重投。首次投递与重试均由 BullMQ 执行,不得使用浏览器定时器或 localStorage 冒充。 +- 客户端“短信基础配置”新增“接口对接”,包含接口概览、访问凭据、回调配置、接口文档、调用与回调记录五个页签;企业应用卡片显示 HTTP 开通状态并跳转。客户端上行列表改为真实服务端条件查询,不再先拉全量数据后仅在浏览器过滤。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index cdbcf30..d50a593 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -29,6 +29,7 @@ REPO_URL=http://175.27.255.91:3000/hectorzhao/lislgosms.git BRANCH=main PUBLIC_HTTP_PORT=12026 API_PORT=3000 +HTTP_API_MASTER_KEY=<至少32位随机值,用于AES-256-GCM加密HTTP访问凭据和Webhook密钥> API_ENABLE_SEND_WORKER=true API_SEND_WORKER_CONCURRENCY=50 ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 23744ed..54f7608 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -105,6 +105,21 @@ - 提交后签名状态变为 pending。 - 生成审核记录。 +### TC-CLIENT-003A 签名与引流信息工作台及通道信息隔离 + +- 优先级:P0 +- 前置条件:真实 PostgreSQL 中存在多个审核状态的企业签名、至少一个已通过签名包含引流信息;应用的生效路由配置了通道级动态资料字段。 +- 步骤: + 1. 企业客户打开“签名与引流信息”,核对顶部全部、审核中、通过、需修改数量与真实 API/数据库。 + 2. 按签名关键字、所属应用、审核状态筛选,并展开签名查看关联引流信息。 + 3. 新增签名并填写动态审核资料;对可编辑签名和引流信息执行修改,对记录执行删除确认。 + 4. 检查 `/client/signatures`、`/client/signatures-workspace`、`/client/applications/:id/report-fields` 响应和页面文本。 +- 预期结果: + - 列表、统计、筛选、审核状态、资料数量、修改说明和引流信息均来自真实 NestJS API 与 PostgreSQL,刷新后保持一致。 + - 客户端只展示“待提交、资料审核中、审核通过、需修改”等客户状态;签名审核通过后才可新增引流信息,待审记录不可重复编辑。 + - 页面及客户端 API 均不包含通道 ID/编码/名称、通道组、路由、运营商报备汇总、报备任务或内部资料要求快照;动态字段仍按真实应用路由合并并由 API 校验必填值。 + - 删除操作经过确认并写入真实状态,其他企业的签名无法读取、修改或删除。 + ### TC-CLIENT-004 模板变量识别与提交审核 - 优先级:P0 @@ -3389,6 +3404,7 @@ npm run verify:phase8 | TC-AUTH-010 | 持续操作至绝对期限,将默认 12 小时在测试环境缩短验证。 | 用户活动只能刷新空闲时间,不能延长绝对期限;到期返回 `401/SESSION_ABSOLUTE_TIMEOUT`,必须重新输入账号、密码和验证码。 | | TC-AUTH-011 | 登录超过最近认证窗口后执行用户禁用、手工充值、通道修改、路由修改或报备状态修改。 | 后端先返回 `403/RECENT_AUTHENTICATION_REQUIRED`,输入当前密码后 30 分钟内自动重试;错误密码不执行原操作,数据库无副作用。 | | TC-AUTH-012 | 分别执行主动退出、修改密码、禁用、删除和角色变更,并在另一标签页继续请求。 | Redis 会话删除或 `sessionVersion` 失效;所有标签页同步退出;旧 Cookie 均返回 401;系统日志可查询创建、锁定、解锁、再认证和退出事件。 | +| TC-AUTH-013 | 同一浏览器先登录运营端并保持一个受保护页面,再打开客户端登录页,分别提交错误账号、错误密码、错误角色和错误验证码。 | 客户端仅显示本次登录失败原因并刷新验证码;不得清除现有运营端展示会话、广播 logout 或把运营端页面跳回登录页;运营端随后请求真实受保护 API 仍成功。反向从客户端会话测试运营端错误登录结果相同。 | | TC-USER-ADMIN-001 | 运营端新增平台管理员,填写用户名/登录账号、邮箱或手机号、初始密码。 | 创建成功;用户无 `tenantId`;可登录运营端;写 `user.created` 日志。 | | TC-USER-ADMIN-002 | 运营端新增企业管理员但不选择企业。 | 返回 400;不创建用户。 | | TC-USER-ADMIN-003 | 运营端新增企业管理员并选择企业。 | 创建成功;用户关联企业;可登录客户端;客户端数据按该企业隔离。 | @@ -3407,6 +3423,7 @@ npm run verify:phase8 | TC-UI-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 | | TC-ADMIN-ENTERPRISE-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开运营端“企业模板管理”,查看长企业名、长模板内容和包含多变量的真实记录。 | 列表行按视口自适应重排,无水平滚动;预览、编辑、删除始终可见且可操作,内容摘要不撑破容器。 | | TC-ADMIN-ENTERPRISE-SIGNATURE-LAYOUT-001 | 在运营端“企业签名管理”打开移动/联通/电信显示“未报备(0/2)”的真实签名,分别使用 1024px 和 1366px 视口。 | 状态标签和数量可分行但各自保持完整;四个操作按钮按两列两行排列,文字不被挤成单字换行,卡片不产生水平滚动。 | +| TC-ADMIN-ENTERPRISE-SIGNATURE-COLOR-001 | 使用真实签名和通道任务分别覆盖草稿、待审核、审核驳回、未报备、报备中、资料待补充、部分通过、全部通过和报备失败。 | 总体色条依次遵循审核优先、报备汇总次优先;全部通过为绿、部分通过为蓝、处理中或待补资料为橙、失败为红、未开始或不适用为灰。三网标签使用同一语义,“报备中”不得显示成“部分通过”的蓝色。 | | TC-ADMIN-ENTERPRISE-SIGNATURE-SELECT-001 | 在运营端打开“添加签名”,展开企业下拉并输入部分名称,选择企业后再展开企业应用;分别使用常规高度和 600px 高视口。 | 两个下拉均通过浮层完整显示在弹窗和底部操作栏之上,可搜索、滚动并选择真实 API 选项;空间不足时自动向上展开,列表不被裁剪。 | | TC-ADMIN-REPORT-FIELD-CODE-001 | 在报备字段库分别提交 `License2026`、`license_code`、中文和空白代码,并直接调用真实新增 API 复验。 | 只有 `License2026` 写入 PostgreSQL;前端阻止非法值,API 同样返回 400,不依赖前端校验。 | | TC-ADMIN-CHANNEL-REPORT-SIGNATURE-001 | 打开包含数据库签名 `【安徽航天信息】` 的通道报备详情及签名详情弹窗。 | 两处均只显示单层 `【安徽航天信息】`,不出现重复中括号。 | @@ -3415,7 +3432,7 @@ npm run verify:phase8 | TC-MOCK-CLEAN-005 | 运营端短信审核通过、批量通过、驳回风控审核任务。 | 调用 `admin/risk-review/tasks` 真实接口;通过必须弹窗确认;状态刷新后仍持久化;不再显示固定手机号样例。 | | TC-MOCK-CLEAN-006 | 运营端短信记录按手机号、状态、日期和内容查询,打开详情。 | 数据来自 `sms_message_records`;详情展示真实 messageId、状态、失败原因;无数据时为空态。 | | TC-MOCK-CLEAN-007 | 访问明确标注待开发的彩信菜单。 | 可以显示待开发/空态;不得作为第一版短信真实功能通过依据。 | -| TC-PHONE-SEGMENT-001 | 生产库存在 50 万级手机号段时打开手机号段库,连续点击下一页、上一页,并按号段、省份、城市或运营商搜索。 | API 使用 `prefix` 游标分页并返回 `hasMore/nextCursor`;页面数据来自真实数据库,可稳定前后翻页和搜索;接口不执行全表总数统计,页面不展示号段总条数。 | +| TC-PHONE-SEGMENT-001 | 生产库存在 50 万级手机号段时打开手机号段库,连续点击下一页、上一页,并按号段、省份、城市或运营商搜索。 | 页面数据来自真实数据库,可按服务端 `total/page/pageSize` 稳定分页和搜索;Tab 位于标题下、搜索条件上。手机号段 Tab 只显示号段总数,运营商区分规则 Tab 只显示规则总数,切换时不同时展示两个统计。 | ### 17.11 下游投递 ACK 与应用级重试策略 @@ -3453,3 +3470,20 @@ npm run verify:phase8 | TC-GW-RATE-002 | 超过通道 TPS 后观察 Redis Stream consumer group,并在存在等待消息时重启 Gateway。 | 超流速消息保留在 Stream pending,不直接失败;重启后通过 PEL/XAUTOCLAIM 恢复并继续按通道 TPS 排队提交,不丢失、不重复 ACK。 | | TC-GW-RATE-003 | 启动两个共享同一 Redis 的 Gateway 消费实例,同时向同一通道发送,再向两个不同通道发送。 | 同一通道的两个实例共享 Redis 限速额度,总 TPS 不叠加;不同通道使用独立 key,不被合并成平台总 TPS。 | | TC-GW-RATE-004 | 在存在 active 上游通道时按生产脚本顺序重启 Gateway 和 API,随后检查 Gateway 日志、连接状态与 Redis。 | Gateway 先启动,API 随后重新下发全部 active 通道连接命令;Gateway 内存连接池恢复,数据库状态反映本次真实连接结果,并生成 `rate:gateway:channel:config:`,不沿用重启前的假 connected。 | + +### 17.14 HTTP 客户接口、上行查询与 Webhook + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-HTTP-CONFIG-001 | 运营端编辑企业应用,独立开关 CMPP 与 HTTP,并配置发送/查询/回调子能力、独立 IP 白名单、QPS、投递模式和 Webhook 策略,保存后刷新。 | 配置写入 `SmsApplicationHttpConfig` 和 HTTP 白名单表;CMPP 原配置不丢失;刷新一致;关闭某项能力后对应 OpenAPI 返回稳定 403 业务码。 | +| TC-HTTP-AUTH-001 | 使用正确 Access Key/Secret 按原始请求体签名,再分别修改 path、body、timestamp、nonce、来源 IP 和签名。 | 正确请求通过;篡改项返回 `application/problem+json`;过期时间、重复 nonce、白名单外 IP 和错误签名被拒绝;错误签名不得提前占用 nonce。 | +| TC-HTTP-AUTH-002 | 同一应用一秒内并发调用超过配置 QPS,再在下一秒继续调用。 | Redis 应用级额度不被多个凭据放大;超额返回 429,下一秒恢复;不依赖单进程内存计数。 | +| TC-HTTP-CREDENTIAL-001 | 客户创建第一把凭据、保存 Secret,再创建第二把完成切换并吊销第一把;刷新页面和查看数据库。 | Secret 仅创建当次可见,数据库为 AES-256-GCM 密文;列表只显示末四位;两把凭据轮换期可并存,吊销后旧凭据立即返回 401。 | +| TC-HTTP-SEND-001 | 调用单条发送接口,使用真实已审核签名/模板、余额和通道路由。 | 返回 202 和平台 messageId;真实创建 API 来源批次与短信记录,执行风控、冻结/计费并进入 Redis/BullMQ/Gateway 链路;不得使用静态数组或直接伪造 delivered。 | +| TC-HTTP-IDEMPOTENCY-001 | 并发使用相同 `Idempotency-Key` 和相同 body 调用,再用相同 key 改变 body;另重复 clientMessageId。 | 只创建一条真实短信;完成后同内容重放原响应,处理中返回 409 processing;不同 body 返回 409 conflict;应用内重复 clientMessageId 被拒绝。 | +| TC-HTTP-QUERY-001 | 用本应用凭据按 messageId/clientMessageId 查询本应用和其他应用短信。 | 只返回当前应用短信状态、提交/回执时间和失败信息;其他应用记录统一 404,不泄露租户数据。 | +| TC-HTTP-UPLINK-001 | 查询默认 24 小时上行,组合手机号、接入号、关键词、时间和 cursor;构造匹配、歧义和未匹配记录。 | 只返回已匹配或人工认领到当前应用的记录;按 `(receivedAt,id)` 稳定倒序游标分页;超查询范围和非法 cursor 返回 400;歧义/未匹配不泄露。 | +| TC-HTTP-WEBHOOK-001 | 配置 HTTP 或 both 投递,分别触发终端回执、平台失败回执、自动匹配上行和人工认领上行。 | 事件只在真实记录落库后产生;HTTP 模式不创建 CMPP 投递,both 同时创建两条独立链路;eventId 唯一,payload 包含可关联 messageId/uplinkId。 | +| TC-HTTP-WEBHOOK-002 | 回调依次返回 500、429、408、400、302 和 200,并模拟超时。 | 500/429/408/网络错误按既定退避重试,400 和重定向终结,2xx 成功;每次尝试、状态码、耗时和截断响应写 PostgreSQL,可授权手工重投。 | +| TC-HTTP-WEBHOOK-003 | 保存指向 localhost、RFC1918、链路本地、共享地址、云元数据 IP、会解析到私网的域名和发生 DNS 重绑定的 URL。 | 保存或投递前被 SSRF 校验拒绝;不跟随重定向;生产 HTTPS 约束开启时 HTTP URL 被拒绝。 | +| TC-HTTP-CLIENT-001 | 客户端打开“接口对接”五个页签,切换应用、创建凭据、配置回调、查看文档与日志;API 断开后重试。 | 所有状态来自真实 API/PostgreSQL/Redis;应用卡片显示 HTTP 状态;API 失败展示错误,不使用 localStorage 或前端静态数据伪造成功。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index d0bd0f0..9ce3984 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1,6 +1,14 @@ # 第一版系统化测试进度 -## 2026-07-15 短信模板签名自动填充与真实校验(未提交、未部署) +## 2026-07-16 客户端签名与引流信息页面重做(已验收,待发布) + +- 客户端“签名与引流信息”按运营端信息结构重做为签名父级、引流信息子级的可展开工作台,增加真实后端状态统计、关键字/应用/状态筛选、已交资料数、修改说明、新增/修改/删除确认;客户端文案不再出现通道和内部报备概念。 +- 新增客户端专用安全视图和工作台 API。NestJS 查询仅选择客户需要的签名、应用、材料和引流字段;动态资料字段移除通道来源,签名响应移除通道、路由、运营商汇总、报备任务和内部要求快照,避免只靠前端隐藏造成泄露。 +- 客户端签名更新补充企业归属校验并重新进入审核;签名列表的顶部统计由 PostgreSQL 状态分组通过真实 API 返回,不使用 mock、localStorage 或前端临时统计冒充。 +- Prisma validate/generate/migrate status 通过,本地 PostgreSQL 共 52 条 migration 且无待执行项;SmsConfig 定向 1 suite/37 项、API 全量 20 suites/209 项、API build、前端 build、Gateway `go test ./...` 和 `git diff --check` 均通过。Jest 延续既有 open-handle 提示,使用同一全量用例加 `--forceExit` 复核退出码为 0;前端仅有既有 Vite chunk size warning。 +- 应用内浏览器可加载最新本地构建,但目标路由因无现成客户端登录态跳转至真实图形验证码登录页;Chrome 也没有可复用的平台登录页。本轮未绕过验证码,因此没有把登录页误记为目标页面视觉通过,登录后的展开、筛选和弹窗交互仍建议补一次可见复测。 + +## 2026-07-15 短信模板签名自动填充与真实校验(已验收,待发布) - 客户端和运营端短信模板表单将签名改为必选;选择签名时自动在模板内容开头填入规范 `【签名】`,切换签名只替换原前缀并保留正文,清空选择时移除自动前缀。 - 两套内容输入框均明确提示“模板内容必须以所选签名开头”,字符数、变量识别和计费条数继续基于包含签名的完整内容计算。 @@ -1916,7 +1924,7 @@ git diff --check - 发布快照本地与服务器 SHA-256 均为 `489d6c969252403689dfdcca15b87e4f5a93b65187551fa6650451527366942e`。生产 `.deployed-commit=a7a4e8d9f6aba00b8137e5b70c59bdef67ed3b57`,47 条 migration 全部齐全;`cmpp-api`、`cmpp-gateway`、MinIO、Nginx、PostgreSQL、Redis 均为 active,`12026/17890/8090/3000/9000` 正常监听,API/Gateway health、Redis PONG、PostgreSQL readiness 和外部首页/运营端/API HTTP 均通过,部署后 API/Gateway 无 error 级日志。 - 生产浏览器确认登录页加载成功且标题为“聆界短信管理平台”。服务器凭据文件对存量管理员仅记录 `password=unchanged`,不是可用明文密码,因此未擅自重置生产密码;通用 Select 的企业搜索、企业与应用联动、弹窗越界和普通筛选区 Portal 交互已在部署前通过本地真实 NestJS API、PostgreSQL 数据和生产同构建验证,未使用 mock、localStorage 或静态数组。 -## 2026-07-15 签名与引流资料批量导入及统一通道报备(未提交) +## 2026-07-15 签名与引流资料批量导入及统一通道报备(已验收,待发布) - 新增真实待报备资料工作台:WPS 表格另存 `.xlsx` 后由 NestJS + ExcelJS 解析多行表头、文本和内嵌图片,原文件及图片走 MinIO,导入批次、可复用映射方案、材料版本和待报备状态走 Prisma/PostgreSQL;导入只更新资料池,不自动生成通道任务。 - 新增统一报备批次:运营勾选新建/修改的签名与引流信息后,按企业应用当前生效路由展开全部通道,每通道生成一份内嵌图片的 `.xlsx`,并关联批次材料版本快照、文件行号和真实通道报备任务。无路由、未配置通道字段或缺必填资料不会清除待报备标记。 @@ -1924,7 +1932,7 @@ git diff --check - Prisma migrations `20260715190000_add_report_material_import_export_workflow`、`20260715193000_scope_channel_report_fields_by_type`、`20260715194000_initialize_existing_report_material_pending` 已在本地真实 PostgreSQL 成功应用,50 条 migration status 齐全;字段范围迁移将旧 `both` 配置拆成签名/引流两份,并允许同一标准字段在两类中使用不同表头和顺序;初始化迁移不把上线前所有历史签名误认成本次新建/修改资料。Prisma validate/generate、API 全量 19 suites/198 项、API build、前端 build、Gateway 全量 Go 测试、根目录与 API 生产依赖 audit、`git diff --check` 均通过;audit 为 0 漏洞,前端仅有既有 Vite chunk size warning,Jest 仍需 `--forceExit` 退出既有异步句柄。 - 应用内浏览器使用本地真实 NestJS API/PostgreSQL 会话验证 `/admin/report-materials`:真实待报备签名加载成功,勾选后“统一生成通道报备”由禁用变为可用;导入弹窗展示企业/应用、映射方案、表头行和 XLSX 文件控件。进入真实通道报备详情后,签名字段配置弹窗按字段池/导出字段双栏渲染,页面和两次交互均无 console error/warn、无框架错误覆盖。未点击统一生成、未上传客户文件、未写入烟测业务数据。 -## 2026-07-15 通道 TPS 配置口径清理(未提交) +## 2026-07-15 通道 TPS 配置口径清理(已验收,待发布) - 明确 `SmsChannel.rateLimitPerSecond` 是单个物理通道的 TPS 上限,不是平台总流速;Redis 限速键包含通道 ID,不同通道独立计数。 - 同一物理通道即使被多个通道组引用,也共享通道自身的同一限速桶,不按通道组重复获得额度。 @@ -1942,3 +1950,18 @@ git diff --check - 启动恢复修正提交 `85ff0376` 已 push 并完成最终运行时发布;发布前第二次备份至 `/opt/cmpp-platform/backups/releases/20260715-183017`,数据库、源码、环境文件 SHA-256 分别为 `8d632d63db1afcad37889b445e3989819c15053d544f3a63239dca20cd0baecd`、`5ffc421046f436d5f8ed8178c7a884c4e476567826740d469f4560360304744d`、`87ab2efd509b39849b0ee22ead1b4cf0568fba4ab8525c59b5c78da51c73bc86`;最终运行发布包本地/服务器 SHA-256 均为 `930ac2a24bf6c1ee24cdfb2f90dff26d89bf7e1f9d84425aa800e66aa8ef4b9a`。 - 生产 51 条 migration 全部齐全;`cmpp-api`、`cmpp-gateway`、MinIO、Nginx、PostgreSQL 和 Redis 正常,`12026/17890/8090/3000/9000` 监听,API/Gateway health、Redis PONG、外部首页和运营入口均通过,受保护异常列表无会话返回 401,Redis Stream consumer group pending=0、lag=0,部署后近期 API/Gateway 无 error 级日志。 - 两个 active 上游通道在本次 Gateway/API 启动后均产生新的 `cmpp_connection.connect_requested` 与 `cmpp_connection.connected` 审计,currentConnections=1;Redis 生成 `rate:gateway:channel:config:` 两个独立 key,值均为各自真实配置 100,证明 Gateway 内存连接池与最终 TPS 上限已由当前进程恢复。生产前端资源包含“Gateway提交异常”;应用内浏览器访问新路由按真实鉴权跳转运营端登录,标题、DOM 正常且 console 无 error/warn,未绕过图形验证码。 + +## 2026-07-16 签名颜色、手机号段布局与跨入口登录失败隔离(已验收,待发布) + +- 企业签名卡片建立审核优先、报备汇总次优先的统一颜色规则:全部目标通道通过为绿色、部分通过为蓝色、待审核/报备中/资料待补充为橙色、审核或报备失败为红色、草稿/未报备/不适用为灰色。三网标签将“报备中”从蓝色改为橙色,与“部分通过”明确区分;总体色条增加可访问状态说明。 +- 手机号段库通用 Tab 移到页面标题下、搜索条件上;手机号段和运营商区分规则的统计卡分别放入各自 Tab,只展示当前类型的真实服务端总数。搜索、分页、新增和删除仍调用原 NestJS/Prisma API。 +- 修复同浏览器跨入口错误登录误踢已有会话:`/admin/auth/login`、`/client/auth/login` 的 401 仅作为本次凭据失败返回,不再进入通用“现有会话失效”分支,不清理展示会话、不广播 logout、不跳转原入口。受保护 API 自身返回的 401 仍按 Redis 会话失效、锁定或撤销规则处理。 +- 本地 PostgreSQL、Redis、正式构建的 NestJS API 和 Vite 生产构建已启动;API 19 suites、200 项全部通过,API build、前端 TypeScript/Vite build 和 `git diff --check` 通过,前端仅有既有 chunk size warning。应用内浏览器确认本地真实 API 登录页、标题、DOM 和验证码请求正常;因未获本轮图形验证码代填授权,未绕过 HttpOnly Cookie 或 localStorage 进入受保护页面,签名颜色、Tab 切换及“已有管理端会话 + 客户端错误密码”可视交互待授权后补测。 +## 2026-07-16 HTTP 单条发送、回执/上行回调与上行查询(已验收,待发布) + +- 已新增独立 HTTP 接入数据模型与 migration `20260716110000_add_http_open_api`:应用能力配置、独立 IP 白名单、AES-256-GCM 凭据、数据库幂等请求、Webhook 端点/事件/投递/尝试记录,以及应用内唯一 `clientMessageId`。 +- 已实现 HMAC-SHA256 机器鉴权、Redis nonce 防重放和应用级 QPS、单条发送、短信状态查询、上行游标查询/详情。单发复用真实 `SendChainService` 的模板、风控、余额、计费和 Redis 入队链路;相同幂等键同内容重放、不同内容冲突。 +- 已将现有 Gateway 回执和上行落库后投递扩展为 `cmpp/http/both/none` 分流,HTTP Webhook 使用 BullMQ、事件 ID、签名、超时、尝试记录和退避重试;保存与投递前执行 HTTPS/SSRF 校验且不跟随重定向。 +- 运营端企业应用编辑页已将 CMPP 和 HTTP 改为独立开关,并增加 HTTP 子能力、白名单、QPS、凭据数、投递模式和回调策略配置。客户端新增“接口对接”五个页签,企业应用卡片显示 HTTP 状态;上行短信页改为 NestJS/Prisma 服务端条件查询。 +- 本地真实 PostgreSQL、Redis、MinIO 已启动,migration 成功应用,52 条 migration 全部齐全。临时企业应用通过真实 API 完成 HMAC/时间戳/IP、错误签名、签名后 nonce 防重放、单发业务拒绝、失败幂等重放和上行查询验收:错误签名 401 且不占 nonce,正确签名后同 nonce 重放 401,上行空集合 200;无审核签名的单发首次及相同幂等键重放均为 422,数据库未产生批次、短信、Submit 或回执,因此未向验收手机号发送。临时数据已清理。 +- API 全量 20 suites、211 项通过,Prisma validate/migrate status、API build、前端 TypeScript/生产 build、Gateway 全量 Go 测试和 `git diff --check` 通过;前端仅有既有 chunk size warning。Browser 插件不可用,使用工作区 Playwright fallback:真实图形验证码登录客户端和运营端,客户端五个页签、真实凭据/请求日志、运营 HTTP 开关及能力字段均渲染并可交互;桌面 DOM 非空、无框架覆盖、console 无 error/warn。移动端仍受既有 AppShell 侧栏布局影响,本轮未扩展修改全局响应式框架。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 0dc6f1a..14ff92a 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -46,7 +46,8 @@ async function request(path: string, options: RequestOptions = {}): Promise>; }; +export type ClientSmsSignatureView = Pick & { + application?: Pick | null; + submittedMaterialCount: number; + reportValues: Record; + drainageInfo: { links: Array<{ + id: string; + siteName: string; + url: string; + remark?: string | null; + reportValues: Record; + auditStatus: string; + rejectReason?: string | null; + submittedAt: string; + reviewedAt?: string | null; + createdAt: string; + updatedAt: string; + }> }; +}; + +export type ClientSignatureWorkspace = { + items: ClientSmsSignatureView[]; + summary: { total: number; pending: number; approved: number; rejected: number; draft: number }; +}; + export type SmsDrainageInfo = { id: string; tenantId: string; @@ -557,6 +585,36 @@ export type SmsUplinkMatchCandidate = { messageRecord?: SmsMessageRecord | null; }; +export type HttpApiConfig = { + enabled: boolean; + sendEnabled: boolean; + messageQueryEnabled: boolean; + receiptWebhookEnabled: boolean; + uplinkWebhookEnabled: boolean; + uplinkQueryEnabled: boolean; + credentialSelfServiceEnabled: boolean; + qpsLimit: number; + timestampToleranceSeconds: number; + maxCredentialCount: number; + uplinkRetentionDays: number; + maxQueryRangeDays: number; + maxPageSize: number; + receiptDeliveryMode: 'cmpp' | 'http' | 'both' | 'none'; + uplinkDeliveryMode: 'cmpp' | 'http' | 'both' | 'none'; + webhookRetryEnabled: boolean; + webhookMaxAttempts: number; + webhookTimeoutSeconds: number; + requireHttps: boolean; + allowClientManualRetry: boolean; + allowClientTest: boolean; +}; + +export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; config: HttpApiConfig | null; ipAllowlist: string[] }; +export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string }; +export type HttpWebhookEndpoint = { id: string; eventType: 'receipt' | 'uplink'; url: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; lastTestAt?: string | null; lastTestStatus?: string | null; updatedAt: string }; +export type HttpApiRequestLog = { id: string; requestId: string; clientMessageId?: string | null; sourceIp?: string | null; httpStatus?: number | null; businessCode?: string | null; status: string; durationMs?: number | null; createdAt: string; completedAt?: string | null }; +export type HttpWebhookDelivery = { id: string; status: string; attemptCount: number; lastHttpStatus?: number | null; lastError?: string | null; createdAt: string; event: { eventId: string; eventType: string; messageId?: string | null }; endpoint: { eventType: string; url: string } }; + export type DictionaryItem = Record & { id: string; status?: string; @@ -655,6 +713,8 @@ export type ApplicationReportField = { channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>; }; +export type ClientApplicationReportField = Omit; + export type CommonReportField = DictionaryItem & { drainageFieldId: string; reportType: 'signature' | 'drainage'; @@ -1167,6 +1227,8 @@ export const adminApi = { request(withQuery('/admin/report-fields/common', { reportType })), getApplicationCmppParams: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), + getApplicationHttpApiConfig: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/http-api`), + updateApplicationHttpApiConfig: (applicationId: string, body: Partial & { ipAllowlist?: string[] }) => request(`/admin/enterprise-applications/${applicationId}/http-api`, { method: 'PUT', body: JSON.stringify(body) }), listChannels: () => request('/admin/channels'), listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/reports/reconciliation', query)), @@ -1442,18 +1504,31 @@ export const clientApi = { request('/client/applications', { tenantId }), getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/cmpp-params`, { tenantId }), + getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api`, { tenantId }), + listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }), + createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/credentials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), + revokeHttpApiCredential: (applicationId: string, credentialId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`, { method: 'POST', tenantId, body: JSON.stringify({}) }), + listHttpWebhooks: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhooks`, { tenantId }), + saveHttpWebhook: (applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), + listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/requests`, { tenantId }), + listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhook-deliveries`, { tenantId }), + retryHttpWebhookDelivery: (applicationId: string, deliveryId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`, { method: 'POST', tenantId, body: JSON.stringify({}) }), listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }), + request(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }), listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/report-fields/common', { reportType }), { tenantId }), + request(withQuery('/client/report-fields/common', { reportType }), { tenantId }), listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/signatures', { tenantId }), + request('/client/signatures', { tenantId }), + getSignatureWorkspace: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/signatures-workspace', { tenantId }), createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + request('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), + request(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), + request(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -1486,7 +1561,7 @@ export const clientApi = { request(`/client/send/batch-tasks/${id}/messages`, { tenantId }), listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/messages', query), { tenantId }), - listUplinkMessages: (query: { channelId?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/uplink-messages', query), { tenantId }), createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }), diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index 719d774..f275f5f 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -45,25 +45,8 @@ type SignatureFormState = { reportValues: ReportValues; }; -const statusLabelMap: Record = { - approved: '已通过', - pending: '审核中', - rejected: '已驳回', - filing: '待报备', -}; - -const statusToneMap: Record = { - approved: 'success', - pending: 'info', - rejected: 'danger', - filing: 'neutral', -}; - -function StatusTag({ status }: { status: CarrierStatus }) { - return {statusLabelMap[status]}; -} - type CarrierReportSummary = { status: string; approved: number; total: number }; +type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray'; function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) { if (!summary || summary.status === 'not_applicable' || summary.total === 0) return 不适用; @@ -73,10 +56,26 @@ function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) { else if (summary.status === 'failed' || summary.status === 'rejected') { label = '报备失败'; tone = 'danger'; } else if (summary.status === 'waiting_material') { label = '资料待补充'; tone = 'warning'; } else if (summary.approved > 0) { label = '部分通过'; tone = 'info'; } - else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'info'; } + else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'warning'; } return {label}({summary.approved}/{summary.total}); } +function signatureCardVisual(auditStatus: string, summaries?: Record) { + if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone }; + if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone }; + if (auditStatus !== 'approved') return { label: '签名尚未提交审核', tone: 'gray' as SignatureCardTone }; + + const values = Object.values(summaries ?? {}); + const applicable = values.filter((summary) => summary.total > 0 && summary.status !== 'not_applicable'); + if (applicable.some((summary) => ['failed', 'rejected'].includes(summary.status))) return { label: '存在报备失败', tone: 'red' as SignatureCardTone }; + if (applicable.some((summary) => summary.approved > 0 && summary.approved < summary.total)) return { label: '部分通道报备通过', tone: 'blue' as SignatureCardTone }; + if (applicable.some((summary) => summary.status === 'waiting_material')) return { label: '报备资料待补充', tone: 'amber' as SignatureCardTone }; + if (applicable.some((summary) => ['reporting', 'exporting'].includes(summary.status))) return { label: '通道报备处理中', tone: 'amber' as SignatureCardTone }; + if (applicable.length > 0 && applicable.every((summary) => summary.status === 'approved')) return { label: '所有目标通道报备通过', tone: 'green' as SignatureCardTone }; + if (applicable.some((summary) => summary.approved > 0)) return { label: '部分运营商报备通过', tone: 'blue' as SignatureCardTone }; + return { label: applicable.length > 0 ? '目标通道尚未报备' : '没有适用的目标通道', tone: 'gray' as SignatureCardTone }; +} + function AuditStatusTag({ status }: { status: string }) { const meta: Record = { draft: { label: '草稿', tone: 'neutral' }, pending: { label: '待审核', tone: 'info' }, approved: { label: '已通过', tone: 'success' }, rejected: { label: '已驳回', tone: 'danger' }, @@ -151,14 +150,6 @@ function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filin return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback; } -function signatureCardTone(statuses: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }) { - const values = Object.values(statuses); - if (values.includes('rejected')) return 'red'; - if (values.every((status) => status === 'approved')) return 'green'; - if (values.includes('pending')) return 'blue'; - return 'gray'; -} - function formatDate(value?: string) { return formatDateTime(value); } @@ -697,11 +688,10 @@ export function AdminEnterpriseSignaturesPage() { const visibleDrainageLinks = appliedDrainageKeyword ? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword)) : payload.links; - const summaryStatuses = Object.values(signature.carrierReportSummary ?? {}).map((summary) => summary.status); - const cardTone = summaryStatuses.includes('failed') ? 'red' : summaryStatuses.length > 0 && summaryStatuses.every((status) => status === 'approved' || status === 'not_applicable') ? 'green' : summaryStatuses.some((status) => status === 'reporting') ? 'blue' : 'gray'; + const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary); const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword); return ( -
+
+ +
+ + ); + return (
@@ -149,25 +159,6 @@ export function AdminPhoneSegmentsPage() {
{error ?

{error}

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

已收录手机号段

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

运营商识别规则

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

已收录手机号段

+
+
+ {queryPanel} - + ), }, { label: '运营商区分规则', value: 'rules', content: ( - <> +
+
+
+ +
{ruleTotal.toLocaleString('zh-CN')}

运营商识别规则

+
+
+ {queryPanel}
setRulePage((current) => Math.min(ruleTotalPages, current + 1))} onPageChange={setRulePage} /> - + ), }, ]} diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 475fb49..54be526 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft, Info, RadioTower, RefreshCw } from 'lucide-react'; -import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi'; +import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui'; type Carrier = 'mobile' | 'unicom' | 'telecom'; @@ -14,6 +14,13 @@ const carrierMeta: Record = { telecom: { label: '电信', description: '电信号码只会进入电信通道组' }, }; +const deliveryModeOptions = [ + { label: '仅 CMPP', value: 'cmpp' }, + { label: '仅 HTTP', value: 'http' }, + { label: 'CMPP + HTTP 双投', value: 'both' }, + { label: '不投递', value: 'none' }, +]; + export function AdminSmsApplicationFormPage() { const navigate = useNavigate(); const { enterpriseId, appId } = useParams(); @@ -36,6 +43,15 @@ export function AdminSmsApplicationFormPage() { const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true); const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true); const [ipAddress, setIpAddress] = useState(''); + const [httpConfig, setHttpConfig] = useState({ + enabled: false, sendEnabled: false, messageQueryEnabled: false, receiptWebhookEnabled: false, + uplinkWebhookEnabled: false, uplinkQueryEnabled: false, credentialSelfServiceEnabled: false, + qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90, + maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'cmpp', uplinkDeliveryMode: 'cmpp', + webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true, + allowClientManualRetry: true, allowClientTest: true, + }); + const [httpIpAddress, setHttpIpAddress] = useState(''); const [groups, setGroups] = useState([]); const [mobileGroupId, setMobileGroupId] = useState(''); const [unicomGroupId, setUnicomGroupId] = useState(''); @@ -76,6 +92,19 @@ export function AdminSmsApplicationFormPage() { }; }, [appId, enterpriseId, isEdit]); + useEffect(() => { + if (!appId) return; + let cancelled = false; + adminApi.getApplicationHttpApiConfig(appId).then((result) => { + if (cancelled) return; + if (result.config) setHttpConfig(result.config); + setHttpIpAddress(result.ipAllowlist.join('\n')); + }).catch((failure: Error) => { + if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败'); + }); + return () => { cancelled = true; }; + }, [appId]); + function goBack() { navigate('/admin/enterprise-applications'); } @@ -178,6 +207,7 @@ export function AdminSmsApplicationFormPage() { status: 'active', })), }); + await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) }); goBack(); } catch (failure) { setError(failure instanceof Error ? failure.message : '短信应用保存失败'); @@ -252,29 +282,56 @@ export function AdminSmsApplicationFormPage() {

接口配置

-

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

+

CMPP 与 HTTP 可独立开通;回执和上行可按 CMPP、HTTP、双投或不投递配置。

- 短信接口 + CMPP 接口
- 接口类型 + CMPP 协议
-
+
+ HTTP 接口 + +
开启后仍需分别开通发送、状态查询、回执回调和上行查询/回调能力;访问密钥由客户端“接口对接”页面按权限创建。
+
+ {httpConfig.enabled ? ( + <> +
+ HTTP 能力 +
+ {([ + ['sendEnabled', '单条发送'], ['messageQueryEnabled', '状态查询'], ['receiptWebhookEnabled', '回执回调'], + ['uplinkQueryEnabled', '上行查询'], ['uplinkWebhookEnabled', '上行回调'], ['credentialSelfServiceEnabled', '客户端自助密钥'], + ] as Array<[keyof HttpApiConfig, string]>).map(([key, label]) => )} +
+
+ setHttpIpAddress(event.target.value)} placeholder="独立于CMPP;多个IP/CIDR可换行填写,留空表示不限制" value={httpIpAddress} /> + setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} /> + setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} /> + setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} /> + setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} /> + setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} /> + setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} /> +
安全与重试
+ + + +
+ + ) : null} setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> ([]); const [selectedApp, setSelectedApp] = useState(null); const [params, setParams] = useState(null); @@ -158,8 +160,9 @@ export function ClientApplicationsPage() {
CMPP连接状态
{statusLabelMap[linkStatus]}
+
HTTP接口
{application.httpConfig?.enabled ? '已开通' : '未开通'}
- +
); })} diff --git a/src/apps/client/ClientHttpApiPage.tsx b/src/apps/client/ClientHttpApiPage.tsx new file mode 100644 index 0000000..0d61c05 --- /dev/null +++ b/src/apps/client/ClientHttpApiPage.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react'; +import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react'; +import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi'; +import { Button, Input, Select, Tabs, Tag } from '@/components/ui'; + +export function ClientHttpApiPage() { + const [applications, setApplications] = useState([]); + const [applicationId, setApplicationId] = useState(''); + const [config, setConfig] = useState(null); + const [credentials, setCredentials] = useState([]); + const [webhooks, setWebhooks] = useState([]); + const [requests, setRequests] = useState([]); + const [deliveries, setDeliveries] = useState([]); + const [receiptUrl, setReceiptUrl] = useState(''); + const [uplinkUrl, setUplinkUrl] = useState(''); + const [revealedSecret, setRevealedSecret] = useState<{ title: string; accessKey?: string; secret: string } | null>(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + clientApi.listApplications().then((items) => { + const active = items.filter((item) => item.status !== 'deleted'); + setApplications(active); + setApplicationId((current) => current || active[0]?.id || ''); + }).catch((failure: Error) => setError(failure.message)).finally(() => setLoading(false)); + }, []); + + async function loadApplication(id: string) { + if (!id) return; + setLoading(true); + setError(''); + try { + const [nextConfig, nextCredentials, nextWebhooks, nextRequests, nextDeliveries] = await Promise.all([ + clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id), + clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id), + ]); + setConfig(nextConfig); + setCredentials(nextCredentials); + setWebhooks(nextWebhooks); + setRequests(nextRequests); + setDeliveries(nextDeliveries); + setReceiptUrl(nextWebhooks.find((item) => item.eventType === 'receipt')?.url ?? ''); + setUplinkUrl(nextWebhooks.find((item) => item.eventType === 'uplink')?.url ?? ''); + } catch (failure) { setError(failure instanceof Error ? failure.message : 'HTTP接口资料加载失败'); } + finally { setLoading(false); } + } + + useEffect(() => { void loadApplication(applicationId); }, [applicationId]); + + async function createCredential() { + try { + const created = await clientApi.createHttpApiCredential(applicationId, { name: `客户端凭据 ${credentials.length + 1}` }); + setRevealedSecret({ title: '访问凭据仅展示一次,请立即保存', accessKey: created.accessKey, secret: created.secret ?? '' }); + await loadApplication(applicationId); + } catch (failure) { setError(failure instanceof Error ? failure.message : '创建凭据失败'); } + } + + async function saveWebhook(eventType: 'receipt' | 'uplink', rotateSecret = false) { + const url = eventType === 'receipt' ? receiptUrl : uplinkUrl; + try { + const saved = await clientApi.saveHttpWebhook(applicationId, eventType, { url, rotateSecret }); + if (saved.secret) setRevealedSecret({ title: `${eventType === 'receipt' ? '回执' : '上行'}回调签名密钥仅展示一次`, secret: saved.secret }); + await loadApplication(applicationId); + } catch (failure) { setError(failure instanceof Error ? failure.message : '保存Webhook失败'); } + } + + const api = config?.config; + const overview =
+ {!api?.enabled ?

当前应用尚未由运营端开通 HTTP 接口。

: null} +

{config?.applicationName ?? '企业应用'}

基础地址:{window.location.origin}/api/openapi/v1

+ 单条发送 {api?.sendEnabled ? '已开通' : '未开通'} + 状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'} + 上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'} + 回执回调 {api?.receiptWebhookEnabled ? '已开通' : '未开通'} + 上行回调 {api?.uplinkWebhookEnabled ? '已开通' : '未开通'} +
+

调用限制

QPS:{api?.qpsLimit ?? '-'} · 签名时间容差:{api?.timestampToleranceSeconds ?? '-'} 秒 · 上行单次查询跨度:{api?.maxQueryRangeDays ?? '-'} 天 · 最大分页:{api?.maxPageSize ?? '-'}

HTTP IP 白名单:{config?.ipAllowlist.join('、') || '未限制'}

+
; + + const credentialPanel =

访问凭据

密钥只在创建时展示一次;建议轮换时先创建新凭据,完成切换后再吊销旧凭据。

+ {credentials.map((item) =>
{item.name}{item.accessKey}****{item.secretLast4}最近使用:{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}{item.status === 'active' ? : 已吊销}
)} + {credentials.length === 0 ?

暂无访问凭据。

: null} +
; + + const callbackPanel =

回执回调

setReceiptUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/receipt" value={receiptUrl} />
{webhooks.some((item) => item.eventType === 'receipt') ? : null}
+

上行回调

setUplinkUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/uplink" value={uplinkUrl} />
{webhooks.some((item) => item.eventType === 'uplink') ? : null}
; + + const docsPanel =

鉴权规则

每次请求携带 X-App-KeyX-TimestampX-NonceX-Signature。签名原文为:

{`METHOD
+/api/openapi/v1/...
+TIMESTAMP
+NONCE
+SHA256(rawBody)`}

使用访问密钥执行 HMAC-SHA256,输出小写十六进制。单发还必须携带 Idempotency-Key

+

接口清单

{`POST /api/openapi/v1/sms/messages\nGET  /api/openapi/v1/sms/messages/{messageId}\nGET  /api/openapi/v1/sms/uplinks\nGET  /api/openapi/v1/sms/uplinks/{uplinkId}`}

完整 OpenAPI 文档:/api/client-docs

+

回调验签

回调请求头包含 X-Event-Id、X-Event-Type、X-Timestamp、X-Signature。签名原文为 TIMESTAMP + '\\n' + rawBody,同样使用 HMAC-SHA256。客户系统必须按 X-Event-Id 幂等。

; + + const logsPanel =

最近调用

{requests.map((item) =>

{item.requestId} · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}

)}{requests.length === 0 ?

暂无调用记录。

: null}
+

最近回调投递

{deliveries.map((item) =>

{item.event.eventId} · {item.endpoint.eventType} · {item.status} · 尝试 {item.attemptCount} 次{item.lastError ? ` · ${item.lastError}` : ''}

{item.status !== 'delivered' && api?.allowClientManualRetry ? : null}
)}{deliveries.length === 0 ?

暂无回调投递记录。

: null}
; + + const tabs = [ + { label: '接口概览', value: 'overview', content: overview }, { label: '访问凭据', value: 'credentials', content: credentialPanel }, + { label: '回调配置', value: 'callbacks', content: callbackPanel }, { label: '接口文档', value: 'docs', content: docsPanel }, + { label: '调用与回调记录', value: 'logs', content: logsPanel }, + ]; + + return

接口对接

管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。

onChange(field.code, event.target.value)} + value={String(values[field.code] ?? '')} + /> + : )} +
; +} + +function SignatureModal({ + applications, + signature, + onClose, + onSaved, +}: { + applications: ClientSmsApplication[]; + signature?: ClientSmsSignatureView; + onClose: () => void; + onSaved: () => void; +}) { + const [applicationId, setApplicationId] = useState(signature?.applicationId ?? ''); + const [name, setName] = useState(signature?.name ?? ''); + const [purpose, setPurpose] = useState(signature?.purpose ?? ''); + const [fields, setFields] = useState([]); + const [values, setValues] = useState>(signature?.reportValues ?? {}); const [uploadingCode, setUploadingCode] = useState(''); + const [saving, setSaving] = useState(false); const [error, setError] = useState(''); useEffect(() => { - const request = signature.applicationId - ? clientApi.listApplicationReportFields(signature.applicationId, 'drainage') - : clientApi.listCommonApplicationReportFields('drainage'); - request.then(setFields).catch((failure: Error) => setError(failure.message || '引流报备字段加载失败')); - }, [signature.applicationId]); + const request = applicationId + ? clientApi.listApplicationReportFields(applicationId, 'signature') + : clientApi.listCommonApplicationReportFields('signature'); + request.then(setFields).catch((failure: Error) => setError(failure.message || '审核资料加载失败')); + }, [applicationId]); - async function upload(field: ApplicationReportField, file?: File) { + async function upload(field: ClientApplicationReportField, file?: File) { if (!file) return; setUploadingCode(field.code); + setError(''); try { - const uploaded = await clientApi.uploadFileObject(file, { purpose: 'drainage_report_material', prefix: `drainage-materials/${signature.id}` }); + const uploaded = await clientApi.uploadFileObject(file, { purpose: 'signature_report_material', prefix: 'signature-materials' }); setValues((current) => ({ ...current, [field.code]: { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType } })); - } catch (failure) { setError(failure instanceof Error ? failure.message : '文件上传失败'); } finally { setUploadingCode(''); } + } catch (failure) { + setError(failure instanceof Error ? failure.message : '资料上传失败'); + } finally { + setUploadingCode(''); + } } async function save() { setSaving(true); setError(''); try { - const body = { siteName, url, remark, reportValues: values }; - if (item) await clientApi.updateDrainageInfo(item.id, body); - else await clientApi.createDrainageInfo(signature.id, body); + const body = { applicationId: applicationId || undefined, name: name.trim(), purpose: purpose.trim(), drainageInfo: { signatureReportValues: values } }; + if (signature) await clientApi.updateSignature(signature.id, body); + else { + const created = await clientApi.createSignature(body); + await clientApi.submitSignature(created.id); + } onSaved(); - } catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息提交审核失败'); } finally { setSaving(false); } + } catch (failure) { + setError(failure instanceof Error ? failure.message : '签名资料提交失败'); + } finally { + setSaving(false); + } } const missingRequired = fields.some((field) => field.required && !values[field.code]); - return } onClose={onClose} open size="xl" title={item ? '修改引流信息' : '新增引流信息'}> -
- setSiteName(event.target.value)} required value={siteName} /> - setUrl(event.target.value)} placeholder="https://" required value={url} /> -