diff --git a/api/prisma/migrations/20260730093000_add_phone_frequency_controls/migration.sql b/api/prisma/migrations/20260730093000_add_phone_frequency_controls/migration.sql new file mode 100644 index 0000000..dce5da3 --- /dev/null +++ b/api/prisma/migrations/20260730093000_add_phone_frequency_controls/migration.sql @@ -0,0 +1,131 @@ +CREATE TABLE "PhoneFrequencyHit" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "ruleId" TEXT, + "ruleCode" TEXT NOT NULL, + "ruleName" TEXT NOT NULL, + "phoneNumber" TEXT NOT NULL, + "thresholdValue" INTEGER NOT NULL, + "actualValue" INTEGER NOT NULL, + "windowStartedAt" TIMESTAMP(3) NOT NULL, + "windowEndsAt" TIMESTAMP(3) NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "action" TEXT NOT NULL DEFAULT 'block', + "sourceType" TEXT, + "releasedAt" TIMESTAMP(3), + "releasedById" TEXT, + "releaseReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PhoneFrequencyHit_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "PhoneFrequencyState" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "ruleId" TEXT, + "ruleCode" TEXT NOT NULL, + "phoneNumber" TEXT NOT NULL, + "windowStartedAt" TIMESTAMP(3) NOT NULL, + "windowEndsAt" TIMESTAMP(3) NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "generation" INTEGER NOT NULL DEFAULT 0, + "activeHitId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PhoneFrequencyState_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PhoneFrequencyHit_applicationId_ruleCode_phoneNumber_windowStartedAt_generation_key" +ON "PhoneFrequencyHit"("applicationId", "ruleCode", "phoneNumber", "windowStartedAt", "generation"); + +CREATE INDEX "PhoneFrequencyHit_tenantId_applicationId_createdAt_idx" +ON "PhoneFrequencyHit"("tenantId", "applicationId", "createdAt"); + +CREATE INDEX "PhoneFrequencyHit_applicationId_phoneNumber_createdAt_idx" +ON "PhoneFrequencyHit"("applicationId", "phoneNumber", "createdAt"); + +CREATE INDEX "PhoneFrequencyHit_windowEndsAt_releasedAt_idx" +ON "PhoneFrequencyHit"("windowEndsAt", "releasedAt"); + +CREATE INDEX "PhoneFrequencyHit_ruleId_idx" ON "PhoneFrequencyHit"("ruleId"); +CREATE INDEX "PhoneFrequencyHit_releasedById_idx" ON "PhoneFrequencyHit"("releasedById"); + +CREATE UNIQUE INDEX "PhoneFrequencyState_activeHitId_key" ON "PhoneFrequencyState"("activeHitId"); + +CREATE UNIQUE INDEX "PhoneFrequencyState_applicationId_ruleCode_phoneNumber_key" +ON "PhoneFrequencyState"("applicationId", "ruleCode", "phoneNumber"); + +CREATE INDEX "PhoneFrequencyState_tenantId_applicationId_windowEndsAt_idx" +ON "PhoneFrequencyState"("tenantId", "applicationId", "windowEndsAt"); + +CREATE INDEX "PhoneFrequencyState_ruleId_idx" ON "PhoneFrequencyState"("ruleId"); + +ALTER TABLE "PhoneFrequencyHit" +ADD CONSTRAINT "PhoneFrequencyHit_tenantId_fkey" +FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyHit" +ADD CONSTRAINT "PhoneFrequencyHit_applicationId_fkey" +FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyHit" +ADD CONSTRAINT "PhoneFrequencyHit_ruleId_fkey" +FOREIGN KEY ("ruleId") REFERENCES "RiskRule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyHit" +ADD CONSTRAINT "PhoneFrequencyHit_releasedById_fkey" +FOREIGN KEY ("releasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyState" +ADD CONSTRAINT "PhoneFrequencyState_tenantId_fkey" +FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyState" +ADD CONSTRAINT "PhoneFrequencyState_applicationId_fkey" +FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyState" +ADD CONSTRAINT "PhoneFrequencyState_ruleId_fkey" +FOREIGN KEY ("ruleId") REFERENCES "RiskRule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyState" +ADD CONSTRAINT "PhoneFrequencyState_activeHitId_fkey" +FOREIGN KEY ("activeHitId") REFERENCES "PhoneFrequencyHit"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +INSERT INTO "RiskRule" ( + "id", "tenantId", "applicationId", "code", "name", "description", "metric", + "thresholdValue", "action", "status", "priority", "config", "createdAt", "updatedAt" +) +SELECT + 'default-phone-frequency-24h', NULL, NULL, 'PHONE_FREQUENCY_24H', + '单号码24小时发送频次', '同一企业应用下,单个号码在北京时间自然日内最多允许10条业务短信。', + 'phoneFrequencyCount', 10, 'block', 'active', 40, + '{"periodSeconds":86400,"timeZone":"Asia/Shanghai","alignment":"fixed"}'::jsonb, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS ( + SELECT 1 FROM "RiskRule" + WHERE "applicationId" IS NULL + AND "code" = 'PHONE_FREQUENCY_24H' + AND "status" <> 'deleted' +); + +INSERT INTO "RiskRule" ( + "id", "tenantId", "applicationId", "code", "name", "description", "metric", + "thresholdValue", "action", "status", "priority", "config", "createdAt", "updatedAt" +) +SELECT + 'default-phone-frequency-5m', NULL, NULL, 'PHONE_FREQUENCY_5M', + '单号码5分钟发送频次', '同一企业应用下,单个号码在固定5分钟周期内最多允许5条业务短信。', + 'phoneFrequencyCount', 5, 'block', 'active', 50, + '{"periodSeconds":300,"timeZone":"Asia/Shanghai","alignment":"fixed"}'::jsonb, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS ( + SELECT 1 FROM "RiskRule" + WHERE "applicationId" IS NULL + AND "code" = 'PHONE_FREQUENCY_5M' + AND "status" <> 'deleted' +); diff --git a/api/prisma/migrations/20260730114500_add_phone_frequency_whitelist/migration.sql b/api/prisma/migrations/20260730114500_add_phone_frequency_whitelist/migration.sql new file mode 100644 index 0000000..42427f8 --- /dev/null +++ b/api/prisma/migrations/20260730114500_add_phone_frequency_whitelist/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE "PhoneFrequencyWhitelist" ( + "id" TEXT NOT NULL, + "phoneNumber" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "reason" TEXT NOT NULL, + "remark" TEXT, + "createdById" TEXT NOT NULL, + "updatedById" TEXT NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PhoneFrequencyWhitelist_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PhoneFrequencyWhitelist_phoneNumber_key" +ON "PhoneFrequencyWhitelist"("phoneNumber"); + +CREATE INDEX "PhoneFrequencyWhitelist_status_updatedAt_idx" +ON "PhoneFrequencyWhitelist"("status", "updatedAt"); + +CREATE INDEX "PhoneFrequencyWhitelist_createdById_idx" +ON "PhoneFrequencyWhitelist"("createdById"); + +CREATE INDEX "PhoneFrequencyWhitelist_updatedById_idx" +ON "PhoneFrequencyWhitelist"("updatedById"); + +ALTER TABLE "PhoneFrequencyWhitelist" +ADD CONSTRAINT "PhoneFrequencyWhitelist_createdById_fkey" +FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "PhoneFrequencyWhitelist" +ADD CONSTRAINT "PhoneFrequencyWhitelist_updatedById_fkey" +FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index af15682..fac5998 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -32,6 +32,8 @@ model Tenant { riskRules RiskRule[] smsSendTasks SmsSendTask[] riskHitRecords RiskHitRecord[] + phoneFrequencyStates PhoneFrequencyState[] + phoneFrequencyHits PhoneFrequencyHit[] smsBatchTasks SmsBatchTask[] smsMessageRecords SmsMessageRecord[] smsApiRequests SmsApiRequest[] @@ -88,13 +90,16 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant? @relation(fields: [tenantId], references: [id]) - roles UserRole[] - operationLogs OperationLog[] - auditRecords AuditRecord[] - createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator") - reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer") - createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator") + tenant Tenant? @relation(fields: [tenantId], references: [id]) + roles UserRole[] + operationLogs OperationLog[] + auditRecords AuditRecord[] + createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator") + reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer") + createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator") + releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser") + createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator") + updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater") } model Role { @@ -440,6 +445,8 @@ model SmsApplication { dailyUsages SmsApplicationDailyUsage[] inboundLongMessages CmppInboundLongMessage[] riskRules RiskRule[] + phoneFrequencyStates PhoneFrequencyState[] + phoneFrequencyHits PhoneFrequencyHit[] @@index([tenantId, status]) @@index([status, createdAt]) @@ -1268,9 +1275,11 @@ model RiskRule { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant? @relation(fields: [tenantId], references: [id]) - application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade) - hits RiskHitRecord[] + tenant Tenant? @relation(fields: [tenantId], references: [id]) + application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade) + hits RiskHitRecord[] + phoneFrequencyStates PhoneFrequencyState[] + phoneFrequencyHits PhoneFrequencyHit[] @@unique([applicationId, code]) @@index([tenantId, applicationId, status, priority]) @@ -1339,6 +1348,85 @@ model RiskHitRecord { @@index([ruleCode]) } +model PhoneFrequencyState { + id String @id @default(cuid()) + tenantId String + applicationId String + ruleId String? + ruleCode String + phoneNumber String + windowStartedAt DateTime + windowEndsAt DateTime + count Int @default(0) + generation Int @default(0) + activeHitId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + rule RiskRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull) + activeHit PhoneFrequencyHit? @relation("ActivePhoneFrequencyHit", fields: [activeHitId], references: [id], onDelete: SetNull) + + @@unique([applicationId, ruleCode, phoneNumber]) + @@index([tenantId, applicationId, windowEndsAt]) + @@index([ruleId]) +} + +model PhoneFrequencyHit { + id String @id @default(cuid()) + tenantId String + applicationId String + ruleId String? + ruleCode String + ruleName String + phoneNumber String + thresholdValue Int + actualValue Int + windowStartedAt DateTime + windowEndsAt DateTime + generation Int @default(0) + action String @default("block") + sourceType String? + releasedAt DateTime? + releasedById String? + releaseReason String? + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id]) + application SmsApplication @relation(fields: [applicationId], references: [id]) + rule RiskRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull) + releasedBy User? @relation("PhoneFrequencyHitReleaser", fields: [releasedById], references: [id]) + activeForState PhoneFrequencyState? @relation("ActivePhoneFrequencyHit") + + @@unique([applicationId, ruleCode, phoneNumber, windowStartedAt, generation]) + @@index([tenantId, applicationId, createdAt]) + @@index([applicationId, phoneNumber, createdAt]) + @@index([windowEndsAt, releasedAt]) + @@index([ruleId]) + @@index([releasedById]) +} + +model PhoneFrequencyWhitelist { + id String @id @default(cuid()) + phoneNumber String @unique + status String @default("active") + reason String + remark String? + createdById String + updatedById String + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + createdBy User @relation("PhoneFrequencyWhitelistCreator", fields: [createdById], references: [id]) + updatedBy User @relation("PhoneFrequencyWhitelistUpdater", fields: [updatedById], references: [id]) + + @@index([status, updatedAt]) + @@index([createdById]) + @@index([updatedById]) +} + model SmsBatchTask { id String @id @default(cuid()) tenantId String diff --git a/api/src/channels/channel-configuration.service.ts b/api/src/channels/channel-configuration.service.ts new file mode 100644 index 0000000..5dc4d58 --- /dev/null +++ b/api/src/channels/channel-configuration.service.ts @@ -0,0 +1,213 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; +import { ChannelConnectionService } from './channel-connection.service'; + +/** R5 channel domain service composed behind ChannelsService. */ +export class ChannelConfigurationService { + constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} + + listChannels() { + return this.prisma.smsChannel.findMany({ + include: { connectionStates: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsChannelWhereInput = { + status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined, + name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.smsChannel.findMany({ + where, + include: { connectionStates: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.smsChannel.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async createChannel(data: CreateChannelDto) { + assertMoneyUnits(data.unitPrice ?? 0, '通道单价'); + const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => { + const value = data[field as keyof CreateChannelDto]; + return value === undefined || value === null || value === ''; + }); + if (missingFields.length > 0) { + throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`); + } + const gatewayPort = Number(data.gatewayPort ?? 7890); + if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) { + throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); + } + const cmppVersion = normalizeCmppVersion(data.cmppVersion); + const config = normalizeChannelRuntimeConfig( + undefined, + data.config, + data.desiredConnections, + data.windowSize, + data.heartbeatIntervalSeconds, + data.heartbeatMissThreshold, + ); + const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond); + const channel = await this.prisma.smsChannel.create({ + data: { + code: data.code, + name: data.name, + carrier: data.carrier, + sendRegion: data.sendRegion ?? '全国', + protocol: 'CMPP', + gatewayHost: data.gatewayHost, + gatewayPort, + enterpriseCode: data.enterpriseCode, + account: data.account, + passwordCipher: data.passwordCipher, + srcId: data.srcId, + cmppVersion, + rateLimitPerSecond, + unitPrice: data.unitPrice ?? 0, + status: data.status ?? 'active', + config: config as Prisma.InputJsonValue, + }, + }); + if (channel.status === 'active') { + await this.connection.requestChannelConnection(channel, 'channel_created'); + } + return channel; + } + + async updateChannel(channelId: string, data: UpdateChannelDto) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + if (data.unitPrice !== undefined) { + assertMoneyUnits(data.unitPrice, '通道单价'); + } + const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort); + if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) { + throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); + } + const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); + const config = data.config !== undefined + || data.desiredConnections !== undefined + || data.windowSize !== undefined + || data.heartbeatIntervalSeconds !== undefined + || data.heartbeatMissThreshold !== undefined + ? normalizeChannelRuntimeConfig( + channel.config, + data.config, + data.desiredConnections, + data.windowSize, + data.heartbeatIntervalSeconds, + data.heartbeatMissThreshold, + ) + : undefined; + const rateLimitPerSecond = data.rateLimitPerSecond === undefined + ? undefined + : normalizeChannelRateLimit(data.rateLimitPerSecond); + const connectionConfigChanged = channelConnectionSettingsChanged(channel, { + gatewayHost: data.gatewayHost ?? channel.gatewayHost, + gatewayPort: gatewayPort ?? channel.gatewayPort, + account: data.account ?? channel.account, + passwordCipher: data.passwordCipher ?? channel.passwordCipher, + cmppVersion: cmppVersion ?? channel.cmppVersion, + config: config ?? channel.config, + }); + const updated = await this.prisma.smsChannel.update({ + where: { id: channelId }, + data: { + code: data.code, + name: data.name, + carrier: data.carrier, + sendRegion: data.sendRegion, + protocol: 'CMPP', + gatewayHost: data.gatewayHost, + gatewayPort, + enterpriseCode: data.enterpriseCode, + account: data.account, + passwordCipher: data.passwordCipher, + srcId: data.srcId, + cmppVersion, + rateLimitPerSecond, + unitPrice: data.unitPrice, + status: data.status, + config: config as Prisma.InputJsonValue | undefined, + }, + }); + await this.prisma.operationLog.create({ + data: { + action: 'sms_channel.update', + resource: 'sms_channel', + resourceId: channelId, + detail: { + before: { + code: channel.code, + name: channel.name, + carrier: channel.carrier, + sendRegion: channel.sendRegion, + gatewayHost: channel.gatewayHost, + gatewayPort: channel.gatewayPort, + enterpriseCode: channel.enterpriseCode, + account: channel.account, + srcId: channel.srcId, + unitPrice: moneyToNumber(channel.unitPrice), + }, + after: data, + } as Prisma.InputJsonValue, + }, + }); + const updatedStatus = data.status ?? channel.status; + if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) { + await this.connection.requestChannelConnection(updated, 'channel_updated'); + } else if (updatedStatus !== 'active' && channel.status === 'active') { + await this.connection.requestChannelDisconnection(updated, 'channel_disabled'); + } + return updated; + } + + async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } }); + await this.prisma.operationLog.create({ + data: { + userId: data.operatorId, + action: `sms_channel.${data.status}`, + resource: 'sms_channel', + resourceId: channelId, + detail: { + statusBefore: channel.status, + statusAfter: data.status, + reason: data.reason, + } as Prisma.InputJsonValue, + }, + }); + if (data.status === 'active') { + await this.connection.requestChannelConnection(updated, 'channel_enabled', data.operatorId); + } else if (channel.status === 'active' || data.status === 'deleted') { + await this.connection.requestChannelDisconnection( + updated, + data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled', + data.operatorId, + ); + } + return updated; + } +} diff --git a/api/src/channels/channel-connection.service.ts b/api/src/channels/channel-connection.service.ts new file mode 100644 index 0000000..d804247 --- /dev/null +++ b/api/src/channels/channel-connection.service.ts @@ -0,0 +1,612 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; + + +/** R5 channel domain service composed behind ChannelsService. */ +export class ChannelConnectionService { + private readonly logger = new Logger(ChannelConnectionService.name); + private gatewayConnectionQueue?: Queue; + private gatewaySubmitQueue?: Queue; + private redis?: IORedis; + private connectionTimeoutTimer?: ReturnType; + private gatewayStartupReconnectTimer?: ReturnType; + private gatewayReconcileTimer?: ReturnType; + constructor(private readonly prisma: PrismaService) {} + + onModuleInit() { + if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED !== 'true') { + this.connectionTimeoutTimer = setInterval(() => { + void this.markTimedOutConnectingChannels().catch((error) => { + this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`); + }); + }, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS)); + this.connectionTimeoutTimer.unref?.(); + } + this.gatewayStartupReconnectTimer = setTimeout(() => { + void this.reconnectActiveChannelsAfterGatewayRestart(); + }, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS)); + this.gatewayStartupReconnectTimer.unref?.(); + if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') { + this.gatewayReconcileTimer = setInterval(() => { + void this.reconcileGatewayConnections().catch((error) => { + this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`); + }); + }, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS)); + this.gatewayReconcileTimer.unref?.(); + } + } + + async onModuleDestroy() { + if (this.connectionTimeoutTimer) { + clearInterval(this.connectionTimeoutTimer); + } + if (this.gatewayStartupReconnectTimer) { + clearTimeout(this.gatewayStartupReconnectTimer); + } + if (this.gatewayReconcileTimer) { + clearInterval(this.gatewayReconcileTimer); + } + await this.gatewayConnectionQueue?.close(); + await this.gatewaySubmitQueue?.close(); + this.redis?.disconnect(); + } + + listChannelMetrics(channelId: string) { + return this.prisma.channelHealthMetric.findMany({ + where: { channelId }, + orderBy: { windowStart: 'desc' }, + take: 100, + }); + } + + listChannelConnections(channelId: string) { + return this.prisma.cmppConnectionState.findMany({ + where: { channelId }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async listChannelConnectionLogs(channelId: string) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + const [connectionStates, logs] = await Promise.all([ + this.prisma.cmppConnectionState.findMany({ + where: { channelId }, + orderBy: { updatedAt: 'desc' }, + take: 50, + }), + this.prisma.operationLog.findMany({ + where: { + OR: [ + { resource: 'sms_channel', resourceId: channelId }, + { resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }), + ]); + return { + channelId, + connectionStates, + logs: logs.map((log) => ({ + id: log.id, + time: log.createdAt, + event: normalizeLinkEvent(log.action), + action: log.action, + resourceId: log.resourceId, + detail: log.detail, + })), + }; + } + + listTenantConnections(tenantId: string) { + return this.prisma.cmppConnectionState.findMany({ + where: { tenantId }, + include: { channel: true }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async upsertConnectionState(data: UpsertConnectionStateDto) { + const rawStatus = data.status; + const status = normalizeGatewayConnectionStatus(rawStatus); + if (data.applicationId) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); + if (!application) { + throw new BadRequestException('applicationId does not reference an existing application'); + } + if (data.tenantId && data.tenantId !== application.tenantId) { + throw new BadRequestException('applicationId does not belong to tenantId'); + } + data.tenantId = application.tenantId; + } + const payload = { + tenantId: data.tenantId, + applicationId: data.applicationId, + status, + desiredConnections: data.desiredConnections ?? 1, + currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0), + lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined, + lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined, + lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined, + reconnectCount: data.reconnectCount ?? 0, + lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined, + nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined, + lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory, + lastError: status === 'connected' ? null : data.lastError, + }; + const existing = await this.prisma.cmppConnectionState.findFirst({ + where: { + applicationId: data.applicationId ?? null, + channelId: data.channelId, + connectionId: data.connectionId, + }, + }); + let state; + if (existing) { + state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload }); + } else { + try { + state = await this.prisma.cmppConnectionState.create({ + data: { + channelId: data.channelId, + connectionId: data.connectionId, + ...payload, + }, + }); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') { + throw error; + } + const concurrent = await this.prisma.cmppConnectionState.findFirst({ + where: { + applicationId: data.applicationId ?? null, + channelId: data.channelId, + connectionId: data.connectionId, + }, + }); + if (!concurrent) { + throw error; + } + state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload }); + } + } + const action = normalizeConnectionAction( + ['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status, + ); + const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date(); + const shouldWriteAudit = action !== 'heartbeat' + || !existing?.lastHeartbeatAt + || heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS; + if (shouldWriteAudit) { + await this.prisma.operationLog.create({ + data: { + tenantId: data.tenantId, + action: `cmpp_connection.${action}`, + resource: 'cmpp_connection', + resourceId: `${data.channelId}:${data.connectionId}`, + detail: { + status, + applicationId: state.applicationId, + desiredConnections: state.desiredConnections, + currentConnections: state.currentConnections, + lastError: state.lastError, + } as Prisma.InputJsonValue, + }, + }); + } + return state; + } + + async markTimedOutConnectingChannels(now = new Date()) { + const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS); + const cutoff = new Date(now.getTime() - timeoutMs); + const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`; + const states = await this.prisma.cmppConnectionState.findMany({ + where: { + status: 'connecting', + updatedAt: { lte: cutoff }, + }, + select: { + id: true, + tenantId: true, + applicationId: true, + channelId: true, + connectionId: true, + desiredConnections: true, + currentConnections: true, + updatedAt: true, + }, + take: 100, + }); + let failed = 0; + for (const state of states) { + const result = await this.prisma.cmppConnectionState.updateMany({ + where: { + id: state.id, + status: 'connecting', + updatedAt: { lte: cutoff }, + }, + data: { + status: 'failed', + currentConnections: 0, + lastDisconnectedAt: now, + nextReconnectAt: now, + lastErrorCategory: 'timeout', + lastError, + }, + }); + if (result.count === 0) { + continue; + } + failed += result.count; + await this.prisma.operationLog.create({ + data: { + tenantId: state.tenantId, + action: 'cmpp_connection.failed', + resource: 'cmpp_connection', + resourceId: `${state.channelId}:${state.connectionId}`, + detail: { + reason: 'connect_timeout', + applicationId: state.applicationId, + status: 'failed', + previousStatus: 'connecting', + desiredConnections: state.desiredConnections, + currentConnectionsBefore: state.currentConnections, + currentConnections: 0, + timeoutMs, + lastError, + } as Prisma.InputJsonValue, + }, + }); + } + return { checked: states.length, failed }; + } + + async requestChannelConnection( + channel: { + id: string; + code: string; + name: string; + gatewayHost: string; + gatewayPort: number; + account: string; + passwordCipher: string; + srcId: string; + cmppVersion: string; + rateLimitPerSecond: number; + config?: Prisma.JsonValue | null; + }, + reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect', + operatorId?: string, + ) { + const desiredConnections = getDesiredConnections(channel.config); + const connectionId = defaultChannelConnectionId(channel.id); + const existing = await this.prisma.cmppConnectionState.findFirst({ + where: { + applicationId: null, + channelId: channel.id, + connectionId, + }, + }); + const data = { + applicationId: null, + status: 'connecting', + desiredConnections, + currentConnections: 0, + lastError: null, + lastReconnectAttemptAt: new Date(), + nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)), + }; + let state; + if (existing) { + state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data }); + } else { + try { + state = await this.prisma.cmppConnectionState.create({ + data: { + channelId: channel.id, + connectionId, + ...data, + }, + }); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') { + throw error; + } + const concurrent = await this.prisma.cmppConnectionState.findFirst({ + where: { applicationId: null, channelId: channel.id, connectionId }, + }); + if (!concurrent) { + throw error; + } + state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data }); + } + } + await this.prisma.operationLog.create({ + data: { + userId: operatorId, + action: 'cmpp_connection.connect_requested', + resource: 'cmpp_connection', + resourceId: `${channel.id}:${connectionId}`, + detail: { + reason, + status: state.status, + desiredConnections: state.desiredConnections, + currentConnections: state.currentConnections, + } as Prisma.InputJsonValue, + }, + }); + const command = { + schemaVersion: 'v1', + messageType: 'ConnectChannel', + traceId: randomUUID(), + channelId: channel.id, + connectionId, + createdAt: new Date().toISOString(), + reason, + desiredConnections, + channel: { + code: channel.code, + name: channel.name, + gatewayHost: channel.gatewayHost, + gatewayPort: channel.gatewayPort, + account: channel.account, + passwordCipher: channel.passwordCipher, + srcId: channel.srcId, + cmppVersion: channel.cmppVersion, + rateLimitPerSecond: channel.rateLimitPerSecond, + windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), + heartbeatIntervalSeconds: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatIntervalSeconds'), + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ), + heartbeatMissThreshold: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatMissThreshold'), + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ), + }, + }; + const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, { + jobId: `gateway-connect-${channel.id}-${command.traceId}`, + removeOnComplete: 1000, + removeOnFail: 1000, + }).catch((error) => { + this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + }); + try { + await this.notifyGatewayConnect(command); + } finally { + if (queuedJob) { + await queuedJob.remove().catch((error) => { + this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + return state; + } + + async reconnectActiveChannelsAfterGatewayRestart() { + const channels = await this.prisma.smsChannel.findMany({ where: { status: 'active' } }); + const results = await Promise.allSettled( + channels.map((channel) => this.requestChannelConnection(channel, 'gateway_restarted')), + ); + results.forEach((result, index) => { + if (result.status === 'rejected') { + const channel = channels[index]; + this.logger.error( + `Failed to restore active CMPP channel ${channel?.code ?? channel?.id ?? index}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`, + ); + } + }); + } + + async reconcileGatewayConnections(now = new Date()) { + const channels = await this.prisma.smsChannel.findMany({ + where: { status: { in: ['active', 'disabled', 'deleted'] } }, + include: { + connectionStates: { + where: { applicationId: null }, + }, + }, + take: 200, + }); + let reconnectRequested = 0; + let disconnectRequested = 0; + for (const channel of channels) { + const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id)); + if (channel.status !== 'active') { + if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) { + await this.withGatewayReconcileLock(channel.id, async () => { + await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile'); + disconnectRequested++; + }); + } + continue; + } + const desiredConnections = getDesiredConnections(channel.config); + const heartbeatIntervalSeconds = getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatIntervalSeconds'), + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ); + const heartbeatMissThreshold = getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatMissThreshold'), + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ); + const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000); + const connectedAndFresh = state?.status === 'connected' + && state.currentConnections >= desiredConnections + && Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff); + const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now; + if (!connectedAndFresh && retryDue) { + await this.withGatewayReconcileLock(channel.id, async () => { + await this.requestChannelConnection(channel, 'automatic_reconnect'); + reconnectRequested++; + }); + } + } + return { scanned: channels.length, reconnectRequested, disconnectRequested }; + } + + async withGatewayReconcileLock(channelId: string, action: () => Promise) { + const redis = this.getRedis(); + const key = `cmpp:gateway:reconcile:${channelId}`; + const token = randomUUID(); + const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX'); + if (acquired !== 'OK') { + return; + } + try { + await action(); + } finally { + await redis.eval( + 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end', + 1, + key, + token, + ); + } + } + + async requestChannelDisconnection( + channel: { id: string }, + reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile', + operatorId?: string, + ) { + const connectionId = defaultChannelConnectionId(channel.id); + const now = new Date(); + await this.prisma.cmppConnectionState.updateMany({ + where: { + applicationId: null, + channelId: channel.id, + connectionId, + }, + data: { + status: 'disconnected', + currentConnections: 0, + lastDisconnectedAt: now, + nextReconnectAt: null, + lastErrorCategory: null, + lastError: null, + }, + }); + await this.prisma.operationLog.create({ + data: { + userId: operatorId, + action: 'cmpp_connection.disconnect_requested', + resource: 'cmpp_connection', + resourceId: `${channel.id}:${connectionId}`, + detail: { reason } as Prisma.InputJsonValue, + }, + }); + const command = { + schemaVersion: 'v1', + messageType: 'DisconnectChannel', + traceId: randomUUID(), + channelId: channel.id, + connectionId, + createdAt: now.toISOString(), + reason, + }; + const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, { + jobId: `gateway-disconnect-${channel.id}-${command.traceId}`, + removeOnComplete: 1000, + removeOnFail: 1000, + }).catch((error) => { + this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + }); + try { + await this.notifyGatewayDisconnect(command); + } finally { + if (queuedJob) { + await queuedJob.remove().catch((error) => { + this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + } + + getGatewayConnectionQueue() { + this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() }); + return this.gatewayConnectionQueue; + } + + getGatewaySubmitQueue() { + this.gatewaySubmitQueue ??= new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() }); + return this.gatewaySubmitQueue; + } + + getRedis() { + if (!this.redis) { + this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { + maxRetriesPerRequest: null, + }); + } + return this.redis; + } + + async publishGatewaySubmitCommand(command: unknown) { + return this.getRedis().xadd( + process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM, + '*', + 'messageType', + 'SubmitCommand', + 'data', + JSON.stringify(command), + ); + } + + async notifyGatewayConnect(command: Record) { + const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, ''); + let response: { ok: boolean; status: number; text: () => Promise }; + try { + response = await fetch(`${baseUrl}/connections/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)), + }); + } catch (error) { + throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`); + } + if (!response.ok) { + const responseText = await response.text(); + throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`); + } + } + + async notifyGatewayDisconnect(command: Record) { + const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, ''); + let response: { ok: boolean; status: number; text: () => Promise }; + try { + response = await fetch(`${baseUrl}/connections/disconnect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)), + }); + } catch (error) { + throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`); + } + if (!response.ok) { + const responseText = await response.text(); + throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`); + } + } +} diff --git a/api/src/channels/channel-copy.service.ts b/api/src/channels/channel-copy.service.ts new file mode 100644 index 0000000..d9a06cc --- /dev/null +++ b/api/src/channels/channel-copy.service.ts @@ -0,0 +1,101 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; + + +/** R5 channel domain service composed behind ChannelsService. */ +export class ChannelCopyService { + constructor(private readonly prisma: PrismaService) {} + + async copyChannel(channelId: string, data: CopyChannelDto = {}) { + const source = await this.prisma.smsChannel.findUnique({ + where: { id: channelId }, + include: { reportFields: true }, + }); + if (!source) { + throw new NotFoundException('Channel not found'); + } + + const suffix = Date.now().toString(36).toUpperCase(); + const nextName = data.name ?? `${source.name}副本`; + const nextCode = data.code ?? `${source.code}-COPY-${suffix}`; + + const copied = await this.prisma.$transaction(async (tx) => { + const nextChannel = await tx.smsChannel.create({ + data: { + code: nextCode, + name: nextName, + carrier: source.carrier, + protocol: source.protocol, + gatewayHost: source.gatewayHost, + gatewayPort: source.gatewayPort, + enterpriseCode: source.enterpriseCode, + account: source.account, + passwordCipher: source.passwordCipher, + srcId: source.srcId, + sendRegion: source.sendRegion, + cmppVersion: source.cmppVersion, + rateLimitPerSecond: source.rateLimitPerSecond, + unitPrice: source.unitPrice, + status: 'disabled', + config: source.config as Prisma.InputJsonValue | undefined, + reportFields: { + create: source.reportFields.map((field) => ({ + drainageFieldId: field.drainageFieldId, + reportType: field.reportType, + code: field.code, + name: field.name, + fieldType: field.fieldType, + required: field.required, + description: field.description, + sortOrder: field.sortOrder, + status: field.status, + })), + }, + }, + include: { reportFields: true }, + }); + + const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } }); + if (reportMaterials.length > 0) { + await tx.signatureReportMaterial.createMany({ + data: reportMaterials.map((material) => ({ + signatureId: material.signatureId, + channelId: nextChannel.id, + fieldCode: material.fieldCode, + fieldValue: material.fieldValue, + fileObjectId: material.fileObjectId, + })), + skipDuplicates: true, + }); + } + + await tx.operationLog.create({ + data: { + userId: data.operatorId, + action: 'sms_channel.copy', + resource: 'sms_channel', + resourceId: nextChannel.id, + detail: { + sourceChannelId: source.id, + sourceCode: source.code, + sourceStatus: source.status, + copiedStatus: 'disabled', + copiedReportFields: source.reportFields.length, + copiedReportMaterials: reportMaterials.length, + } as Prisma.InputJsonValue, + }, + }); + + return nextChannel; + }); + + return copied; + } +} diff --git a/api/src/channels/channel-deletion.service.ts b/api/src/channels/channel-deletion.service.ts new file mode 100644 index 0000000..118470a --- /dev/null +++ b/api/src/channels/channel-deletion.service.ts @@ -0,0 +1,19 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; +import { ChannelConfigurationService } from './channel-configuration.service'; + +/** R5 channel domain service composed behind ChannelsService. */ +export class ChannelDeletionService { + constructor(private readonly prisma: PrismaService, private readonly configuration: ChannelConfigurationService) {} + + async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) { + return this.configuration.changeChannelStatus(channelId, { ...data, status: 'deleted' }); + } +} diff --git a/api/src/channels/channel-group-routing.service.ts b/api/src/channels/channel-group-routing.service.ts new file mode 100644 index 0000000..dfb4277 --- /dev/null +++ b/api/src/channels/channel-group-routing.service.ts @@ -0,0 +1,221 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; + + +/** R5 channel domain service composed behind ChannelsService. */ +export class ChannelGroupRoutingService { + constructor(private readonly prisma: PrismaService) {} + + listGroups() { + return this.prisma.smsChannelGroup.findMany({ + include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, + orderBy: { createdAt: 'desc' }, + }); + } + + createGroup(data: CreateChannelGroupDto) { + const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720); + const carrier = normalizeBusinessCarrier(data.carrier); + return this.prisma.smsChannelGroup.create({ + data: { + code: data.code, + name: data.name, + carrier, + description: data.description, + status: data.status ?? 'active', + retryEnabled: data.retryEnabled ?? true, + retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), + retryTimeLimitMinutes, + }, + }); + } + + async addGroupItem(data: CreateChannelGroupItemDto) { + const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); + if (!group) { + throw new NotFoundException('Channel group not found'); + } + const groupCarrier = normalizeBusinessCarrier(group.carrier); + const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier; + if (itemCarrier !== groupCarrier) { + throw new BadRequestException('Channel group items must use the same carrier as the channel group'); + } + const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { + throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); + } + if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) { + throw new BadRequestException('Province route must use a channel with the same sendRegion'); + } + const existing = await this.prisma.smsChannelGroupItem.findFirst({ + where: { groupId: data.groupId, channelId: data.channelId }, + }); + if (existing) { + throw new BadRequestException('通道组内不能重复配置同一通道'); + } + if (data.province) { + const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({ + where: { groupId: data.groupId, province: data.province }, + }); + if (existingProvince) { + throw new BadRequestException('同一通道组内同一省份只能配置一个通道'); + } + } else { + const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({ + where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 }, + }); + if (existingPriority) { + throw new BadRequestException('同一通道组内全国通道优先级不能重复'); + } + } + return this.prisma.smsChannelGroupItem.create({ + data: { + groupId: data.groupId, + channelId: data.channelId, + carrier: itemCarrier, + province: data.province, + priority: data.priority ?? 100, + weight: data.weight ?? 1, + isBackup: data.isBackup ?? false, + }, + }); + } + + async updateGroup(groupId: string, data: UpdateChannelGroupDto) { + const current = await this.prisma.smsChannelGroup.findUnique({ + where: { id: groupId }, + include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, + }); + if (!current) { + throw new NotFoundException('Channel group not found'); + } + const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes( + data.retryTimeLimitMinutes, + data.retryTimeLimitHours, + current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60, + ); + const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier); + const items = data.items ?? []; + const channelIds = [...new Set(items.map((item) => item.channelId))]; + const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } }); + const channelById = new Map(channels.map((channel) => [channel.id, channel])); + validateGroupItems(carrier, items, channelById); + + return this.prisma.$transaction(async (tx) => { + await tx.smsChannelGroupItem.deleteMany({ where: { groupId } }); + await tx.smsChannelGroup.update({ + where: { id: groupId }, + data: { + code: data.code ?? current.code, + name: data.name ?? current.name, + carrier, + description: data.description, + status: data.status ?? current.status, + retryEnabled: data.retryEnabled ?? current.retryEnabled, + retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), + retryTimeLimitMinutes, + }, + }); + if (items.length > 0) { + await tx.smsChannelGroupItem.createMany({ + data: items.map((item) => ({ + groupId, + channelId: item.channelId, + carrier, + province: item.province, + priority: item.priority ?? 100, + weight: item.weight ?? 1, + isBackup: item.isBackup ?? false, + })), + }); + } + const updated = await tx.smsChannelGroup.findUnique({ + where: { id: groupId }, + include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, + }); + await tx.operationLog.create({ + data: { + action: 'sms_channel_group.update', + resource: 'sms_channel_group', + resourceId: groupId, + detail: { + before: channelGroupAuditSnapshot(current), + after: updated ? channelGroupAuditSnapshot(updated) : null, + } as Prisma.InputJsonValue, + }, + }); + return updated; + }); + } + + async deleteGroup(groupId: string) { + const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } }); + if (!group) { + throw new NotFoundException('Channel group not found'); + } + const boundRoute = await this.prisma.channelRouteRule.findFirst({ + where: { + groupId, + status: 'active', + }, + select: { id: true }, + }); + if (boundRoute) { + throw new BadRequestException('Channel group is used by application route rules and cannot be deleted'); + } + await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } }); + return this.prisma.smsChannelGroup.delete({ where: { id: groupId } }); + } + + listRouteRules() { + return this.prisma.channelRouteRule.findMany({ + include: { group: true, channel: true }, + orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], + }); + } + + async createRouteRule(data: CreateRouteRuleDto) { + if (!data.applicationId) { + throw new BadRequestException('applicationId is required for channel group routing'); + } + if (!data.carrier) { + throw new BadRequestException('carrier is required for application channel group routing'); + } + const carrier = normalizeBusinessCarrier(data.carrier); + if (data.channelId) { + throw new BadRequestException('Route rules can only bind channel groups, not single channels'); + } + if (data.province) { + throw new BadRequestException('Province routing must be configured inside the channel group'); + } + const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); + if (!group) { + throw new NotFoundException('Channel group not found'); + } + if (normalizeBusinessCarrier(group.carrier) !== carrier) { + throw new BadRequestException('Route rule carrier must match the channel group carrier'); + } + return this.prisma.channelRouteRule.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + groupId: data.groupId, + channelId: undefined, + carrier, + province: undefined, + priority: data.priority ?? 100, + status: data.status ?? 'active', + }, + }); + } +} diff --git a/api/src/channels/channel-reporting.service.ts b/api/src/channels/channel-reporting.service.ts new file mode 100644 index 0000000..578f015 --- /dev/null +++ b/api/src/channels/channel-reporting.service.ts @@ -0,0 +1,541 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; + + +/** R5 channel domain service composed behind ChannelsService. */ +export class ChannelReportingService { + constructor(private readonly prisma: PrismaService) {} + + listReportFields(channelId?: string) { + return this.prisma.channelReportField.findMany({ + where: channelId ? { channelId } : undefined, + include: { drainageField: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + }); + } + + async createReportField(data: CreateReportFieldDto) { + if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required'); + const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } }); + if (!field || field.status !== 'active') { + throw new BadRequestException('报备字段库字段不存在或已停用'); + } + const reportType = normalizeReportType(data.reportType); + return this.prisma.channelReportField.create({ + data: { + channelId: data.channelId, + drainageFieldId: field.id, + reportType, + code: field.code, + name: field.name, + exportName: data.exportName?.trim() || field.name, + fieldType: field.fieldType, + required: data.required ?? field.required, + description: data.description ?? field.description, + sortOrder: data.sortOrder ?? 100, + columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80), + imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600), + imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600), + defaultValue: data.defaultValue, + transform: data.transform, + status: data.status ?? 'active', + }, + }); + } + + async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); + if (!channel) throw new NotFoundException('Channel not found'); + const ids = data.fields.map((field) => field.drainageFieldId); + if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段'); + const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } }); + if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用'); + const fieldById = new Map(libraryFields.map((field) => [field.id, field])); + return this.prisma.$transaction(async (tx) => { + const oppositeType = reportType === 'signature' ? 'drainage' : 'signature'; + const [legacyBoth, oppositeFields] = await Promise.all([ + tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }), + tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }), + ]); + const oppositeCodes = new Set(oppositeFields.map((field) => field.code)); + await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } }); + for (const legacy of legacyBoth) { + if (oppositeCodes.has(legacy.code)) continue; + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy; + await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } }); + } + for (const [index, configured] of data.fields.entries()) { + const field = fieldById.get(configured.drainageFieldId)!; + await tx.channelReportField.create({ + data: { + channelId, + drainageFieldId: field.id, + reportType, + code: field.code, + name: field.name, + exportName: configured.exportName?.trim() || field.name, + fieldType: field.fieldType, + required: configured.required ?? field.required, + description: configured.description ?? field.description, + sortOrder: configured.sortOrder ?? (index + 1) * 10, + columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80), + imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600), + imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600), + defaultValue: configured.defaultValue, + transform: configured.transform, + status: configured.status ?? 'active', + }, + }); + } + return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); + }); + } + + listReportMaterials(signatureId?: string, channelId?: string) { + return this.prisma.signatureReportMaterial.findMany({ + where: { + signatureId, + channelId, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + upsertReportMaterial(data: CreateReportMaterialDto) { + return this.prisma.signatureReportMaterial.upsert({ + where: { + signatureId_channelId_fieldCode: { + signatureId: data.signatureId, + channelId: data.channelId, + fieldCode: data.fieldCode, + }, + }, + update: { + fieldValue: data.fieldValue, + fileObjectId: data.fileObjectId, + }, + create: { + signatureId: data.signatureId, + channelId: data.channelId, + fieldCode: data.fieldCode, + fieldValue: data.fieldValue, + fileObjectId: data.fileObjectId, + }, + }); + } + + async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) { + const tasks = await this.prisma.channelSignatureReportTask.findMany({ + where: { + tenantId, + status, + channelId, + reportType, + signature: { auditStatus: { not: 'deleted' } }, + }, + include: { + signature: { include: { tenant: true, application: true } }, + channel: true, + drainageInfo: true, + exportItems: { + include: { exportFile: true, batchItem: { include: { batch: true } } }, + orderBy: { id: 'desc' }, + take: 1, + }, + records: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + orderBy: { createdAt: 'desc' }, + }); + if (tasks.length === 0) { + return tasks; + } + + const channelIds = [...new Set(tasks.map((task) => task.channelId))]; + const signatureIds = [...new Set(tasks.map((task) => task.signatureId))]; + const day = currentShanghaiDayRange(); + const rows = await this.prisma.$queryRaw(Prisma.sql` + WITH base AS ( + SELECT + submit."channelId" AS channel_id, + message."signatureId" AS signature_id, + message."drainageInfoId" AS drainage_info_id, + submit."submitStatus" AS submit_status, + COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at, + CASE + WHEN segment_summary.segment_count > 0 + AND segment_summary.delivered_count = segment_summary.segment_count + THEN segment_summary.completed_at + WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at + END AS successful_at, + CASE + WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed' + WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure' + WHEN segment_summary.segment_count > 0 + AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success' + WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure' + WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success' + ELSE 'unknown' + END AS delivery_status + FROM "SmsSubmitRecord" submit + JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::integer AS segment_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, + MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at + FROM "SmsMessageSegmentAudit" segment + WHERE segment."submitRecordId" = submit.id + ) segment_summary ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS delivered_at + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) delivered_receipt ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS failed_at + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'undelivered' + ) failed_receipt ON TRUE + WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') + AND submit."channelId" IN (${Prisma.join(channelIds)}) + AND message."signatureId" IN (${Prisma.join(signatureIds)}) + ) + SELECT + channel_id AS "channelId", + signature_id AS "signatureId", + drainage_info_id AS "drainageInfoId", + COUNT(*) FILTER ( + WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} + )::integer AS total, + COUNT(*) FILTER ( + WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} + AND submit_status = 'accepted' + )::integer AS "acceptedCount", + COUNT(*) FILTER ( + WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} + AND delivery_status = 'submit_failed' + )::integer AS "submitFailureCount", + COUNT(*) FILTER ( + WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} + AND delivery_status = 'success' + )::integer AS "successCount", + COUNT(*) FILTER ( + WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} + AND delivery_status = 'unknown' + )::integer AS "unknownCount", + COUNT(*) FILTER ( + WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} + AND delivery_status = 'failure' + )::integer AS "failureCount", + MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt" + FROM base + GROUP BY channel_id, signature_id, drainage_info_id + `); + + return tasks.map((task) => { + const taskRows = rows.filter((row) => ( + row.channelId === task.channelId + && row.signatureId === task.signatureId + && ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId) + )); + const deliveryStats = summarizeChannelReportDelivery(taskRows); + return { + ...task, + deliveryStats, + lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)), + }; + }); + } + + async listReportTasksPage(query: { + tenantId?: string; + status?: string; + channelId?: string; + reportType?: string; + keyword?: string; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const keyword = query.keyword?.trim(); + const where: Prisma.ChannelSignatureReportTaskWhereInput = { + tenantId: query.tenantId, + status: query.status, + channelId: query.channelId, + reportType: query.reportType, + signature: { auditStatus: { not: 'deleted' } }, + createdAt: query.createdAtFrom || query.createdAtTo ? { + gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, + lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, + } : undefined, + OR: keyword ? [ + { id: { contains: keyword } }, + { channel: { name: { contains: keyword } } }, + { signature: { name: { contains: keyword } } }, + { signature: { tenant: { name: { contains: keyword } } } }, + { signature: { application: { name: { contains: keyword } } } }, + { drainageInfo: { siteName: { contains: keyword } } }, + { drainageInfo: { url: { contains: keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.channelSignatureReportTask.findMany({ + where, + include: { + signature: { include: { tenant: true, application: true } }, + channel: true, + drainageInfo: true, + exportItems: { + include: { exportFile: true, batchItem: { include: { batch: true } } }, + orderBy: { id: 'desc' }, + take: 1, + }, + records: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.channelSignatureReportTask.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async createReportTask(data: CreateReportTaskDto) { + const reportType = data.reportType ?? 'signature'; + if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required'); + if (reportType === 'drainage') { + const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } }); + if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found'); + if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); + throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成'); + } + const task = await this.prisma.channelSignatureReportTask.create({ + data: { + tenantId: data.tenantId, + signatureId: data.signatureId, + channelId: data.channelId, + reportType, + drainageItemId: undefined, + createdById: data.createdById, + status: 'pending', + }, + }); + await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending'); + return task; + } + + async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) { + if (!data.items.length) throw new BadRequestException('items is required'); + const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']); + for (const item of data.items) { + if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status'); + } + const sourceEntry = data.sourceEntry ?? 'report_task'; + if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) { + throw new BadRequestException('unsupported report task source entry'); + } + return this.prisma.$transaction(async (tx) => { + const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))]; + const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = []; + for (const item of data.items) { + const reportType = item.reportType ?? 'signature'; + if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required'); + const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } }); + const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } }); + if (!signature || !channel) throw new NotFoundException('Signature or channel not found'); + if (reportType === 'drainage') { + const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } }); + if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found'); + if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态'); + } + const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } }); + if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核'); + const task = existing + ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } }) + : await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } }); + await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } }); + if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status }); + } + const summaries = []; + for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId)); + return [...summaries, ...drainageResults]; + }); + } + + async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) { + const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature) throw new NotFoundException('Signature not found'); + const routes = signature.applicationId ? await tx.channelRouteRule.findMany({ + where: { applicationId: signature.applicationId, status: 'active' }, + include: { group: { include: { items: { include: { channel: true } } } } }, + }) : []; + const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); + const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } }); + const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel); + const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; + const taskByChannel = new Map(tasks.map((task) => [task.channelId, task])); + const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { + const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); + const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + return [carrier, summarizeReportStatuses(statuses)]; + })); + const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + const reportStatus = summarizeReportStatuses(allStatuses).status; + await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } }); + return { signatureId, reportStatus, carrierReportSummary }; + } + + async createReportExport(taskId: string, data: CreateReportExportDto) { + const task = await this.getReportTaskOrThrow(taskId); + const exported = await this.prisma.reportExportFile.create({ + data: { + taskId, + fileObjectId: data.fileObjectId, + fileName: data.fileName, + rowCount: data.rowCount ?? 0, + }, + }); + await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export'); + return exported; + } + + async importReportReceipt(taskId: string, data: CreateReceiptImportDto) { + const task = await this.getReportTaskOrThrow(taskId); + const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined; + const rowCount = data.rowCount ?? parsed?.rowCount ?? 0; + const successCount = data.successCount ?? parsed?.successCount ?? 0; + const failedCount = data.failedCount ?? parsed?.failedCount ?? 0; + const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount); + const imported = await this.prisma.reportReceiptImport.create({ + data: { + taskId, + fileObjectId: data.fileObjectId, + fileName: data.fileName, + rowCount, + successCount, + failedCount, + status: 'imported', + result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined, + }, + }); + await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); + if ((task.reportType ?? 'signature') === 'signature') { + await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId); + } + return imported; + } + + listReportRecords(taskId?: string, channelId?: string) { + return this.prisma.channelSignatureReportRecord.findMany({ + where: { taskId, channelId }, + include: { channel: true, task: { include: { signature: true, drainageInfo: true } } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async listReportRecordsPage(query: { + taskId?: string; + channelId?: string; + keyword?: string; + reportType?: string; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const keyword = query.keyword?.trim(); + const where: Prisma.ChannelSignatureReportRecordWhereInput = { + taskId: query.taskId, + channelId: query.channelId, + task: query.reportType ? { reportType: query.reportType } : undefined, + createdAt: query.createdAtFrom || query.createdAtTo ? { + gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, + lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, + } : undefined, + OR: keyword ? [ + { taskId: { contains: keyword } }, + { action: { contains: keyword } }, + { reason: { contains: keyword } }, + { channel: { name: { contains: keyword } } }, + { task: { signature: { name: { contains: keyword } } } }, + { task: { drainageInfo: { siteName: { contains: keyword } } } }, + { task: { drainageInfo: { url: { contains: keyword } } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.channelSignatureReportRecord.findMany({ + where, + include: { channel: true, task: { include: { signature: true, drainageInfo: true } } }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.channelSignatureReportRecord.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async getReportTaskOrThrow(taskId: string) { + const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } }); + if (!task) { + throw new NotFoundException('Report task not found'); + } + if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') { + throw new BadRequestException('引流信息审核通过后才能处理通道报备任务'); + } + return task; + } + + async updateReportTaskStatus( + taskId: string, + channelId: string, + statusBefore: string, + statusAfter: string, + action: string, + reason?: string, + ) { + await this.prisma.channelSignatureReportTask.update({ + where: { id: taskId }, + data: { status: statusAfter, reason }, + }); + await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason); + } + + recordReportTask( + taskId: string, + channelId: string, + action: string, + statusBefore: string | undefined, + statusAfter: string, + reason?: string, + ) { + return this.prisma.channelSignatureReportRecord.create({ + data: { + taskId, + channelId, + action, + statusBefore, + statusAfter, + reason, + }, + }); + } +} diff --git a/api/src/channels/channel-test.service.ts b/api/src/channels/channel-test.service.ts new file mode 100644 index 0000000..e22ec10 --- /dev/null +++ b/api/src/channels/channel-test.service.ts @@ -0,0 +1,117 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; +import { ChannelConnectionService } from './channel-connection.service'; + +/** R5 channel domain service composed behind ChannelsService. */ +export class ChannelTestService { + constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} + + async testChannel(channelId: string, data: TestChannelDto = {}) { + const phoneNumbers = normalizeTestPhones(data); + const content = normalizeTestContent(data.content); + const channel = await this.prisma.smsChannel.findUnique({ + where: { id: channelId }, + include: { connectionStates: true }, + }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + if (channel.status !== 'active') { + throw new BadRequestException('通道未启用,不能发送测试短信'); + } + const connectedState = channel.connectionStates.find((state) => + normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0, + ); + if (!connectedState) { + throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送'); + } + + const createdAt = new Date(); + const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`; + const results = []; + for (const [index, phoneNumber] of phoneNumbers.entries()) { + const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; + const submitId = `SUB-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; + const session = await this.prisma.cmppSubmitSession.upsert({ + where: { sessionNo: `OPEN-${channel.id}` }, + update: { submitTotal: { increment: 1 } }, + create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 }, + }); + const messageRecord = await this.prisma.smsMessageRecord.create({ + data: { + messageId, + phoneNumber, + content, + billingUnits: calculateBillingUnits(content), + unitPrice: 0, + amountCents: 0, + queuePriority: 'normal', + channelId: channel.id, + submitId, + status: 'submit_queued', + submitStatus: 'queued', + }, + }); + await this.prisma.smsSubmitRecord.create({ + data: { + messageRecordId: messageRecord.id, + channelId: channel.id, + sessionId: session.id, + submitId, + submitStatus: 'queued', + costUnitPrice: channel.unitPrice, + costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits, + }, + }); + const command = buildChannelTestSubmitCommand({ + channel, + content, + phoneNumber, + messageId, + submitId, + testNo, + attempt: index, + accessNo: data.accessNo, + }); + await this.connection.getGatewaySubmitQueue().add('submit-command', command); + const streamMessageId = await this.connection.publishGatewaySubmitCommand(command); + results.push({ + phoneNumber, + messageRecordId: messageRecord.id, + submitId, + streamMessageId, + }); + } + + await this.prisma.operationLog.create({ + data: { + userId: data.operatorId, + action: 'sms_channel.test_submit', + resource: 'sms_channel', + resourceId: channel.id, + detail: { + testNo, + phoneTotal: phoneNumbers.length, + messageRecordIds: results.map((item) => item.messageRecordId), + connectionId: connectedState.connectionId, + } as Prisma.InputJsonValue, + }, + }); + + return { + channelId, + status: 'submit_queued', + testNo, + submitted: results.length, + messages: results, + queuedAt: createdAt, + }; + } +} diff --git a/api/src/channels/channels.contracts.ts b/api/src/channels/channels.contracts.ts new file mode 100644 index 0000000..65f0368 --- /dev/null +++ b/api/src/channels/channels.contracts.ts @@ -0,0 +1,174 @@ +/** Stable request contracts shared by the channel controller and R5 domains. */ + +export interface CreateChannelDto { + code: string; + name: string; + carrier?: string; + sendRegion?: string; + protocol?: string; + gatewayHost: string; + gatewayPort?: number; + enterpriseCode?: string; + account: string; + passwordCipher: string; + srcId: string; + cmppVersion?: string; + rateLimitPerSecond?: number; + unitPrice?: number; + status?: string; + desiredConnections?: number; + windowSize?: number; + heartbeatIntervalSeconds?: number; + heartbeatMissThreshold?: number; + config?: Record; +} + +export type UpdateChannelDto = Partial; + +export interface CreateChannelGroupDto { + code: string; + name: string; + carrier: string; + description?: string; + status?: string; + retryEnabled?: boolean; + retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; +} + +export interface CreateChannelGroupItemDto { + groupId: string; + channelId: string; + carrier?: string; + province?: string; + priority?: number; + weight?: number; + isBackup?: boolean; +} + +export interface UpdateChannelGroupDto { + code?: string; + name?: string; + carrier?: string; + description?: string; + status?: string; + retryEnabled?: boolean; + retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; + items?: Array>; +} + +export interface CreateRouteRuleDto { + tenantId?: string; + applicationId?: string; + groupId: string; + channelId?: string; + carrier?: string; + province?: string; + priority?: number; + status?: string; +} + +export interface CreateReportFieldDto { + channelId: string; + drainageFieldId: string; + reportType: 'signature' | 'drainage' | 'both'; + code?: string; + name?: string; + fieldType?: string; + required?: boolean; + description?: string; + sortOrder?: number; + exportName?: string; + columnWidth?: number; + imageWidth?: number; + imageHeight?: number; + defaultValue?: string; + transform?: string; + status?: string; +} + +export interface ReplaceReportFieldsDto { + fields: Array>; +} + +export interface CreateReportMaterialDto { + signatureId: string; + channelId: string; + fieldCode: string; + fieldValue?: string; + fileObjectId?: string; +} + +export interface CreateReportTaskDto { + tenantId: string; + signatureId: string; + channelId: string; + reportType?: 'signature' | 'drainage'; + drainageItemId?: string; + createdById?: string; +} + +export interface ChangeReportTaskStatusesDto { + items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; + reason?: string; + operatorId?: string; + sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report'; +} + +export interface CreateReportExportDto { + fileObjectId?: string; + fileName: string; + rowCount?: number; +} + +export interface CreateReceiptImportDto { + fileObjectId?: string; + fileName: string; + fileContent?: string; + delimiter?: ',' | '\t'; + rowCount?: number; + successCount?: number; + failedCount?: number; + statusAfter?: string; + reason?: string; + result?: Record; +} + +export interface UpsertConnectionStateDto { + tenantId?: string; + applicationId?: string; + channelId: string; + connectionId: string; + status: string; + desiredConnections?: number; + currentConnections?: number; + lastConnectedAt?: string; + lastDisconnectedAt?: string; + lastHeartbeatAt?: string; + reconnectCount?: number; + lastReconnectAttemptAt?: string; + nextReconnectAt?: string; + lastErrorCategory?: string; + lastError?: string; +} + +export interface ChangeChannelStatusDto { + status: string; + operatorId?: string; + reason?: string; +} + +export interface CopyChannelDto { + name?: string; + code?: string; + operatorId?: string; +} + +export interface TestChannelDto { + phoneNumber?: string; + phones?: string[] | string; + content?: string; + accessNo?: string; + operatorId?: string; +} diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index ade38a7..6d8b133 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -4,7 +4,6 @@ import { RequireRecentAuthentication } from '../auth/require-recent-authenticati import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service'; import { - ChannelsService, ChangeChannelStatusDto, CopyChannelDto, CreateChannelDto, @@ -22,7 +21,8 @@ import { UpsertConnectionStateDto, UpdateChannelDto, UpdateChannelGroupDto, -} from './channels.service'; +} from './channels.contracts'; +import { ChannelsService } from './channels.service'; @ApiTags('channels') @Controller('admin') diff --git a/api/src/channels/channels.helpers.ts b/api/src/channels/channels.helpers.ts new file mode 100644 index 0000000..16575a8 --- /dev/null +++ b/api/src/channels/channels.helpers.ts @@ -0,0 +1,685 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; + +/** Constants and pure validation/normalization helpers shared by R5 domains. */ +export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands'; + +export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue'; + +export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands'; + +export const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090'; + +export const DEFAULT_CHANNEL_CONNECTION_ID = 'primary'; + +export const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000; + +export const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000; + +export const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000; + +export const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000; + +export const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000; + +export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30; + +export const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3; + +export const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000; + +export const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out'; + +export const DEFAULT_CMPP_VERSION = '2.0'; + +export function normalizeTestPhones(data: TestChannelDto) { + const rawPhones = Array.isArray(data.phones) + ? data.phones + : String(data.phoneNumber ?? data.phones ?? '').split(/[,\n,\s]+/u); + const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean); + const uniquePhones = Array.from(new Set(phones)); + if (uniquePhones.length === 0) { + throw new BadRequestException('请填写测试手机号'); + } + if (uniquePhones.length > 10) { + throw new BadRequestException('测试手机号最多允许 10 个'); + } + for (const phone of uniquePhones) { + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw new BadRequestException(`手机号格式不正确:${phone}`); + } + } + return uniquePhones; +} + +export function normalizeTestContent(content?: string) { + const normalized = (content ?? '').trim(); + if (!normalized) { + throw new BadRequestException('请填写测试短信内容'); + } + if (normalized.length > 1000) { + throw new BadRequestException('测试短信内容不能超过 1000 字符'); + } + return normalized; +} + +export function calculateBillingUnits(content: string) { + return Math.max(1, Math.ceil([...content].length / 67)); +} + +export function buildChannelTestSubmitCommand({ + channel, + content, + phoneNumber, + messageId, + submitId, + testNo, + attempt, + accessNo, +}: { + channel: { + id: string; + code: string; + gatewayHost: string; + gatewayPort: number; + account: string; + passwordCipher: string; + srcId: string; + cmppVersion: string; + rateLimitPerSecond: number; + config?: Prisma.JsonValue | null; + }; + content: string; + phoneNumber: string; + messageId: string; + submitId: string; + testNo: string; + attempt: number; + accessNo?: string; +}) { + const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId; + return { + schemaVersion: 'v1', + messageType: 'SubmitCommand', + traceId: randomUUID(), + messageId, + channelId: channel.id, + createdAt: new Date().toISOString(), + tenantId: 'platform-channel-test', + applicationId: 'admin-channel-test', + taskId: testNo, + submitId, + queuePriority: 'normal', + phoneNumber, + content, + signature: 'CHANNEL_TEST', + templateId: 'admin-channel-test', + billingUnits: calculateBillingUnits(content), + route: { + channelCode: channel.code, + cmppAccountCode: channel.account, + priority: attempt, + rateLimitPerSecond: channel.rateLimitPerSecond, + }, + cmpp: { + serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'), + srcId, + extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')), + registeredDelivery: 1, + msgFmt: 8, + }, + upstream: { + gatewayHost: channel.gatewayHost, + gatewayPort: channel.gatewayPort, + account: channel.account, + passwordCipher: channel.passwordCipher, + cmppVersion: channel.cmppVersion, + desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'), + windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), + heartbeatIntervalSeconds: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatIntervalSeconds'), + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ), + heartbeatMissThreshold: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatMissThreshold'), + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ), + }, + retry: { attempt: 0, maxAttempts: 1 }, + }; +} + +export function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) { + if (config && typeof config === 'object' && !Array.isArray(config) && key in config) { + return config[key as keyof typeof config]; + } + return undefined; +} + +export function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) { + const value = getConfigValue(config, key); + if (value === undefined || value === null || value === '') { + return fallback; + } + return String(value); +} + +export function normalizeConnectionAction(status: string) { + const normalized = status.toLowerCase(); + if (normalized === 'connected') { + return 'connected'; + } + if (['heartbeat', 'active_test'].includes(normalized)) { + return 'heartbeat'; + } + if (['reconnecting', 'reconnect'].includes(normalized)) { + return 'reconnecting'; + } + if (['offline', 'closed', 'disconnected'].includes(normalized)) { + return 'disconnected'; + } + if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) { + return 'failed'; + } + return 'updated'; +} + +export function normalizeCmppVersion(version?: string) { + const normalized = (version ?? DEFAULT_CMPP_VERSION).trim(); + if (normalized === '2.0' || normalized === '3.0') { + return normalized; + } + throw new BadRequestException('cmppVersion must be 2.0 or 3.0'); +} + +export function normalizeGatewayConnectionStatus(status: string) { + const normalized = status.toLowerCase(); + if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) { + return 'connected'; + } + if (['connecting', 'connect_requested'].includes(normalized)) { + return 'connecting'; + } + if (['reconnecting', 'reconnect'].includes(normalized)) { + return 'reconnecting'; + } + if (['offline', 'closed', 'disconnected'].includes(normalized)) { + return 'disconnected'; + } + if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) { + return 'failed'; + } + return normalized; +} + +export function defaultChannelConnectionId(channelId: string) { + return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`; +} + +export function getDesiredConnections(config?: Prisma.JsonValue | null) { + if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) { + const value = Number(config.desiredConnections); + if (Number.isInteger(value) && value > 0) { + return value; + } + } + return 1; +} + +export type ChannelConnectionSettings = { + gatewayHost: string; + gatewayPort: number; + account: string; + passwordCipher: string; + cmppVersion: string; + config?: Prisma.JsonValue | Record | null; +}; + +export function getRuntimeConfigInteger( + config: Prisma.JsonValue | Record | null | undefined, + key: string, + fallback: number, +) { + if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback; + const value = Number((config as Record)[key]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +export function channelConnectionSettingsChanged( + before: ChannelConnectionSettings, + after: ChannelConnectionSettings, +) { + return before.gatewayHost !== after.gatewayHost + || before.gatewayPort !== after.gatewayPort + || before.account !== after.account + || before.passwordCipher !== after.passwordCipher + || before.cmppVersion !== after.cmppVersion + || getRuntimeConfigInteger(before.config, 'desiredConnections', 1) + !== getRuntimeConfigInteger(after.config, 'desiredConnections', 1) + || getRuntimeConfigInteger(before.config, 'windowSize', 16) + !== getRuntimeConfigInteger(after.config, 'windowSize', 16) + || getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) + !== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) + || getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) + !== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD); +} + +export function channelGroupAuditSnapshot(group: { + code: string; + name: string; + carrier: string; + description?: string | null; + status: string; + retryEnabled: boolean; + retryTimeLimitMinutes: number; + items?: Array<{ + channelId: string; + carrier?: string | null; + province?: string | null; + priority: number; + weight: number; + isBackup: boolean; + channel?: { code?: string; name?: string } | null; + }>; +}) { + return { + code: group.code, + name: group.name, + carrier: group.carrier, + description: group.description ?? null, + status: group.status, + retryEnabled: group.retryEnabled, + retryTimeLimitMinutes: group.retryTimeLimitMinutes, + items: (group.items ?? []).map((item) => ({ + channelId: item.channelId, + channelCode: item.channel?.code ?? null, + channelName: item.channel?.name ?? null, + carrier: item.carrier ?? null, + province: item.province ?? null, + priority: item.priority, + weight: item.weight, + isBackup: item.isBackup, + })), + }; +} + +export function normalizeChannelRuntimeConfig( + existingConfig?: Prisma.JsonValue | Record | null, + incomingConfig?: Record | null, + desiredConnections?: number, + windowSize?: number, + heartbeatIntervalSeconds?: number, + heartbeatMissThreshold?: number, +) { + const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) + ? existingConfig as Record + : {}; + const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) + ? incomingConfig + : {}; + const base = { ...existing, ...incoming }; + base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections'); + base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize'); + base.heartbeatIntervalSeconds = getPositiveRuntimeInteger( + heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds, + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ); + base.heartbeatMissThreshold = getPositiveRuntimeInteger( + heartbeatMissThreshold ?? base.heartbeatMissThreshold, + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ); + base.extensionDigits = normalizeExtensionDigits(base.extensionDigits); + base.serviceId = normalizeCmppServiceId(base.serviceId); + return base; +} + +export function normalizeCmppServiceId(value: unknown) { + const normalized = String(value ?? 'SMS').trim() || 'SMS'; + if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) { + throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters'); + } + return normalized; +} + +export function normalizeChannelRateLimit(value: unknown) { + const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond'); + if (normalized > 2000) { + throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000'); + } + return normalized; +} + +export function normalizeExtensionDigits(value: unknown) { + if (value === undefined || value === null || value === '') { + return 0; + } + const normalized = Number(value); + if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) { + throw new BadRequestException('extensionDigits must be an integer between 0 and 20'); + } + return normalized; +} + +export function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) { + if (value === undefined || value === null || value === '') { + return fallback; + } + const normalized = Number(value); + if (!Number.isInteger(normalized) || normalized <= 0) { + throw new BadRequestException(`${fieldName} must be a positive integer`); + } + return normalized; +} + +export function bullmqConnection() { + const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); + return { + host: redisUrl.hostname, + port: Number(redisUrl.port || 6379), + username: redisUrl.username || undefined, + password: redisUrl.password || undefined, + maxRetriesPerRequest: null, + }; +} + +export function getPositiveIntegerEnv(name: string, fallback: number) { + const value = Number(process.env[name]); + if (Number.isInteger(value) && value > 0) { + return value; + } + return fallback; +} + +export function parseReceiptContent(content: string, delimiter?: ',' | '\t') { + const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (lines.length === 0) { + throw new BadRequestException('Receipt file is empty'); + } + const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ','); + const firstCells = splitReceiptLine(lines[0], separator); + const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase())); + const header = hasHeader ? firstCells : []; + const rows = hasHeader ? lines.slice(1) : lines; + const statusIndex = findReceiptStatusIndex(header); + let successCount = 0; + let failedCount = 0; + const resultRows = rows.map((line, index) => { + const cells = splitReceiptLine(line, separator); + const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? ''; + const normalizedStatus = normalizeReceiptStatus(rawStatus); + if (normalizedStatus === 'success') { + successCount += 1; + } else { + failedCount += 1; + } + return { + rowNumber: (hasHeader ? index + 2 : index + 1), + phone: cells[0] ?? '', + status: normalizedStatus, + rawStatus, + raw: cells, + }; + }); + return { + rowCount: resultRows.length, + successCount, + failedCount, + result: { + delimiter: separator === '\t' ? 'tab' : 'comma', + hasHeader, + rows: resultRows, + }, + }; +} + +export function splitReceiptLine(line: string, delimiter: ',' | '\t') { + if (delimiter === '\t') { + return line.split('\t').map((cell) => stripReceiptCell(cell)); + } + const cells: string[] = []; + let current = ''; + let quoted = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + if (char === '"' && quoted && next === '"') { + current += '"'; + index += 1; + } else if (char === '"') { + quoted = !quoted; + } else if (char === ',' && !quoted) { + cells.push(stripReceiptCell(current)); + current = ''; + } else { + current += char; + } + } + cells.push(stripReceiptCell(current)); + return cells; +} + +export function stripReceiptCell(value: string) { + return value.trim().replace(/^"|"$/g, '').trim(); +} + +export function findReceiptStatusIndex(header: string[]) { + if (header.length === 0) { + return 1; + } + const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase())); + return index >= 0 ? index : Math.max(0, header.length - 1); +} + +export function normalizeReceiptStatus(value: string) { + const normalized = value.trim().toLowerCase(); + if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) { + return 'success'; + } + if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) { + return 'failed'; + } + return 'failed'; +} + +export function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) { + if (rowCount <= 0 || successCount <= 0) { + return 'failed'; + } + if (failedCount > 0) { + return 'partial'; + } + return 'completed'; +} + +export type ChannelReportDeliveryRow = { + channelId: string; + signatureId: string; + drainageInfoId: string | null; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + lastSuccessfulSentAt: Date | null; +}; + +export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) { + const total = sumReportDelivery(rows, 'total'); + const acceptedCount = sumReportDelivery(rows, 'acceptedCount'); + const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount'); + const successCount = sumReportDelivery(rows, 'successCount'); + const unknownCount = sumReportDelivery(rows, 'unknownCount'); + const failureCount = sumReportDelivery(rows, 'failureCount'); + return { + total, + acceptedCount, + submitFailureCount, + submitFailureRate: percentage(submitFailureCount, total), + successCount, + successRate: percentage(successCount, acceptedCount), + unknownCount, + unknownRate: percentage(unknownCount, acceptedCount), + failureCount, + failureRate: percentage(failureCount, acceptedCount), + }; +} + +export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick< + ChannelReportDeliveryRow, + 'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount' +>) { + return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0); +} + +export function percentage(count: number, total: number) { + return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0; +} + +export function latestDate(values: Array) { + const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime()); + return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null; +} + +export function currentShanghaiDayRange(now = new Date()) { + const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000); + const localDate = shifted.toISOString().slice(0, 10); + const startAt = new Date(`${localDate}T00:00:00+08:00`); + return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) }; +} + +export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) { + const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60); + if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) { + throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320'); + } + return value; +} + +export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) { + if (value === undefined || !Number.isFinite(value)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} + +export function normalizeBusinessCarrier(carrier?: string | null) { + const normalized = normalizeChannelCarrier(carrier); + if (!['mobile', 'unicom', 'telecom'].includes(normalized)) { + throw new BadRequestException('carrier must be mobile, unicom, or telecom'); + } + return normalized; +} + +export function normalizeChannelCarrier(carrier?: string | null) { + const value = String(carrier ?? '').trim().toLowerCase(); + if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; + if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; + if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; + if (['all', 'tri', '三网', '全网'].includes(value)) return 'all'; + return value; +} + +export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) { + const normalized = normalizeChannelCarrier(channelCarrier); + return normalized === 'all' || normalized === groupCarrier; +} + +export function normalizeRegion(region?: string | null) { + return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); +} + +export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) { + return normalizeRegion(channelRegion) === normalizeRegion(itemProvince); +} + +export function validateGroupItems( + groupCarrier: string, + items: Array>, + channels: Map, +) { + const channelIds = new Set(); + const provinces = new Set(); + const nationalPriorities = new Set(); + for (const item of items) { + const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier; + if (itemCarrier !== groupCarrier) { + throw new BadRequestException('Channel group items must use the same carrier as the channel group'); + } + const channel = channels.get(item.channelId); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + if (channelIds.has(item.channelId)) { + throw new BadRequestException('通道组内不能重复配置同一通道'); + } + channelIds.add(item.channelId); + if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { + throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); + } + if (item.province) { + const province = normalizeRegion(item.province); + if (provinces.has(province)) { + throw new BadRequestException('同一通道组内同一省份只能配置一个通道'); + } + provinces.add(province); + if (!isRegionCompatible(channel.sendRegion, item.province)) { + throw new BadRequestException('Province route must use a channel with the same sendRegion'); + } + } else { + const priority = item.priority ?? 100; + if (nationalPriorities.has(priority)) { + throw new BadRequestException('同一通道组内全国通道优先级不能重复'); + } + nationalPriorities.add(priority); + } + } +} + +export function normalizeReportType(value?: string) { + if (value === 'signature' || value === 'drainage' || value === 'both') return value; + throw new BadRequestException('reportType must be signature, drainage or both'); +} + +export function summarizeReportStatuses(statuses: string[]) { + if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 }; + const approved = statuses.filter((status) => status === 'approved').length; + let status = 'pending'; + if (approved === statuses.length) status = 'approved'; + else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed'; + else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting'; + else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material'; + return { status, approved, total: statuses.length }; +} + +export function normalizeLinkEvent(action: string) { + if (action.includes('connect_requested')) { + return '连接请求'; + } + if (action.includes('connected')) { + return '连接成功'; + } + if (action.includes('heartbeat')) { + return '心跳'; + } + if (action.includes('reconnecting')) { + return '重连'; + } + if (action.includes('disconnected')) { + return '断开'; + } + if (action.includes('failed')) { + return '连接失败'; + } + if (action.includes('copy')) { + return '复制'; + } + if (action.includes('deleted')) { + return '删除'; + } + return '更新'; +} diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index c50a9b3..88f74fa 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -1,1305 +1,149 @@ -import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; -import { Prisma } from '@prisma/client'; -import { randomUUID } from 'crypto'; -import { assertMoneyUnits, moneyToNumber } from '../common/money'; +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import { ChannelConfigurationService } from './channel-configuration.service'; +import { ChannelConnectionService } from './channel-connection.service'; +import { ChannelCopyService } from './channel-copy.service'; +import { ChannelDeletionService } from './channel-deletion.service'; +import { ChannelGroupRoutingService } from './channel-group-routing.service'; +import { ChannelReportingService } from './channel-reporting.service'; +import { ChannelTestService } from './channel-test.service'; -export interface CreateChannelDto { - code: string; - name: string; - carrier?: string; - sendRegion?: string; - protocol?: string; - gatewayHost: string; - gatewayPort?: number; - enterpriseCode?: string; - account: string; - passwordCipher: string; - srcId: string; - cmppVersion?: string; - rateLimitPerSecond?: number; - unitPrice?: number; - status?: string; - desiredConnections?: number; - windowSize?: number; - heartbeatIntervalSeconds?: number; - heartbeatMissThreshold?: number; - config?: Record; -} - -export type UpdateChannelDto = Partial; - -export interface CreateChannelGroupDto { - code: string; - name: string; - carrier: string; - description?: string; - status?: string; - retryEnabled?: boolean; - retryTimeLimitHours?: number; - retryTimeLimitMinutes?: number; -} - -export interface CreateChannelGroupItemDto { - groupId: string; - channelId: string; - carrier?: string; - province?: string; - priority?: number; - weight?: number; - isBackup?: boolean; -} - -export interface UpdateChannelGroupDto { - code?: string; - name?: string; - carrier?: string; - description?: string; - status?: string; - retryEnabled?: boolean; - retryTimeLimitHours?: number; - retryTimeLimitMinutes?: number; - items?: Array>; -} - -export interface CreateRouteRuleDto { - tenantId?: string; - applicationId?: string; - groupId: string; - channelId?: string; - carrier?: string; - province?: string; - priority?: number; - status?: string; -} - -export interface CreateReportFieldDto { - channelId: string; - drainageFieldId: string; - reportType: 'signature' | 'drainage' | 'both'; - code?: string; - name?: string; - fieldType?: string; - required?: boolean; - description?: string; - sortOrder?: number; - exportName?: string; - columnWidth?: number; - imageWidth?: number; - imageHeight?: number; - defaultValue?: string; - transform?: string; - status?: string; -} - -export interface ReplaceReportFieldsDto { - fields: Array>; -} - -export interface CreateReportMaterialDto { - signatureId: string; - channelId: string; - fieldCode: string; - fieldValue?: string; - fileObjectId?: string; -} - -export interface CreateReportTaskDto { - tenantId: string; - signatureId: string; - channelId: string; - reportType?: 'signature' | 'drainage'; - drainageItemId?: string; - createdById?: string; -} - -export interface ChangeReportTaskStatusesDto { - items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; - reason?: string; - operatorId?: string; - sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report'; -} - -export interface CreateReportExportDto { - fileObjectId?: string; - fileName: string; - rowCount?: number; -} - -export interface CreateReceiptImportDto { - fileObjectId?: string; - fileName: string; - fileContent?: string; - delimiter?: ',' | '\t'; - rowCount?: number; - successCount?: number; - failedCount?: number; - statusAfter?: string; - reason?: string; - result?: Record; -} - -export interface UpsertConnectionStateDto { - tenantId?: string; - applicationId?: string; - channelId: string; - connectionId: string; - status: string; - desiredConnections?: number; - currentConnections?: number; - lastConnectedAt?: string; - lastDisconnectedAt?: string; - lastHeartbeatAt?: string; - reconnectCount?: number; - lastReconnectAttemptAt?: string; - nextReconnectAt?: string; - lastErrorCategory?: string; - lastError?: string; -} - -export interface ChangeChannelStatusDto { - status: string; - operatorId?: string; - reason?: string; -} - -export interface CopyChannelDto { - name?: string; - code?: string; - operatorId?: string; -} - -export interface TestChannelDto { - phoneNumber?: string; - phones?: string[] | string; - content?: string; - accessNo?: string; - operatorId?: string; -} - -const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands'; -const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue'; -const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands'; -const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090'; -const DEFAULT_CHANNEL_CONNECTION_ID = 'primary'; -const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000; -const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000; -const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000; -const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000; -const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000; -const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30; -const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3; -const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000; -const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out'; -const DEFAULT_CMPP_VERSION = '2.0'; - +/** Stable compatibility facade; R5 delegates channel behavior to focused domains. */ @Injectable() export class ChannelsService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(ChannelsService.name); - private gatewayConnectionQueue?: Queue; - private gatewaySubmitQueue?: Queue; - private redis?: IORedis; - private connectionTimeoutTimer?: ReturnType; - private gatewayStartupReconnectTimer?: ReturnType; - private gatewayReconcileTimer?: ReturnType; + private readonly connection: ChannelConnectionService; + private readonly configuration: ChannelConfigurationService; + private readonly testing: ChannelTestService; + private readonly groups: ChannelGroupRoutingService; + private readonly reporting: ChannelReportingService; + private readonly copy: ChannelCopyService; + private readonly deletion: ChannelDeletionService; - constructor(private readonly prisma: PrismaService) {} + constructor(prisma: PrismaService) { + this.connection = new ChannelConnectionService(prisma); + this.configuration = new ChannelConfigurationService(prisma, this.connection); + this.testing = new ChannelTestService(prisma, this.connection); + this.groups = new ChannelGroupRoutingService(prisma); + this.reporting = new ChannelReportingService(prisma); + this.copy = new ChannelCopyService(prisma); + this.deletion = new ChannelDeletionService(prisma, this.configuration); + } onModuleInit() { - if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED !== 'true') { - this.connectionTimeoutTimer = setInterval(() => { - void this.markTimedOutConnectingChannels().catch((error) => { - this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`); - }); - }, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS)); - this.connectionTimeoutTimer.unref?.(); - } - this.gatewayStartupReconnectTimer = setTimeout(() => { - void this.reconnectActiveChannelsAfterGatewayRestart(); - }, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS)); - this.gatewayStartupReconnectTimer.unref?.(); - if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') { - this.gatewayReconcileTimer = setInterval(() => { - void this.reconcileGatewayConnections().catch((error) => { - this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`); - }); - }, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS)); - this.gatewayReconcileTimer.unref?.(); - } + return this.connection.onModuleInit(); } async onModuleDestroy() { - if (this.connectionTimeoutTimer) { - clearInterval(this.connectionTimeoutTimer); - } - if (this.gatewayStartupReconnectTimer) { - clearTimeout(this.gatewayStartupReconnectTimer); - } - if (this.gatewayReconcileTimer) { - clearInterval(this.gatewayReconcileTimer); - } - await this.gatewayConnectionQueue?.close(); - await this.gatewaySubmitQueue?.close(); - this.redis?.disconnect(); + return this.connection.onModuleDestroy(); } listChannels() { - return this.prisma.smsChannel.findMany({ - include: { connectionStates: true }, - orderBy: { createdAt: 'desc' }, - }); + return this.configuration.listChannels(); } async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const where: Prisma.SmsChannelWhereInput = { - status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined, - name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, - }; - const [items, total] = await Promise.all([ - this.prisma.smsChannel.findMany({ - where, - include: { connectionStates: true }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.smsChannel.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.configuration.listChannelsPage(query); } async createChannel(data: CreateChannelDto) { - assertMoneyUnits(data.unitPrice ?? 0, '通道单价'); - const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => { - const value = data[field as keyof CreateChannelDto]; - return value === undefined || value === null || value === ''; - }); - if (missingFields.length > 0) { - throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`); - } - const gatewayPort = Number(data.gatewayPort ?? 7890); - if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) { - throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); - } - const cmppVersion = normalizeCmppVersion(data.cmppVersion); - const config = normalizeChannelRuntimeConfig( - undefined, - data.config, - data.desiredConnections, - data.windowSize, - data.heartbeatIntervalSeconds, - data.heartbeatMissThreshold, - ); - const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond); - const channel = await this.prisma.smsChannel.create({ - data: { - code: data.code, - name: data.name, - carrier: data.carrier, - sendRegion: data.sendRegion ?? '全国', - protocol: 'CMPP', - gatewayHost: data.gatewayHost, - gatewayPort, - enterpriseCode: data.enterpriseCode, - account: data.account, - passwordCipher: data.passwordCipher, - srcId: data.srcId, - cmppVersion, - rateLimitPerSecond, - unitPrice: data.unitPrice ?? 0, - status: data.status ?? 'active', - config: config as Prisma.InputJsonValue, - }, - }); - if (channel.status === 'active') { - await this.requestChannelConnection(channel, 'channel_created'); - } - return channel; + return this.configuration.createChannel(data); } async updateChannel(channelId: string, data: UpdateChannelDto) { - const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); - if (!channel) { - throw new NotFoundException('Channel not found'); - } - if (data.unitPrice !== undefined) { - assertMoneyUnits(data.unitPrice, '通道单价'); - } - const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort); - if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) { - throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); - } - const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); - const config = data.config !== undefined - || data.desiredConnections !== undefined - || data.windowSize !== undefined - || data.heartbeatIntervalSeconds !== undefined - || data.heartbeatMissThreshold !== undefined - ? normalizeChannelRuntimeConfig( - channel.config, - data.config, - data.desiredConnections, - data.windowSize, - data.heartbeatIntervalSeconds, - data.heartbeatMissThreshold, - ) - : undefined; - const rateLimitPerSecond = data.rateLimitPerSecond === undefined - ? undefined - : normalizeChannelRateLimit(data.rateLimitPerSecond); - const connectionConfigChanged = channelConnectionSettingsChanged(channel, { - gatewayHost: data.gatewayHost ?? channel.gatewayHost, - gatewayPort: gatewayPort ?? channel.gatewayPort, - account: data.account ?? channel.account, - passwordCipher: data.passwordCipher ?? channel.passwordCipher, - cmppVersion: cmppVersion ?? channel.cmppVersion, - config: config ?? channel.config, - }); - const updated = await this.prisma.smsChannel.update({ - where: { id: channelId }, - data: { - code: data.code, - name: data.name, - carrier: data.carrier, - sendRegion: data.sendRegion, - protocol: 'CMPP', - gatewayHost: data.gatewayHost, - gatewayPort, - enterpriseCode: data.enterpriseCode, - account: data.account, - passwordCipher: data.passwordCipher, - srcId: data.srcId, - cmppVersion, - rateLimitPerSecond, - unitPrice: data.unitPrice, - status: data.status, - config: config as Prisma.InputJsonValue | undefined, - }, - }); - await this.prisma.operationLog.create({ - data: { - action: 'sms_channel.update', - resource: 'sms_channel', - resourceId: channelId, - detail: { - before: { - code: channel.code, - name: channel.name, - carrier: channel.carrier, - sendRegion: channel.sendRegion, - gatewayHost: channel.gatewayHost, - gatewayPort: channel.gatewayPort, - enterpriseCode: channel.enterpriseCode, - account: channel.account, - srcId: channel.srcId, - unitPrice: moneyToNumber(channel.unitPrice), - }, - after: data, - } as Prisma.InputJsonValue, - }, - }); - const updatedStatus = data.status ?? channel.status; - if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) { - await this.requestChannelConnection(updated, 'channel_updated'); - } else if (updatedStatus !== 'active' && channel.status === 'active') { - await this.requestChannelDisconnection(updated, 'channel_disabled'); - } - return updated; + return this.configuration.updateChannel(channelId, data); } async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) { - const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); - if (!channel) { - throw new NotFoundException('Channel not found'); - } - const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } }); - await this.prisma.operationLog.create({ - data: { - userId: data.operatorId, - action: `sms_channel.${data.status}`, - resource: 'sms_channel', - resourceId: channelId, - detail: { - statusBefore: channel.status, - statusAfter: data.status, - reason: data.reason, - } as Prisma.InputJsonValue, - }, - }); - if (data.status === 'active') { - await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId); - } else if (channel.status === 'active' || data.status === 'deleted') { - await this.requestChannelDisconnection( - updated, - data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled', - data.operatorId, - ); - } - return updated; + return this.configuration.changeChannelStatus(channelId, data); } async copyChannel(channelId: string, data: CopyChannelDto = {}) { - const source = await this.prisma.smsChannel.findUnique({ - where: { id: channelId }, - include: { reportFields: true }, - }); - if (!source) { - throw new NotFoundException('Channel not found'); - } - - const suffix = Date.now().toString(36).toUpperCase(); - const nextName = data.name ?? `${source.name}副本`; - const nextCode = data.code ?? `${source.code}-COPY-${suffix}`; - - const copied = await this.prisma.$transaction(async (tx) => { - const nextChannel = await tx.smsChannel.create({ - data: { - code: nextCode, - name: nextName, - carrier: source.carrier, - protocol: source.protocol, - gatewayHost: source.gatewayHost, - gatewayPort: source.gatewayPort, - enterpriseCode: source.enterpriseCode, - account: source.account, - passwordCipher: source.passwordCipher, - srcId: source.srcId, - sendRegion: source.sendRegion, - cmppVersion: source.cmppVersion, - rateLimitPerSecond: source.rateLimitPerSecond, - unitPrice: source.unitPrice, - status: 'disabled', - config: source.config as Prisma.InputJsonValue | undefined, - reportFields: { - create: source.reportFields.map((field) => ({ - drainageFieldId: field.drainageFieldId, - reportType: field.reportType, - code: field.code, - name: field.name, - fieldType: field.fieldType, - required: field.required, - description: field.description, - sortOrder: field.sortOrder, - status: field.status, - })), - }, - }, - include: { reportFields: true }, - }); - - const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } }); - if (reportMaterials.length > 0) { - await tx.signatureReportMaterial.createMany({ - data: reportMaterials.map((material) => ({ - signatureId: material.signatureId, - channelId: nextChannel.id, - fieldCode: material.fieldCode, - fieldValue: material.fieldValue, - fileObjectId: material.fileObjectId, - })), - skipDuplicates: true, - }); - } - - await tx.operationLog.create({ - data: { - userId: data.operatorId, - action: 'sms_channel.copy', - resource: 'sms_channel', - resourceId: nextChannel.id, - detail: { - sourceChannelId: source.id, - sourceCode: source.code, - sourceStatus: source.status, - copiedStatus: 'disabled', - copiedReportFields: source.reportFields.length, - copiedReportMaterials: reportMaterials.length, - } as Prisma.InputJsonValue, - }, - }); - - return nextChannel; - }); - - return copied; + return this.copy.copyChannel(channelId, data); } async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) { - return this.changeChannelStatus(channelId, { ...data, status: 'deleted' }); + return this.deletion.deleteChannel(channelId, data); } async testChannel(channelId: string, data: TestChannelDto = {}) { - const phoneNumbers = normalizeTestPhones(data); - const content = normalizeTestContent(data.content); - const channel = await this.prisma.smsChannel.findUnique({ - where: { id: channelId }, - include: { connectionStates: true }, - }); - if (!channel) { - throw new NotFoundException('Channel not found'); - } - if (channel.status !== 'active') { - throw new BadRequestException('通道未启用,不能发送测试短信'); - } - const connectedState = channel.connectionStates.find((state) => - normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0, - ); - if (!connectedState) { - throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送'); - } - - const createdAt = new Date(); - const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`; - const results = []; - for (const [index, phoneNumber] of phoneNumbers.entries()) { - const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; - const submitId = `SUB-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; - const session = await this.prisma.cmppSubmitSession.upsert({ - where: { sessionNo: `OPEN-${channel.id}` }, - update: { submitTotal: { increment: 1 } }, - create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 }, - }); - const messageRecord = await this.prisma.smsMessageRecord.create({ - data: { - messageId, - phoneNumber, - content, - billingUnits: calculateBillingUnits(content), - unitPrice: 0, - amountCents: 0, - queuePriority: 'normal', - channelId: channel.id, - submitId, - status: 'submit_queued', - submitStatus: 'queued', - }, - }); - await this.prisma.smsSubmitRecord.create({ - data: { - messageRecordId: messageRecord.id, - channelId: channel.id, - sessionId: session.id, - submitId, - submitStatus: 'queued', - costUnitPrice: channel.unitPrice, - costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits, - }, - }); - const command = buildChannelTestSubmitCommand({ - channel, - content, - phoneNumber, - messageId, - submitId, - testNo, - attempt: index, - accessNo: data.accessNo, - }); - await this.getGatewaySubmitQueue().add('submit-command', command); - const streamMessageId = await this.publishGatewaySubmitCommand(command); - results.push({ - phoneNumber, - messageRecordId: messageRecord.id, - submitId, - streamMessageId, - }); - } - - await this.prisma.operationLog.create({ - data: { - userId: data.operatorId, - action: 'sms_channel.test_submit', - resource: 'sms_channel', - resourceId: channel.id, - detail: { - testNo, - phoneTotal: phoneNumbers.length, - messageRecordIds: results.map((item) => item.messageRecordId), - connectionId: connectedState.connectionId, - } as Prisma.InputJsonValue, - }, - }); - - return { - channelId, - status: 'submit_queued', - testNo, - submitted: results.length, - messages: results, - queuedAt: createdAt, - }; + return this.testing.testChannel(channelId, data); } listChannelMetrics(channelId: string) { - return this.prisma.channelHealthMetric.findMany({ - where: { channelId }, - orderBy: { windowStart: 'desc' }, - take: 100, - }); + return this.connection.listChannelMetrics(channelId); } listChannelConnections(channelId: string) { - return this.prisma.cmppConnectionState.findMany({ - where: { channelId }, - orderBy: { updatedAt: 'desc' }, - }); + return this.connection.listChannelConnections(channelId); } async listChannelConnectionLogs(channelId: string) { - const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } }); - if (!channel) { - throw new NotFoundException('Channel not found'); - } - const [connectionStates, logs] = await Promise.all([ - this.prisma.cmppConnectionState.findMany({ - where: { channelId }, - orderBy: { updatedAt: 'desc' }, - take: 50, - }), - this.prisma.operationLog.findMany({ - where: { - OR: [ - { resource: 'sms_channel', resourceId: channelId }, - { resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } }, - ], - }, - orderBy: { createdAt: 'desc' }, - take: 100, - }), - ]); - return { - channelId, - connectionStates, - logs: logs.map((log) => ({ - id: log.id, - time: log.createdAt, - event: normalizeLinkEvent(log.action), - action: log.action, - resourceId: log.resourceId, - detail: log.detail, - })), - }; + return this.connection.listChannelConnectionLogs(channelId); } listTenantConnections(tenantId: string) { - return this.prisma.cmppConnectionState.findMany({ - where: { tenantId }, - include: { channel: true }, - orderBy: { updatedAt: 'desc' }, - }); + return this.connection.listTenantConnections(tenantId); } async upsertConnectionState(data: UpsertConnectionStateDto) { - const rawStatus = data.status; - const status = normalizeGatewayConnectionStatus(rawStatus); - if (data.applicationId) { - const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); - if (!application) { - throw new BadRequestException('applicationId does not reference an existing application'); - } - if (data.tenantId && data.tenantId !== application.tenantId) { - throw new BadRequestException('applicationId does not belong to tenantId'); - } - data.tenantId = application.tenantId; - } - const payload = { - tenantId: data.tenantId, - applicationId: data.applicationId, - status, - desiredConnections: data.desiredConnections ?? 1, - currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0), - lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined, - lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined, - lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined, - reconnectCount: data.reconnectCount ?? 0, - lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined, - nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined, - lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory, - lastError: status === 'connected' ? null : data.lastError, - }; - const existing = await this.prisma.cmppConnectionState.findFirst({ - where: { - applicationId: data.applicationId ?? null, - channelId: data.channelId, - connectionId: data.connectionId, - }, - }); - let state; - if (existing) { - state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload }); - } else { - try { - state = await this.prisma.cmppConnectionState.create({ - data: { - channelId: data.channelId, - connectionId: data.connectionId, - ...payload, - }, - }); - } catch (error) { - if ((error as { code?: string }).code !== 'P2002') { - throw error; - } - const concurrent = await this.prisma.cmppConnectionState.findFirst({ - where: { - applicationId: data.applicationId ?? null, - channelId: data.channelId, - connectionId: data.connectionId, - }, - }); - if (!concurrent) { - throw error; - } - state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload }); - } - } - const action = normalizeConnectionAction( - ['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status, - ); - const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date(); - const shouldWriteAudit = action !== 'heartbeat' - || !existing?.lastHeartbeatAt - || heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS; - if (shouldWriteAudit) { - await this.prisma.operationLog.create({ - data: { - tenantId: data.tenantId, - action: `cmpp_connection.${action}`, - resource: 'cmpp_connection', - resourceId: `${data.channelId}:${data.connectionId}`, - detail: { - status, - applicationId: state.applicationId, - desiredConnections: state.desiredConnections, - currentConnections: state.currentConnections, - lastError: state.lastError, - } as Prisma.InputJsonValue, - }, - }); - } - return state; + return this.connection.upsertConnectionState(data); } async markTimedOutConnectingChannels(now = new Date()) { - const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS); - const cutoff = new Date(now.getTime() - timeoutMs); - const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`; - const states = await this.prisma.cmppConnectionState.findMany({ - where: { - status: 'connecting', - updatedAt: { lte: cutoff }, - }, - select: { - id: true, - tenantId: true, - applicationId: true, - channelId: true, - connectionId: true, - desiredConnections: true, - currentConnections: true, - updatedAt: true, - }, - take: 100, - }); - let failed = 0; - for (const state of states) { - const result = await this.prisma.cmppConnectionState.updateMany({ - where: { - id: state.id, - status: 'connecting', - updatedAt: { lte: cutoff }, - }, - data: { - status: 'failed', - currentConnections: 0, - lastDisconnectedAt: now, - nextReconnectAt: now, - lastErrorCategory: 'timeout', - lastError, - }, - }); - if (result.count === 0) { - continue; - } - failed += result.count; - await this.prisma.operationLog.create({ - data: { - tenantId: state.tenantId, - action: 'cmpp_connection.failed', - resource: 'cmpp_connection', - resourceId: `${state.channelId}:${state.connectionId}`, - detail: { - reason: 'connect_timeout', - applicationId: state.applicationId, - status: 'failed', - previousStatus: 'connecting', - desiredConnections: state.desiredConnections, - currentConnectionsBefore: state.currentConnections, - currentConnections: 0, - timeoutMs, - lastError, - } as Prisma.InputJsonValue, - }, - }); - } - return { checked: states.length, failed }; + return this.connection.markTimedOutConnectingChannels(now); } listGroups() { - return this.prisma.smsChannelGroup.findMany({ - include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, - orderBy: { createdAt: 'desc' }, - }); + return this.groups.listGroups(); } createGroup(data: CreateChannelGroupDto) { - const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720); - const carrier = normalizeBusinessCarrier(data.carrier); - return this.prisma.smsChannelGroup.create({ - data: { - code: data.code, - name: data.name, - carrier, - description: data.description, - status: data.status ?? 'active', - retryEnabled: data.retryEnabled ?? true, - retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), - retryTimeLimitMinutes, - }, - }); + return this.groups.createGroup(data); } async addGroupItem(data: CreateChannelGroupItemDto) { - const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); - if (!group) { - throw new NotFoundException('Channel group not found'); - } - const groupCarrier = normalizeBusinessCarrier(group.carrier); - const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier; - if (itemCarrier !== groupCarrier) { - throw new BadRequestException('Channel group items must use the same carrier as the channel group'); - } - const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); - if (!channel) { - throw new NotFoundException('Channel not found'); - } - if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { - throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); - } - if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) { - throw new BadRequestException('Province route must use a channel with the same sendRegion'); - } - const existing = await this.prisma.smsChannelGroupItem.findFirst({ - where: { groupId: data.groupId, channelId: data.channelId }, - }); - if (existing) { - throw new BadRequestException('通道组内不能重复配置同一通道'); - } - if (data.province) { - const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({ - where: { groupId: data.groupId, province: data.province }, - }); - if (existingProvince) { - throw new BadRequestException('同一通道组内同一省份只能配置一个通道'); - } - } else { - const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({ - where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 }, - }); - if (existingPriority) { - throw new BadRequestException('同一通道组内全国通道优先级不能重复'); - } - } - return this.prisma.smsChannelGroupItem.create({ - data: { - groupId: data.groupId, - channelId: data.channelId, - carrier: itemCarrier, - province: data.province, - priority: data.priority ?? 100, - weight: data.weight ?? 1, - isBackup: data.isBackup ?? false, - }, - }); + return this.groups.addGroupItem(data); } async updateGroup(groupId: string, data: UpdateChannelGroupDto) { - const current = await this.prisma.smsChannelGroup.findUnique({ - where: { id: groupId }, - include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, - }); - if (!current) { - throw new NotFoundException('Channel group not found'); - } - const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes( - data.retryTimeLimitMinutes, - data.retryTimeLimitHours, - current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60, - ); - const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier); - const items = data.items ?? []; - const channelIds = [...new Set(items.map((item) => item.channelId))]; - const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } }); - const channelById = new Map(channels.map((channel) => [channel.id, channel])); - validateGroupItems(carrier, items, channelById); - - return this.prisma.$transaction(async (tx) => { - await tx.smsChannelGroupItem.deleteMany({ where: { groupId } }); - await tx.smsChannelGroup.update({ - where: { id: groupId }, - data: { - code: data.code ?? current.code, - name: data.name ?? current.name, - carrier, - description: data.description, - status: data.status ?? current.status, - retryEnabled: data.retryEnabled ?? current.retryEnabled, - retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), - retryTimeLimitMinutes, - }, - }); - if (items.length > 0) { - await tx.smsChannelGroupItem.createMany({ - data: items.map((item) => ({ - groupId, - channelId: item.channelId, - carrier, - province: item.province, - priority: item.priority ?? 100, - weight: item.weight ?? 1, - isBackup: item.isBackup ?? false, - })), - }); - } - const updated = await tx.smsChannelGroup.findUnique({ - where: { id: groupId }, - include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, - }); - await tx.operationLog.create({ - data: { - action: 'sms_channel_group.update', - resource: 'sms_channel_group', - resourceId: groupId, - detail: { - before: channelGroupAuditSnapshot(current), - after: updated ? channelGroupAuditSnapshot(updated) : null, - } as Prisma.InputJsonValue, - }, - }); - return updated; - }); + return this.groups.updateGroup(groupId, data); } async deleteGroup(groupId: string) { - const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } }); - if (!group) { - throw new NotFoundException('Channel group not found'); - } - const boundRoute = await this.prisma.channelRouteRule.findFirst({ - where: { - groupId, - status: 'active', - }, - select: { id: true }, - }); - if (boundRoute) { - throw new BadRequestException('Channel group is used by application route rules and cannot be deleted'); - } - await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } }); - return this.prisma.smsChannelGroup.delete({ where: { id: groupId } }); + return this.groups.deleteGroup(groupId); } listRouteRules() { - return this.prisma.channelRouteRule.findMany({ - include: { group: true, channel: true }, - orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], - }); + return this.groups.listRouteRules(); } async createRouteRule(data: CreateRouteRuleDto) { - if (!data.applicationId) { - throw new BadRequestException('applicationId is required for channel group routing'); - } - if (!data.carrier) { - throw new BadRequestException('carrier is required for application channel group routing'); - } - const carrier = normalizeBusinessCarrier(data.carrier); - if (data.channelId) { - throw new BadRequestException('Route rules can only bind channel groups, not single channels'); - } - if (data.province) { - throw new BadRequestException('Province routing must be configured inside the channel group'); - } - const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); - if (!group) { - throw new NotFoundException('Channel group not found'); - } - if (normalizeBusinessCarrier(group.carrier) !== carrier) { - throw new BadRequestException('Route rule carrier must match the channel group carrier'); - } - return this.prisma.channelRouteRule.create({ - data: { - tenantId: data.tenantId, - applicationId: data.applicationId, - groupId: data.groupId, - channelId: undefined, - carrier, - province: undefined, - priority: data.priority ?? 100, - status: data.status ?? 'active', - }, - }); + return this.groups.createRouteRule(data); } listReportFields(channelId?: string) { - return this.prisma.channelReportField.findMany({ - where: channelId ? { channelId } : undefined, - include: { drainageField: true }, - orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], - }); + return this.reporting.listReportFields(channelId); } async createReportField(data: CreateReportFieldDto) { - if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required'); - const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } }); - if (!field || field.status !== 'active') { - throw new BadRequestException('报备字段库字段不存在或已停用'); - } - const reportType = normalizeReportType(data.reportType); - return this.prisma.channelReportField.create({ - data: { - channelId: data.channelId, - drainageFieldId: field.id, - reportType, - code: field.code, - name: field.name, - exportName: data.exportName?.trim() || field.name, - fieldType: field.fieldType, - required: data.required ?? field.required, - description: data.description ?? field.description, - sortOrder: data.sortOrder ?? 100, - columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80), - imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600), - imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600), - defaultValue: data.defaultValue, - transform: data.transform, - status: data.status ?? 'active', - }, - }); + return this.reporting.createReportField(data); } async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) { - const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); - if (!channel) throw new NotFoundException('Channel not found'); - const ids = data.fields.map((field) => field.drainageFieldId); - if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段'); - const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } }); - if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用'); - const fieldById = new Map(libraryFields.map((field) => [field.id, field])); - return this.prisma.$transaction(async (tx) => { - const oppositeType = reportType === 'signature' ? 'drainage' : 'signature'; - const [legacyBoth, oppositeFields] = await Promise.all([ - tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }), - tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }), - ]); - const oppositeCodes = new Set(oppositeFields.map((field) => field.code)); - await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } }); - for (const legacy of legacyBoth) { - if (oppositeCodes.has(legacy.code)) continue; - const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy; - await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } }); - } - for (const [index, configured] of data.fields.entries()) { - const field = fieldById.get(configured.drainageFieldId)!; - await tx.channelReportField.create({ - data: { - channelId, - drainageFieldId: field.id, - reportType, - code: field.code, - name: field.name, - exportName: configured.exportName?.trim() || field.name, - fieldType: field.fieldType, - required: configured.required ?? field.required, - description: configured.description ?? field.description, - sortOrder: configured.sortOrder ?? (index + 1) * 10, - columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80), - imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600), - imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600), - defaultValue: configured.defaultValue, - transform: configured.transform, - status: configured.status ?? 'active', - }, - }); - } - return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); - }); + return this.reporting.replaceReportFields(channelId, reportType, data); } listReportMaterials(signatureId?: string, channelId?: string) { - return this.prisma.signatureReportMaterial.findMany({ - where: { - signatureId, - channelId, - }, - orderBy: { createdAt: 'desc' }, - }); + return this.reporting.listReportMaterials(signatureId, channelId); } upsertReportMaterial(data: CreateReportMaterialDto) { - return this.prisma.signatureReportMaterial.upsert({ - where: { - signatureId_channelId_fieldCode: { - signatureId: data.signatureId, - channelId: data.channelId, - fieldCode: data.fieldCode, - }, - }, - update: { - fieldValue: data.fieldValue, - fileObjectId: data.fileObjectId, - }, - create: { - signatureId: data.signatureId, - channelId: data.channelId, - fieldCode: data.fieldCode, - fieldValue: data.fieldValue, - fileObjectId: data.fileObjectId, - }, - }); + return this.reporting.upsertReportMaterial(data); } async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) { - const tasks = await this.prisma.channelSignatureReportTask.findMany({ - where: { - tenantId, - status, - channelId, - reportType, - signature: { auditStatus: { not: 'deleted' } }, - }, - include: { - signature: { include: { tenant: true, application: true } }, - channel: true, - drainageInfo: true, - exportItems: { - include: { exportFile: true, batchItem: { include: { batch: true } } }, - orderBy: { id: 'desc' }, - take: 1, - }, - records: { orderBy: { createdAt: 'desc' }, take: 20 }, - }, - orderBy: { createdAt: 'desc' }, - }); - if (tasks.length === 0) { - return tasks; - } - - const channelIds = [...new Set(tasks.map((task) => task.channelId))]; - const signatureIds = [...new Set(tasks.map((task) => task.signatureId))]; - const day = currentShanghaiDayRange(); - const rows = await this.prisma.$queryRaw(Prisma.sql` - WITH base AS ( - SELECT - submit."channelId" AS channel_id, - message."signatureId" AS signature_id, - message."drainageInfoId" AS drainage_info_id, - submit."submitStatus" AS submit_status, - COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at, - CASE - WHEN segment_summary.segment_count > 0 - AND segment_summary.delivered_count = segment_summary.segment_count - THEN segment_summary.completed_at - WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at - END AS successful_at, - CASE - WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed' - WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure' - WHEN segment_summary.segment_count > 0 - AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success' - WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure' - WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success' - ELSE 'unknown' - END AS delivery_status - FROM "SmsSubmitRecord" submit - JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" - LEFT JOIN LATERAL ( - SELECT - COUNT(*)::integer AS segment_count, - COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, - COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, - MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at - FROM "SmsMessageSegmentAudit" segment - WHERE segment."submitRecordId" = submit.id - ) segment_summary ON TRUE - LEFT JOIN LATERAL ( - SELECT MIN(receipt."deliveredAt") AS delivered_at - FROM "SmsReceiptRecord" receipt - WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" - AND receipt."channelId" = submit."channelId" - AND receipt."receiptStatus" = 'delivered' - ) delivered_receipt ON TRUE - LEFT JOIN LATERAL ( - SELECT MIN(receipt."deliveredAt") AS failed_at - FROM "SmsReceiptRecord" receipt - WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" - AND receipt."channelId" = submit."channelId" - AND receipt."receiptStatus" = 'undelivered' - ) failed_receipt ON TRUE - WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') - AND submit."channelId" IN (${Prisma.join(channelIds)}) - AND message."signatureId" IN (${Prisma.join(signatureIds)}) - ) - SELECT - channel_id AS "channelId", - signature_id AS "signatureId", - drainage_info_id AS "drainageInfoId", - COUNT(*) FILTER ( - WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} - )::integer AS total, - COUNT(*) FILTER ( - WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} - AND submit_status = 'accepted' - )::integer AS "acceptedCount", - COUNT(*) FILTER ( - WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} - AND delivery_status = 'submit_failed' - )::integer AS "submitFailureCount", - COUNT(*) FILTER ( - WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} - AND delivery_status = 'success' - )::integer AS "successCount", - COUNT(*) FILTER ( - WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} - AND delivery_status = 'unknown' - )::integer AS "unknownCount", - COUNT(*) FILTER ( - WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} - AND delivery_status = 'failure' - )::integer AS "failureCount", - MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt" - FROM base - GROUP BY channel_id, signature_id, drainage_info_id - `); - - return tasks.map((task) => { - const taskRows = rows.filter((row) => ( - row.channelId === task.channelId - && row.signatureId === task.signatureId - && ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId) - )); - const deliveryStats = summarizeChannelReportDelivery(taskRows); - return { - ...task, - deliveryStats, - lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)), - }; - }); + return this.reporting.listReportTasks(tenantId, status, channelId, reportType); } async listReportTasksPage(query: { @@ -1313,183 +157,27 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { page?: number; pageSize?: number; }) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const keyword = query.keyword?.trim(); - const where: Prisma.ChannelSignatureReportTaskWhereInput = { - tenantId: query.tenantId, - status: query.status, - channelId: query.channelId, - reportType: query.reportType, - signature: { auditStatus: { not: 'deleted' } }, - createdAt: query.createdAtFrom || query.createdAtTo ? { - gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, - lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, - } : undefined, - OR: keyword ? [ - { id: { contains: keyword } }, - { channel: { name: { contains: keyword } } }, - { signature: { name: { contains: keyword } } }, - { signature: { tenant: { name: { contains: keyword } } } }, - { signature: { application: { name: { contains: keyword } } } }, - { drainageInfo: { siteName: { contains: keyword } } }, - { drainageInfo: { url: { contains: keyword } } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.prisma.channelSignatureReportTask.findMany({ - where, - include: { - signature: { include: { tenant: true, application: true } }, - channel: true, - drainageInfo: true, - exportItems: { - include: { exportFile: true, batchItem: { include: { batch: true } } }, - orderBy: { id: 'desc' }, - take: 1, - }, - records: { orderBy: { createdAt: 'desc' }, take: 20 }, - }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.channelSignatureReportTask.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.reporting.listReportTasksPage(query); } async createReportTask(data: CreateReportTaskDto) { - const reportType = data.reportType ?? 'signature'; - if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required'); - if (reportType === 'drainage') { - const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } }); - if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found'); - if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); - throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成'); - } - const task = await this.prisma.channelSignatureReportTask.create({ - data: { - tenantId: data.tenantId, - signatureId: data.signatureId, - channelId: data.channelId, - reportType, - drainageItemId: undefined, - createdById: data.createdById, - status: 'pending', - }, - }); - await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending'); - return task; + return this.reporting.createReportTask(data); } async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) { - if (!data.items.length) throw new BadRequestException('items is required'); - const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']); - for (const item of data.items) { - if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status'); - } - const sourceEntry = data.sourceEntry ?? 'report_task'; - if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) { - throw new BadRequestException('unsupported report task source entry'); - } - return this.prisma.$transaction(async (tx) => { - const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))]; - const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = []; - for (const item of data.items) { - const reportType = item.reportType ?? 'signature'; - if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required'); - const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } }); - const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } }); - if (!signature || !channel) throw new NotFoundException('Signature or channel not found'); - if (reportType === 'drainage') { - const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } }); - if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found'); - if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态'); - } - const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } }); - if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核'); - const task = existing - ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } }) - : await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } }); - await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } }); - if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status }); - } - const summaries = []; - for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId)); - return [...summaries, ...drainageResults]; - }); - } - - private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) { - const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) throw new NotFoundException('Signature not found'); - const routes = signature.applicationId ? await tx.channelRouteRule.findMany({ - where: { applicationId: signature.applicationId, status: 'active' }, - include: { group: { include: { items: { include: { channel: true } } } } }, - }) : []; - const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); - const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } }); - const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel); - const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map(tasks.map((task) => [task.channelId, task])); - const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); - const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); - return [carrier, summarizeReportStatuses(statuses)]; - })); - const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); - const reportStatus = summarizeReportStatuses(allStatuses).status; - await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } }); - return { signatureId, reportStatus, carrierReportSummary }; + return this.reporting.changeReportTaskStatuses(data); } async createReportExport(taskId: string, data: CreateReportExportDto) { - const task = await this.getReportTaskOrThrow(taskId); - const exported = await this.prisma.reportExportFile.create({ - data: { - taskId, - fileObjectId: data.fileObjectId, - fileName: data.fileName, - rowCount: data.rowCount ?? 0, - }, - }); - await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export'); - return exported; + return this.reporting.createReportExport(taskId, data); } async importReportReceipt(taskId: string, data: CreateReceiptImportDto) { - const task = await this.getReportTaskOrThrow(taskId); - const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined; - const rowCount = data.rowCount ?? parsed?.rowCount ?? 0; - const successCount = data.successCount ?? parsed?.successCount ?? 0; - const failedCount = data.failedCount ?? parsed?.failedCount ?? 0; - const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount); - const imported = await this.prisma.reportReceiptImport.create({ - data: { - taskId, - fileObjectId: data.fileObjectId, - fileName: data.fileName, - rowCount, - successCount, - failedCount, - status: 'imported', - result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined, - }, - }); - await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); - if ((task.reportType ?? 'signature') === 'signature') { - await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId); - } - return imported; + return this.reporting.importReportReceipt(taskId, data); } listReportRecords(taskId?: string, channelId?: string) { - return this.prisma.channelSignatureReportRecord.findMany({ - where: { taskId, channelId }, - include: { channel: true, task: { include: { signature: true, drainageInfo: true } } }, - orderBy: { createdAt: 'desc' }, - }); + return this.reporting.listReportRecords(taskId, channelId); } async listReportRecordsPage(query: { @@ -1502,1071 +190,15 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { page?: number; pageSize?: number; }) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const keyword = query.keyword?.trim(); - const where: Prisma.ChannelSignatureReportRecordWhereInput = { - taskId: query.taskId, - channelId: query.channelId, - task: query.reportType ? { reportType: query.reportType } : undefined, - createdAt: query.createdAtFrom || query.createdAtTo ? { - gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, - lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, - } : undefined, - OR: keyword ? [ - { taskId: { contains: keyword } }, - { action: { contains: keyword } }, - { reason: { contains: keyword } }, - { channel: { name: { contains: keyword } } }, - { task: { signature: { name: { contains: keyword } } } }, - { task: { drainageInfo: { siteName: { contains: keyword } } } }, - { task: { drainageInfo: { url: { contains: keyword } } } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.prisma.channelSignatureReportRecord.findMany({ - where, - include: { channel: true, task: { include: { signature: true, drainageInfo: true } } }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.channelSignatureReportRecord.count({ where }), - ]); - return { items, total, page, pageSize }; - } - - private async getReportTaskOrThrow(taskId: string) { - const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } }); - if (!task) { - throw new NotFoundException('Report task not found'); - } - if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') { - throw new BadRequestException('引流信息审核通过后才能处理通道报备任务'); - } - return task; - } - - private async updateReportTaskStatus( - taskId: string, - channelId: string, - statusBefore: string, - statusAfter: string, - action: string, - reason?: string, - ) { - await this.prisma.channelSignatureReportTask.update({ - where: { id: taskId }, - data: { status: statusAfter, reason }, - }); - await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason); - } - - private recordReportTask( - taskId: string, - channelId: string, - action: string, - statusBefore: string | undefined, - statusAfter: string, - reason?: string, - ) { - return this.prisma.channelSignatureReportRecord.create({ - data: { - taskId, - channelId, - action, - statusBefore, - statusAfter, - reason, - }, - }); - } - - private async requestChannelConnection( - channel: { - id: string; - code: string; - name: string; - gatewayHost: string; - gatewayPort: number; - account: string; - passwordCipher: string; - srcId: string; - cmppVersion: string; - rateLimitPerSecond: number; - config?: Prisma.JsonValue | null; - }, - reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect', - operatorId?: string, - ) { - const desiredConnections = getDesiredConnections(channel.config); - const connectionId = defaultChannelConnectionId(channel.id); - const existing = await this.prisma.cmppConnectionState.findFirst({ - where: { - applicationId: null, - channelId: channel.id, - connectionId, - }, - }); - const data = { - applicationId: null, - status: 'connecting', - desiredConnections, - currentConnections: 0, - lastError: null, - lastReconnectAttemptAt: new Date(), - nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)), - }; - let state; - if (existing) { - state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data }); - } else { - try { - state = await this.prisma.cmppConnectionState.create({ - data: { - channelId: channel.id, - connectionId, - ...data, - }, - }); - } catch (error) { - if ((error as { code?: string }).code !== 'P2002') { - throw error; - } - const concurrent = await this.prisma.cmppConnectionState.findFirst({ - where: { applicationId: null, channelId: channel.id, connectionId }, - }); - if (!concurrent) { - throw error; - } - state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data }); - } - } - await this.prisma.operationLog.create({ - data: { - userId: operatorId, - action: 'cmpp_connection.connect_requested', - resource: 'cmpp_connection', - resourceId: `${channel.id}:${connectionId}`, - detail: { - reason, - status: state.status, - desiredConnections: state.desiredConnections, - currentConnections: state.currentConnections, - } as Prisma.InputJsonValue, - }, - }); - const command = { - schemaVersion: 'v1', - messageType: 'ConnectChannel', - traceId: randomUUID(), - channelId: channel.id, - connectionId, - createdAt: new Date().toISOString(), - reason, - desiredConnections, - channel: { - code: channel.code, - name: channel.name, - gatewayHost: channel.gatewayHost, - gatewayPort: channel.gatewayPort, - account: channel.account, - passwordCipher: channel.passwordCipher, - srcId: channel.srcId, - cmppVersion: channel.cmppVersion, - rateLimitPerSecond: channel.rateLimitPerSecond, - windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), - heartbeatIntervalSeconds: getPositiveRuntimeInteger( - getConfigValue(channel.config, 'heartbeatIntervalSeconds'), - DEFAULT_HEARTBEAT_INTERVAL_SECONDS, - 'heartbeatIntervalSeconds', - ), - heartbeatMissThreshold: getPositiveRuntimeInteger( - getConfigValue(channel.config, 'heartbeatMissThreshold'), - DEFAULT_HEARTBEAT_MISS_THRESHOLD, - 'heartbeatMissThreshold', - ), - }, - }; - const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, { - jobId: `gateway-connect-${channel.id}-${command.traceId}`, - removeOnComplete: 1000, - removeOnFail: 1000, - }).catch((error) => { - this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - }); - try { - await this.notifyGatewayConnect(command); - } finally { - if (queuedJob) { - await queuedJob.remove().catch((error) => { - this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`); - }); - } - } - return state; - } - - private async reconnectActiveChannelsAfterGatewayRestart() { - const channels = await this.prisma.smsChannel.findMany({ where: { status: 'active' } }); - const results = await Promise.allSettled( - channels.map((channel) => this.requestChannelConnection(channel, 'gateway_restarted')), - ); - results.forEach((result, index) => { - if (result.status === 'rejected') { - const channel = channels[index]; - this.logger.error( - `Failed to restore active CMPP channel ${channel?.code ?? channel?.id ?? index}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`, - ); - } - }); + return this.reporting.listReportRecordsPage(query); } async reconcileGatewayConnections(now = new Date()) { - const channels = await this.prisma.smsChannel.findMany({ - where: { status: { in: ['active', 'disabled', 'deleted'] } }, - include: { - connectionStates: { - where: { applicationId: null }, - }, - }, - take: 200, - }); - let reconnectRequested = 0; - let disconnectRequested = 0; - for (const channel of channels) { - const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id)); - if (channel.status !== 'active') { - if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) { - await this.withGatewayReconcileLock(channel.id, async () => { - await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile'); - disconnectRequested++; - }); - } - continue; - } - const desiredConnections = getDesiredConnections(channel.config); - const heartbeatIntervalSeconds = getPositiveRuntimeInteger( - getConfigValue(channel.config, 'heartbeatIntervalSeconds'), - DEFAULT_HEARTBEAT_INTERVAL_SECONDS, - 'heartbeatIntervalSeconds', - ); - const heartbeatMissThreshold = getPositiveRuntimeInteger( - getConfigValue(channel.config, 'heartbeatMissThreshold'), - DEFAULT_HEARTBEAT_MISS_THRESHOLD, - 'heartbeatMissThreshold', - ); - const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000); - const connectedAndFresh = state?.status === 'connected' - && state.currentConnections >= desiredConnections - && Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff); - const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now; - if (!connectedAndFresh && retryDue) { - await this.withGatewayReconcileLock(channel.id, async () => { - await this.requestChannelConnection(channel, 'automatic_reconnect'); - reconnectRequested++; - }); - } - } - return { scanned: channels.length, reconnectRequested, disconnectRequested }; + return this.connection.reconcileGatewayConnections(now); } - private async withGatewayReconcileLock(channelId: string, action: () => Promise) { - const redis = this.getRedis(); - const key = `cmpp:gateway:reconcile:${channelId}`; - const token = randomUUID(); - const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX'); - if (acquired !== 'OK') { - return; - } - try { - await action(); - } finally { - await redis.eval( - 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end', - 1, - key, - token, - ); - } - } - - private async requestChannelDisconnection( - channel: { id: string }, - reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile', - operatorId?: string, - ) { - const connectionId = defaultChannelConnectionId(channel.id); - const now = new Date(); - await this.prisma.cmppConnectionState.updateMany({ - where: { - applicationId: null, - channelId: channel.id, - connectionId, - }, - data: { - status: 'disconnected', - currentConnections: 0, - lastDisconnectedAt: now, - nextReconnectAt: null, - lastErrorCategory: null, - lastError: null, - }, - }); - await this.prisma.operationLog.create({ - data: { - userId: operatorId, - action: 'cmpp_connection.disconnect_requested', - resource: 'cmpp_connection', - resourceId: `${channel.id}:${connectionId}`, - detail: { reason } as Prisma.InputJsonValue, - }, - }); - const command = { - schemaVersion: 'v1', - messageType: 'DisconnectChannel', - traceId: randomUUID(), - channelId: channel.id, - connectionId, - createdAt: now.toISOString(), - reason, - }; - const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, { - jobId: `gateway-disconnect-${channel.id}-${command.traceId}`, - removeOnComplete: 1000, - removeOnFail: 1000, - }).catch((error) => { - this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - }); - try { - await this.notifyGatewayDisconnect(command); - } finally { - if (queuedJob) { - await queuedJob.remove().catch((error) => { - this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`); - }); - } - } - } - - private getGatewayConnectionQueue() { - this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() }); - return this.gatewayConnectionQueue; - } - - private getGatewaySubmitQueue() { - this.gatewaySubmitQueue ??= new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() }); - return this.gatewaySubmitQueue; - } - - private getRedis() { - if (!this.redis) { - this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { - maxRetriesPerRequest: null, - }); - } - return this.redis; - } - - private async publishGatewaySubmitCommand(command: unknown) { - return this.getRedis().xadd( - process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM, - '*', - 'messageType', - 'SubmitCommand', - 'data', - JSON.stringify(command), - ); - } - - private async notifyGatewayConnect(command: Record) { - const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, ''); - let response: { ok: boolean; status: number; text: () => Promise }; - try { - response = await fetch(`${baseUrl}/connections/connect`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(command), - signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)), - }); - } catch (error) { - throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`); - } - if (!response.ok) { - const responseText = await response.text(); - throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`); - } - } - - private async notifyGatewayDisconnect(command: Record) { - const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, ''); - let response: { ok: boolean; status: number; text: () => Promise }; - try { - response = await fetch(`${baseUrl}/connections/disconnect`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(command), - signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)), - }); - } catch (error) { - throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`); - } - if (!response.ok) { - const responseText = await response.text(); - throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`); - } + /** Preserves the existing private restart-replay test seam. */ + private reconnectActiveChannelsAfterGatewayRestart() { + return this.connection.reconnectActiveChannelsAfterGatewayRestart(); } } - -function normalizeTestPhones(data: TestChannelDto) { - const rawPhones = Array.isArray(data.phones) - ? data.phones - : String(data.phoneNumber ?? data.phones ?? '').split(/[,\n,\s]+/u); - const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean); - const uniquePhones = Array.from(new Set(phones)); - if (uniquePhones.length === 0) { - throw new BadRequestException('请填写测试手机号'); - } - if (uniquePhones.length > 10) { - throw new BadRequestException('测试手机号最多允许 10 个'); - } - for (const phone of uniquePhones) { - if (!/^1[3-9]\d{9}$/.test(phone)) { - throw new BadRequestException(`手机号格式不正确:${phone}`); - } - } - return uniquePhones; -} - -function normalizeTestContent(content?: string) { - const normalized = (content ?? '').trim(); - if (!normalized) { - throw new BadRequestException('请填写测试短信内容'); - } - if (normalized.length > 1000) { - throw new BadRequestException('测试短信内容不能超过 1000 字符'); - } - return normalized; -} - -function calculateBillingUnits(content: string) { - return Math.max(1, Math.ceil([...content].length / 67)); -} - -function buildChannelTestSubmitCommand({ - channel, - content, - phoneNumber, - messageId, - submitId, - testNo, - attempt, - accessNo, -}: { - channel: { - id: string; - code: string; - gatewayHost: string; - gatewayPort: number; - account: string; - passwordCipher: string; - srcId: string; - cmppVersion: string; - rateLimitPerSecond: number; - config?: Prisma.JsonValue | null; - }; - content: string; - phoneNumber: string; - messageId: string; - submitId: string; - testNo: string; - attempt: number; - accessNo?: string; -}) { - const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId; - return { - schemaVersion: 'v1', - messageType: 'SubmitCommand', - traceId: randomUUID(), - messageId, - channelId: channel.id, - createdAt: new Date().toISOString(), - tenantId: 'platform-channel-test', - applicationId: 'admin-channel-test', - taskId: testNo, - submitId, - queuePriority: 'normal', - phoneNumber, - content, - signature: 'CHANNEL_TEST', - templateId: 'admin-channel-test', - billingUnits: calculateBillingUnits(content), - route: { - channelCode: channel.code, - cmppAccountCode: channel.account, - priority: attempt, - rateLimitPerSecond: channel.rateLimitPerSecond, - }, - cmpp: { - serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'), - srcId, - extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')), - registeredDelivery: 1, - msgFmt: 8, - }, - upstream: { - gatewayHost: channel.gatewayHost, - gatewayPort: channel.gatewayPort, - account: channel.account, - passwordCipher: channel.passwordCipher, - cmppVersion: channel.cmppVersion, - desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'), - windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), - heartbeatIntervalSeconds: getPositiveRuntimeInteger( - getConfigValue(channel.config, 'heartbeatIntervalSeconds'), - DEFAULT_HEARTBEAT_INTERVAL_SECONDS, - 'heartbeatIntervalSeconds', - ), - heartbeatMissThreshold: getPositiveRuntimeInteger( - getConfigValue(channel.config, 'heartbeatMissThreshold'), - DEFAULT_HEARTBEAT_MISS_THRESHOLD, - 'heartbeatMissThreshold', - ), - }, - retry: { attempt: 0, maxAttempts: 1 }, - }; -} - -function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) { - if (config && typeof config === 'object' && !Array.isArray(config) && key in config) { - return config[key as keyof typeof config]; - } - return undefined; -} - -function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) { - const value = getConfigValue(config, key); - if (value === undefined || value === null || value === '') { - return fallback; - } - return String(value); -} - -function normalizeConnectionAction(status: string) { - const normalized = status.toLowerCase(); - if (normalized === 'connected') { - return 'connected'; - } - if (['heartbeat', 'active_test'].includes(normalized)) { - return 'heartbeat'; - } - if (['reconnecting', 'reconnect'].includes(normalized)) { - return 'reconnecting'; - } - if (['offline', 'closed', 'disconnected'].includes(normalized)) { - return 'disconnected'; - } - if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) { - return 'failed'; - } - return 'updated'; -} - -function normalizeCmppVersion(version?: string) { - const normalized = (version ?? DEFAULT_CMPP_VERSION).trim(); - if (normalized === '2.0' || normalized === '3.0') { - return normalized; - } - throw new BadRequestException('cmppVersion must be 2.0 or 3.0'); -} - -function normalizeGatewayConnectionStatus(status: string) { - const normalized = status.toLowerCase(); - if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) { - return 'connected'; - } - if (['connecting', 'connect_requested'].includes(normalized)) { - return 'connecting'; - } - if (['reconnecting', 'reconnect'].includes(normalized)) { - return 'reconnecting'; - } - if (['offline', 'closed', 'disconnected'].includes(normalized)) { - return 'disconnected'; - } - if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) { - return 'failed'; - } - return normalized; -} - -function defaultChannelConnectionId(channelId: string) { - return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`; -} - -function getDesiredConnections(config?: Prisma.JsonValue | null) { - if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) { - const value = Number(config.desiredConnections); - if (Number.isInteger(value) && value > 0) { - return value; - } - } - return 1; -} - -type ChannelConnectionSettings = { - gatewayHost: string; - gatewayPort: number; - account: string; - passwordCipher: string; - cmppVersion: string; - config?: Prisma.JsonValue | Record | null; -}; - -function getRuntimeConfigInteger( - config: Prisma.JsonValue | Record | null | undefined, - key: string, - fallback: number, -) { - if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback; - const value = Number((config as Record)[key]); - return Number.isInteger(value) && value > 0 ? value : fallback; -} - -function channelConnectionSettingsChanged( - before: ChannelConnectionSettings, - after: ChannelConnectionSettings, -) { - return before.gatewayHost !== after.gatewayHost - || before.gatewayPort !== after.gatewayPort - || before.account !== after.account - || before.passwordCipher !== after.passwordCipher - || before.cmppVersion !== after.cmppVersion - || getRuntimeConfigInteger(before.config, 'desiredConnections', 1) - !== getRuntimeConfigInteger(after.config, 'desiredConnections', 1) - || getRuntimeConfigInteger(before.config, 'windowSize', 16) - !== getRuntimeConfigInteger(after.config, 'windowSize', 16) - || getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) - !== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) - || getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) - !== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD); -} - -function channelGroupAuditSnapshot(group: { - code: string; - name: string; - carrier: string; - description?: string | null; - status: string; - retryEnabled: boolean; - retryTimeLimitMinutes: number; - items?: Array<{ - channelId: string; - carrier?: string | null; - province?: string | null; - priority: number; - weight: number; - isBackup: boolean; - channel?: { code?: string; name?: string } | null; - }>; -}) { - return { - code: group.code, - name: group.name, - carrier: group.carrier, - description: group.description ?? null, - status: group.status, - retryEnabled: group.retryEnabled, - retryTimeLimitMinutes: group.retryTimeLimitMinutes, - items: (group.items ?? []).map((item) => ({ - channelId: item.channelId, - channelCode: item.channel?.code ?? null, - channelName: item.channel?.name ?? null, - carrier: item.carrier ?? null, - province: item.province ?? null, - priority: item.priority, - weight: item.weight, - isBackup: item.isBackup, - })), - }; -} - -function normalizeChannelRuntimeConfig( - existingConfig?: Prisma.JsonValue | Record | null, - incomingConfig?: Record | null, - desiredConnections?: number, - windowSize?: number, - heartbeatIntervalSeconds?: number, - heartbeatMissThreshold?: number, -) { - const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) - ? existingConfig as Record - : {}; - const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) - ? incomingConfig - : {}; - const base = { ...existing, ...incoming }; - base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections'); - base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize'); - base.heartbeatIntervalSeconds = getPositiveRuntimeInteger( - heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds, - DEFAULT_HEARTBEAT_INTERVAL_SECONDS, - 'heartbeatIntervalSeconds', - ); - base.heartbeatMissThreshold = getPositiveRuntimeInteger( - heartbeatMissThreshold ?? base.heartbeatMissThreshold, - DEFAULT_HEARTBEAT_MISS_THRESHOLD, - 'heartbeatMissThreshold', - ); - base.extensionDigits = normalizeExtensionDigits(base.extensionDigits); - base.serviceId = normalizeCmppServiceId(base.serviceId); - return base; -} - -function normalizeCmppServiceId(value: unknown) { - const normalized = String(value ?? 'SMS').trim() || 'SMS'; - if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) { - throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters'); - } - return normalized; -} - -function normalizeChannelRateLimit(value: unknown) { - const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond'); - if (normalized > 2000) { - throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000'); - } - return normalized; -} - -function normalizeExtensionDigits(value: unknown) { - if (value === undefined || value === null || value === '') { - return 0; - } - const normalized = Number(value); - if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) { - throw new BadRequestException('extensionDigits must be an integer between 0 and 20'); - } - return normalized; -} - -function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) { - if (value === undefined || value === null || value === '') { - return fallback; - } - const normalized = Number(value); - if (!Number.isInteger(normalized) || normalized <= 0) { - throw new BadRequestException(`${fieldName} must be a positive integer`); - } - return normalized; -} - -function bullmqConnection() { - const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); - return { - host: redisUrl.hostname, - port: Number(redisUrl.port || 6379), - username: redisUrl.username || undefined, - password: redisUrl.password || undefined, - maxRetriesPerRequest: null, - }; -} - -function getPositiveIntegerEnv(name: string, fallback: number) { - const value = Number(process.env[name]); - if (Number.isInteger(value) && value > 0) { - return value; - } - return fallback; -} - - -function parseReceiptContent(content: string, delimiter?: ',' | '\t') { - const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - if (lines.length === 0) { - throw new BadRequestException('Receipt file is empty'); - } - const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ','); - const firstCells = splitReceiptLine(lines[0], separator); - const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase())); - const header = hasHeader ? firstCells : []; - const rows = hasHeader ? lines.slice(1) : lines; - const statusIndex = findReceiptStatusIndex(header); - let successCount = 0; - let failedCount = 0; - const resultRows = rows.map((line, index) => { - const cells = splitReceiptLine(line, separator); - const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? ''; - const normalizedStatus = normalizeReceiptStatus(rawStatus); - if (normalizedStatus === 'success') { - successCount += 1; - } else { - failedCount += 1; - } - return { - rowNumber: (hasHeader ? index + 2 : index + 1), - phone: cells[0] ?? '', - status: normalizedStatus, - rawStatus, - raw: cells, - }; - }); - return { - rowCount: resultRows.length, - successCount, - failedCount, - result: { - delimiter: separator === '\t' ? 'tab' : 'comma', - hasHeader, - rows: resultRows, - }, - }; -} - -function splitReceiptLine(line: string, delimiter: ',' | '\t') { - if (delimiter === '\t') { - return line.split('\t').map((cell) => stripReceiptCell(cell)); - } - const cells: string[] = []; - let current = ''; - let quoted = false; - for (let index = 0; index < line.length; index += 1) { - const char = line[index]; - const next = line[index + 1]; - if (char === '"' && quoted && next === '"') { - current += '"'; - index += 1; - } else if (char === '"') { - quoted = !quoted; - } else if (char === ',' && !quoted) { - cells.push(stripReceiptCell(current)); - current = ''; - } else { - current += char; - } - } - cells.push(stripReceiptCell(current)); - return cells; -} - -function stripReceiptCell(value: string) { - return value.trim().replace(/^"|"$/g, '').trim(); -} - -function findReceiptStatusIndex(header: string[]) { - if (header.length === 0) { - return 1; - } - const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase())); - return index >= 0 ? index : Math.max(0, header.length - 1); -} - -function normalizeReceiptStatus(value: string) { - const normalized = value.trim().toLowerCase(); - if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) { - return 'success'; - } - if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) { - return 'failed'; - } - return 'failed'; -} - -function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) { - if (rowCount <= 0 || successCount <= 0) { - return 'failed'; - } - if (failedCount > 0) { - return 'partial'; - } - return 'completed'; -} - -type ChannelReportDeliveryRow = { - channelId: string; - signatureId: string; - drainageInfoId: string | null; - total: number; - acceptedCount: number; - submitFailureCount: number; - successCount: number; - unknownCount: number; - failureCount: number; - lastSuccessfulSentAt: Date | null; -}; - -function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) { - const total = sumReportDelivery(rows, 'total'); - const acceptedCount = sumReportDelivery(rows, 'acceptedCount'); - const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount'); - const successCount = sumReportDelivery(rows, 'successCount'); - const unknownCount = sumReportDelivery(rows, 'unknownCount'); - const failureCount = sumReportDelivery(rows, 'failureCount'); - return { - total, - acceptedCount, - submitFailureCount, - submitFailureRate: percentage(submitFailureCount, total), - successCount, - successRate: percentage(successCount, acceptedCount), - unknownCount, - unknownRate: percentage(unknownCount, acceptedCount), - failureCount, - failureRate: percentage(failureCount, acceptedCount), - }; -} - -function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick< - ChannelReportDeliveryRow, - 'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount' ->) { - return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0); -} - -function percentage(count: number, total: number) { - return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0; -} - -function latestDate(values: Array) { - const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime()); - return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null; -} - -function currentShanghaiDayRange(now = new Date()) { - const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000); - const localDate = shifted.toISOString().slice(0, 10); - const startAt = new Date(`${localDate}T00:00:00+08:00`); - return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) }; -} - -function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) { - const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60); - if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) { - throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320'); - } - return value; -} - -function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) { - if (value === undefined || !Number.isFinite(value)) return fallback; - return Math.min(maximum, Math.max(minimum, Math.round(value))); -} - -function normalizeBusinessCarrier(carrier?: string | null) { - const normalized = normalizeChannelCarrier(carrier); - if (!['mobile', 'unicom', 'telecom'].includes(normalized)) { - throw new BadRequestException('carrier must be mobile, unicom, or telecom'); - } - return normalized; -} - -function normalizeChannelCarrier(carrier?: string | null) { - const value = String(carrier ?? '').trim().toLowerCase(); - if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; - if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; - if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; - if (['all', 'tri', '三网', '全网'].includes(value)) return 'all'; - return value; -} - -function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) { - const normalized = normalizeChannelCarrier(channelCarrier); - return normalized === 'all' || normalized === groupCarrier; -} - -function normalizeRegion(region?: string | null) { - return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); -} - -function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) { - return normalizeRegion(channelRegion) === normalizeRegion(itemProvince); -} - -function validateGroupItems( - groupCarrier: string, - items: Array>, - channels: Map, -) { - const channelIds = new Set(); - const provinces = new Set(); - const nationalPriorities = new Set(); - for (const item of items) { - const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier; - if (itemCarrier !== groupCarrier) { - throw new BadRequestException('Channel group items must use the same carrier as the channel group'); - } - const channel = channels.get(item.channelId); - if (!channel) { - throw new NotFoundException('Channel not found'); - } - if (channelIds.has(item.channelId)) { - throw new BadRequestException('通道组内不能重复配置同一通道'); - } - channelIds.add(item.channelId); - if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { - throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); - } - if (item.province) { - const province = normalizeRegion(item.province); - if (provinces.has(province)) { - throw new BadRequestException('同一通道组内同一省份只能配置一个通道'); - } - provinces.add(province); - if (!isRegionCompatible(channel.sendRegion, item.province)) { - throw new BadRequestException('Province route must use a channel with the same sendRegion'); - } - } else { - const priority = item.priority ?? 100; - if (nationalPriorities.has(priority)) { - throw new BadRequestException('同一通道组内全国通道优先级不能重复'); - } - nationalPriorities.add(priority); - } - } -} - -function normalizeReportType(value?: string) { - if (value === 'signature' || value === 'drainage' || value === 'both') return value; - throw new BadRequestException('reportType must be signature, drainage or both'); -} - -function summarizeReportStatuses(statuses: string[]) { - if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 }; - const approved = statuses.filter((status) => status === 'approved').length; - let status = 'pending'; - if (approved === statuses.length) status = 'approved'; - else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed'; - else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting'; - else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material'; - return { status, approved, total: statuses.length }; -} - -function normalizeLinkEvent(action: string) { - if (action.includes('connect_requested')) { - return '连接请求'; - } - if (action.includes('connected')) { - return '连接成功'; - } - if (action.includes('heartbeat')) { - return '心跳'; - } - if (action.includes('reconnecting')) { - return '重连'; - } - if (action.includes('disconnected')) { - return '断开'; - } - if (action.includes('failed')) { - return '连接失败'; - } - if (action.includes('copy')) { - return '复制'; - } - if (action.includes('deleted')) { - return '删除'; - } - return '更新'; -} diff --git a/api/src/operations/operations.contracts.ts b/api/src/operations/operations.contracts.ts new file mode 100644 index 0000000..d0d2bc8 --- /dev/null +++ b/api/src/operations/operations.contracts.ts @@ -0,0 +1,87 @@ +// Stable controller/query contracts extracted in R2. + +export interface MessageQuery { + tenantId?: string; + applicationId?: string; + channelId?: string; + channelKeyword?: string; + taskId?: string; + messageId?: string; + phoneNumber?: string; + contentKeyword?: string; + carrier?: string; + status?: string; + queuedAtFrom?: string; + queuedAtTo?: string; + page?: number; + pageSize?: number; +} + +export interface TraceQuery extends MessageQuery { + messageId?: string; +} + +export interface OperationLogQuery { + tenantId?: string; + userId?: string; + keyword?: string; + level?: string; + module?: string; + range?: string; + page?: number; + pageSize?: number; +} + +export interface GatewaySubmitDeadLetterQuery { + tenantId?: string; + applicationId?: string; + channelId?: string; + status?: string; + keyword?: string; + page?: number; + pageSize?: number; +} + +export interface DownstreamDeliveryQuery { + tenantId?: string; + applicationId?: string; + deliveryType?: string; + status?: string; + keyword?: string; + page?: number; + pageSize?: number; + createdAtFrom?: string; + createdAtTo?: string; +} + +export interface DownstreamDeliveryDashboardQuery { + tenantId?: string; + applicationId?: string; + deliveryType?: string; + createdAtFrom?: string; + createdAtTo?: string; +} + +export interface DownstreamRecoveryStatusQuery { + tenantId?: string; + applicationId?: string; + state?: string; + failureCategory?: string; + keyword?: string; + updatedAtFrom?: string; + updatedAtTo?: string; + page?: number; + pageSize?: number; +} + +export interface MessageSegmentAuditQuery { + messageId?: string; + messageRecordId?: string; +} + +export interface SignatureQualityQuery { + date?: string; + keyword?: string; + page?: number; + pageSize?: number; +} diff --git a/api/src/operations/operations.helpers.ts b/api/src/operations/operations.helpers.ts new file mode 100644 index 0000000..01d6897 --- /dev/null +++ b/api/src/operations/operations.helpers.ts @@ -0,0 +1,519 @@ +import { Prisma } from '@prisma/client'; +import { BadRequestException } from '@nestjs/common'; +import { moneyToNumber } from '../common/money'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts'; + +// Pure query builders and response mappers shared by the R2 query domains. +export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { + const statusWhere = query.status === 'submit_failed' + ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } + : query.status === 'failed' + ? { status: 'failed', submitStatus: 'accepted' } + : query.status + ? { status: query.status } + : {}; + return { + tenantId: query.tenantId, + applicationId: query.applicationId, + channelId: query.channelId, + batchTaskId: query.taskId, + messageId: query.messageId, + phoneNumber: query.phoneNumber, + ...carrierWhere(query.carrier), + ...statusWhere, + ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), + ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), + ...(query.queuedAtFrom || query.queuedAtTo ? { + queuedAt: { + ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), + ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), + }, + } : {}), + }; +} + +export const recognizedCarrierValues = [ + 'mobile', 'cmcc', '移动', '中国移动', + 'unicom', 'cucc', '联通', '中国联通', + 'telecom', 'ctcc', '电信', '中国电信', +]; +export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput { + if (!carrier) return {}; + // Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized. + if (carrier === 'unknown') { + return { + AND: [ + { + OR: [ + { carrier: null }, + { carrier: { notIn: recognizedCarrierValues } }, + ], + }, + ], + }; + } + const valuesByCarrier: Record = { + mobile: ['mobile', 'cmcc', '移动', '中国移动'], + unicom: ['unicom', 'cucc', '联通', '中国联通'], + telecom: ['telecom', 'ctcc', '电信', '中国电信'], + }; + return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {}; +} +export function startOfShanghaiDay(value: string) { + return new Date(`${value}T00:00:00+08:00`); +} +export function endOfShanghaiDay(value: string) { + return new Date(`${value}T23:59:59.999+08:00`); +} +export function qualityBusinessDay(value?: string) { + const key = value || shanghaiDateKey(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) { + throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD'); + } + const startAt = startOfShanghaiDay(key); + if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) { + throw new BadRequestException('统计日期无效'); + } + return { + key, + startAt, + endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000), + }; +} +export function shanghaiDateKey(value = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(value); + const byType = new Map(parts.map((part) => [part.type, part.value])); + return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`; +} +export function normalizeGroupBy(groupBy?: string) { + if (groupBy === 'tenant' || groupBy === 'tenantId') { + return 'tenantId'; + } + if (groupBy === 'application' || groupBy === 'applicationId') { + return 'applicationId'; + } + return 'channelId'; +} +export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput { + return { + tenantId, + createdAt: { gte: since }, + OR: [ + { transactionType: 'refunded' }, + { transactionType: 'released', relatedType: 'sms_message_record' }, + ], + }; +} +export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined { + if (!range || range === 'all') { + return undefined; + } + const date = new Date(); + date.setHours(0, 0, 0, 0); + if (range === '7d') { + date.setDate(date.getDate() - 6); + } else if (range === '30d') { + date.setDate(date.getDate() - 29); + } + return { gte: date }; +} +export function downstreamAlertPendingMinutes() { + const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10); + return Number.isFinite(value) && value > 0 ? value : 10; +} +export function downstreamAlertRecentFailedHours() { + const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1); + return Number.isFinite(value) && value > 0 ? value : 1; +} +export function downstreamAlertWindows(now = new Date()) { + return { + now, + stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000), + recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000), + }; +} +export function downstreamAlertWhere( + scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput, + window: ReturnType, +): Prisma.CmppDownstreamDeliveryWhereInput { + return { + AND: [ + scopedWhere, + { + OR: [ + stalledPendingWhere(window.stalledPendingAt), + { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } }, + { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } }, + ], + }, + ], + }; +} +export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput { + return { + status: 'pending', + OR: [ + { lastRetriedAt: null, createdAt: { lte: cutoff } }, + { lastRetriedAt: { lte: cutoff } }, + ], + }; +} +export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput { + const createdAtFrom = parseDateBoundary(query.createdAtFrom, false); + const createdAtTo = parseDateBoundary(query.createdAtTo, true); + return { + tenantId: query.tenantId, + applicationId: query.applicationId, + deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined, + createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined, + }; +} +export function parseDateBoundary(value?: string, endOfDay = false) { + if (!value) return undefined; + const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} +export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) { + const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false); + const updatedAtTo = parseDateBoundary(query.updatedAtTo, true); + return { + tenantId: query.tenantId, + applicationId: query.applicationId, + state: query.state && query.state !== 'all' ? query.state : undefined, + failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined, + updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, + OR: query.keyword ? [ + { account: { contains: query.keyword } }, + { gatewayInstanceId: { contains: query.keyword } }, + { lastError: { contains: query.keyword } }, + { lastSkipReason: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] : undefined, + }; +} +export function escapeCsvCell(value: string) { + let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (/^[=+\-@]/.test(normalized)) { + normalized = `'${normalized}`; + } + if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) { + return `"${normalized.replace(/"/g, '""')}"`; + } + return normalized; +} +export function formatCsvDate(value?: Date | string | null) { + if (!value) { + return ''; + } + return value instanceof Date ? value.toISOString() : value; +} +export function formatExportTimestamp(date: Date) { + const parts = [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + String(date.getHours()).padStart(2, '0'), + String(date.getMinutes()).padStart(2, '0'), + String(date.getSeconds()).padStart(2, '0'), + ]; + return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`; +} +export function clientApplicationView(application?: Record | null) { + if (!application) return null; + return { id: application.id, name: application.name }; +} +export function clientReceiptView(receipt: Record) { + return { + id: receipt.id, + messageId: receipt.messageId, + receiptStatus: receipt.receiptStatus, + rawStatus: receipt.rawStatus, + errorCode: receipt.errorCode ?? null, + errorMessage: receipt.errorMessage ?? null, + deliveredAt: receipt.deliveredAt, + createdAt: receipt.createdAt, + }; +} +export function clientMessageView(message: Record) { + return { + id: message.id, + batchTaskId: message.batchTaskId ?? null, + applicationId: message.applicationId ?? null, + messageId: message.messageId, + phoneNumber: message.phoneNumber, + carrier: message.carrier ?? null, + province: message.province ?? null, + content: message.content, + billingUnits: message.billingUnits, + amountCents: moneyToNumber(message.amountCents), + status: message.status, + submitStatus: message.submitStatus ?? null, + receiptStatus: message.receiptStatus ?? null, + errorCode: message.errorCode ?? null, + errorMessage: message.errorMessage ?? null, + queuedAt: message.queuedAt, + submittedAt: message.submittedAt ?? null, + deliveredAt: message.deliveredAt ?? null, + application: clientApplicationView(message.application), + receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [], + }; +} +export function clientBatchTaskView(task: Record) { + return { + id: task.id, + taskNo: task.taskNo, + applicationId: task.applicationId ?? null, + templateId: task.templateId ?? null, + content: task.content, + category: task.category ?? null, + phoneTotal: task.phoneTotal, + status: task.status, + auditStatus: task.auditStatus ?? null, + reviewReason: task.reviewReason ?? null, + rejectReason: task.rejectReason ?? null, + progressTotal: task.progressTotal, + progressSent: task.progressSent ?? 0, + progressDelivered: task.progressDelivered ?? 0, + progressFailed: task.progressFailed ?? 0, + submittedTotal: task.submittedTotal ?? 0, + successTotal: task.successTotal ?? 0, + failedTotal: task.failedTotal ?? 0, + unknownTotal: task.unknownTotal ?? 0, + timeoutTotal: task.timeoutTotal ?? 0, + scheduledAt: task.scheduledAt ?? null, + canceledAt: task.canceledAt ?? null, + createdAt: task.createdAt, + application: clientApplicationView(task.application), + messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [], + }; +} +export function clientUplinkView(message: Record) { + return { + id: message.id, + applicationId: message.applicationId ?? null, + messageRecordId: message.messageRecordId ?? null, + messageId: message.messageId ?? null, + phoneNumber: message.phoneNumber, + destId: message.destId, + content: message.content, + matchStatus: message.matchStatus, + matchReason: message.matchReason ?? null, + receivedAt: message.receivedAt, + createdAt: message.createdAt, + application: clientApplicationView(message.application), + messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null, + }; +} +export function clientAccountView(account: Record) { + return { + id: account.id, + tenantId: account.tenantId, + balanceCents: moneyToNumber(account.balanceCents), + creditCents: moneyToNumber(account.creditCents), + status: account.status, + updatedAt: account.updatedAt, + tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null, + }; +} +export function clientRechargeView(order: Record) { + return { + id: order.id, + orderNo: order.orderNo, + amountCents: moneyToNumber(order.amountCents), + status: order.status, + payMethod: order.payMethod, + remark: order.remark ?? null, + createdAt: order.createdAt, + completedAt: order.completedAt ?? null, + }; +} +export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) { + return groups.reduce( + (summary, group) => { + const count = group._count._all; + summary.total += count; + summary.amountCents += moneyToNumber(group._sum.amountCents); + summary.billingUnits += group._sum.billingUnits ?? 0; + if (group.status === 'delivered') { + summary.delivered += count; + } else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) { + summary.failed += count; + } else if (group.status === 'unknown') { + summary.unknown += count; + } + return summary; + }, + { total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 }, + ); +} +export function groupDownstreamByType( + groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>, +) { + return groups.reduce>((accumulator, item) => { + const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 }; + current.total += item._count._all; + if (item.status === 'pending') { + current.pending += item._count._all; + } else if (item.status === 'awaiting_ack') { + current.awaitingAck += item._count._all; + } else if (item.status === 'delivered') { + current.delivered += item._count._all; + } else if (item.status === 'failed') { + current.failed += item._count._all; + } else if (item.status === 'unconfirmed') { + current.unconfirmed += item._count._all; + } else if (item.status === 'rejected') { + current.rejected += item._count._all; + } + accumulator[item.deliveryType] = current; + return accumulator; + }, {}); +} +export function groupDownstreamByApplication( + groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>, + applicationMap: Map, + applicationAlertMap: Map, +) { + const summaryMap = new Map(); + groups.forEach((item) => { + const current = summaryMap.get(item.applicationId) ?? { + applicationId: item.applicationId, + name: applicationMap.get(item.applicationId) ?? item.applicationId, + pending: 0, + awaitingAck: 0, + failed: 0, + unconfirmed: 0, + rejected: 0, + delivered: 0, + alertCount: 0, + }; + if (item.status === 'pending') { + current.pending += item._count._all; + } else if (item.status === 'awaiting_ack') { + current.awaitingAck += item._count._all; + } else if (item.status === 'failed') { + current.failed += item._count._all; + } else if (item.status === 'unconfirmed') { + current.unconfirmed += item._count._all; + } else if (item.status === 'rejected') { + current.rejected += item._count._all; + } else if (item.status === 'delivered') { + current.delivered += item._count._all; + } + current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0; + summaryMap.set(item.applicationId, current); + }); + return [...summaryMap.values()]; +} +export function positiveInteger(value: number | undefined, fallback: number) { + const normalized = Number(value); + return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback; +} +export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput { + const error: Prisma.OperationLogWhereInput = { + OR: [ + { action: { contains: 'failed' } }, + { action: { contains: 'reject' } }, + { detail: { path: ['result'], string_contains: 'fail' } }, + { detail: { path: ['status'], string_contains: 'fail' } }, + ], + }; + const warning: Prisma.OperationLogWhereInput = { + OR: [ + { action: { contains: 'warning' } }, + { action: { contains: 'risk' } }, + ], + }; + const success: Prisma.OperationLogWhereInput = { + OR: [ + { action: { contains: 'approve' } }, + { action: { contains: 'recharge' } }, + { action: { contains: 'connected' } }, + ], + }; + if (level === 'error') { + return error; + } + if (level === 'warning') { + return { AND: [{ NOT: error }, warning] }; + } + if (level === 'success') { + return { AND: [{ NOT: error }, { NOT: warning }, success] }; + } + if (level === 'info') { + return { NOT: { OR: [error, warning, success] } }; + } + return {}; +} +export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { + const detail = (log.detail ?? {}) as Record; + const result = String(detail.result ?? detail.status ?? ''); + const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') + ? 'error' + : log.action.includes('warning') || log.action.includes('risk') + ? 'warning' + : log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected') + ? 'success' + : 'info'; + return { + id: log.id, + time: log.createdAt, + level, + tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'), + module: log.resource, + operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system', + action: log.action, + resourceId: log.resourceId ?? '', + detail, + ip: log.ipAddress ?? '', + userAgent: log.userAgent ?? '', + }; +} +export function sanitizeGatewaySubmitException( + item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>, + messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string }, +) { + const { rawPayload, commandPayload, tenant, application, channel, ...record } = item; + return { + ...record, + tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null, + application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null, + channel: channel ? { + id: channel.id, + code: channel.code, + name: channel.name, + status: channel.status, + carrier: channel.carrier, + sendRegion: channel.sendRegion, + rateLimitPerSecond: channel.rateLimitPerSecond, + } : null, + rawPayloadAvailable: Boolean(rawPayload), + commandPayload: redactGatewayCommandValue(commandPayload), + messageState: messageState ?? null, + }; +} +export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null { + if (Array.isArray(value)) { + return value.map((item) => redactGatewayCommandValue(item)); + } + if (value && typeof value === 'object') { + const redacted: Record = {}; + for (const [key, child] of Object.entries(value)) { + const normalizedKey = key.toLowerCase(); + redacted[key] = [ + 'password', 'passwordcipher', 'secret', 'secrethash', 'authsource', + 'token', 'apikey', 'accesskey', 'secretkey', + ].includes(normalizedKey) + ? '[REDACTED]' + : redactGatewayCommandValue(child as Prisma.JsonValue); + } + return redacted; + } + return value; +} diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index a6ada54..b200c91 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -6,6 +6,9 @@ function createPrismaMock() { user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }), }, + tenant: { + findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '企业A' }), + }, smsBatchTask: { findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]), count: jest.fn().mockResolvedValue(3), @@ -50,6 +53,7 @@ function createPrismaMock() { }, enterpriseCertification: { count: jest.fn().mockResolvedValue(1), + findFirst: jest.fn().mockResolvedValue({ id: 'certification-1' }), }, smsApplication: { findMany: jest.fn().mockResolvedValue([ @@ -388,6 +392,22 @@ describe('OperationsService', () => { expect(dashboard.gatewayConnections).toEqual([]); expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]); + expect(dashboard.clientOverview).toEqual({ + enterpriseName: '企业A', + certificationStatus: 'certified', + signatureCount: 1, + pendingBatchTaskCount: 3, + }); + expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', sourceType: 'client', status: 'pending_review' }, + }); + expect(prisma.enterpriseCertification.findFirst).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', status: 'approved' }, + select: { id: true }, + }); + expect(prisma.smsSignature.count).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } }, + }); expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/); }); @@ -480,6 +500,10 @@ describe('OperationsService', () => { updatedAt: { gte: expect.any(Date) }, }, }); + const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string }; + expect(hourlyTrendQuery.sql).toContain( + `HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`, + ); expect(prisma.accountTransaction.aggregate).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 51ab274..ec933b0 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -1,2338 +1,150 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { moneyToNumber } from '../common/money'; -import { randomUUID } from 'node:crypto'; +import type { DownstreamDeliveryDashboardQuery, DownstreamDeliveryQuery, DownstreamRecoveryStatusQuery, GatewaySubmitDeadLetterQuery, MessageQuery, MessageSegmentAuditQuery, OperationLogQuery, SignatureQualityQuery, TraceQuery } from './operations.contracts'; +import { OperationsMessageQueries } from './queries/messages.queries'; +import { OperationsUplinkQueries } from './queries/uplink.queries'; +import { OperationsDashboardQueries } from './queries/dashboard.queries'; +import { OperationsQualityQueries } from './queries/quality.queries'; +import { OperationsLogQueries } from './queries/logs.queries'; +import { OperationsDownstreamQueries } from './queries/downstream.queries'; +import { OperationsTraceQueries } from './queries/trace.queries'; -export interface MessageQuery { - tenantId?: string; - applicationId?: string; - channelId?: string; - channelKeyword?: string; - taskId?: string; - messageId?: string; - phoneNumber?: string; - contentKeyword?: string; - carrier?: string; - status?: string; - queuedAtFrom?: string; - queuedAtTo?: string; - page?: number; - pageSize?: number; -} - -export interface TraceQuery extends MessageQuery { - messageId?: string; -} - -export interface OperationLogQuery { - tenantId?: string; - userId?: string; - keyword?: string; - level?: string; - module?: string; - range?: string; - page?: number; - pageSize?: number; -} - -export interface GatewaySubmitDeadLetterQuery { - tenantId?: string; - applicationId?: string; - channelId?: string; - status?: string; - keyword?: string; - page?: number; - pageSize?: number; -} - -export interface DownstreamDeliveryQuery { - tenantId?: string; - applicationId?: string; - deliveryType?: string; - status?: string; - keyword?: string; - page?: number; - pageSize?: number; - createdAtFrom?: string; - createdAtTo?: string; -} - -export interface DownstreamDeliveryDashboardQuery { - tenantId?: string; - applicationId?: string; - deliveryType?: string; - createdAtFrom?: string; - createdAtTo?: string; -} - -export interface DownstreamRecoveryStatusQuery { - tenantId?: string; - applicationId?: string; - state?: string; - failureCategory?: string; - keyword?: string; - updatedAtFrom?: string; - updatedAtTo?: string; - page?: number; - pageSize?: number; -} - -export interface MessageSegmentAuditQuery { - messageId?: string; - messageRecordId?: string; -} - -export interface SignatureQualityQuery { - date?: string; - keyword?: string; - page?: number; - pageSize?: number; -} +export * from './operations.contracts'; @Injectable() export class OperationsService { - constructor(private readonly prisma: PrismaService) {} + private readonly messagesQueries: OperationsMessageQueries; + private readonly uplinkQueries: OperationsUplinkQueries; + private readonly dashboardQueries: OperationsDashboardQueries; + private readonly qualityQueries: OperationsQualityQueries; + private readonly logsQueries: OperationsLogQueries; + private readonly downstreamQueries: OperationsDownstreamQueries; + private readonly traceQueries: OperationsTraceQueries; + constructor(prisma: PrismaService) { + this.messagesQueries = new OperationsMessageQueries(prisma); + this.uplinkQueries = new OperationsUplinkQueries(prisma); + this.dashboardQueries = new OperationsDashboardQueries(prisma); + this.qualityQueries = new OperationsQualityQueries(prisma); + this.logsQueries = new OperationsLogQueries(prisma); + this.downstreamQueries = new OperationsDownstreamQueries(prisma); + this.traceQueries = new OperationsTraceQueries(prisma); + } + + // Stable controller facade: routes and public method signatures remain unchanged. listBatchTasks(query: { tenantId?: string; status?: string }) { - return this.prisma.smsBatchTask.findMany({ - where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' }, - include: { apiRequests: true }, - orderBy: { createdAt: 'desc' }, - }); + return this.messagesQueries.listBatchTasks(query); } async listClientBatchTasks(query: { tenantId?: string; status?: string }) { - const items = await this.listBatchTasks(query); - return items.map(clientBatchTaskView); + return this.messagesQueries.listClientBatchTasks(query); } listMessages(query: MessageQuery) { - return this.prisma.smsMessageRecord.findMany({ - where: messageWhere(query), - include: { - tenant: true, - application: true, - channel: true, - submitRecords: { include: { channel: true, channelGroup: true } }, - receiptRecords: { include: { channel: true } }, - downstreamDeliveries: { - where: { deliveryType: 'receipt' }, - select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, - }, - }, - orderBy: { queuedAt: 'desc' }, - }); + return this.messagesQueries.listMessages(query); } async listMessagesPage(query: MessageQuery) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25))); - const where = messageWhere(query); - const [items, total] = await Promise.all([ - this.prisma.smsMessageRecord.findMany({ - where, - include: { - tenant: { select: { id: true, name: true } }, - application: { select: { id: true, name: true } }, - channel: { select: { id: true, name: true, srcId: true } }, - submitRecords: { - select: { - id: true, - submitId: true, - channelId: true, - channelGroupId: true, - channelGroupName: true, - gatewayMessageId: true, - submitStatus: true, - submittedAt: true, - createdAt: true, - channel: { select: { id: true, name: true } }, - channelGroup: { select: { id: true, name: true } }, - }, - }, - receiptRecords: { - select: { - id: true, - messageId: true, - gatewayMessageId: true, - receiptStatus: true, - rawStatus: true, - errorCode: true, - errorMessage: true, - deliveredAt: true, - createdAt: true, - channelId: true, - channel: { select: { id: true, name: true } }, - }, - }, - downstreamDeliveries: { - where: { deliveryType: 'receipt' }, - select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, - }, - }, - orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.smsMessageRecord.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.messagesQueries.listMessagesPage(query); } async exportMessages(query: MessageQuery) { - const items = await this.prisma.smsMessageRecord.findMany({ - where: messageWhere(query), - select: { - messageId: true, - queuedAt: true, - phoneNumber: true, - province: true, - carrier: true, - billingUnits: true, - amountCents: true, - status: true, - submitStatus: true, - deliveredAt: true, - content: true, - tenant: { select: { name: true } }, - application: { select: { name: true } }, - channel: { select: { name: true } }, - }, - orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], - }); - const rows = [ - ['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'], - ...items.map((item) => [ - item.messageId, - item.tenant?.name ?? '', - item.application?.name ?? '', - item.queuedAt.toISOString(), - item.phoneNumber, - item.province ?? '', - item.carrier ?? '', - String(item.billingUnits), - String(moneyToNumber(item.amountCents)), - item.channel?.name ?? '', - item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status, - item.deliveredAt?.toISOString() ?? '', - item.content, - ]), - ]; - return { - fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`, - content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'), - }; + return this.messagesQueries.exportMessages(query); } async listClientMessages(query: MessageQuery) { - const items = await this.listMessages(query); - return items.map(clientMessageView); + return this.messagesQueries.listClientMessages(query); } async listClientMessagesPage(query: MessageQuery) { - const result = await this.listMessagesPage(query); - return { ...result, items: result.items.map(clientMessageView) }; + return this.messagesQueries.listClientMessagesPage(query); } listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { - return this.prisma.smsUplinkMessage.findMany({ - 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, - channel: true, - messageRecord: { include: { application: true } }, - matchCandidates: { - include: { - tenant: true, - application: true, - messageRecord: { include: { application: true, tenant: true, channel: true } }, - }, - orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }], - }, - }, - orderBy: { receivedAt: 'desc' }, - skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, - take: query.pageSize ?? 500, - }); + return this.uplinkQueries.listUplinkMessages(query); } async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { - const items = await this.listUplinkMessages(query); - return items.map(clientUplinkView); + return this.uplinkQueries.listClientUplinkMessages(query); } async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const where: Prisma.SmsUplinkMessageWhereInput = { - 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, - }; - const [rawItems, total] = await Promise.all([ - this.listUplinkMessages({ ...query, page, pageSize }), - this.prisma.smsUplinkMessage.count({ where }), - ]); - return { - items: clientView ? rawItems.map(clientUplinkView) : rawItems, - total, - page, - pageSize, - }; + return this.uplinkQueries.listUplinkMessagesPage(query, clientView); } async monitor(query: { tenantId?: string; channelId?: string }) { - const where = messageWhere(query); - const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ - this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }), - this.prisma.smsMessageRecord.findMany({ - where, - include: { submitRecords: true, receiptRecords: true }, - orderBy: { queuedAt: 'desc' }, - take: 20, - }), - this.prisma.smsReceiptRecord.findMany({ - where: { tenantId: query.tenantId, channelId: query.channelId }, - orderBy: { createdAt: 'desc' }, - take: 20, - }), - this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }), - ]); - return { - byStatus, - recentMessages, - recentReceipts, - recentUplinks: recentUplinks.slice(0, 20), - }; + return this.uplinkQueries.monitor(query); } async dashboard(query: { tenantId?: string }) { - const businessDay = qualityBusinessDay(); - const sinceToday = businessDay.startAt; - const downstreamAlertWindow = downstreamAlertWindows(); - const messageWhereClause = messageWhere({ tenantId: query.tenantId }); - const todayMessageWhereClause = { - ...messageWhereClause, - queuedAt: { gte: sinceToday, lt: businessDay.endAt }, - }; - const [ - taskCount, - messageGroups, - todayMessageGroups, - uplinkCount, - billingAggregate, - transactionAggregate, - connectionGroups, - pendingAudits, - tenantAccounts, - recentTasks, - recentRecharges, - enterpriseSpendRows, - downstreamPendingCount, - downstreamFailedCount, - downstreamDeliveredCount, - downstreamStalledPendingCount, - downstreamStalledAckCount, - downstreamRecentFailedCount, - hourlySendRows, - auditSpeedRows, - ] = await Promise.all([ - this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }), - this.prisma.smsMessageRecord.groupBy({ - by: ['status'], - where: messageWhereClause, - _count: { _all: true }, - _sum: { amountCents: true, billingUnits: true }, - }), - this.prisma.smsMessageRecord.groupBy({ - by: ['status'], - where: todayMessageWhereClause, - _count: { _all: true }, - _sum: { amountCents: true, billingUnits: true }, - }), - this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }), - this.prisma.smsBillingRecord.aggregate({ - where: { tenantId: query.tenantId }, - _sum: { amountCents: true, billingUnits: true }, - _count: { _all: true }, - }), - this.prisma.accountTransaction.aggregate({ - where: returnedTransactionWhere(sinceToday, query.tenantId), - _sum: { amountCents: true }, - _count: { _all: true }, - }), - this.prisma.cmppConnectionState.groupBy({ - by: ['status'], - where: { tenantId: query.tenantId }, - _count: { _all: true }, - _sum: { currentConnections: true, desiredConnections: true }, - }), - this.countPendingAudits(query.tenantId), - this.prisma.tenantAccount.findMany({ - where: query.tenantId ? { tenantId: query.tenantId } : undefined, - include: { tenant: true }, - orderBy: { updatedAt: 'desc' }, - take: 20, - }), - this.prisma.smsBatchTask.findMany({ - where: query.tenantId ? { tenantId: query.tenantId } : undefined, - include: { application: true, messages: { take: 1, include: { channel: true } } }, - orderBy: { createdAt: 'desc' }, - take: 10, - }), - this.prisma.rechargeOrder.findMany({ - where: { - tenantId: query.tenantId, - payMethod: 'manual_topup', - }, - include: { tenant: true }, - orderBy: { createdAt: 'desc' }, - take: 10, - }), - this.prisma.$queryRaw>(Prisma.sql` - SELECT - tenant.id AS "tenantId", - tenant.name AS "tenantName", - COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents", - account."balanceCents" AS "balanceCents", - account."creditCents" AS "creditCents" - FROM "TenantAccount" account - JOIN "Tenant" tenant ON tenant.id = account."tenantId" - LEFT JOIN "SmsBillingRecord" billing - ON billing."tenantId" = tenant.id - AND billing."createdAt" >= ${businessDay.startAt} - AND billing."createdAt" < ${businessDay.endAt} - WHERE tenant.status <> 'deleted' - AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null}) - GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" - ORDER BY "todaySpendCents" DESC, tenant.name ASC - `), - this.prisma.cmppDownstreamDelivery.count({ - where: { tenantId: query.tenantId, status: 'pending' }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { tenantId: query.tenantId, status: 'failed' }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { tenantId: query.tenantId, status: 'delivered' }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - tenantId: query.tenantId, - ...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt), - }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - tenantId: query.tenantId, - status: 'awaiting_ack', - ackDeadlineAt: { lte: downstreamAlertWindow.now }, - }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - tenantId: query.tenantId, - status: { in: ['failed', 'unconfirmed', 'rejected'] }, - updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, - }, - }), - this.prisma.$queryRaw>(Prisma.sql` - SELECT - EXTRACT(HOUR FROM message."queuedAt" AT TIME ZONE 'Asia/Shanghai')::integer AS hour, - COUNT(*)::bigint AS "submittedCount", - COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount" - FROM "SmsMessageRecord" message - WHERE message."queuedAt" >= ${businessDay.startAt} - AND message."queuedAt" < ${businessDay.endAt} - AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null}) - GROUP BY 1 - ORDER BY 1 - `), - // Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit. - this.prisma.$queryRaw>(Prisma.sql` - WITH review_samples AS ( - SELECT - 'enterpriseCertifications'::text AS category, - certification."submittedAt" AS "submittedAt", - certification."reviewedAt" AS "reviewedAt" - FROM "EnterpriseCertification" certification - WHERE certification."reviewedAt" >= ${businessDay.startAt} - AND certification."reviewedAt" < ${businessDay.endAt} - AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null}) - - UNION ALL - - SELECT - 'smsAudits'::text, - task."createdAt", - task."reviewedAt" - FROM "SmsSendTask" task - WHERE task."reviewedAt" >= ${businessDay.startAt} - AND task."reviewedAt" < ${businessDay.endAt} - AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null}) - - UNION ALL - - SELECT - 'drainageInfos'::text, - drainage."submittedAt", - drainage."reviewedAt" - FROM "SmsDrainageInfo" drainage - WHERE drainage."reviewedAt" >= ${businessDay.startAt} - AND drainage."reviewedAt" < ${businessDay.endAt} - AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null}) - - UNION ALL - - SELECT - CASE review."targetType" - WHEN 'sms_signature' THEN 'signatures' - WHEN 'sms_template' THEN 'templates' - END, - submission."createdAt", - review."createdAt" - FROM "AuditRecord" review - JOIN LATERAL ( - SELECT pending."createdAt" - FROM "AuditRecord" pending - WHERE pending."targetType" = review."targetType" - AND pending."targetId" = review."targetId" - AND pending."statusAfter" = 'pending' - AND pending."createdAt" <= review."createdAt" - ORDER BY pending."createdAt" DESC - LIMIT 1 - ) submission ON true - WHERE review."targetType" IN ('sms_signature', 'sms_template') - AND review."statusBefore" = 'pending' - AND review."statusAfter" IN ('approved', 'rejected') - AND review."createdAt" >= ${businessDay.startAt} - AND review."createdAt" < ${businessDay.endAt} - AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null}) - ) - SELECT - category, - COUNT(*)::bigint AS count, - ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs" - FROM review_samples - WHERE "reviewedAt" >= "submittedAt" - GROUP BY category - `), - ]); - const todayTotals = summarizeMessageGroups(todayMessageGroups); - const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row])); - // Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data. - const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => { - const row = hourlyRowsByHour.get(hour); - return { - hour, - label: `${String(hour).padStart(2, '0')}:00`, - submittedCount: Number(row?.submittedCount ?? 0), - successCount: Number(row?.successCount ?? 0), - }; - }); - const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row])); - const auditProcessingSpeed = [ - ['enterpriseCertifications', '企业认证'], - ['smsAudits', '短信审核'], - ['templates', '模板'], - ['signatures', '签名'], - ['drainageInfos', '引流信息'], - ].map(([category, label]) => { - const row = auditSpeedByCategory.get(category); - return { - category, - label, - count: Number(row?.count ?? 0), - averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs), - }; - }); - const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount; - return { - taskCount, - messageStatus: messageGroups, - today: { - sent: todayTotals.total, - delivered: todayTotals.delivered, - failed: todayTotals.failed, - unknown: todayTotals.unknown, - successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0, - spendCents: todayTotals.amountCents, - returnedCents: moneyToNumber(transactionAggregate._sum.amountCents), - billingUnits: todayTotals.billingUnits, - }, - uplinkCount, - billing: billingAggregate, - transactions: transactionAggregate, - gatewayConnections: connectionGroups, - pendingAuditCount: pendingAudits.total, - pendingAudits, - hourlySendTrend, - auditProcessingSpeed, - downstreamDeliverySummary: { - pending: downstreamPendingCount, - failed: downstreamFailedCount, - delivered: downstreamDeliveredCount, - stalledPending: downstreamStalledPendingCount, - stalledAck: downstreamStalledAckCount, - recentFailed: downstreamRecentFailedCount, - alertCount: downstreamAlertCount, - }, - accounts: tenantAccounts, - enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({ - tenantId: row.tenantId, - tenantName: row.tenantName, - todaySpendCents: moneyToNumber(row.todaySpendCents), - balanceCents: moneyToNumber(row.balanceCents), - creditCents: moneyToNumber(row.creditCents), - })), - recentTasks, - recentRecharges, - }; + return this.dashboardQueries.dashboard(query); } async clientDashboard(query: { tenantId?: string }) { - const dashboard = await this.dashboard(query); - return { - taskCount: dashboard.taskCount, - messageStatus: dashboard.messageStatus, - today: dashboard.today, - uplinkCount: dashboard.uplinkCount, - billing: dashboard.billing, - transactions: dashboard.transactions, - gatewayConnections: [], - pendingAuditCount: dashboard.pendingAuditCount, - pendingAudits: dashboard.pendingAudits, - hourlySendTrend: dashboard.hourlySendTrend, - auditProcessingSpeed: dashboard.auditProcessingSpeed, - downstreamDeliverySummary: dashboard.downstreamDeliverySummary, - accounts: dashboard.accounts.map(clientAccountView), - enterpriseSpendRanks: dashboard.enterpriseSpendRanks, - recentTasks: dashboard.recentTasks.map(clientBatchTaskView), - recentRecharges: dashboard.recentRecharges.map(clientRechargeView), - }; + return this.dashboardQueries.clientDashboard(query); } async statistics(query: { tenantId?: string; groupBy?: string }) { - const groupBy = normalizeGroupBy(query.groupBy); - if (groupBy === 'tenantId') { - return this.prisma.smsMessageRecord.groupBy({ - by: ['tenantId'], - where: messageWhere({ tenantId: query.tenantId }), - _count: { _all: true }, - _sum: { amountCents: true, billingUnits: true }, - }); - } - if (groupBy === 'applicationId') { - return this.prisma.smsMessageRecord.groupBy({ - by: ['applicationId'], - where: messageWhere({ tenantId: query.tenantId }), - _count: { _all: true }, - _sum: { amountCents: true, billingUnits: true }, - }); - } - return this.prisma.smsMessageRecord.groupBy({ - by: ['channelId'], - where: messageWhere({ tenantId: query.tenantId }), - _count: { _all: true }, - _sum: { amountCents: true, billingUnits: true }, - }); + return this.qualityQueries.statistics(query); } async sendQuality(date?: string) { - const day = qualityBusinessDay(date); - const [channels, signatures, summaryRows, applications] = await Promise.all([ - this.prisma.$queryRaw>(Prisma.sql` - WITH base AS ( - SELECT - submit."channelId" AS channel_id, - channel.name AS channel_name, - submit."submitStatus" AS submit_status, - receipt."deliveredAt" AS delivered_at, - failed_receipt."failedAt" AS failed_at, - COALESCE(segment_summary.segment_count, 0) AS segment_count, - COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, - COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, - CASE - WHEN segment_summary.segment_count > 0 - AND segment_summary.delivered_count = segment_summary.segment_count - AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") - THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 - WHEN segment_summary.segment_count = 0 - AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") - THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 - END AS arrival_ms - FROM "SmsSubmitRecord" submit - JOIN "SmsChannel" channel ON channel.id = submit."channelId" - LEFT JOIN LATERAL ( - SELECT - COUNT(*)::integer AS segment_count, - COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, - COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, - MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at - FROM "SmsMessageSegmentAudit" segment - WHERE segment."submitRecordId" = submit.id - ) segment_summary ON TRUE - LEFT JOIN LATERAL ( - SELECT MIN(receipt."deliveredAt") AS "deliveredAt" - FROM "SmsReceiptRecord" receipt - WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" - AND receipt."channelId" = submit."channelId" - AND receipt."receiptStatus" = 'delivered' - ) receipt ON TRUE - LEFT JOIN LATERAL ( - SELECT MIN(receipt."deliveredAt") AS "failedAt" - FROM "SmsReceiptRecord" receipt - WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" - AND receipt."channelId" = submit."channelId" - AND receipt."receiptStatus" = 'undelivered' - ) failed_receipt ON TRUE - WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') - AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} - AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} - ), classified AS ( - SELECT - *, - CASE - WHEN submit_status <> 'accepted' THEN 'submit_failed' - WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' - WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' - WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' - WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' - ELSE 'unknown' - END AS delivery_status - FROM base - ) - SELECT - channel_id AS "channelId", - MAX(channel_name) AS "channelName", - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", - COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", - CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate", - COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", - COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", - COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", - CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate", - CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate", - CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate", - ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" - FROM classified - GROUP BY channel_id - ORDER BY COUNT(*) DESC, channel_id - `), - this.prisma.$queryRaw>(Prisma.sql` - WITH base AS ( - SELECT - message."signatureId" AS signature_id, - (message."drainageInfoId" IS NOT NULL) AS has_drainage, - message.status, - message."submitStatus" AS submit_status, - message."receiptStatus" AS receipt_status, - CASE - WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') - AND message."submittedAt" IS NOT NULL - AND message."deliveredAt" >= message."submittedAt" - THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 - END AS arrival_ms - FROM "SmsMessageRecord" message - WHERE message."signatureId" IS NOT NULL - AND message."queuedAt" >= ${day.startAt} - AND message."queuedAt" < ${day.endAt} - ) - SELECT - signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id, - signature.id AS "signatureId", - signature.name AS "signatureName", - tenant.id AS "tenantId", - tenant.name AS "tenantName", - base.has_drainage AS "hasDrainage", - COUNT(*)::integer AS total, - COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - )::integer AS "acceptedCount", - COUNT(*) FILTER ( - WHERE base.status = 'submit_failed' - OR base.submit_status IN ('rejected', 'timeout') - )::integer AS "submitFailureCount", - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", - COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "unknownCount", - COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "failureCount", - CASE - WHEN COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - ) = 0 THEN 0 - ELSE ROUND( - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') - * 100.0 - / COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - ), - 1 - )::double precision - END AS "successRate", - ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" - FROM base - JOIN "SmsSignature" signature ON signature.id = base.signature_id - JOIN "Tenant" tenant ON tenant.id = signature."tenantId" - GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage - ORDER BY "successCount" DESC, total DESC, signature.name - `), - this.prisma.$queryRaw>(Prisma.sql` - WITH base AS ( - SELECT message.status, message."receiptStatus" AS receipt_status - FROM "SmsMessageRecord" message - WHERE message."queuedAt" >= ${day.startAt} - AND message."queuedAt" < ${day.endAt} - AND COALESCE(message.status, '') <> 'rejected' - ) - SELECT - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", - COUNT(*) FILTER ( - WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "unknownCount", - COUNT(*) FILTER ( - WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "failureCount", - CASE - WHEN COUNT(*) = 0 THEN 0 - ELSE ROUND( - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') - * 100.0 / COUNT(*), - 1 - )::double precision - END AS "successRate" - FROM base - `), - this.prisma.$queryRaw>(Prisma.sql` - WITH base AS ( - SELECT - message."applicationId" AS application_id, - message.status, - message."receiptStatus" AS receipt_status - FROM "SmsMessageRecord" message - WHERE message."applicationId" IS NOT NULL - AND message."queuedAt" >= ${day.startAt} - AND message."queuedAt" < ${day.endAt} - AND COALESCE(message.status, '') <> 'rejected' - ) - SELECT - application.id AS "applicationId", - application.name AS "applicationName", - tenant.id AS "tenantId", - tenant.name AS "tenantName", - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", - COUNT(*) FILTER ( - WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "unknownCount", - COUNT(*) FILTER ( - WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "failureCount", - CASE - WHEN COUNT(*) = 0 THEN 0 - ELSE ROUND( - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') - * 100.0 / COUNT(*), - 1 - )::double precision - END AS "successRate" - FROM base - JOIN "SmsApplication" application ON application.id = base.application_id - JOIN "Tenant" tenant ON tenant.id = application."tenantId" - GROUP BY application.id, application.name, tenant.id, tenant.name - ORDER BY total DESC, application.name - `), - ]); - const summary = summaryRows[0] ?? { - total: 0, - successCount: 0, - unknownCount: 0, - failureCount: 0, - successRate: 0, - }; - return { date: day.key, summary, channels, signatures, applications }; + return this.qualityQueries.sendQuality(date); } async signatureQuality(query: SignatureQualityQuery) { - const day = qualityBusinessDay(query.date); - const page = positiveInteger(query.page, 1); - const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); - const keyword = query.keyword?.trim() || null; - const keywordPattern = keyword ? `%${keyword}%` : null; - const summaries = await this.prisma.$queryRaw>(Prisma.sql` - WITH base AS ( - SELECT - message."signatureId" AS signature_id, - message."applicationId" AS application_id, - message.status, - message."submitStatus" AS submit_status, - message."receiptStatus" AS receipt_status, - CASE - WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') - AND message."submittedAt" IS NOT NULL - AND message."deliveredAt" >= message."submittedAt" - THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 - END AS arrival_ms - FROM "SmsMessageRecord" message - WHERE message."signatureId" IS NOT NULL - AND message."queuedAt" >= ${day.startAt} - AND message."queuedAt" < ${day.endAt} - ) - SELECT - signature.id AS "signatureId", - signature.name AS "signatureName", - tenant.id AS "tenantId", - tenant.name AS "tenantName", - STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", - COUNT(*)::integer AS total, - COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - )::integer AS "acceptedCount", - COUNT(*) FILTER ( - WHERE base.status = 'submit_failed' - OR base.submit_status IN ('rejected', 'timeout') - )::integer AS "submitFailureCount", - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", - COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "unknownCount", - COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "failureCount", - CASE - WHEN COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - ) = 0 THEN 0 - ELSE ROUND( - COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') - * 100.0 - / COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - ), - 1 - )::double precision - END AS "successRate", - ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs", - COUNT(*) OVER()::integer AS "rowCount" - FROM base - JOIN "SmsSignature" signature ON signature.id = base.signature_id - JOIN "Tenant" tenant ON tenant.id = signature."tenantId" - LEFT JOIN "SmsApplication" application ON application.id = base.application_id - WHERE ( - ${keyword}::text IS NULL - OR signature.name ILIKE ${keywordPattern} - OR tenant.name ILIKE ${keywordPattern} - OR application.name ILIKE ${keywordPattern} - ) - GROUP BY signature.id, signature.name, tenant.id, tenant.name - ORDER BY total DESC, signature.name - LIMIT ${pageSize} - OFFSET ${(page - 1) * pageSize} - `); - const signatureIds = summaries.map((item) => item.signatureId); - const breakdowns = signatureIds.length === 0 - ? [] - : await this.prisma.$queryRaw>(Prisma.sql` - WITH base AS ( - SELECT - message."signatureId" AS signature_id, - submit."channelId" AS channel_id, - channel.name AS channel_name, - COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, - submit."submitStatus" AS submit_status, - receipt."deliveredAt" AS delivered_at, - failed_receipt."failedAt" AS failed_at, - COALESCE(segment_summary.segment_count, 0) AS segment_count, - COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, - COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, - CASE - WHEN segment_summary.segment_count > 0 - AND segment_summary.delivered_count = segment_summary.segment_count - AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") - THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 - WHEN segment_summary.segment_count = 0 - AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") - THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 - END AS arrival_ms - FROM "SmsSubmitRecord" submit - JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" - JOIN "SmsChannel" channel ON channel.id = submit."channelId" - LEFT JOIN LATERAL ( - SELECT - COUNT(*)::integer AS segment_count, - COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, - COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, - MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at - FROM "SmsMessageSegmentAudit" segment - WHERE segment."submitRecordId" = submit.id - ) segment_summary ON TRUE - LEFT JOIN LATERAL ( - SELECT MIN(receipt."deliveredAt") AS "deliveredAt" - FROM "SmsReceiptRecord" receipt - WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" - AND receipt."channelId" = submit."channelId" - AND receipt."receiptStatus" = 'delivered' - ) receipt ON TRUE - LEFT JOIN LATERAL ( - SELECT MIN(receipt."deliveredAt") AS "failedAt" - FROM "SmsReceiptRecord" receipt - WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" - AND receipt."channelId" = submit."channelId" - AND receipt."receiptStatus" = 'undelivered' - ) failed_receipt ON TRUE - WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) - AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout') - AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} - AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} - ), classified AS ( - SELECT - *, - CASE - WHEN submit_status <> 'accepted' THEN 'submit_failed' - WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' - WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' - WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' - WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' - ELSE 'unknown' - END AS delivery_status - FROM base - ) - SELECT - signature_id AS "signatureId", - channel_id AS "channelId", - MAX(channel_name) AS "channelName", - carrier, - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", - COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", - COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", - COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", - COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", - CASE - WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 - ELSE ROUND( - COUNT(*) FILTER (WHERE delivery_status = 'success') - * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), - 1 - )::double precision - END AS "successRate", - ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" - FROM classified - GROUP BY signature_id, channel_id, carrier - ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier - `); - const carrierOverview = signatureIds.length === 0 - ? [] - : await this.prisma.$queryRaw>(Prisma.sql` - SELECT - message."signatureId" AS "signatureId", - COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, - COUNT(*)::integer AS "businessMessageCount", - COUNT(*) FILTER ( - WHERE message.status = 'delivered' - OR message."receiptStatus" = 'delivered' - )::integer AS "finalSuccessCount", - CASE - WHEN COUNT(*) = 0 THEN 0 - ELSE ROUND( - COUNT(*) FILTER ( - WHERE message.status = 'delivered' - OR message."receiptStatus" = 'delivered' - ) * 100.0 / COUNT(*), - 1 - )::double precision - END AS "finalSuccessRate", - ROUND(AVG( - CASE - WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') - AND message."submittedAt" IS NOT NULL - AND message."deliveredAt" >= message."submittedAt" - THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 - END - ))::integer AS "averageArrivalMs" - FROM "SmsMessageRecord" message - WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) - AND message."queuedAt" >= ${day.startAt} - AND message."queuedAt" < ${day.endAt} - GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown') - ORDER BY message."signatureId", COUNT(*) DESC, carrier - `); - const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { - const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId); - return { - ...summary, - channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0), - carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId), - breakdowns: signatureBreakdowns, - }; - }); - return { - date: day.key, - items, - total: summaries[0]?.rowCount ?? 0, - page, - pageSize, - }; + return this.qualityQueries.signatureQuality(query); } async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) { - const page = positiveInteger(query.page, 1); - const pageSize = Math.min(100, positiveInteger(query.pageSize, 20)); - const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId }; - const [items, total] = await Promise.all([ - this.prisma.operationLog.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.operationLog.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.logsQueries.auditLogs(query); } async systemLogs(query: OperationLogQuery) { - const page = positiveInteger(query.page, 1); - const pageSize = Math.min(100, positiveInteger(query.pageSize, 10)); - const where: Prisma.OperationLogWhereInput = { - tenantId: query.tenantId, - userId: query.userId, - createdAt: createdAtRange(query.range), - resource: query.module && query.module !== 'all' ? query.module : undefined, - AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined, - OR: query.keyword ? [ - { action: { contains: query.keyword } }, - { resource: { contains: query.keyword } }, - { resourceId: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - { user: { displayName: { contains: query.keyword } } }, - { user: { username: { contains: query.keyword } } }, - ] : undefined, - }; - const [items, total, modules] = await Promise.all([ - this.prisma.operationLog.findMany({ - where, - include: { tenant: true, user: true }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.operationLog.count({ where }), - this.prisma.operationLog.groupBy({ - by: ['resource'], - where: { tenantId: query.tenantId }, - _count: { _all: true }, - orderBy: { resource: 'asc' }, - }), - ]); - return { - items: items.map((item) => normalizeOperationLog(item)), - total, - page, - pageSize, - modules: modules.map((item) => item.resource), - }; + return this.logsQueries.systemLogs(query); } async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) { - const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined; - const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId }; - const where: Prisma.OperationLogWhereInput = { - tenantId: effectiveQuery.tenantId, - userId: effectiveQuery.userId, - createdAt: createdAtRange(effectiveQuery.range), - resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined, - AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined, - OR: effectiveQuery.keyword ? [ - { action: { contains: effectiveQuery.keyword } }, - { resource: { contains: effectiveQuery.keyword } }, - { resourceId: { contains: effectiveQuery.keyword } }, - { tenant: { name: { contains: effectiveQuery.keyword } } }, - { user: { displayName: { contains: effectiveQuery.keyword } } }, - { user: { username: { contains: effectiveQuery.keyword } } }, - ] : undefined, - }; - const rows = await this.prisma.operationLog.findMany({ - where, - include: { tenant: true, user: true }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - take: 10_001, - }); - const truncated = rows.length > 10_000; - const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog); - const clientExport = Boolean(clientUserId); - const headers = clientExport - ? ['时间', '级别', '模块', '操作人', '动作', '资源ID'] - : ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP']; - const values = exportedRows.map((item) => clientExport - ? [item.time, item.level, item.module, item.operator, item.action, item.resourceId] - : [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]); - return { - operationId: randomUUID(), - status: 'completed' as const, - fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`, - recordCount: exportedRows.length, - truncated, - content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'), - filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range }, - }; - } - - private async resolveClientTenantId(userId: string) { - const user = await this.prisma.user.findFirst({ - where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } }, - select: { tenantId: true }, - }); - if (!user?.tenantId) throw new NotFoundException('Client tenant not found'); - return user.tenantId; + return this.logsQueries.exportSystemLogs(query, clientUserId); } async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) { - const page = Math.max(1, Number(query.page ?? 1)); - const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); - const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = { - tenantId: query.tenantId, - applicationId: query.applicationId, - channelId: query.channelId, - OR: query.keyword ? [ - { streamMessageId: { contains: query.keyword } }, - { traceId: { contains: query.keyword } }, - { messageId: { contains: query.keyword } }, - { submitId: { contains: query.keyword } }, - { failureCode: { contains: query.keyword } }, - { failureMessage: { contains: query.keyword } }, - ] : undefined, - }; - const where: Prisma.GatewaySubmitDeadLetterWhereInput = { - ...baseWhere, - status: query.status && query.status !== 'all' ? query.status : undefined, - }; - const [items, total, statusGroups, oldestPending] = await Promise.all([ - this.prisma.gatewaySubmitDeadLetter.findMany({ - where, - include: { tenant: true, application: true, channel: true }, - orderBy: { createdAt: 'desc' }, - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.gatewaySubmitDeadLetter.count({ where }), - this.prisma.gatewaySubmitDeadLetter.groupBy({ - by: ['status'], - where: baseWhere, - _count: { _all: true }, - }), - this.prisma.gatewaySubmitDeadLetter.findFirst({ - where: { ...baseWhere, status: 'pending' }, - orderBy: { createdAt: 'asc' }, - select: { createdAt: true }, - }), - ]); - const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all])); - const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value)); - const messageStates = messageIds.length > 0 - ? await this.prisma.smsMessageRecord.findMany({ - where: { messageId: { in: messageIds } }, - select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true }, - }) - : []; - const messageStateById = new Map(messageStates.map((item) => [item.messageId, item])); - return { - items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)), - total, - page, - pageSize, - summary: { - pending: statusCounts.get('pending') ?? 0, - requeueing: statusCounts.get('requeueing') ?? 0, - requeued: statusCounts.get('requeued') ?? 0, - resolved: statusCounts.get('resolved') ?? 0, - oldestPendingAt: oldestPending?.createdAt ?? null, - }, - }; + return this.downstreamQueries.listGatewaySubmitDeadLetters(query); } async listDownstreamDeliveries(query: DownstreamDeliveryQuery) { - const page = Math.max(1, Number(query.page ?? 1)); - const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); - const where: Prisma.CmppDownstreamDeliveryWhereInput = { - ...downstreamDeliveryScopedWhere(query), - status: query.status && query.status !== 'all' ? query.status : undefined, - OR: query.keyword ? [ - { messageId: { contains: query.keyword } }, - { payload: { path: ['account'], string_contains: query.keyword } }, - { payload: { path: ['phoneNumber'], string_contains: query.keyword } }, - { lastError: { contains: query.keyword } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.prisma.cmppDownstreamDelivery.findMany({ - where, - include: { - tenant: true, - application: true, - messageRecord: true, - attempts: { orderBy: [{ attemptNo: 'desc' }, { createdAt: 'desc' }] }, - }, - orderBy: { createdAt: 'desc' }, - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.cmppDownstreamDelivery.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.downstreamQueries.listDownstreamDeliveries(query); } async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) { - const scopedWhere = downstreamDeliveryScopedWhere(query); - const downstreamAlertWindow = downstreamAlertWindows(); - const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([ - this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }), - this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }), - this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }), - this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }), - this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }), - this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'unconfirmed' } }), - this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'rejected' } }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - ...scopedWhere, - ...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt), - }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - ...scopedWhere, - status: { in: ['failed', 'unconfirmed', 'rejected'] }, - updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, - }, - }), - this.prisma.cmppDownstreamDelivery.groupBy({ - by: ['deliveryType', 'status'], - where: scopedWhere, - _count: { _all: true }, - }), - this.prisma.cmppDownstreamDelivery.groupBy({ - by: ['applicationId', 'status'], - where: scopedWhere, - _count: { _all: true }, - }), - this.prisma.cmppDownstreamDelivery.groupBy({ - by: ['applicationId'], - where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow), - _count: { _all: true }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - ...scopedWhere, - status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, - retryCount: 0, - }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - ...scopedWhere, - status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, - retryCount: { gte: 1, lte: 3 }, - }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { - ...scopedWhere, - status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, - retryCount: { gte: 4 }, - }, - }), - ]); - const applicationIds = [...new Set(applicationGroups.map((item) => item.applicationId).filter((value): value is string => Boolean(value)))]; - const applications: Array<{ id: string; name: string }> = applicationIds.length > 0 - ? await this.prisma.smsApplication.findMany({ - where: { id: { in: applicationIds } }, - select: { id: true, name: true }, - }) - : []; - const applicationMap = new Map(applications.map((item) => [item.id, item.name])); - const applicationAlertMap = new Map( - applicationAlertGroups.map((item) => [item.applicationId, item._count._all]), - ); - const groupedByType = groupDownstreamByType(typeGroups); - const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap); - - return { - summary: { - total, - pending, - awaitingAck, - delivered, - failed, - unconfirmed, - rejected, - stalledPending, - stalledAck, - recentFailed, - alertCount: stalledPending + stalledAck + recentFailed, - }, - typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({ - deliveryType, - total: groupedByType[deliveryType]?.total ?? 0, - pending: groupedByType[deliveryType]?.pending ?? 0, - awaitingAck: groupedByType[deliveryType]?.awaitingAck ?? 0, - delivered: groupedByType[deliveryType]?.delivered ?? 0, - failed: groupedByType[deliveryType]?.failed ?? 0, - unconfirmed: groupedByType[deliveryType]?.unconfirmed ?? 0, - rejected: groupedByType[deliveryType]?.rejected ?? 0, - })), - retryBuckets: [ - { label: '0次', count: retryZero }, - { label: '1-3次', count: retryLow }, - { label: '4次及以上', count: retryHigh }, - ], - topApplications: groupedByApplication - .sort((left, right) => ( - right.alertCount - left.alertCount - || right.failed - left.failed - || right.pending - left.pending - || left.name.localeCompare(right.name, 'zh-CN') - )) - .slice(0, 5), - }; + return this.downstreamQueries.downstreamDeliveryDashboard(query); } async listDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) { - const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); - const page = Math.max(1, Number(query.page ?? 1)); - const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); - const where = downstreamRecoveryStatusWhere(query); - const now = new Date(); - const [items, total, runningCount, successCount, failedCount, waitingConnectionCount, backoffCount, categoryGroups] = await Promise.all([ - recoveryStatuses.findMany({ - where, - include: { tenant: true, application: true }, - orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - recoveryStatuses.count({ where }), - recoveryStatuses.count({ where: { ...where, state: 'running' } }), - recoveryStatuses.count({ where: { ...where, state: 'success' } }), - recoveryStatuses.count({ where: { ...where, state: 'failed' } }), - recoveryStatuses.count({ where: { ...where, state: 'waiting_connection' } }), - recoveryStatuses.count({ - where: { - ...where, - nextRetryAt: { gt: now }, - }, - }), - recoveryStatuses.groupBy({ - by: ['failureCategory'], - where, - _count: { _all: true }, - }), - ]); - return { - items, - total, - page, - pageSize, - summary: { - total, - running: runningCount, - success: successCount, - failed: failedCount, - waitingConnection: waitingConnectionCount, - backoff: backoffCount, - failureCategories: categoryGroups - .filter((item) => item.failureCategory) - .map((item) => ({ - category: String(item.failureCategory), - count: item._count?._all ?? 0, - })) - .sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)), - }, - }; + return this.downstreamQueries.listDownstreamRecoveryStatuses(query); } async listMessageSegmentAudits(query: MessageSegmentAuditQuery) { - const segmentAudits = (this.prisma as PrismaService & { - smsMessageSegmentAudit: { - findMany: (args: Record) => Promise; - }; - }).smsMessageSegmentAudit; - if (!query.messageId && !query.messageRecordId) { - return []; - } - return segmentAudits.findMany({ - where: { - messageRecordId: query.messageRecordId, - messageRecord: query.messageId ? { messageId: query.messageId } : undefined, - }, - include: { channel: true, submitRecord: true }, - orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }], - }); + return this.downstreamQueries.listMessageSegmentAudits(query); } async getDownstreamRecoveryStatus(id: string) { - const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); - const item = await recoveryStatuses.findUnique({ - where: { id }, - include: { tenant: true, application: true }, - }); - if (!item) { - throw new NotFoundException('Recovery status not found'); - } - return item; + return this.downstreamQueries.getDownstreamRecoveryStatus(id); } async exportDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) { - const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); - const where = downstreamRecoveryStatusWhere(query); - const items = await recoveryStatuses.findMany({ - where, - include: { tenant: true, application: true }, - orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }], - take: 5000, - }); - const rows = [ - [ - '账号', - '企业', - '应用', - 'Gateway实例', - '恢复状态', - '锁持有实例', - '锁过期时间', - '失败分类', - '尝试次数', - '最后尝试时间', - '恢复成功时间', - '恢复失败时间', - '下次恢复时间', - '最后错误', - '最后跳过原因', - '创建时间', - '更新时间', - ], - ...items.map((item) => [ - item.account ?? '', - item.tenant?.name ?? '', - item.application?.name ?? '', - item.gatewayInstanceId ?? '', - item.state ?? '', - (item as { lockOwner?: string | null }).lockOwner ?? '', - formatCsvDate((item as { lockExpiresAt?: Date | string | null }).lockExpiresAt), - (item as { failureCategory?: string | null }).failureCategory ?? '', - String(item.attemptCount ?? 0), - formatCsvDate(item.lastAttemptAt), - formatCsvDate(item.lastSuccessAt), - formatCsvDate(item.lastFailureAt), - formatCsvDate(item.nextRetryAt), - item.lastError ?? '', - item.lastSkipReason ?? '', - formatCsvDate(item.createdAt), - formatCsvDate(item.updatedAt), - ]), - ]; - - return { - fileName: `gateway-downstream-recovery-statuses-${formatExportTimestamp(new Date())}.csv`, - content: rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n'), - total: items.length, - }; + return this.downstreamQueries.exportDownstreamRecoveryStatuses(query); } auditSummary(query: { tenantId?: string }) { - return this.prisma.operationLog.groupBy({ - by: ['action', 'resource'], - where: { tenantId: query.tenantId }, - _count: { _all: true }, - orderBy: { _count: { action: 'desc' } }, - take: 100, - }); + return this.traceQueries.auditSummary(query); } async trace(query: TraceQuery) { - const messages = await this.prisma.smsMessageRecord.findMany({ - where: { - ...messageWhere(query), - messageId: query.messageId, - }, - include: { - batchTask: { include: { apiRequests: true } }, - submitRecords: { include: { session: true } }, - receiptRecords: true, - }, - orderBy: { queuedAt: 'desc' }, - take: 100, - }); - const messageIds = messages.map((message) => message.messageId); - const [billingRecords, uplinks] = await Promise.all([ - this.prisma.smsBillingRecord.findMany({ - where: { - tenantId: query.tenantId, - taskId: query.taskId, - messageId: messageIds.length > 0 ? { in: messageIds } : undefined, - }, - orderBy: { createdAt: 'desc' }, - }), - this.prisma.smsUplinkMessage.findMany({ - where: { - tenantId: query.tenantId, - messageId: messageIds.length > 0 ? { in: messageIds } : undefined, - }, - orderBy: { receivedAt: 'desc' }, - }), - ]); - return { messages, billingRecords, uplinks }; + return this.traceQueries.trace(query); } async reconciliation(query: { tenantId?: string; taskId?: string }) { - const [messages, billing, transactions] = await Promise.all([ - this.prisma.smsMessageRecord.aggregate({ - where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }), - _count: { _all: true }, - _sum: { amountCents: true, billingUnits: true }, - }), - this.prisma.smsBillingRecord.aggregate({ - where: { tenantId: query.tenantId, taskId: query.taskId }, - _count: { _all: true }, - _sum: { amountCents: true, billingUnits: true }, - }), - this.prisma.accountTransaction.aggregate({ - where: { - tenantId: query.tenantId, - relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined, - relatedId: query.taskId, - }, - _count: { _all: true }, - _sum: { amountCents: true }, - }), - ]); - const messageAmount = moneyToNumber(messages._sum.amountCents); - const billingAmount = moneyToNumber(billing._sum.amountCents); - const transactionAmount = moneyToNumber(transactions._sum.amountCents); - return { - messages, - billing, - transactions, - diff: { - messageVsBillingAmountCents: messageAmount - billingAmount, - billingVsTransactionAmountCents: billingAmount + transactionAmount, - messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0), - }, - }; - } - - private countPendingAudits(tenantId?: string) { - return Promise.all([ - this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }), - this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), - this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }), - this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }), - this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }), - ]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({ - templates, - signatures, - drainageInfos, - enterpriseCertifications, - smsAudits, - total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits, - })); - } - - private gatewayDownstreamRecoveryStatusDelegate() { - return (this.prisma as PrismaService & { - gatewayDownstreamRecoveryStatus: { - findMany: (args: Record) => Promise; - count: (args: Record) => Promise; - findUnique: (args: Record) => Promise; - groupBy: (args: Record) => Promise; - }; - }).gatewayDownstreamRecoveryStatus; + return this.traceQueries.reconciliation(query); } } - -function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { - const statusWhere = query.status === 'submit_failed' - ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } - : query.status === 'failed' - ? { status: 'failed', submitStatus: 'accepted' } - : query.status - ? { status: query.status } - : {}; - return { - tenantId: query.tenantId, - applicationId: query.applicationId, - channelId: query.channelId, - batchTaskId: query.taskId, - messageId: query.messageId, - phoneNumber: query.phoneNumber, - ...carrierWhere(query.carrier), - ...statusWhere, - ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), - ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), - ...(query.queuedAtFrom || query.queuedAtTo ? { - queuedAt: { - ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), - ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), - }, - } : {}), - }; -} - -const recognizedCarrierValues = [ - 'mobile', 'cmcc', '移动', '中国移动', - 'unicom', 'cucc', '联通', '中国联通', - 'telecom', 'ctcc', '电信', '中国电信', -]; - -function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput { - if (!carrier) return {}; - // Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized. - if (carrier === 'unknown') { - return { - AND: [ - { - OR: [ - { carrier: null }, - { carrier: { notIn: recognizedCarrierValues } }, - ], - }, - ], - }; - } - const valuesByCarrier: Record = { - mobile: ['mobile', 'cmcc', '移动', '中国移动'], - unicom: ['unicom', 'cucc', '联通', '中国联通'], - telecom: ['telecom', 'ctcc', '电信', '中国电信'], - }; - return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {}; -} - -function startOfShanghaiDay(value: string) { - return new Date(`${value}T00:00:00+08:00`); -} - -function endOfShanghaiDay(value: string) { - return new Date(`${value}T23:59:59.999+08:00`); -} - -function qualityBusinessDay(value?: string) { - const key = value || shanghaiDateKey(); - if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) { - throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD'); - } - const startAt = startOfShanghaiDay(key); - if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) { - throw new BadRequestException('统计日期无效'); - } - return { - key, - startAt, - endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000), - }; -} - -function shanghaiDateKey(value = new Date()) { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(value); - const byType = new Map(parts.map((part) => [part.type, part.value])); - return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`; -} - -function normalizeGroupBy(groupBy?: string) { - if (groupBy === 'tenant' || groupBy === 'tenantId') { - return 'tenantId'; - } - if (groupBy === 'application' || groupBy === 'applicationId') { - return 'applicationId'; - } - return 'channelId'; -} - -function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput { - return { - tenantId, - createdAt: { gte: since }, - OR: [ - { transactionType: 'refunded' }, - { transactionType: 'released', relatedType: 'sms_message_record' }, - ], - }; -} - -function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined { - if (!range || range === 'all') { - return undefined; - } - const date = new Date(); - date.setHours(0, 0, 0, 0); - if (range === '7d') { - date.setDate(date.getDate() - 6); - } else if (range === '30d') { - date.setDate(date.getDate() - 29); - } - return { gte: date }; -} - -function downstreamAlertPendingMinutes() { - const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10); - return Number.isFinite(value) && value > 0 ? value : 10; -} - -function downstreamAlertRecentFailedHours() { - const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1); - return Number.isFinite(value) && value > 0 ? value : 1; -} - -function downstreamAlertWindows(now = new Date()) { - return { - now, - stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000), - recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000), - }; -} - -function downstreamAlertWhere( - scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput, - window: ReturnType, -): Prisma.CmppDownstreamDeliveryWhereInput { - return { - AND: [ - scopedWhere, - { - OR: [ - stalledPendingWhere(window.stalledPendingAt), - { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } }, - { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } }, - ], - }, - ], - }; -} - -function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput { - return { - status: 'pending', - OR: [ - { lastRetriedAt: null, createdAt: { lte: cutoff } }, - { lastRetriedAt: { lte: cutoff } }, - ], - }; -} - -function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput { - const createdAtFrom = parseDateBoundary(query.createdAtFrom, false); - const createdAtTo = parseDateBoundary(query.createdAtTo, true); - return { - tenantId: query.tenantId, - applicationId: query.applicationId, - deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined, - createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined, - }; -} - -function parseDateBoundary(value?: string, endOfDay = false) { - if (!value) return undefined; - const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`); - return Number.isNaN(parsed.getTime()) ? undefined : parsed; -} - -function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) { - const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false); - const updatedAtTo = parseDateBoundary(query.updatedAtTo, true); - return { - tenantId: query.tenantId, - applicationId: query.applicationId, - state: query.state && query.state !== 'all' ? query.state : undefined, - failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined, - updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, - OR: query.keyword ? [ - { account: { contains: query.keyword } }, - { gatewayInstanceId: { contains: query.keyword } }, - { lastError: { contains: query.keyword } }, - { lastSkipReason: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - { application: { name: { contains: query.keyword } } }, - ] : undefined, - }; -} - -function escapeCsvCell(value: string) { - let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - if (/^[=+\-@]/.test(normalized)) { - normalized = `'${normalized}`; - } - if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) { - return `"${normalized.replace(/"/g, '""')}"`; - } - return normalized; -} - -function formatCsvDate(value?: Date | string | null) { - if (!value) { - return ''; - } - return value instanceof Date ? value.toISOString() : value; -} - -function formatExportTimestamp(date: Date) { - const parts = [ - date.getFullYear(), - String(date.getMonth() + 1).padStart(2, '0'), - String(date.getDate()).padStart(2, '0'), - String(date.getHours()).padStart(2, '0'), - String(date.getMinutes()).padStart(2, '0'), - String(date.getSeconds()).padStart(2, '0'), - ]; - return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`; -} - -function clientApplicationView(application?: Record | null) { - if (!application) return null; - return { id: application.id, name: application.name }; -} - -function clientReceiptView(receipt: Record) { - return { - id: receipt.id, - messageId: receipt.messageId, - receiptStatus: receipt.receiptStatus, - rawStatus: receipt.rawStatus, - errorCode: receipt.errorCode ?? null, - errorMessage: receipt.errorMessage ?? null, - deliveredAt: receipt.deliveredAt, - createdAt: receipt.createdAt, - }; -} - -function clientMessageView(message: Record) { - return { - id: message.id, - batchTaskId: message.batchTaskId ?? null, - applicationId: message.applicationId ?? null, - messageId: message.messageId, - phoneNumber: message.phoneNumber, - carrier: message.carrier ?? null, - province: message.province ?? null, - content: message.content, - billingUnits: message.billingUnits, - amountCents: moneyToNumber(message.amountCents), - status: message.status, - submitStatus: message.submitStatus ?? null, - receiptStatus: message.receiptStatus ?? null, - errorCode: message.errorCode ?? null, - errorMessage: message.errorMessage ?? null, - queuedAt: message.queuedAt, - submittedAt: message.submittedAt ?? null, - deliveredAt: message.deliveredAt ?? null, - application: clientApplicationView(message.application), - receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [], - }; -} - -function clientBatchTaskView(task: Record) { - return { - id: task.id, - taskNo: task.taskNo, - applicationId: task.applicationId ?? null, - templateId: task.templateId ?? null, - content: task.content, - category: task.category ?? null, - phoneTotal: task.phoneTotal, - status: task.status, - auditStatus: task.auditStatus ?? null, - reviewReason: task.reviewReason ?? null, - rejectReason: task.rejectReason ?? null, - progressTotal: task.progressTotal, - progressSent: task.progressSent ?? 0, - progressDelivered: task.progressDelivered ?? 0, - progressFailed: task.progressFailed ?? 0, - submittedTotal: task.submittedTotal ?? 0, - successTotal: task.successTotal ?? 0, - failedTotal: task.failedTotal ?? 0, - unknownTotal: task.unknownTotal ?? 0, - timeoutTotal: task.timeoutTotal ?? 0, - scheduledAt: task.scheduledAt ?? null, - canceledAt: task.canceledAt ?? null, - createdAt: task.createdAt, - application: clientApplicationView(task.application), - messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [], - }; -} - -function clientUplinkView(message: Record) { - return { - id: message.id, - applicationId: message.applicationId ?? null, - messageRecordId: message.messageRecordId ?? null, - messageId: message.messageId ?? null, - phoneNumber: message.phoneNumber, - destId: message.destId, - content: message.content, - matchStatus: message.matchStatus, - matchReason: message.matchReason ?? null, - receivedAt: message.receivedAt, - createdAt: message.createdAt, - application: clientApplicationView(message.application), - messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null, - }; -} - -function clientAccountView(account: Record) { - return { - id: account.id, - tenantId: account.tenantId, - balanceCents: moneyToNumber(account.balanceCents), - creditCents: moneyToNumber(account.creditCents), - status: account.status, - updatedAt: account.updatedAt, - tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null, - }; -} - -function clientRechargeView(order: Record) { - return { - id: order.id, - orderNo: order.orderNo, - amountCents: moneyToNumber(order.amountCents), - status: order.status, - payMethod: order.payMethod, - remark: order.remark ?? null, - createdAt: order.createdAt, - completedAt: order.completedAt ?? null, - }; -} - -function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) { - return groups.reduce( - (summary, group) => { - const count = group._count._all; - summary.total += count; - summary.amountCents += moneyToNumber(group._sum.amountCents); - summary.billingUnits += group._sum.billingUnits ?? 0; - if (group.status === 'delivered') { - summary.delivered += count; - } else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) { - summary.failed += count; - } else if (group.status === 'unknown') { - summary.unknown += count; - } - return summary; - }, - { total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 }, - ); -} - -function groupDownstreamByType( - groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>, -) { - return groups.reduce>((accumulator, item) => { - const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 }; - current.total += item._count._all; - if (item.status === 'pending') { - current.pending += item._count._all; - } else if (item.status === 'awaiting_ack') { - current.awaitingAck += item._count._all; - } else if (item.status === 'delivered') { - current.delivered += item._count._all; - } else if (item.status === 'failed') { - current.failed += item._count._all; - } else if (item.status === 'unconfirmed') { - current.unconfirmed += item._count._all; - } else if (item.status === 'rejected') { - current.rejected += item._count._all; - } - accumulator[item.deliveryType] = current; - return accumulator; - }, {}); -} - -function groupDownstreamByApplication( - groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>, - applicationMap: Map, - applicationAlertMap: Map, -) { - const summaryMap = new Map(); - groups.forEach((item) => { - const current = summaryMap.get(item.applicationId) ?? { - applicationId: item.applicationId, - name: applicationMap.get(item.applicationId) ?? item.applicationId, - pending: 0, - awaitingAck: 0, - failed: 0, - unconfirmed: 0, - rejected: 0, - delivered: 0, - alertCount: 0, - }; - if (item.status === 'pending') { - current.pending += item._count._all; - } else if (item.status === 'awaiting_ack') { - current.awaitingAck += item._count._all; - } else if (item.status === 'failed') { - current.failed += item._count._all; - } else if (item.status === 'unconfirmed') { - current.unconfirmed += item._count._all; - } else if (item.status === 'rejected') { - current.rejected += item._count._all; - } else if (item.status === 'delivered') { - current.delivered += item._count._all; - } - current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0; - summaryMap.set(item.applicationId, current); - }); - return [...summaryMap.values()]; -} - -function positiveInteger(value: number | undefined, fallback: number) { - const normalized = Number(value); - return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback; -} - -function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput { - const error: Prisma.OperationLogWhereInput = { - OR: [ - { action: { contains: 'failed' } }, - { action: { contains: 'reject' } }, - { detail: { path: ['result'], string_contains: 'fail' } }, - { detail: { path: ['status'], string_contains: 'fail' } }, - ], - }; - const warning: Prisma.OperationLogWhereInput = { - OR: [ - { action: { contains: 'warning' } }, - { action: { contains: 'risk' } }, - ], - }; - const success: Prisma.OperationLogWhereInput = { - OR: [ - { action: { contains: 'approve' } }, - { action: { contains: 'recharge' } }, - { action: { contains: 'connected' } }, - ], - }; - if (level === 'error') { - return error; - } - if (level === 'warning') { - return { AND: [{ NOT: error }, warning] }; - } - if (level === 'success') { - return { AND: [{ NOT: error }, { NOT: warning }, success] }; - } - if (level === 'info') { - return { NOT: { OR: [error, warning, success] } }; - } - return {}; -} - -function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { - const detail = (log.detail ?? {}) as Record; - const result = String(detail.result ?? detail.status ?? ''); - const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') - ? 'error' - : log.action.includes('warning') || log.action.includes('risk') - ? 'warning' - : log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected') - ? 'success' - : 'info'; - return { - id: log.id, - time: log.createdAt, - level, - tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'), - module: log.resource, - operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system', - action: log.action, - resourceId: log.resourceId ?? '', - detail, - ip: log.ipAddress ?? '', - userAgent: log.userAgent ?? '', - }; -} - -function sanitizeGatewaySubmitException( - item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>, - messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string }, -) { - const { rawPayload, commandPayload, tenant, application, channel, ...record } = item; - return { - ...record, - tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null, - application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null, - channel: channel ? { - id: channel.id, - code: channel.code, - name: channel.name, - status: channel.status, - carrier: channel.carrier, - sendRegion: channel.sendRegion, - rateLimitPerSecond: channel.rateLimitPerSecond, - } : null, - rawPayloadAvailable: Boolean(rawPayload), - commandPayload: redactGatewayCommandValue(commandPayload), - messageState: messageState ?? null, - }; -} - -function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null { - if (Array.isArray(value)) { - return value.map((item) => redactGatewayCommandValue(item)); - } - if (value && typeof value === 'object') { - const redacted: Record = {}; - for (const [key, child] of Object.entries(value)) { - const normalizedKey = key.toLowerCase(); - redacted[key] = [ - 'password', 'passwordcipher', 'secret', 'secrethash', 'authsource', - 'token', 'apikey', 'accesskey', 'secretkey', - ].includes(normalizedKey) - ? '[REDACTED]' - : redactGatewayCommandValue(child as Prisma.JsonValue); - } - return redacted; - } - return value; -} diff --git a/api/src/operations/queries/dashboard.queries.ts b/api/src/operations/queries/dashboard.queries.ts new file mode 100644 index 0000000..8c2f8a6 --- /dev/null +++ b/api/src/operations/queries/dashboard.queries.ts @@ -0,0 +1,377 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { moneyToNumber } from '../../common/money'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; +import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline. +export class OperationsDashboardQueries { + constructor(private readonly prisma: PrismaService) {} + +async dashboard(query: { tenantId?: string }) { + const businessDay = qualityBusinessDay(); + const sinceToday = businessDay.startAt; + const downstreamAlertWindow = downstreamAlertWindows(); + const messageWhereClause = messageWhere({ tenantId: query.tenantId }); + const todayMessageWhereClause = { + ...messageWhereClause, + queuedAt: { gte: sinceToday, lt: businessDay.endAt }, + }; + const [ + taskCount, + messageGroups, + todayMessageGroups, + uplinkCount, + billingAggregate, + transactionAggregate, + connectionGroups, + pendingAudits, + tenantAccounts, + recentTasks, + recentRecharges, + enterpriseSpendRows, + downstreamPendingCount, + downstreamFailedCount, + downstreamDeliveredCount, + downstreamStalledPendingCount, + downstreamStalledAckCount, + downstreamRecentFailedCount, + hourlySendRows, + auditSpeedRows, + ] = await Promise.all([ + this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }), + this.prisma.smsMessageRecord.groupBy({ + by: ['status'], + where: messageWhereClause, + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }), + this.prisma.smsMessageRecord.groupBy({ + by: ['status'], + where: todayMessageWhereClause, + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }), + this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }), + this.prisma.smsBillingRecord.aggregate({ + where: { tenantId: query.tenantId }, + _sum: { amountCents: true, billingUnits: true }, + _count: { _all: true }, + }), + this.prisma.accountTransaction.aggregate({ + where: returnedTransactionWhere(sinceToday, query.tenantId), + _sum: { amountCents: true }, + _count: { _all: true }, + }), + this.prisma.cmppConnectionState.groupBy({ + by: ['status'], + where: { tenantId: query.tenantId }, + _count: { _all: true }, + _sum: { currentConnections: true, desiredConnections: true }, + }), + this.countPendingAudits(query.tenantId), + this.prisma.tenantAccount.findMany({ + where: query.tenantId ? { tenantId: query.tenantId } : undefined, + include: { tenant: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, + }), + this.prisma.smsBatchTask.findMany({ + where: query.tenantId ? { tenantId: query.tenantId } : undefined, + include: { application: true, messages: { take: 1, include: { channel: true } } }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + this.prisma.rechargeOrder.findMany({ + where: { + tenantId: query.tenantId, + payMethod: 'manual_topup', + }, + include: { tenant: true }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + this.prisma.$queryRaw>(Prisma.sql` + SELECT + tenant.id AS "tenantId", + tenant.name AS "tenantName", + COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents", + account."balanceCents" AS "balanceCents", + account."creditCents" AS "creditCents" + FROM "TenantAccount" account + JOIN "Tenant" tenant ON tenant.id = account."tenantId" + LEFT JOIN "SmsBillingRecord" billing + ON billing."tenantId" = tenant.id + AND billing."createdAt" >= ${businessDay.startAt} + AND billing."createdAt" < ${businessDay.endAt} + WHERE tenant.status <> 'deleted' + AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null}) + GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" + ORDER BY "todaySpendCents" DESC, tenant.name ASC + `), + this.prisma.cmppDownstreamDelivery.count({ + where: { tenantId: query.tenantId, status: 'pending' }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { tenantId: query.tenantId, status: 'failed' }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { tenantId: query.tenantId, status: 'delivered' }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + tenantId: query.tenantId, + ...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt), + }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + tenantId: query.tenantId, + status: 'awaiting_ack', + ackDeadlineAt: { lte: downstreamAlertWindow.now }, + }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + tenantId: query.tenantId, + status: { in: ['failed', 'unconfirmed', 'rejected'] }, + updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, + }, + }), + this.prisma.$queryRaw>(Prisma.sql` + SELECT + EXTRACT( + HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai' + )::integer AS hour, + COUNT(*)::bigint AS "submittedCount", + COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount" + FROM "SmsMessageRecord" message + WHERE message."queuedAt" >= ${businessDay.startAt} + AND message."queuedAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null}) + GROUP BY 1 + ORDER BY 1 + `), + // Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit. + this.prisma.$queryRaw>(Prisma.sql` + WITH review_samples AS ( + SELECT + 'enterpriseCertifications'::text AS category, + certification."submittedAt" AS "submittedAt", + certification."reviewedAt" AS "reviewedAt" + FROM "EnterpriseCertification" certification + WHERE certification."reviewedAt" >= ${businessDay.startAt} + AND certification."reviewedAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null}) + + UNION ALL + + SELECT + 'smsAudits'::text, + task."createdAt", + task."reviewedAt" + FROM "SmsSendTask" task + WHERE task."reviewedAt" >= ${businessDay.startAt} + AND task."reviewedAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null}) + + UNION ALL + + SELECT + 'drainageInfos'::text, + drainage."submittedAt", + drainage."reviewedAt" + FROM "SmsDrainageInfo" drainage + WHERE drainage."reviewedAt" >= ${businessDay.startAt} + AND drainage."reviewedAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null}) + + UNION ALL + + SELECT + CASE review."targetType" + WHEN 'sms_signature' THEN 'signatures' + WHEN 'sms_template' THEN 'templates' + END, + submission."createdAt", + review."createdAt" + FROM "AuditRecord" review + JOIN LATERAL ( + SELECT pending."createdAt" + FROM "AuditRecord" pending + WHERE pending."targetType" = review."targetType" + AND pending."targetId" = review."targetId" + AND pending."statusAfter" = 'pending' + AND pending."createdAt" <= review."createdAt" + ORDER BY pending."createdAt" DESC + LIMIT 1 + ) submission ON true + WHERE review."targetType" IN ('sms_signature', 'sms_template') + AND review."statusBefore" = 'pending' + AND review."statusAfter" IN ('approved', 'rejected') + AND review."createdAt" >= ${businessDay.startAt} + AND review."createdAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null}) + ) + SELECT + category, + COUNT(*)::bigint AS count, + ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs" + FROM review_samples + WHERE "reviewedAt" >= "submittedAt" + GROUP BY category + `), + ]); + const todayTotals = summarizeMessageGroups(todayMessageGroups); + const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row])); + // Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data. + const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => { + const row = hourlyRowsByHour.get(hour); + return { + hour, + label: `${String(hour).padStart(2, '0')}:00`, + submittedCount: Number(row?.submittedCount ?? 0), + successCount: Number(row?.successCount ?? 0), + }; + }); + const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row])); + const auditProcessingSpeed = [ + ['enterpriseCertifications', '企业认证'], + ['smsAudits', '短信审核'], + ['templates', '模板'], + ['signatures', '签名'], + ['drainageInfos', '引流信息'], + ].map(([category, label]) => { + const row = auditSpeedByCategory.get(category); + return { + category, + label, + count: Number(row?.count ?? 0), + averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs), + }; + }); + const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount; + return { + taskCount, + messageStatus: messageGroups, + today: { + sent: todayTotals.total, + delivered: todayTotals.delivered, + failed: todayTotals.failed, + unknown: todayTotals.unknown, + successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0, + spendCents: todayTotals.amountCents, + returnedCents: moneyToNumber(transactionAggregate._sum.amountCents), + billingUnits: todayTotals.billingUnits, + }, + uplinkCount, + billing: billingAggregate, + transactions: transactionAggregate, + gatewayConnections: connectionGroups, + pendingAuditCount: pendingAudits.total, + pendingAudits, + hourlySendTrend, + auditProcessingSpeed, + downstreamDeliverySummary: { + pending: downstreamPendingCount, + failed: downstreamFailedCount, + delivered: downstreamDeliveredCount, + stalledPending: downstreamStalledPendingCount, + stalledAck: downstreamStalledAckCount, + recentFailed: downstreamRecentFailedCount, + alertCount: downstreamAlertCount, + }, + accounts: tenantAccounts, + enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({ + tenantId: row.tenantId, + tenantName: row.tenantName, + todaySpendCents: moneyToNumber(row.todaySpendCents), + balanceCents: moneyToNumber(row.balanceCents), + creditCents: moneyToNumber(row.creditCents), + })), + recentTasks, + recentRecharges, + }; + } +async clientDashboard(query: { tenantId?: string }) { + const tenantId = query.tenantId; + const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([ + this.dashboard(query), + tenantId + ? this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true } }) + : Promise.resolve(null), + tenantId + ? this.prisma.enterpriseCertification.findFirst({ + where: { tenantId, status: 'approved' }, + select: { id: true }, + }) + : Promise.resolve(null), + tenantId + ? this.prisma.smsSignature.count({ + where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, + }) + : Promise.resolve(0), + tenantId + ? this.prisma.smsBatchTask.count({ + where: { tenantId, sourceType: 'client', status: 'pending_review' }, + }) + : Promise.resolve(0), + ]); + return { + taskCount: dashboard.taskCount, + messageStatus: dashboard.messageStatus, + today: dashboard.today, + uplinkCount: dashboard.uplinkCount, + billing: dashboard.billing, + transactions: dashboard.transactions, + gatewayConnections: [], + pendingAuditCount: dashboard.pendingAuditCount, + pendingAudits: dashboard.pendingAudits, + hourlySendTrend: dashboard.hourlySendTrend, + auditProcessingSpeed: dashboard.auditProcessingSpeed, + downstreamDeliverySummary: dashboard.downstreamDeliverySummary, + accounts: dashboard.accounts.map(clientAccountView), + enterpriseSpendRanks: dashboard.enterpriseSpendRanks, + recentTasks: dashboard.recentTasks.map(clientBatchTaskView), + recentRecharges: dashboard.recentRecharges.map(clientRechargeView), + clientOverview: { + enterpriseName: tenant?.name ?? null, + certificationStatus: approvedCertification ? 'certified' : 'uncertified', + signatureCount, + pendingBatchTaskCount, + }, + }; + } +private countPendingAudits(tenantId?: string) { + return Promise.all([ + this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }), + this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), + this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }), + this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }), + this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }), + ]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({ + templates, + signatures, + drainageInfos, + enterpriseCertifications, + smsAudits, + total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits, + })); + } +} diff --git a/api/src/operations/queries/downstream.queries.ts b/api/src/operations/queries/downstream.queries.ts new file mode 100644 index 0000000..82872e5 --- /dev/null +++ b/api/src/operations/queries/downstream.queries.ts @@ -0,0 +1,371 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { moneyToNumber } from '../../common/money'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; +import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +// R2 downstream query domain. Method bodies are preserved byte-for-byte from the facade baseline. +export class OperationsDownstreamQueries { + constructor(private readonly prisma: PrismaService) {} + +async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) { + const page = Math.max(1, Number(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); + const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = { + tenantId: query.tenantId, + applicationId: query.applicationId, + channelId: query.channelId, + OR: query.keyword ? [ + { streamMessageId: { contains: query.keyword } }, + { traceId: { contains: query.keyword } }, + { messageId: { contains: query.keyword } }, + { submitId: { contains: query.keyword } }, + { failureCode: { contains: query.keyword } }, + { failureMessage: { contains: query.keyword } }, + ] : undefined, + }; + const where: Prisma.GatewaySubmitDeadLetterWhereInput = { + ...baseWhere, + status: query.status && query.status !== 'all' ? query.status : undefined, + }; + const [items, total, statusGroups, oldestPending] = await Promise.all([ + this.prisma.gatewaySubmitDeadLetter.findMany({ + where, + include: { tenant: true, application: true, channel: true }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.gatewaySubmitDeadLetter.count({ where }), + this.prisma.gatewaySubmitDeadLetter.groupBy({ + by: ['status'], + where: baseWhere, + _count: { _all: true }, + }), + this.prisma.gatewaySubmitDeadLetter.findFirst({ + where: { ...baseWhere, status: 'pending' }, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true }, + }), + ]); + const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all])); + const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value)); + const messageStates = messageIds.length > 0 + ? await this.prisma.smsMessageRecord.findMany({ + where: { messageId: { in: messageIds } }, + select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true }, + }) + : []; + const messageStateById = new Map(messageStates.map((item) => [item.messageId, item])); + return { + items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)), + total, + page, + pageSize, + summary: { + pending: statusCounts.get('pending') ?? 0, + requeueing: statusCounts.get('requeueing') ?? 0, + requeued: statusCounts.get('requeued') ?? 0, + resolved: statusCounts.get('resolved') ?? 0, + oldestPendingAt: oldestPending?.createdAt ?? null, + }, + }; + } +async listDownstreamDeliveries(query: DownstreamDeliveryQuery) { + const page = Math.max(1, Number(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); + const where: Prisma.CmppDownstreamDeliveryWhereInput = { + ...downstreamDeliveryScopedWhere(query), + status: query.status && query.status !== 'all' ? query.status : undefined, + OR: query.keyword ? [ + { messageId: { contains: query.keyword } }, + { payload: { path: ['account'], string_contains: query.keyword } }, + { payload: { path: ['phoneNumber'], string_contains: query.keyword } }, + { lastError: { contains: query.keyword } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.cmppDownstreamDelivery.findMany({ + where, + include: { + tenant: true, + application: true, + messageRecord: true, + attempts: { orderBy: [{ attemptNo: 'desc' }, { createdAt: 'desc' }] }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.cmppDownstreamDelivery.count({ where }), + ]); + return { items, total, page, pageSize }; + } +async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) { + const scopedWhere = downstreamDeliveryScopedWhere(query); + const downstreamAlertWindow = downstreamAlertWindows(); + const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([ + this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }), + this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }), + this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }), + this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }), + this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }), + this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'unconfirmed' } }), + this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'rejected' } }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + ...scopedWhere, + ...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt), + }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + ...scopedWhere, + status: { in: ['failed', 'unconfirmed', 'rejected'] }, + updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, + }, + }), + this.prisma.cmppDownstreamDelivery.groupBy({ + by: ['deliveryType', 'status'], + where: scopedWhere, + _count: { _all: true }, + }), + this.prisma.cmppDownstreamDelivery.groupBy({ + by: ['applicationId', 'status'], + where: scopedWhere, + _count: { _all: true }, + }), + this.prisma.cmppDownstreamDelivery.groupBy({ + by: ['applicationId'], + where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow), + _count: { _all: true }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + ...scopedWhere, + status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, + retryCount: 0, + }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + ...scopedWhere, + status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, + retryCount: { gte: 1, lte: 3 }, + }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { + ...scopedWhere, + status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, + retryCount: { gte: 4 }, + }, + }), + ]); + const applicationIds = [...new Set(applicationGroups.map((item) => item.applicationId).filter((value): value is string => Boolean(value)))]; + const applications: Array<{ id: string; name: string }> = applicationIds.length > 0 + ? await this.prisma.smsApplication.findMany({ + where: { id: { in: applicationIds } }, + select: { id: true, name: true }, + }) + : []; + const applicationMap = new Map(applications.map((item) => [item.id, item.name])); + const applicationAlertMap = new Map( + applicationAlertGroups.map((item) => [item.applicationId, item._count._all]), + ); + const groupedByType = groupDownstreamByType(typeGroups); + const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap); + + return { + summary: { + total, + pending, + awaitingAck, + delivered, + failed, + unconfirmed, + rejected, + stalledPending, + stalledAck, + recentFailed, + alertCount: stalledPending + stalledAck + recentFailed, + }, + typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({ + deliveryType, + total: groupedByType[deliveryType]?.total ?? 0, + pending: groupedByType[deliveryType]?.pending ?? 0, + awaitingAck: groupedByType[deliveryType]?.awaitingAck ?? 0, + delivered: groupedByType[deliveryType]?.delivered ?? 0, + failed: groupedByType[deliveryType]?.failed ?? 0, + unconfirmed: groupedByType[deliveryType]?.unconfirmed ?? 0, + rejected: groupedByType[deliveryType]?.rejected ?? 0, + })), + retryBuckets: [ + { label: '0次', count: retryZero }, + { label: '1-3次', count: retryLow }, + { label: '4次及以上', count: retryHigh }, + ], + topApplications: groupedByApplication + .sort((left, right) => ( + right.alertCount - left.alertCount + || right.failed - left.failed + || right.pending - left.pending + || left.name.localeCompare(right.name, 'zh-CN') + )) + .slice(0, 5), + }; + } +async listDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) { + const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); + const page = Math.max(1, Number(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); + const where = downstreamRecoveryStatusWhere(query); + const now = new Date(); + const [items, total, runningCount, successCount, failedCount, waitingConnectionCount, backoffCount, categoryGroups] = await Promise.all([ + recoveryStatuses.findMany({ + where, + include: { tenant: true, application: true }, + orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + recoveryStatuses.count({ where }), + recoveryStatuses.count({ where: { ...where, state: 'running' } }), + recoveryStatuses.count({ where: { ...where, state: 'success' } }), + recoveryStatuses.count({ where: { ...where, state: 'failed' } }), + recoveryStatuses.count({ where: { ...where, state: 'waiting_connection' } }), + recoveryStatuses.count({ + where: { + ...where, + nextRetryAt: { gt: now }, + }, + }), + recoveryStatuses.groupBy({ + by: ['failureCategory'], + where, + _count: { _all: true }, + }), + ]); + return { + items, + total, + page, + pageSize, + summary: { + total, + running: runningCount, + success: successCount, + failed: failedCount, + waitingConnection: waitingConnectionCount, + backoff: backoffCount, + failureCategories: categoryGroups + .filter((item) => item.failureCategory) + .map((item) => ({ + category: String(item.failureCategory), + count: item._count?._all ?? 0, + })) + .sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)), + }, + }; + } +async listMessageSegmentAudits(query: MessageSegmentAuditQuery) { + const segmentAudits = (this.prisma as PrismaService & { + smsMessageSegmentAudit: { + findMany: (args: Record) => Promise; + }; + }).smsMessageSegmentAudit; + if (!query.messageId && !query.messageRecordId) { + return []; + } + return segmentAudits.findMany({ + where: { + messageRecordId: query.messageRecordId, + messageRecord: query.messageId ? { messageId: query.messageId } : undefined, + }, + include: { channel: true, submitRecord: true }, + orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }], + }); + } +async getDownstreamRecoveryStatus(id: string) { + const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); + const item = await recoveryStatuses.findUnique({ + where: { id }, + include: { tenant: true, application: true }, + }); + if (!item) { + throw new NotFoundException('Recovery status not found'); + } + return item; + } +async exportDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) { + const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); + const where = downstreamRecoveryStatusWhere(query); + const items = await recoveryStatuses.findMany({ + where, + include: { tenant: true, application: true }, + orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }], + take: 5000, + }); + const rows = [ + [ + '账号', + '企业', + '应用', + 'Gateway实例', + '恢复状态', + '锁持有实例', + '锁过期时间', + '失败分类', + '尝试次数', + '最后尝试时间', + '恢复成功时间', + '恢复失败时间', + '下次恢复时间', + '最后错误', + '最后跳过原因', + '创建时间', + '更新时间', + ], + ...items.map((item) => [ + item.account ?? '', + item.tenant?.name ?? '', + item.application?.name ?? '', + item.gatewayInstanceId ?? '', + item.state ?? '', + (item as { lockOwner?: string | null }).lockOwner ?? '', + formatCsvDate((item as { lockExpiresAt?: Date | string | null }).lockExpiresAt), + (item as { failureCategory?: string | null }).failureCategory ?? '', + String(item.attemptCount ?? 0), + formatCsvDate(item.lastAttemptAt), + formatCsvDate(item.lastSuccessAt), + formatCsvDate(item.lastFailureAt), + formatCsvDate(item.nextRetryAt), + item.lastError ?? '', + item.lastSkipReason ?? '', + formatCsvDate(item.createdAt), + formatCsvDate(item.updatedAt), + ]), + ]; + + return { + fileName: `gateway-downstream-recovery-statuses-${formatExportTimestamp(new Date())}.csv`, + content: rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n'), + total: items.length, + }; + } +private gatewayDownstreamRecoveryStatusDelegate() { + return (this.prisma as PrismaService & { + gatewayDownstreamRecoveryStatus: { + findMany: (args: Record) => Promise; + count: (args: Record) => Promise; + findUnique: (args: Record) => Promise; + groupBy: (args: Record) => Promise; + }; + }).gatewayDownstreamRecoveryStatus; + } +} diff --git a/api/src/operations/queries/logs.queries.ts b/api/src/operations/queries/logs.queries.ts new file mode 100644 index 0000000..706fcac --- /dev/null +++ b/api/src/operations/queries/logs.queries.ts @@ -0,0 +1,121 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { moneyToNumber } from '../../common/money'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; +import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +// R2 logs query domain. Method bodies are preserved byte-for-byte from the facade baseline. +export class OperationsLogQueries { + constructor(private readonly prisma: PrismaService) {} + +async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) { + const page = positiveInteger(query.page, 1); + const pageSize = Math.min(100, positiveInteger(query.pageSize, 20)); + const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId }; + const [items, total] = await Promise.all([ + this.prisma.operationLog.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.operationLog.count({ where }), + ]); + return { items, total, page, pageSize }; + } +async systemLogs(query: OperationLogQuery) { + const page = positiveInteger(query.page, 1); + const pageSize = Math.min(100, positiveInteger(query.pageSize, 10)); + const where: Prisma.OperationLogWhereInput = { + tenantId: query.tenantId, + userId: query.userId, + createdAt: createdAtRange(query.range), + resource: query.module && query.module !== 'all' ? query.module : undefined, + AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined, + OR: query.keyword ? [ + { action: { contains: query.keyword } }, + { resource: { contains: query.keyword } }, + { resourceId: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { user: { displayName: { contains: query.keyword } } }, + { user: { username: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total, modules] = await Promise.all([ + this.prisma.operationLog.findMany({ + where, + include: { tenant: true, user: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.operationLog.count({ where }), + this.prisma.operationLog.groupBy({ + by: ['resource'], + where: { tenantId: query.tenantId }, + _count: { _all: true }, + orderBy: { resource: 'asc' }, + }), + ]); + return { + items: items.map((item) => normalizeOperationLog(item)), + total, + page, + pageSize, + modules: modules.map((item) => item.resource), + }; + } +async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) { + const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined; + const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId }; + const where: Prisma.OperationLogWhereInput = { + tenantId: effectiveQuery.tenantId, + userId: effectiveQuery.userId, + createdAt: createdAtRange(effectiveQuery.range), + resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined, + AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined, + OR: effectiveQuery.keyword ? [ + { action: { contains: effectiveQuery.keyword } }, + { resource: { contains: effectiveQuery.keyword } }, + { resourceId: { contains: effectiveQuery.keyword } }, + { tenant: { name: { contains: effectiveQuery.keyword } } }, + { user: { displayName: { contains: effectiveQuery.keyword } } }, + { user: { username: { contains: effectiveQuery.keyword } } }, + ] : undefined, + }; + const rows = await this.prisma.operationLog.findMany({ + where, + include: { tenant: true, user: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: 10_001, + }); + const truncated = rows.length > 10_000; + const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog); + const clientExport = Boolean(clientUserId); + const headers = clientExport + ? ['时间', '级别', '模块', '操作人', '动作', '资源ID'] + : ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP']; + const values = exportedRows.map((item) => clientExport + ? [item.time, item.level, item.module, item.operator, item.action, item.resourceId] + : [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]); + return { + operationId: randomUUID(), + status: 'completed' as const, + fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`, + recordCount: exportedRows.length, + truncated, + content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'), + filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range }, + }; + } +private async resolveClientTenantId(userId: string) { + const user = await this.prisma.user.findFirst({ + where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } }, + select: { tenantId: true }, + }); + if (!user?.tenantId) throw new NotFoundException('Client tenant not found'); + return user.tenantId; + } +} diff --git a/api/src/operations/queries/messages.queries.ts b/api/src/operations/queries/messages.queries.ts new file mode 100644 index 0000000..26eb8fe --- /dev/null +++ b/api/src/operations/queries/messages.queries.ts @@ -0,0 +1,147 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { moneyToNumber } from '../../common/money'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; +import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +// R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline. +export class OperationsMessageQueries { + constructor(private readonly prisma: PrismaService) {} + +listBatchTasks(query: { tenantId?: string; status?: string }) { + return this.prisma.smsBatchTask.findMany({ + where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' }, + include: { apiRequests: true }, + orderBy: { createdAt: 'desc' }, + }); + } +async listClientBatchTasks(query: { tenantId?: string; status?: string }) { + const items = await this.listBatchTasks(query); + return items.map(clientBatchTaskView); + } +listMessages(query: MessageQuery) { + return this.prisma.smsMessageRecord.findMany({ + where: messageWhere(query), + include: { + tenant: true, + application: true, + channel: true, + submitRecords: { include: { channel: true, channelGroup: true } }, + receiptRecords: { include: { channel: true } }, + downstreamDeliveries: { + where: { deliveryType: 'receipt' }, + select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, + }, + }, + orderBy: { queuedAt: 'desc' }, + }); + } +async listMessagesPage(query: MessageQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25))); + const where = messageWhere(query); + const [items, total] = await Promise.all([ + this.prisma.smsMessageRecord.findMany({ + where, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + channel: { select: { id: true, name: true, srcId: true } }, + submitRecords: { + select: { + id: true, + submitId: true, + channelId: true, + channelGroupId: true, + channelGroupName: true, + gatewayMessageId: true, + submitStatus: true, + submittedAt: true, + createdAt: true, + channel: { select: { id: true, name: true } }, + channelGroup: { select: { id: true, name: true } }, + }, + }, + receiptRecords: { + select: { + id: true, + messageId: true, + gatewayMessageId: true, + receiptStatus: true, + rawStatus: true, + errorCode: true, + errorMessage: true, + deliveredAt: true, + createdAt: true, + channelId: true, + channel: { select: { id: true, name: true } }, + }, + }, + downstreamDeliveries: { + where: { deliveryType: 'receipt' }, + select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, + }, + }, + orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.smsMessageRecord.count({ where }), + ]); + return { items, total, page, pageSize }; + } +async exportMessages(query: MessageQuery) { + const items = await this.prisma.smsMessageRecord.findMany({ + where: messageWhere(query), + select: { + messageId: true, + queuedAt: true, + phoneNumber: true, + province: true, + carrier: true, + billingUnits: true, + amountCents: true, + status: true, + submitStatus: true, + deliveredAt: true, + content: true, + tenant: { select: { name: true } }, + application: { select: { name: true } }, + channel: { select: { name: true } }, + }, + orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], + }); + const rows = [ + ['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'], + ...items.map((item) => [ + item.messageId, + item.tenant?.name ?? '', + item.application?.name ?? '', + item.queuedAt.toISOString(), + item.phoneNumber, + item.province ?? '', + item.carrier ?? '', + String(item.billingUnits), + String(moneyToNumber(item.amountCents)), + item.channel?.name ?? '', + item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status, + item.deliveredAt?.toISOString() ?? '', + item.content, + ]), + ]; + return { + fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`, + content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'), + }; + } +async listClientMessages(query: MessageQuery) { + const items = await this.listMessages(query); + return items.map(clientMessageView); + } +async listClientMessagesPage(query: MessageQuery) { + const result = await this.listMessagesPage(query); + return { ...result, items: result.items.map(clientMessageView) }; + } +} diff --git a/api/src/operations/queries/quality.queries.ts b/api/src/operations/queries/quality.queries.ts new file mode 100644 index 0000000..8ae6eaf --- /dev/null +++ b/api/src/operations/queries/quality.queries.ts @@ -0,0 +1,580 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { moneyToNumber } from '../../common/money'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; +import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline. +export class OperationsQualityQueries { + constructor(private readonly prisma: PrismaService) {} + +async statistics(query: { tenantId?: string; groupBy?: string }) { + const groupBy = normalizeGroupBy(query.groupBy); + if (groupBy === 'tenantId') { + return this.prisma.smsMessageRecord.groupBy({ + by: ['tenantId'], + where: messageWhere({ tenantId: query.tenantId }), + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }); + } + if (groupBy === 'applicationId') { + return this.prisma.smsMessageRecord.groupBy({ + by: ['applicationId'], + where: messageWhere({ tenantId: query.tenantId }), + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }); + } + return this.prisma.smsMessageRecord.groupBy({ + by: ['channelId'], + where: messageWhere({ tenantId: query.tenantId }), + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }); + } +async sendQuality(date?: string) { + const day = qualityBusinessDay(date); + const [channels, signatures, summaryRows, applications] = await Promise.all([ + this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + submit."channelId" AS channel_id, + channel.name AS channel_name, + submit."submitStatus" AS submit_status, + receipt."deliveredAt" AS delivered_at, + failed_receipt."failedAt" AS failed_at, + COALESCE(segment_summary.segment_count, 0) AS segment_count, + COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, + COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, + CASE + WHEN segment_summary.segment_count > 0 + AND segment_summary.delivered_count = segment_summary.segment_count + AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") + THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 + WHEN segment_summary.segment_count = 0 + AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") + THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 + END AS arrival_ms + FROM "SmsSubmitRecord" submit + JOIN "SmsChannel" channel ON channel.id = submit."channelId" + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::integer AS segment_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, + MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at + FROM "SmsMessageSegmentAudit" segment + WHERE segment."submitRecordId" = submit.id + ) segment_summary ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS "deliveredAt" + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) receipt ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS "failedAt" + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'undelivered' + ) failed_receipt ON TRUE + WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') + AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} + AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} + ), classified AS ( + SELECT + *, + CASE + WHEN submit_status <> 'accepted' THEN 'submit_failed' + WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' + WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' + WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' + WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' + ELSE 'unknown' + END AS delivery_status + FROM base + ) + SELECT + channel_id AS "channelId", + MAX(channel_name) AS "channelName", + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", + COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", + CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate", + COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", + COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", + COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", + CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate", + CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate", + CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate", + ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" + FROM classified + GROUP BY channel_id + ORDER BY COUNT(*) DESC, channel_id + `), + this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + message."signatureId" AS signature_id, + (message."drainageInfoId" IS NOT NULL) AS has_drainage, + message.status, + message."submitStatus" AS submit_status, + message."receiptStatus" AS receipt_status, + CASE + WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') + AND message."submittedAt" IS NOT NULL + AND message."deliveredAt" >= message."submittedAt" + THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 + END AS arrival_ms + FROM "SmsMessageRecord" message + WHERE message."signatureId" IS NOT NULL + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + ) + SELECT + signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id, + signature.id AS "signatureId", + signature.name AS "signatureName", + tenant.id AS "tenantId", + tenant.name AS "tenantName", + base.has_drainage AS "hasDrainage", + COUNT(*)::integer AS total, + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + )::integer AS "acceptedCount", + COUNT(*) FILTER ( + WHERE base.status = 'submit_failed' + OR base.submit_status IN ('rejected', 'timeout') + )::integer AS "submitFailureCount", + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "unknownCount", + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "failureCount", + CASE + WHEN COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') + * 100.0 + / COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ), + 1 + )::double precision + END AS "successRate", + ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" + FROM base + JOIN "SmsSignature" signature ON signature.id = base.signature_id + JOIN "Tenant" tenant ON tenant.id = signature."tenantId" + GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage + ORDER BY "successCount" DESC, total DESC, signature.name + `), + this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT message.status, message."receiptStatus" AS receipt_status + FROM "SmsMessageRecord" message + WHERE message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + AND COALESCE(message.status, '') <> 'rejected' + ) + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", + COUNT(*) FILTER ( + WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "unknownCount", + COUNT(*) FILTER ( + WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "failureCount", + CASE + WHEN COUNT(*) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') + * 100.0 / COUNT(*), + 1 + )::double precision + END AS "successRate" + FROM base + `), + this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + message."applicationId" AS application_id, + message.status, + message."receiptStatus" AS receipt_status + FROM "SmsMessageRecord" message + WHERE message."applicationId" IS NOT NULL + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + AND COALESCE(message.status, '') <> 'rejected' + ) + SELECT + application.id AS "applicationId", + application.name AS "applicationName", + tenant.id AS "tenantId", + tenant.name AS "tenantName", + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", + COUNT(*) FILTER ( + WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "unknownCount", + COUNT(*) FILTER ( + WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "failureCount", + CASE + WHEN COUNT(*) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') + * 100.0 / COUNT(*), + 1 + )::double precision + END AS "successRate" + FROM base + JOIN "SmsApplication" application ON application.id = base.application_id + JOIN "Tenant" tenant ON tenant.id = application."tenantId" + GROUP BY application.id, application.name, tenant.id, tenant.name + ORDER BY total DESC, application.name + `), + ]); + const summary = summaryRows[0] ?? { + total: 0, + successCount: 0, + unknownCount: 0, + failureCount: 0, + successRate: 0, + }; + return { date: day.key, summary, channels, signatures, applications }; + } +async signatureQuality(query: SignatureQualityQuery) { + const day = qualityBusinessDay(query.date); + const page = positiveInteger(query.page, 1); + const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); + const keyword = query.keyword?.trim() || null; + const keywordPattern = keyword ? `%${keyword}%` : null; + const summaries = await this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + message."signatureId" AS signature_id, + message."applicationId" AS application_id, + message.status, + message."submitStatus" AS submit_status, + message."receiptStatus" AS receipt_status, + CASE + WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') + AND message."submittedAt" IS NOT NULL + AND message."deliveredAt" >= message."submittedAt" + THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 + END AS arrival_ms + FROM "SmsMessageRecord" message + WHERE message."signatureId" IS NOT NULL + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + ) + SELECT + signature.id AS "signatureId", + signature.name AS "signatureName", + tenant.id AS "tenantId", + tenant.name AS "tenantName", + STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", + COUNT(*)::integer AS total, + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + )::integer AS "acceptedCount", + COUNT(*) FILTER ( + WHERE base.status = 'submit_failed' + OR base.submit_status IN ('rejected', 'timeout') + )::integer AS "submitFailureCount", + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "unknownCount", + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "failureCount", + CASE + WHEN COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') + * 100.0 + / COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ), + 1 + )::double precision + END AS "successRate", + ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs", + COUNT(*) OVER()::integer AS "rowCount" + FROM base + JOIN "SmsSignature" signature ON signature.id = base.signature_id + JOIN "Tenant" tenant ON tenant.id = signature."tenantId" + LEFT JOIN "SmsApplication" application ON application.id = base.application_id + WHERE ( + ${keyword}::text IS NULL + OR signature.name ILIKE ${keywordPattern} + OR tenant.name ILIKE ${keywordPattern} + OR application.name ILIKE ${keywordPattern} + ) + GROUP BY signature.id, signature.name, tenant.id, tenant.name + ORDER BY total DESC, signature.name + LIMIT ${pageSize} + OFFSET ${(page - 1) * pageSize} + `); + const signatureIds = summaries.map((item) => item.signatureId); + const breakdowns = signatureIds.length === 0 + ? [] + : await this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + message."signatureId" AS signature_id, + submit."channelId" AS channel_id, + channel.name AS channel_name, + COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, + submit."submitStatus" AS submit_status, + receipt."deliveredAt" AS delivered_at, + failed_receipt."failedAt" AS failed_at, + COALESCE(segment_summary.segment_count, 0) AS segment_count, + COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, + COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, + CASE + WHEN segment_summary.segment_count > 0 + AND segment_summary.delivered_count = segment_summary.segment_count + AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") + THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 + WHEN segment_summary.segment_count = 0 + AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") + THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 + END AS arrival_ms + FROM "SmsSubmitRecord" submit + JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" + JOIN "SmsChannel" channel ON channel.id = submit."channelId" + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::integer AS segment_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, + MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at + FROM "SmsMessageSegmentAudit" segment + WHERE segment."submitRecordId" = submit.id + ) segment_summary ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS "deliveredAt" + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) receipt ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS "failedAt" + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'undelivered' + ) failed_receipt ON TRUE + WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) + AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout') + AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} + AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} + ), classified AS ( + SELECT + *, + CASE + WHEN submit_status <> 'accepted' THEN 'submit_failed' + WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' + WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' + WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' + WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' + ELSE 'unknown' + END AS delivery_status + FROM base + ) + SELECT + signature_id AS "signatureId", + channel_id AS "channelId", + MAX(channel_name) AS "channelName", + carrier, + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", + COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", + COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", + COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", + COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", + CASE + WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE delivery_status = 'success') + * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), + 1 + )::double precision + END AS "successRate", + ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" + FROM classified + GROUP BY signature_id, channel_id, carrier + ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier + `); + const carrierOverview = signatureIds.length === 0 + ? [] + : await this.prisma.$queryRaw>(Prisma.sql` + SELECT + message."signatureId" AS "signatureId", + COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, + COUNT(*)::integer AS "businessMessageCount", + COUNT(*) FILTER ( + WHERE message.status = 'delivered' + OR message."receiptStatus" = 'delivered' + )::integer AS "finalSuccessCount", + CASE + WHEN COUNT(*) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER ( + WHERE message.status = 'delivered' + OR message."receiptStatus" = 'delivered' + ) * 100.0 / COUNT(*), + 1 + )::double precision + END AS "finalSuccessRate", + ROUND(AVG( + CASE + WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') + AND message."submittedAt" IS NOT NULL + AND message."deliveredAt" >= message."submittedAt" + THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 + END + ))::integer AS "averageArrivalMs" + FROM "SmsMessageRecord" message + WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown') + ORDER BY message."signatureId", COUNT(*) DESC, carrier + `); + const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { + const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId); + return { + ...summary, + channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0), + carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId), + breakdowns: signatureBreakdowns, + }; + }); + return { + date: day.key, + items, + total: summaries[0]?.rowCount ?? 0, + page, + pageSize, + }; + } +} diff --git a/api/src/operations/queries/trace.queries.ts b/api/src/operations/queries/trace.queries.ts new file mode 100644 index 0000000..12d79c5 --- /dev/null +++ b/api/src/operations/queries/trace.queries.ts @@ -0,0 +1,92 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { moneyToNumber } from '../../common/money'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; +import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +// R2 trace query domain. Method bodies are preserved byte-for-byte from the facade baseline. +export class OperationsTraceQueries { + constructor(private readonly prisma: PrismaService) {} + +auditSummary(query: { tenantId?: string }) { + return this.prisma.operationLog.groupBy({ + by: ['action', 'resource'], + where: { tenantId: query.tenantId }, + _count: { _all: true }, + orderBy: { _count: { action: 'desc' } }, + take: 100, + }); + } +async trace(query: TraceQuery) { + const messages = await this.prisma.smsMessageRecord.findMany({ + where: { + ...messageWhere(query), + messageId: query.messageId, + }, + include: { + batchTask: { include: { apiRequests: true } }, + submitRecords: { include: { session: true } }, + receiptRecords: true, + }, + orderBy: { queuedAt: 'desc' }, + take: 100, + }); + const messageIds = messages.map((message) => message.messageId); + const [billingRecords, uplinks] = await Promise.all([ + this.prisma.smsBillingRecord.findMany({ + where: { + tenantId: query.tenantId, + taskId: query.taskId, + messageId: messageIds.length > 0 ? { in: messageIds } : undefined, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.smsUplinkMessage.findMany({ + where: { + tenantId: query.tenantId, + messageId: messageIds.length > 0 ? { in: messageIds } : undefined, + }, + orderBy: { receivedAt: 'desc' }, + }), + ]); + return { messages, billingRecords, uplinks }; + } +async reconciliation(query: { tenantId?: string; taskId?: string }) { + const [messages, billing, transactions] = await Promise.all([ + this.prisma.smsMessageRecord.aggregate({ + where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }), + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }), + this.prisma.smsBillingRecord.aggregate({ + where: { tenantId: query.tenantId, taskId: query.taskId }, + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }), + this.prisma.accountTransaction.aggregate({ + where: { + tenantId: query.tenantId, + relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined, + relatedId: query.taskId, + }, + _count: { _all: true }, + _sum: { amountCents: true }, + }), + ]); + const messageAmount = moneyToNumber(messages._sum.amountCents); + const billingAmount = moneyToNumber(billing._sum.amountCents); + const transactionAmount = moneyToNumber(transactions._sum.amountCents); + return { + messages, + billing, + transactions, + diff: { + messageVsBillingAmountCents: messageAmount - billingAmount, + billingVsTransactionAmountCents: billingAmount + transactionAmount, + messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0), + }, + }; + } +} diff --git a/api/src/operations/queries/uplink.queries.ts b/api/src/operations/queries/uplink.queries.ts new file mode 100644 index 0000000..1aa67a2 --- /dev/null +++ b/api/src/operations/queries/uplink.queries.ts @@ -0,0 +1,95 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { moneyToNumber } from '../../common/money'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; +import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +// R2 uplink query domain. Method bodies are preserved byte-for-byte from the facade baseline. +export class OperationsUplinkQueries { + constructor(private readonly prisma: PrismaService) {} + +listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { + return this.prisma.smsUplinkMessage.findMany({ + 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, + channel: true, + messageRecord: { include: { application: true } }, + matchCandidates: { + include: { + tenant: true, + application: true, + messageRecord: { include: { application: true, tenant: true, channel: true } }, + }, + orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }], + }, + }, + orderBy: { receivedAt: 'desc' }, + skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, + take: query.pageSize ?? 500, + }); + } +async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { + const items = await this.listUplinkMessages(query); + return items.map(clientUplinkView); + } +async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsUplinkMessageWhereInput = { + 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, + }; + const [rawItems, total] = await Promise.all([ + this.listUplinkMessages({ ...query, page, pageSize }), + this.prisma.smsUplinkMessage.count({ where }), + ]); + return { + items: clientView ? rawItems.map(clientUplinkView) : rawItems, + total, + page, + pageSize, + }; + } +async monitor(query: { tenantId?: string; channelId?: string }) { + const where = messageWhere(query); + const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ + this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }), + this.prisma.smsMessageRecord.findMany({ + where, + include: { submitRecords: true, receiptRecords: true }, + orderBy: { queuedAt: 'desc' }, + take: 20, + }), + this.prisma.smsReceiptRecord.findMany({ + where: { tenantId: query.tenantId, channelId: query.channelId }, + orderBy: { createdAt: 'desc' }, + take: 20, + }), + this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }), + ]); + return { + byStatus, + recentMessages, + recentReceipts, + recentUplinks: recentUplinks.slice(0, 20), + }; + } +} diff --git a/api/src/report-materials/batch-generation.service.ts b/api/src/report-materials/batch-generation.service.ts new file mode 100644 index 0000000..de94ef4 --- /dev/null +++ b/api/src/report-materials/batch-generation.service.ts @@ -0,0 +1,252 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { createHash, randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; +import { FilesService } from '../files/files.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; +import { ReportBatchOperationService } from './batch-operation.service'; +import { ReportChannelExportService } from './channel-export.service'; + +/** R4 report-materials domain service composed behind ReportMaterialsService. */ +export class ReportBatchGenerationService { + constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly operations: ReportBatchOperationService, private readonly channelExport: ReportChannelExportService) {} + + async listBatches(query: PagedQuery = {}) { + const page = normalizePage(query.page); + const pageSize = normalizePageSize(query.pageSize); + const where: Prisma.ReportMaterialBatchWhereInput = { + createdAt: dateRange(query.startAt, query.endAt), + batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, + }; + const [batches, total] = await Promise.all([ + this.prisma.reportMaterialBatch.findMany({ + where, + include: { + exportFiles: { + include: { + items: { include: { task: { select: { id: true, status: true } } } }, + }, + }, + items: true, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.reportMaterialBatch.count({ where }), + ]); + return { + items: batches.map((batch) => { + const reportItems = batch.exportFiles.flatMap((file) => file.items); + const reportTotal = reportItems.length; + const successCount = reportItems.filter((item) => item.task.status === 'approved').length; + return { + ...batch, + reportTotal, + successCount, + successRate: reportTotal ? successCount / reportTotal : 0, + }; + }), + total, + page, + pageSize, + }; + } + + async createBatch(data: CreateReportBatchDto) { + if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); + const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey); + const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; + const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex'); + const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById); + if (claimed.replayed) return claimed.result; + + let preflight: Awaited>; + try { + preflight = await this.preflightBatch({ items: uniqueItems }); + } catch (error) { + await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败'); + throw error; + } + if (preflight.eligibleTargetCount === 0) { + await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标'); + throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight }); + } + const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible)); + const batch = await this.prisma.reportMaterialBatch.create({ + data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length }, + }); + try { + const prepared = []; + for (const inspection of eligibleInspections) { + const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!; + prepared.push(await this.prepareBatchItem(batch.id, selected, inspection)); + } + const channelMap = new Map>(); + for (const item of prepared) { + for (const channel of item.channels) { + const current = channelMap.get(channel.id) ?? []; + current.push({ ...item, channels: [channel] }); + channelMap.set(channel.id, current); + } + } + const exportedFiles = []; + const incomplete = new Set(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id)); + let failedTargetCount = 0; + for (const [channelId, items] of channelMap) { + const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items); + exportedFiles.push(result.file); + failedTargetCount += result.incompleteBatchItemIds.length; + for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId); + } + for (const item of prepared) { + if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue; + if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } }); + else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } }); + } + const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } }); + const result = { + ...completed, + operationId: claimed.operationId, + replayed: false, + result: { + successCount: preflight.eligibleTargetCount - failedTargetCount, + skippedCount: preflight.skippedTargetCount, + failedCount: failedTargetCount, + items: preflight.items, + }, + }; + await this.operations.completeBatchOperation(claimed.operationId, batch.id, result); + return result; + } catch (error) { + await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } }); + await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id); + throw error; + } + } + + async preflightBatch(data: Pick) { + if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); + for (const item of data.items) { + if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' }); + if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' }); + } + const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; + const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item))); + return { + checkedAt: new Date().toISOString(), + eligible: items.some((item) => item.eligible), + eligibleItemCount: items.filter((item) => item.eligible).length, + blockedItemCount: items.filter((item) => !item.eligible).length, + eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0), + skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0), + items, + }; + } + + async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); + if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过'); + const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId + ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; + if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过'); + const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ + where: { applicationId: signature.applicationId, status: 'active' }, + include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, + orderBy: { priority: 'asc' }, + }) : []; + const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id)); + const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()]; + const snapshot = selected.reportType === 'signature' + ? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } + : { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) }; + const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion; + const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } }); + return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels }; + } + + async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); + if (!signature) throw new NotFoundException('签名不存在'); + const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId + ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; + const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0; + const blockedReasons: string[] = []; + if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过'); + if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池'); + if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用'); + else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用'); + if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion})`); + if (selected.reportType === 'drainage') { + if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名'); + else { + if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过'); + if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池'); + } + } + const snapshot = selected.reportType === 'signature' + ? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } + : { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) }; + const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ + where: { applicationId: signature.applicationId, status: 'active' }, + include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, + orderBy: { priority: 'asc' }, + }) : []; + const channelCarriers = new Map }>(); + for (const route of routes) { + if (route.group.status !== 'active') continue; + for (const entry of route.group.items) { + if (entry.channel.status !== 'active') continue; + const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set() }; + current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all'); + channelCarriers.set(entry.channel.id, current); + } + } + if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道'); + const previous = await this.prisma.reportMaterialBatchItem.findMany({ + where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } }, + select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } }, + orderBy: { createdAt: 'desc' }, + }); + const priorKeys = new Map(); + for (const item of previous) { + const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value))); + for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) { + if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId); + } + } + const targets: ReportBatchTarget[] = []; + for (const { channel, carriers } of channelCarriers.values()) { + const carrier = [...carriers].sort().join(','); + const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`; + const targetReasons = [...blockedReasons]; + const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); + if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段'); + else { + const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue)); + if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`); + } + const duplicateBatchId = priorKeys.get(businessKey); + if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`); + targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId }); + } + return { + id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`, + reportType: selected.reportType, + signatureId: signature.id, + drainageItemId: drainageInfo?.id, + materialVersion, + name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料', + tenantName: signature.tenant.name, + applicationId: signature.applicationId ?? undefined, + applicationName: signature.application?.name ?? '未指定应用', + eligible: targets.some((target) => target.eligible), + blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons, + targets, + }; + } +} diff --git a/api/src/report-materials/batch-operation.service.ts b/api/src/report-materials/batch-operation.service.ts new file mode 100644 index 0000000..1aba148 --- /dev/null +++ b/api/src/report-materials/batch-operation.service.ts @@ -0,0 +1,40 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { createHash, randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; +import { FilesService } from '../files/files.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; + + +/** R4 report-materials domain service composed behind ReportMaterialsService. */ +export class ReportBatchOperationService { + constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {} + + async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) { + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`; + const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } }); + if (existing) { + const detail = jsonRecord(existing.detail); + if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' }); + if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } }; + throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' }); + } + const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } }); + return { operationId: operation.id, replayed: false as const, result: null }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + async completeBatchOperation(operationId: string, batchId: string, result: Record) { + await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } }); + } + + async failBatchOperation(operationId: string, message: string, batchId?: string) { + const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } }); + await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } }); + } +} diff --git a/api/src/report-materials/channel-export.service.ts b/api/src/report-materials/channel-export.service.ts new file mode 100644 index 0000000..6f9db37 --- /dev/null +++ b/api/src/report-materials/channel-export.service.ts @@ -0,0 +1,81 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { createHash, randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; +import { FilesService } from '../files/files.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; +import type { ReportBatchGenerationService } from './batch-generation.service'; + +/** R4 report-materials domain service composed behind ReportMaterialsService. */ +export class ReportChannelExportService { + constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {} + + async exportChannelBatch(batchId: string, channelId: string, items: Array>>) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); + if (!channel) throw new NotFoundException('通道不存在'); + const reportTypes = [...new Set(items.map((item) => item.reportType))]; + const workbook = new ExcelJS.Workbook(); + const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = []; + const incompleteBatchItemIds: string[] = []; + let totalRows = 0; + for (const reportType of reportTypes) { + const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); + const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] }); + sheet.properties.defaultRowHeight = 22; + sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth })); + styleHeader(sheet.getRow(1)); + for (const item of items.filter((current) => current.reportType === reportType)) { + const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? ''); + const missing = fields.filter((field, index) => field.required && !hasValue(values[index])); + const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null; + const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } }); + const task = existingTask + ? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }) + : await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }); + if (missingReason) { + incompleteBatchItemIds.push(item.batchItem.id); + await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined); + continue; + } + const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform))); + totalRows += 1; + let targetHeight = 22; + for (const [index, value] of values.entries()) { + if (!isFileRef(value)) continue; + const downloaded = await this.files.getDownload(value.fileObjectId); + if (!downloaded.fileObject.contentType.startsWith('image/')) continue; + const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]); + if (!['png', 'jpeg', 'gif'].includes(extension)) continue; + const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' }); + const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7)); + const heightRows = Math.max(0.8, fields[index].imageHeight / 20); + sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never); + targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8); + } + row.height = targetHeight; + fileRows.push({ item, taskId: task.id, rowNumber: row.number }); + await this.recordTask(task.id, channelId, existingTask?.status, 'exporting'); + } + } + if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) { + for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id); + const empty = workbook.addWorksheet('无可导出数据'); + empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。'; + empty.getColumn(1).width = 64; + } + const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); + const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`; + const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }); + const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } }); + if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) }); + return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds }; + } + + recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) { + return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } }); + } +} diff --git a/api/src/report-materials/import-parser.service.ts b/api/src/report-materials/import-parser.service.ts new file mode 100644 index 0000000..e76e449 --- /dev/null +++ b/api/src/report-materials/import-parser.service.ts @@ -0,0 +1,117 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { createHash, randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; +import { FilesService } from '../files/files.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; + + +/** R4 report-materials domain service composed behind ReportMaterialsService. */ +export class ReportImportParserService { + constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {} + + listImportProfiles(reportType?: 'signature' | 'drainage') { + return this.prisma.reportMaterialImportProfile.findMany({ + where: { reportType, status: 'active' }, + include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async saveImportProfile(data: CreateImportProfileDto) { + validateProfile(data); + return this.prisma.$transaction(async (tx) => { + const profile = data.id + ? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) }) + : await tx.reportMaterialImportProfile.create({ data: profileData(data) }); + await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } }); + await tx.reportMaterialImportProfileColumn.createMany({ + data: data.columns.map((column, index) => ({ + profileId: profile.id, + sourceHeader: column.sourceHeader, + sourceHeaderPath: column.sourceHeaderPath, + sourceColumnIndex: column.sourceColumnIndex, + targetFieldCode: column.targetFieldCode, + targetKind: column.targetKind, + fieldType: column.fieldType, + required: column.required ?? false, + transform: column.transform, + sortOrder: column.sortOrder ?? (index + 1) * 10, + })), + }); + return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }); + }); + } + + async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) { + if (!options.tenantId) throw new BadRequestException('tenantId is required'); + if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage'); + if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件'); + const workbook = await loadWorkbook(file.buffer); + assertSafeWorkbook(workbook); + const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null; + const selectedSheetName = options.sheetName || profile?.sheetName || undefined; + const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0]; + if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表'); + const headerRowCount = clamp(options.headerRowCount, 1, 5); + const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1); + const images = readEmbeddedImages(workbook, worksheet); + const columnCount = Math.min(worksheet.columnCount, 200); + const columns = Array.from({ length: columnCount }, (_, offset) => { + const sourceColumnIndex = offset + 1; + const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean); + const sourceHeaderPath = [...new Set(parts)].join('/'); + return { + sourceColumnIndex, + columnLetter: worksheet.getColumn(sourceColumnIndex).letter, + sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`, + sourceHeaderPath, + imageCount: images.filter((image) => image.column === sourceColumnIndex).length, + }; + }).filter((column) => column.sourceHeaderPath || column.imageCount > 0); + const previewRows = []; + for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) { + const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))])); + const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column); + if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns }); + } + const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file); + const profileMappings = profile?.columns.map((column) => ({ + sourceHeader: column.sourceHeader, + sourceHeaderPath: column.sourceHeaderPath ?? undefined, + sourceColumnIndex: column.sourceColumnIndex, + targetFieldCode: column.targetFieldCode, + targetKind: column.targetKind as ImportMapping['targetKind'], + fieldType: column.fieldType as ImportMapping['fieldType'], + required: column.required, + transform: column.transform ?? undefined, + sortOrder: column.sortOrder, + })); + const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType); + const batch = await this.prisma.reportMaterialImportBatch.create({ + data: { + tenantId: options.tenantId, + applicationId: options.applicationId, + profileId: options.profileId, + fileObjectId: sourceFile.id, + fileName: sourceFile.fileName, + reportType: options.reportType, + sheetName: worksheet.name, + headerRowCount, + dataStartRow, + mapping: suggestedMappings as Prisma.InputJsonValue, + preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue, + rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1), + }, + }); + await this.prisma.operationLog.create({ data: { + tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id, + detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue, + } }); + return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings }; + } +} diff --git a/api/src/report-materials/import-review.service.ts b/api/src/report-materials/import-review.service.ts new file mode 100644 index 0000000..c607c46 --- /dev/null +++ b/api/src/report-materials/import-review.service.ts @@ -0,0 +1,342 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { createHash, randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; +import { FilesService } from '../files/files.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; +import { ReportImportParserService } from './import-parser.service'; + +/** R4 report-materials domain service composed behind ReportMaterialsService. */ +export class ReportImportReviewService { + constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly importParser: ReportImportParserService) {} + + async commitImport(batchId: string, data: ImportCommitDto) { + const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } }); + if (!batch) throw new NotFoundException('导入批次不存在'); + if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入'); + if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射'); + if (data.profile) await this.importParser.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings }); + const { content } = await this.files.getDownload(batch.fileObjectId); + const workbook = await loadWorkbook(content); + assertSafeWorkbook(workbook); + const worksheet = workbook.getWorksheet(batch.sheetName); + if (!worksheet) throw new BadRequestException('导入工作表不存在'); + const images = readEmbeddedImages(workbook, worksheet); + const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image])); + let successCount = 0; + const failures: Array<{ rowNumber: number; reason: string }> = []; + const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = []; + for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) { + const values: Record = {}; + try { + for (const mapping of data.mappings) { + const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`); + if (image && mapping.fieldType !== 'string') { + const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, { + originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`, + mimetype: imageContentType(image.extension), + size: image.buffer.length, + buffer: image.buffer, + }); + values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType }; + } else { + values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform); + } + } + if (!Object.values(values).some(hasValue)) continue; + for (const mapping of data.mappings.filter((item) => item.required)) { + if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`); + } + const staged = batch.reportType === 'signature' + ? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values) + : await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values); + stagedItems.push({ + batchId, + rowNumber, + reportType: batch.reportType, + operation: staged.operation, + targetId: staged.targetId, + status: 'pending_review', + payload: staged.payload as Prisma.InputJsonValue, + originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined, + }); + successCount += 1; + } catch (error) { + const reason = error instanceof Error ? error.message : '导入失败'; + failures.push({ rowNumber, reason }); + stagedItems.push({ + batchId, + rowNumber, + reportType: batch.reportType, + operation: 'invalid', + status: 'invalid', + payload: values as Prisma.InputJsonValue, + errorMessage: reason, + }); + } + } + const updated = await this.prisma.$transaction(async (tx) => { + if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems }); + return tx.reportMaterialImportBatch.update({ + where: { id: batchId }, + data: { + status: successCount ? 'pending_review' : 'failed', + mapping: data.mappings as Prisma.InputJsonValue, + result: { failures } as Prisma.InputJsonValue, + successCount, + failedCount: failures.length, + }, + include: { items: { orderBy: { rowNumber: 'asc' } } }, + }); + }); + await this.prisma.operationLog.create({ data: { + tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id, + detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue, + } }); + return updated; + } + + async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) { + const page = normalizePage(query.page); + const pageSize = normalizePageSize(query.pageSize); + const where: Prisma.ReportMaterialImportBatchWhereInput = { + reportType: query.reportType, + status: query.status && query.status !== 'all' ? query.status : undefined, + createdAt: dateRange(query.startAt, query.endAt), + OR: query.keyword?.trim() ? [ + { fileName: { contains: query.keyword.trim() } }, + { id: { contains: query.keyword.trim() } }, + ] : undefined, + }; + const [batches, total] = await Promise.all([ + this.prisma.reportMaterialImportBatch.findMany({ + where, + include: { items: { orderBy: { rowNumber: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.reportMaterialImportBatch.count({ where }), + ]); + const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))]; + const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))]; + const reviewerIds = [...new Set(batches.flatMap((batch) => [ + batch.reviewedById, + ...batch.items.map((item) => item.reviewedById), + ]).filter((id): id is string => Boolean(id)))]; + const [tenants, applications, reviewers] = await Promise.all([ + tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [], + applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [], + reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [], + ]); + const tenantById = new Map(tenants.map((item) => [item.id, item])); + const applicationById = new Map(applications.map((item) => [item.id, item])); + const reviewerById = new Map(reviewers.map((item) => [item.id, item])); + return { + items: batches.map((batch) => ({ + ...batch, + tenant: tenantById.get(batch.tenantId) ?? null, + application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null, + reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null, + items: batch.items.map((item) => ({ + ...item, + reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null, + })), + })), + total, + page, + pageSize, + }; + } + + async reviewImportItems(batchId: string, data: ReviewImportItemsDto) { + if (!data.reviewerId) throw new BadRequestException('Reviewer session is required'); + if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision'); + const batch = await this.prisma.reportMaterialImportBatch.findUnique({ + where: { id: batchId }, + include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } }, + }); + if (!batch) throw new NotFoundException('导入审核批次不存在'); + if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细'); + let approvedCount = 0; + let rejectedCount = 0; + const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = []; + for (const item of batch.items) { + if (data.decision === 'reject') { + await this.prisma.reportMaterialImportItem.update({ + where: { id: item.id }, + data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() }, + }); + rejectedCount += 1; + continue; + } + try { + const targetId = await this.applyImportItem(batch, item, data.reviewerId); + await this.prisma.reportMaterialImportItem.update({ + where: { id: item.id }, + data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null }, + }); + approvedCount += 1; + } catch (error) { + const reason = error instanceof Error ? error.message : '导入审核应用失败'; + failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason }); + await this.prisma.reportMaterialImportItem.update({ + where: { id: item.id }, + data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() }, + }); + } + } + const counts = await this.prisma.reportMaterialImportItem.groupBy({ + by: ['status'], + where: { batchId }, + _count: { _all: true }, + }); + const countByStatus = new Map(counts.map((item) => [item.status, item._count._all])); + const pendingCount = countByStatus.get('pending_review') ?? 0; + const totalApproved = countByStatus.get('approved') ?? 0; + const totalRejected = countByStatus.get('rejected') ?? 0; + const totalInvalid = countByStatus.get('invalid') ?? 0; + const status = pendingCount + ? 'partially_reviewed' + : totalApproved && (totalRejected || totalInvalid) + ? 'partially_approved' + : totalApproved + ? 'approved' + : totalRejected + ? 'rejected' + : 'failed'; + await this.prisma.reportMaterialImportBatch.update({ + where: { id: batchId }, + data: { + status, + reviewedById: pendingCount ? undefined : data.reviewerId, + reviewedAt: pendingCount ? undefined : new Date(), + completedAt: pendingCount ? undefined : new Date(), + }, + }); + return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; + } + + async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { + const name = mappedCoreValue(mappings, values, 'signatureName'); + if (!name) throw new Error('缺少短信签名'); + const purpose = mappedCoreValue(mappings, values, 'purpose'); + const signatureReportValues = dynamicValues(mappings, values); + const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } }); + return { + operation: existing ? 'update' : 'create', + targetId: existing?.id, + payload: { + tenantId, + applicationId, + name, + purpose, + drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues }, + }, + originalSnapshot: existing ? { + id: existing.id, + applicationId: existing.applicationId, + name: existing.name, + purpose: existing.purpose, + drainageInfo: existing.drainageInfo, + auditStatus: existing.auditStatus, + updatedAt: existing.updatedAt, + } : undefined, + }; + } + + async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { + const signatureName = mappedCoreValue(mappings, values, 'signatureName'); + const siteName = mappedCoreValue(mappings, values, 'siteName'); + const url = mappedCoreValue(mappings, values, 'url'); + if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL'); + const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } }); + if (!signature) throw new Error(`未找到已审核签名:${signatureName}`); + const remark = mappedCoreValue(mappings, values, 'remark'); + const reportValues = dynamicValues(mappings, values); + const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } }); + return { + operation: existing ? 'update' : 'create', + targetId: existing?.id, + payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues }, + originalSnapshot: existing ? { + id: existing.id, + siteName: existing.siteName, + url: existing.url, + remark: existing.remark, + reportValues: existing.reportValues, + auditStatus: existing.auditStatus, + updatedAt: existing.updatedAt, + } : undefined, + }; + } + + async applyImportItem( + batch: { tenantId: string; applicationId: string | null; reportType: string }, + item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue }, + reviewerId: string, + ) { + const payload = jsonRecord(item.payload); + if (item.reportType === 'signature') { + const name = String(payload.name ?? ''); + const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined; + const body = { + applicationId, + name, + purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined, + drainageInfo: jsonRecord(payload.drainageInfo), + }; + let targetId = item.targetId; + if (targetId) { + const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } }); + if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); + await this.smsConfig.updateSignature(targetId, body, batch.tenantId); + } else { + const duplicate = await this.prisma.smsSignature.findFirst({ + where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } }, + }); + if (duplicate) { + targetId = duplicate.id; + await this.smsConfig.updateSignature(targetId, body, batch.tenantId); + } else { + const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body }); + targetId = created.id; + } + } + await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` }); + return targetId; + } + const signatureId = String(payload.signatureId ?? ''); + const siteName = String(payload.siteName ?? ''); + const url = String(payload.url ?? ''); + const body = { + siteName, + url, + remark: typeof payload.remark === 'string' ? payload.remark : undefined, + reportValues: jsonRecord(payload.reportValues), + }; + let targetId = item.targetId; + if (targetId) { + const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } }); + if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改'); + await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); + } else { + const duplicate = await this.prisma.smsDrainageInfo.findFirst({ + where: { signatureId, url, auditStatus: { not: 'deleted' } }, + }); + if (duplicate) { + targetId = duplicate.id; + await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); + } else { + const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId); + targetId = created.id; + } + } + await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` }); + return targetId; + } +} diff --git a/api/src/report-materials/official-export.service.ts b/api/src/report-materials/official-export.service.ts new file mode 100644 index 0000000..7c73af5 --- /dev/null +++ b/api/src/report-materials/official-export.service.ts @@ -0,0 +1,58 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { createHash, randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; +import { FilesService } from '../files/files.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; +import { ReportPendingQueryService } from './pending-query.service'; + +/** R4 report-materials domain service composed behind ReportMaterialsService. */ +export class ReportOfficialExportService { + constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly pending: ReportPendingQueryService) {} + + async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'CMPP短信平台'; + const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] }); + const headers = reportType === 'signature' + ? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注'] + : ['短信签名', '站点名称', 'URL', '备注', '网站截图']; + sheet.addRow(headers); + sheet.addRow(reportType === 'signature' + ? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除'] + : ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']); + styleHeader(sheet.getRow(1)); + sheet.columns.forEach((column) => { column.width = 24; }); + sheet.getRow(2).height = 48; + const content = Buffer.from(await workbook.xlsx.writeBuffer()); + const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`; + await this.prisma.operationLog.create({ data: { + userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material', + detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue, + } }); + return { fileName, content }; + } + + async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) { + const items = await this.pending.findPendingItems(query); + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] }); + sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']); + styleHeader(sheet.getRow(1)); + for (const item of items) sheet.addRow([ + item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name), + safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt, + ]); + sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; }); + const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`; + await this.prisma.operationLog.create({ data: { + tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material', + detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue, + } }); + return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) }; + } +} diff --git a/api/src/report-materials/pending-query.service.ts b/api/src/report-materials/pending-query.service.ts new file mode 100644 index 0000000..2fe2e93 --- /dev/null +++ b/api/src/report-materials/pending-query.service.ts @@ -0,0 +1,73 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { createHash, randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; +import { FilesService } from '../files/files.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; + + +/** R4 report-materials domain service composed behind ReportMaterialsService. */ +export class ReportPendingQueryService { + constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {} + + async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) { + const items = await this.findPendingItems(query); + const page = normalizePage(query.page); + const pageSize = normalizePageSize(query.pageSize); + return { + items: items.slice((page - 1) * pageSize, page * pageSize), + total: items.length, + page, + pageSize, + }; + } + + async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) { + const changedAt = dateRange(query.startAt, query.endAt); + const keyword = query.keyword?.trim(); + const [signatures, drainageInfos] = await Promise.all([ + query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({ + where: { + pendingReport: true, + auditStatus: 'approved', + tenantId: query.tenantId, + applicationId: query.applicationId, + reportChangedAt: changedAt, + OR: keyword ? [ + { name: { contains: keyword } }, + { tenant: { name: { contains: keyword } } }, + { application: { name: { contains: keyword } } }, + ] : undefined, + }, + include: { tenant: true, application: true }, + orderBy: { reportChangedAt: 'desc' }, + }), + query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({ + where: { + pendingReport: true, + auditStatus: 'approved', + tenantId: query.tenantId, + applicationId: query.applicationId, + reportChangedAt: changedAt, + OR: keyword ? [ + { siteName: { contains: keyword } }, + { url: { contains: keyword } }, + { signature: { name: { contains: keyword } } }, + { tenant: { name: { contains: keyword } } }, + { application: { name: { contains: keyword } } }, + ] : undefined, + }, + include: { tenant: true, application: true, signature: true }, + orderBy: { reportChangedAt: 'desc' }, + }), + ]); + return [ + ...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })), + ...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })), + ].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()); + } +} diff --git a/api/src/report-materials/report-materials.contracts.ts b/api/src/report-materials/report-materials.contracts.ts new file mode 100644 index 0000000..8c6232f --- /dev/null +++ b/api/src/report-materials/report-materials.contracts.ts @@ -0,0 +1,83 @@ +/** Stable request, query and internal data contracts for report-material domains. */ + +export type ImportMapping = { + sourceHeader: string; + sourceHeaderPath?: string; + sourceColumnIndex: number; + targetFieldCode: string; + targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic'; + fieldType: 'string' | 'image' | 'file'; + required?: boolean; + transform?: string; + sortOrder?: number; +}; + +export interface CreateImportProfileDto { + id?: string; + name: string; + reportType: 'signature' | 'drainage'; + tenantId?: string; + applicationId?: string; + sheetName?: string; + headerRowCount?: number; + dataStartRow?: number; + status?: string; + columns: ImportMapping[]; +} + +export interface ImportCommitDto { + mappings: ImportMapping[]; + profile?: CreateImportProfileDto; + operatorId?: string; +} + +export interface ReviewImportItemsDto { + decision: 'approve' | 'reject'; + itemIds?: string[]; + reason?: string; + reviewerId?: string; +} + +export type PagedQuery = { + keyword?: string; + startAt?: string; + endAt?: string; + page?: number; + pageSize?: number; +}; + +export interface CreateReportBatchDto { + createdById?: string; + idempotencyKey?: string; + items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>; +} + +export type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string }; + +export type ReportBatchInspection = { + id: string; + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string; + materialVersion: number; + name: string; + tenantName: string; + applicationId?: string; + applicationName: string; + eligible: boolean; + blockedReasons: string[]; + targets: ReportBatchTarget[]; +}; + +export type AnalyzeImportOptions = { + tenantId: string; + applicationId?: string; + reportType: 'signature' | 'drainage'; + sheetName?: string; + headerRowCount: number; + dataStartRow: number; + profileId?: string; + operatorId?: string; +}; + +export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer }; diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index 6e18ca5..d04301b 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -3,7 +3,8 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags } from '@nestjs/swagger'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; -import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService, ReviewImportItemsDto } from './report-materials.service'; +import { ReportMaterialsService } from './report-materials.service'; +import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReviewImportItemsDto } from './report-materials.contracts'; type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer }; type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void }; diff --git a/api/src/report-materials/report-materials.helpers.ts b/api/src/report-materials/report-materials.helpers.ts new file mode 100644 index 0000000..4520591 --- /dev/null +++ b/api/src/report-materials/report-materials.helpers.ts @@ -0,0 +1,201 @@ +import { BadRequestException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import { randomUUID } from 'node:crypto'; +import type { CreateImportProfileDto, EmbeddedImage, ImportMapping } from './report-materials.contracts'; + +/** Pure workbook, mapping, pagination and export helpers shared by R4 domains. */ +export function profileData(data: CreateImportProfileDto) { + return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' }; +} + +export function validateProfile(data: CreateImportProfileDto) { + if (!data.name?.trim()) throw new BadRequestException('映射模板名称不能为空'); + if (!data.columns?.length) throw new BadRequestException('映射模板至少包含一个字段'); + const indexes = data.columns.map((column) => column.sourceColumnIndex); + if (new Set(indexes).size !== indexes.length) throw new BadRequestException('同一源列不能重复映射'); +} + +export async function loadWorkbook(buffer: Buffer) { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer as never); + return workbook; +} + +export function assertSafeWorkbook(workbook: ExcelJS.Workbook) { + for (const worksheet of workbook.worksheets) { + worksheet.eachRow((row) => row.eachCell((cell) => { + const value = cell.value; + if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { + throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); + } + const text = typeof value === 'string' ? value.trimStart() : ''; + if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) { + throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); + } + })); + } +} + +export function safeSpreadsheetText(value: unknown) { + const text = value == null ? '' : String(value); + return /^[=+@]/.test(text) || /^-[^\d.]/.test(text) ? `'${text}` : text; +} + +export function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] { + const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages; + if (!getImages) return []; + return getImages.call(worksheet).flatMap((drawing) => { + const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId); + if (!image) return []; + const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1; + const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1; + const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined); + return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : []; + }); +} + +export function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] { + return columns.flatMap((column, index) => { + const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`); + const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized); + if (!core && !column.imageCount) return []; + return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }]; + }); +} + +export function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] { + const used = new Set(); + return profileColumns.flatMap((profileColumn) => { + const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader); + const header = normalizeHeader(profileColumn.sourceHeader); + const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath) + ?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header); + if (!source) return []; + used.add(source.sourceColumnIndex); + return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }]; + }); +} + +export function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined { + if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true }; + if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' }; + return undefined; +} + +export function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined { + if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true }; + if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true }; + if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true }; + if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' }; + return undefined; +} + +export function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); } + +export function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; } + +export function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); } + +export function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); } + +export function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); } + +export function dateRange(startAt?: string, endAt?: string) { + const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined; + const end = endAt ? new Date(`${endAt}T23:59:59.999+08:00`) : undefined; + if (start && Number.isNaN(start.getTime())) throw new BadRequestException('开始日期无效'); + if (end && Number.isNaN(end.getTime())) throw new BadRequestException('结束日期无效'); + return start || end ? { gte: start, lte: end } : undefined; +} + +export function cellText(cell: ExcelJS.Cell) { + const value = cell.value; + if (value === null || value === undefined) return ''; + if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value); + if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim(); + if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim(); + if ('richText' in value) return value.richText.map((item) => item.text).join('').trim(); + if ('text' in value) return String(value.text).trim(); + return cell.text.trim(); +} + +export function transformValue(value: string, transform?: string) { + if (!transform || transform === 'trim') return value.trim(); + if (transform === 'digits') return value.replace(/\D/g, ''); + if (transform === 'uppercase') return value.trim().toUpperCase(); + if (transform === 'lowercase') return value.trim().toLowerCase(); + return value.trim(); +} + +export function mappedCoreValue(mappings: ImportMapping[], values: Record, kind: ImportMapping['targetKind']) { + const mapping = mappings.find((item) => item.targetKind === kind); + return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : ''; +} + +export function dynamicValues(mappings: ImportMapping[], values: Record) { + return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]])); +} + +export function jsonRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } + +export function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; } + +export function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record).fileObjectId === 'string'; } + +export function resolveExportValue(snapshot: Record, code: string, name?: string) { + const values = jsonRecord(snapshot.values); + if (hasValue(values[code])) return values[code]; + const signature = jsonRecord(snapshot.signature); + const drainage = jsonRecord(snapshot.drainage); + const aliases: Record = { + signature_name: signature.name, sign_name: signature.name, signatureName: signature.name, + purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName, + application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark, + }; + if (hasValue(aliases[code])) return aliases[code]; + const semantic = normalizeHeader(`${code}/${name ?? ''}`); + if (/短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)) return signature.name; + if (/签名用途|签名依据|purpose/.test(semantic)) return signature.purpose; + if (/企业名称|公司名称|enterprisename|companyname/.test(semantic)) return signature.tenantName; + if (/应用名称|applicationname|appname/.test(semantic)) return signature.applicationName; + if (/站点名称|网站名称|sitename/.test(semantic)) return drainage.siteName; + if (/引流地址|网址|链接|url/.test(semantic)) return drainage.url; + if (/备注|说明|remark/.test(semantic)) return drainage.remark; + return undefined; +} + +export function applyExportTransform(value: unknown, transform?: string | null) { + const text = value === null || value === undefined ? '' : String(value); + return transformValue(text, transform ?? undefined); +} + +export function styleHeader(row: ExcelJS.Row) { + row.height = 28; + row.eachCell((cell) => { + cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = { bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } } }; + }); +} + +export function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; } + +export function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; } + +export function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; } + +export function normalizeBatchIdempotencyKey(value?: string) { + const key = value?.trim(); + if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' }); + return key; +} + +export function jsonStringArray(value: unknown) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +} + +export function jsonSafe(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} diff --git a/api/src/report-materials/report-materials.service.ts b/api/src/report-materials/report-materials.service.ts index de65b2c..0d523c7 100644 --- a/api/src/report-materials/report-materials.service.ts +++ b/api/src/report-materials/report-materials.service.ts @@ -1,1134 +1,82 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import ExcelJS from 'exceljs'; -import { createHash, randomUUID } from 'node:crypto'; -import { extname } from 'node:path'; +import { Injectable } from '@nestjs/common'; import { FilesService } from '../files/files.service'; import { PrismaService } from '../prisma/prisma.service'; import { SmsConfigService } from '../sms-config/sms-config.service'; +import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; +import { ReportBatchGenerationService } from './batch-generation.service'; +import { ReportBatchOperationService } from './batch-operation.service'; +import { ReportChannelExportService } from './channel-export.service'; +import { ReportImportParserService } from './import-parser.service'; +import { ReportImportReviewService } from './import-review.service'; +import { ReportOfficialExportService } from './official-export.service'; +import { ReportPendingQueryService } from './pending-query.service'; -export type ImportMapping = { - sourceHeader: string; - sourceHeaderPath?: string; - sourceColumnIndex: number; - targetFieldCode: string; - targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic'; - fieldType: 'string' | 'image' | 'file'; - required?: boolean; - transform?: string; - sortOrder?: number; -}; - -export interface CreateImportProfileDto { - id?: string; - name: string; - reportType: 'signature' | 'drainage'; - tenantId?: string; - applicationId?: string; - sheetName?: string; - headerRowCount?: number; - dataStartRow?: number; - status?: string; - columns: ImportMapping[]; -} - -export interface ImportCommitDto { - mappings: ImportMapping[]; - profile?: CreateImportProfileDto; - operatorId?: string; -} - -export interface ReviewImportItemsDto { - decision: 'approve' | 'reject'; - itemIds?: string[]; - reason?: string; - reviewerId?: string; -} - -type PagedQuery = { - keyword?: string; - startAt?: string; - endAt?: string; - page?: number; - pageSize?: number; -}; - -export interface CreateReportBatchDto { - createdById?: string; - idempotencyKey?: string; - items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>; -} - -type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string }; -type ReportBatchInspection = { - id: string; - reportType: 'signature' | 'drainage'; - signatureId: string; - drainageItemId?: string; - materialVersion: number; - name: string; - tenantName: string; - applicationId?: string; - applicationName: string; - eligible: boolean; - blockedReasons: string[]; - targets: ReportBatchTarget[]; -}; - -type AnalyzeImportOptions = { - tenantId: string; - applicationId?: string; - reportType: 'signature' | 'drainage'; - sheetName?: string; - headerRowCount: number; - dataStartRow: number; - profileId?: string; - operatorId?: string; -}; - -type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer }; - +/** Stable compatibility facade; R4 delegates report-material behavior to focused domains. */ @Injectable() export class ReportMaterialsService { - constructor( - private readonly prisma: PrismaService, - private readonly files: FilesService, - private readonly smsConfig: SmsConfigService, - ) {} + private readonly pendingQuery: ReportPendingQueryService; + private readonly officialExport: ReportOfficialExportService; + private readonly importParser: ReportImportParserService; + private readonly importReview: ReportImportReviewService; + private readonly batchOperation: ReportBatchOperationService; + private readonly channelExport: ReportChannelExportService; + private readonly batchGeneration: ReportBatchGenerationService; + + constructor(prisma: PrismaService, files: FilesService, smsConfig: SmsConfigService) { + this.pendingQuery = new ReportPendingQueryService(prisma, files, smsConfig); + this.officialExport = new ReportOfficialExportService(prisma, files, smsConfig, this.pendingQuery); + this.importParser = new ReportImportParserService(prisma, files, smsConfig); + this.importReview = new ReportImportReviewService(prisma, files, smsConfig, this.importParser); + this.batchOperation = new ReportBatchOperationService(prisma, files, smsConfig); + this.channelExport = new ReportChannelExportService(prisma, files, smsConfig); + this.batchGeneration = new ReportBatchGenerationService(prisma, files, smsConfig, this.batchOperation, this.channelExport); + } async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) { - const workbook = new ExcelJS.Workbook(); - workbook.creator = 'CMPP短信平台'; - const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] }); - const headers = reportType === 'signature' - ? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注'] - : ['短信签名', '站点名称', 'URL', '备注', '网站截图']; - sheet.addRow(headers); - sheet.addRow(reportType === 'signature' - ? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除'] - : ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']); - styleHeader(sheet.getRow(1)); - sheet.columns.forEach((column) => { column.width = 24; }); - sheet.getRow(2).height = 48; - const content = Buffer.from(await workbook.xlsx.writeBuffer()); - const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`; - await this.prisma.operationLog.create({ data: { - userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material', - detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue, - } }); - return { fileName, content }; + return this.officialExport.buildOfficialTemplate(reportType, operatorId); } async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) { - const items = await this.findPendingItems(query); - const workbook = new ExcelJS.Workbook(); - const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] }); - sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']); - styleHeader(sheet.getRow(1)); - for (const item of items) sheet.addRow([ - item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name), - safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt, - ]); - sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; }); - const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`; - await this.prisma.operationLog.create({ data: { - tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material', - detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue, - } }); - return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) }; + return this.officialExport.exportPending(query, operatorId); } async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) { - const items = await this.findPendingItems(query); - const page = normalizePage(query.page); - const pageSize = normalizePageSize(query.pageSize); - return { - items: items.slice((page - 1) * pageSize, page * pageSize), - total: items.length, - page, - pageSize, - }; - } - - private async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) { - const changedAt = dateRange(query.startAt, query.endAt); - const keyword = query.keyword?.trim(); - const [signatures, drainageInfos] = await Promise.all([ - query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({ - where: { - pendingReport: true, - auditStatus: 'approved', - tenantId: query.tenantId, - applicationId: query.applicationId, - reportChangedAt: changedAt, - OR: keyword ? [ - { name: { contains: keyword } }, - { tenant: { name: { contains: keyword } } }, - { application: { name: { contains: keyword } } }, - ] : undefined, - }, - include: { tenant: true, application: true }, - orderBy: { reportChangedAt: 'desc' }, - }), - query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({ - where: { - pendingReport: true, - auditStatus: 'approved', - tenantId: query.tenantId, - applicationId: query.applicationId, - reportChangedAt: changedAt, - OR: keyword ? [ - { siteName: { contains: keyword } }, - { url: { contains: keyword } }, - { signature: { name: { contains: keyword } } }, - { tenant: { name: { contains: keyword } } }, - { application: { name: { contains: keyword } } }, - ] : undefined, - }, - include: { tenant: true, application: true, signature: true }, - orderBy: { reportChangedAt: 'desc' }, - }), - ]); - return [ - ...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })), - ...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })), - ].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()); + return this.pendingQuery.listPending(query); } listImportProfiles(reportType?: 'signature' | 'drainage') { - return this.prisma.reportMaterialImportProfile.findMany({ - where: { reportType, status: 'active' }, - include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } }, - orderBy: { updatedAt: 'desc' }, - }); + return this.importParser.listImportProfiles(reportType); } async saveImportProfile(data: CreateImportProfileDto) { - validateProfile(data); - return this.prisma.$transaction(async (tx) => { - const profile = data.id - ? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) }) - : await tx.reportMaterialImportProfile.create({ data: profileData(data) }); - await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } }); - await tx.reportMaterialImportProfileColumn.createMany({ - data: data.columns.map((column, index) => ({ - profileId: profile.id, - sourceHeader: column.sourceHeader, - sourceHeaderPath: column.sourceHeaderPath, - sourceColumnIndex: column.sourceColumnIndex, - targetFieldCode: column.targetFieldCode, - targetKind: column.targetKind, - fieldType: column.fieldType, - required: column.required ?? false, - transform: column.transform, - sortOrder: column.sortOrder ?? (index + 1) * 10, - })), - }); - return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }); - }); + return this.importParser.saveImportProfile(data); } async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) { - if (!options.tenantId) throw new BadRequestException('tenantId is required'); - if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage'); - if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件'); - const workbook = await loadWorkbook(file.buffer); - assertSafeWorkbook(workbook); - const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null; - const selectedSheetName = options.sheetName || profile?.sheetName || undefined; - const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0]; - if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表'); - const headerRowCount = clamp(options.headerRowCount, 1, 5); - const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1); - const images = readEmbeddedImages(workbook, worksheet); - const columnCount = Math.min(worksheet.columnCount, 200); - const columns = Array.from({ length: columnCount }, (_, offset) => { - const sourceColumnIndex = offset + 1; - const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean); - const sourceHeaderPath = [...new Set(parts)].join('/'); - return { - sourceColumnIndex, - columnLetter: worksheet.getColumn(sourceColumnIndex).letter, - sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`, - sourceHeaderPath, - imageCount: images.filter((image) => image.column === sourceColumnIndex).length, - }; - }).filter((column) => column.sourceHeaderPath || column.imageCount > 0); - const previewRows = []; - for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) { - const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))])); - const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column); - if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns }); - } - const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file); - const profileMappings = profile?.columns.map((column) => ({ - sourceHeader: column.sourceHeader, - sourceHeaderPath: column.sourceHeaderPath ?? undefined, - sourceColumnIndex: column.sourceColumnIndex, - targetFieldCode: column.targetFieldCode, - targetKind: column.targetKind as ImportMapping['targetKind'], - fieldType: column.fieldType as ImportMapping['fieldType'], - required: column.required, - transform: column.transform ?? undefined, - sortOrder: column.sortOrder, - })); - const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType); - const batch = await this.prisma.reportMaterialImportBatch.create({ - data: { - tenantId: options.tenantId, - applicationId: options.applicationId, - profileId: options.profileId, - fileObjectId: sourceFile.id, - fileName: sourceFile.fileName, - reportType: options.reportType, - sheetName: worksheet.name, - headerRowCount, - dataStartRow, - mapping: suggestedMappings as Prisma.InputJsonValue, - preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue, - rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1), - }, - }); - await this.prisma.operationLog.create({ data: { - tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id, - detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue, - } }); - return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings }; + return this.importParser.analyzeImport(file, options); } async commitImport(batchId: string, data: ImportCommitDto) { - const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } }); - if (!batch) throw new NotFoundException('导入批次不存在'); - if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入'); - if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射'); - if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings }); - const { content } = await this.files.getDownload(batch.fileObjectId); - const workbook = await loadWorkbook(content); - assertSafeWorkbook(workbook); - const worksheet = workbook.getWorksheet(batch.sheetName); - if (!worksheet) throw new BadRequestException('导入工作表不存在'); - const images = readEmbeddedImages(workbook, worksheet); - const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image])); - let successCount = 0; - const failures: Array<{ rowNumber: number; reason: string }> = []; - const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = []; - for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) { - const values: Record = {}; - try { - for (const mapping of data.mappings) { - const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`); - if (image && mapping.fieldType !== 'string') { - const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, { - originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`, - mimetype: imageContentType(image.extension), - size: image.buffer.length, - buffer: image.buffer, - }); - values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType }; - } else { - values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform); - } - } - if (!Object.values(values).some(hasValue)) continue; - for (const mapping of data.mappings.filter((item) => item.required)) { - if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`); - } - const staged = batch.reportType === 'signature' - ? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values) - : await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values); - stagedItems.push({ - batchId, - rowNumber, - reportType: batch.reportType, - operation: staged.operation, - targetId: staged.targetId, - status: 'pending_review', - payload: staged.payload as Prisma.InputJsonValue, - originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined, - }); - successCount += 1; - } catch (error) { - const reason = error instanceof Error ? error.message : '导入失败'; - failures.push({ rowNumber, reason }); - stagedItems.push({ - batchId, - rowNumber, - reportType: batch.reportType, - operation: 'invalid', - status: 'invalid', - payload: values as Prisma.InputJsonValue, - errorMessage: reason, - }); - } - } - const updated = await this.prisma.$transaction(async (tx) => { - if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems }); - return tx.reportMaterialImportBatch.update({ - where: { id: batchId }, - data: { - status: successCount ? 'pending_review' : 'failed', - mapping: data.mappings as Prisma.InputJsonValue, - result: { failures } as Prisma.InputJsonValue, - successCount, - failedCount: failures.length, - }, - include: { items: { orderBy: { rowNumber: 'asc' } } }, - }); - }); - await this.prisma.operationLog.create({ data: { - tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id, - detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue, - } }); - return updated; + return this.importReview.commitImport(batchId, data); } async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) { - const page = normalizePage(query.page); - const pageSize = normalizePageSize(query.pageSize); - const where: Prisma.ReportMaterialImportBatchWhereInput = { - reportType: query.reportType, - status: query.status && query.status !== 'all' ? query.status : undefined, - createdAt: dateRange(query.startAt, query.endAt), - OR: query.keyword?.trim() ? [ - { fileName: { contains: query.keyword.trim() } }, - { id: { contains: query.keyword.trim() } }, - ] : undefined, - }; - const [batches, total] = await Promise.all([ - this.prisma.reportMaterialImportBatch.findMany({ - where, - include: { items: { orderBy: { rowNumber: 'asc' } } }, - orderBy: { createdAt: 'desc' }, - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.reportMaterialImportBatch.count({ where }), - ]); - const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))]; - const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))]; - const reviewerIds = [...new Set(batches.flatMap((batch) => [ - batch.reviewedById, - ...batch.items.map((item) => item.reviewedById), - ]).filter((id): id is string => Boolean(id)))]; - const [tenants, applications, reviewers] = await Promise.all([ - tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [], - applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [], - reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [], - ]); - const tenantById = new Map(tenants.map((item) => [item.id, item])); - const applicationById = new Map(applications.map((item) => [item.id, item])); - const reviewerById = new Map(reviewers.map((item) => [item.id, item])); - return { - items: batches.map((batch) => ({ - ...batch, - tenant: tenantById.get(batch.tenantId) ?? null, - application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null, - reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null, - items: batch.items.map((item) => ({ - ...item, - reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null, - })), - })), - total, - page, - pageSize, - }; + return this.importReview.listImportReviewBatches(query); } async reviewImportItems(batchId: string, data: ReviewImportItemsDto) { - if (!data.reviewerId) throw new BadRequestException('Reviewer session is required'); - if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision'); - const batch = await this.prisma.reportMaterialImportBatch.findUnique({ - where: { id: batchId }, - include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } }, - }); - if (!batch) throw new NotFoundException('导入审核批次不存在'); - if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细'); - let approvedCount = 0; - let rejectedCount = 0; - const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = []; - for (const item of batch.items) { - if (data.decision === 'reject') { - await this.prisma.reportMaterialImportItem.update({ - where: { id: item.id }, - data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() }, - }); - rejectedCount += 1; - continue; - } - try { - const targetId = await this.applyImportItem(batch, item, data.reviewerId); - await this.prisma.reportMaterialImportItem.update({ - where: { id: item.id }, - data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null }, - }); - approvedCount += 1; - } catch (error) { - const reason = error instanceof Error ? error.message : '导入审核应用失败'; - failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason }); - await this.prisma.reportMaterialImportItem.update({ - where: { id: item.id }, - data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() }, - }); - } - } - const counts = await this.prisma.reportMaterialImportItem.groupBy({ - by: ['status'], - where: { batchId }, - _count: { _all: true }, - }); - const countByStatus = new Map(counts.map((item) => [item.status, item._count._all])); - const pendingCount = countByStatus.get('pending_review') ?? 0; - const totalApproved = countByStatus.get('approved') ?? 0; - const totalRejected = countByStatus.get('rejected') ?? 0; - const totalInvalid = countByStatus.get('invalid') ?? 0; - const status = pendingCount - ? 'partially_reviewed' - : totalApproved && (totalRejected || totalInvalid) - ? 'partially_approved' - : totalApproved - ? 'approved' - : totalRejected - ? 'rejected' - : 'failed'; - await this.prisma.reportMaterialImportBatch.update({ - where: { id: batchId }, - data: { - status, - reviewedById: pendingCount ? undefined : data.reviewerId, - reviewedAt: pendingCount ? undefined : new Date(), - completedAt: pendingCount ? undefined : new Date(), - }, - }); - return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; + return this.importReview.reviewImportItems(batchId, data); } async listBatches(query: PagedQuery = {}) { - const page = normalizePage(query.page); - const pageSize = normalizePageSize(query.pageSize); - const where: Prisma.ReportMaterialBatchWhereInput = { - createdAt: dateRange(query.startAt, query.endAt), - batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, - }; - const [batches, total] = await Promise.all([ - this.prisma.reportMaterialBatch.findMany({ - where, - include: { - exportFiles: { - include: { - items: { include: { task: { select: { id: true, status: true } } } }, - }, - }, - items: true, - }, - orderBy: { createdAt: 'desc' }, - skip: (page - 1) * pageSize, - take: pageSize, - }), - this.prisma.reportMaterialBatch.count({ where }), - ]); - return { - items: batches.map((batch) => { - const reportItems = batch.exportFiles.flatMap((file) => file.items); - const reportTotal = reportItems.length; - const successCount = reportItems.filter((item) => item.task.status === 'approved').length; - return { - ...batch, - reportTotal, - successCount, - successRate: reportTotal ? successCount / reportTotal : 0, - }; - }), - total, - page, - pageSize, - }; + return this.batchGeneration.listBatches(query); } async createBatch(data: CreateReportBatchDto) { - if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); - const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey); - const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; - const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex'); - const claimed = await this.claimBatchOperation(idempotencyKey, fingerprint, data.createdById); - if (claimed.replayed) return claimed.result; - - let preflight: Awaited>; - try { - preflight = await this.preflightBatch({ items: uniqueItems }); - } catch (error) { - await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败'); - throw error; - } - if (preflight.eligibleTargetCount === 0) { - await this.failBatchOperation(claimed.operationId, '没有可生成的报备目标'); - throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight }); - } - const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible)); - const batch = await this.prisma.reportMaterialBatch.create({ - data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length }, - }); - try { - const prepared = []; - for (const inspection of eligibleInspections) { - const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!; - prepared.push(await this.prepareBatchItem(batch.id, selected, inspection)); - } - const channelMap = new Map>(); - for (const item of prepared) { - for (const channel of item.channels) { - const current = channelMap.get(channel.id) ?? []; - current.push({ ...item, channels: [channel] }); - channelMap.set(channel.id, current); - } - } - const exportedFiles = []; - const incomplete = new Set(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id)); - let failedTargetCount = 0; - for (const [channelId, items] of channelMap) { - const result = await this.exportChannelBatch(batch.id, channelId, items); - exportedFiles.push(result.file); - failedTargetCount += result.incompleteBatchItemIds.length; - for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId); - } - for (const item of prepared) { - if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue; - if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } }); - else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } }); - } - const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } }); - const result = { - ...completed, - operationId: claimed.operationId, - replayed: false, - result: { - successCount: preflight.eligibleTargetCount - failedTargetCount, - skippedCount: preflight.skippedTargetCount, - failedCount: failedTargetCount, - items: preflight.items, - }, - }; - await this.completeBatchOperation(claimed.operationId, batch.id, result); - return result; - } catch (error) { - await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } }); - await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id); - throw error; - } + return this.batchGeneration.createBatch(data); } async preflightBatch(data: Pick) { - if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); - for (const item of data.items) { - if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' }); - if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' }); - } - const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; - const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item))); - return { - checkedAt: new Date().toISOString(), - eligible: items.some((item) => item.eligible), - eligibleItemCount: items.filter((item) => item.eligible).length, - blockedItemCount: items.filter((item) => !item.eligible).length, - eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0), - skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0), - items, - }; - } - - private async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { - const name = mappedCoreValue(mappings, values, 'signatureName'); - if (!name) throw new Error('缺少短信签名'); - const purpose = mappedCoreValue(mappings, values, 'purpose'); - const signatureReportValues = dynamicValues(mappings, values); - const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } }); - return { - operation: existing ? 'update' : 'create', - targetId: existing?.id, - payload: { - tenantId, - applicationId, - name, - purpose, - drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues }, - }, - originalSnapshot: existing ? { - id: existing.id, - applicationId: existing.applicationId, - name: existing.name, - purpose: existing.purpose, - drainageInfo: existing.drainageInfo, - auditStatus: existing.auditStatus, - updatedAt: existing.updatedAt, - } : undefined, - }; - } - - private async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { - const signatureName = mappedCoreValue(mappings, values, 'signatureName'); - const siteName = mappedCoreValue(mappings, values, 'siteName'); - const url = mappedCoreValue(mappings, values, 'url'); - if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL'); - const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } }); - if (!signature) throw new Error(`未找到已审核签名:${signatureName}`); - const remark = mappedCoreValue(mappings, values, 'remark'); - const reportValues = dynamicValues(mappings, values); - const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } }); - return { - operation: existing ? 'update' : 'create', - targetId: existing?.id, - payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues }, - originalSnapshot: existing ? { - id: existing.id, - siteName: existing.siteName, - url: existing.url, - remark: existing.remark, - reportValues: existing.reportValues, - auditStatus: existing.auditStatus, - updatedAt: existing.updatedAt, - } : undefined, - }; - } - - private async applyImportItem( - batch: { tenantId: string; applicationId: string | null; reportType: string }, - item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue }, - reviewerId: string, - ) { - const payload = jsonRecord(item.payload); - if (item.reportType === 'signature') { - const name = String(payload.name ?? ''); - const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined; - const body = { - applicationId, - name, - purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined, - drainageInfo: jsonRecord(payload.drainageInfo), - }; - let targetId = item.targetId; - if (targetId) { - const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } }); - if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); - await this.smsConfig.updateSignature(targetId, body, batch.tenantId); - } else { - const duplicate = await this.prisma.smsSignature.findFirst({ - where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } }, - }); - if (duplicate) { - targetId = duplicate.id; - await this.smsConfig.updateSignature(targetId, body, batch.tenantId); - } else { - const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body }); - targetId = created.id; - } - } - await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` }); - return targetId; - } - const signatureId = String(payload.signatureId ?? ''); - const siteName = String(payload.siteName ?? ''); - const url = String(payload.url ?? ''); - const body = { - siteName, - url, - remark: typeof payload.remark === 'string' ? payload.remark : undefined, - reportValues: jsonRecord(payload.reportValues), - }; - let targetId = item.targetId; - if (targetId) { - const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } }); - if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改'); - await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); - } else { - const duplicate = await this.prisma.smsDrainageInfo.findFirst({ - where: { signatureId, url, auditStatus: { not: 'deleted' } }, - }); - if (duplicate) { - targetId = duplicate.id; - await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); - } else { - const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId); - targetId = created.id; - } - } - await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` }); - return targetId; - } - - private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); - if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过'); - const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId - ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; - if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过'); - const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ - where: { applicationId: signature.applicationId, status: 'active' }, - include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, - orderBy: { priority: 'asc' }, - }) : []; - const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id)); - const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()]; - const snapshot = selected.reportType === 'signature' - ? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } - : { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) }; - const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion; - const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } }); - return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels }; - } - - private async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); - if (!signature) throw new NotFoundException('签名不存在'); - const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId - ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; - const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0; - const blockedReasons: string[] = []; - if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过'); - if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池'); - if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用'); - else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用'); - if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion})`); - if (selected.reportType === 'drainage') { - if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名'); - else { - if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过'); - if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池'); - } - } - const snapshot = selected.reportType === 'signature' - ? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } - : { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) }; - const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ - where: { applicationId: signature.applicationId, status: 'active' }, - include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, - orderBy: { priority: 'asc' }, - }) : []; - const channelCarriers = new Map }>(); - for (const route of routes) { - if (route.group.status !== 'active') continue; - for (const entry of route.group.items) { - if (entry.channel.status !== 'active') continue; - const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set() }; - current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all'); - channelCarriers.set(entry.channel.id, current); - } - } - if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道'); - const previous = await this.prisma.reportMaterialBatchItem.findMany({ - where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } }, - select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } }, - orderBy: { createdAt: 'desc' }, - }); - const priorKeys = new Map(); - for (const item of previous) { - const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value))); - for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) { - if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId); - } - } - const targets: ReportBatchTarget[] = []; - for (const { channel, carriers } of channelCarriers.values()) { - const carrier = [...carriers].sort().join(','); - const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`; - const targetReasons = [...blockedReasons]; - const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); - if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段'); - else { - const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue)); - if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`); - } - const duplicateBatchId = priorKeys.get(businessKey); - if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`); - targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId }); - } - return { - id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`, - reportType: selected.reportType, - signatureId: signature.id, - drainageItemId: drainageInfo?.id, - materialVersion, - name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料', - tenantName: signature.tenant.name, - applicationId: signature.applicationId ?? undefined, - applicationName: signature.application?.name ?? '未指定应用', - eligible: targets.some((target) => target.eligible), - blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons, - targets, - }; - } - - private async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) { - return this.prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`; - const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } }); - if (existing) { - const detail = jsonRecord(existing.detail); - if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' }); - if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } }; - throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' }); - } - const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } }); - return { operationId: operation.id, replayed: false as const, result: null }; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); - } - - private async completeBatchOperation(operationId: string, batchId: string, result: Record) { - await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } }); - } - - private async failBatchOperation(operationId: string, message: string, batchId?: string) { - const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } }); - await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } }); - } - - private async exportChannelBatch(batchId: string, channelId: string, items: Array>>) { - const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); - if (!channel) throw new NotFoundException('通道不存在'); - const reportTypes = [...new Set(items.map((item) => item.reportType))]; - const workbook = new ExcelJS.Workbook(); - const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = []; - const incompleteBatchItemIds: string[] = []; - let totalRows = 0; - for (const reportType of reportTypes) { - const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); - const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] }); - sheet.properties.defaultRowHeight = 22; - sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth })); - styleHeader(sheet.getRow(1)); - for (const item of items.filter((current) => current.reportType === reportType)) { - const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? ''); - const missing = fields.filter((field, index) => field.required && !hasValue(values[index])); - const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null; - const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } }); - const task = existingTask - ? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }) - : await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }); - if (missingReason) { - incompleteBatchItemIds.push(item.batchItem.id); - await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined); - continue; - } - const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform))); - totalRows += 1; - let targetHeight = 22; - for (const [index, value] of values.entries()) { - if (!isFileRef(value)) continue; - const downloaded = await this.files.getDownload(value.fileObjectId); - if (!downloaded.fileObject.contentType.startsWith('image/')) continue; - const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]); - if (!['png', 'jpeg', 'gif'].includes(extension)) continue; - const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' }); - const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7)); - const heightRows = Math.max(0.8, fields[index].imageHeight / 20); - sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never); - targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8); - } - row.height = targetHeight; - fileRows.push({ item, taskId: task.id, rowNumber: row.number }); - await this.recordTask(task.id, channelId, existingTask?.status, 'exporting'); - } - } - if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) { - for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id); - const empty = workbook.addWorksheet('无可导出数据'); - empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。'; - empty.getColumn(1).width = 64; - } - const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); - const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`; - const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }); - const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } }); - if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) }); - return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds }; - } - - private recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) { - return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } }); + return this.batchGeneration.preflightBatch(data); } } - -function profileData(data: CreateImportProfileDto) { - return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' }; -} - -function validateProfile(data: CreateImportProfileDto) { - if (!data.name?.trim()) throw new BadRequestException('映射模板名称不能为空'); - if (!data.columns?.length) throw new BadRequestException('映射模板至少包含一个字段'); - const indexes = data.columns.map((column) => column.sourceColumnIndex); - if (new Set(indexes).size !== indexes.length) throw new BadRequestException('同一源列不能重复映射'); -} - -async function loadWorkbook(buffer: Buffer) { - const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer as never); - return workbook; -} - -function assertSafeWorkbook(workbook: ExcelJS.Workbook) { - for (const worksheet of workbook.worksheets) { - worksheet.eachRow((row) => row.eachCell((cell) => { - const value = cell.value; - if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { - throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); - } - const text = typeof value === 'string' ? value.trimStart() : ''; - if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) { - throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); - } - })); - } -} - -function safeSpreadsheetText(value: unknown) { - const text = value == null ? '' : String(value); - return /^[=+@]/.test(text) || /^-[^\d.]/.test(text) ? `'${text}` : text; -} - -function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] { - const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages; - if (!getImages) return []; - return getImages.call(worksheet).flatMap((drawing) => { - const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId); - if (!image) return []; - const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1; - const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1; - const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined); - return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : []; - }); -} - -function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] { - return columns.flatMap((column, index) => { - const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`); - const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized); - if (!core && !column.imageCount) return []; - return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }]; - }); -} - -function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] { - const used = new Set(); - return profileColumns.flatMap((profileColumn) => { - const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader); - const header = normalizeHeader(profileColumn.sourceHeader); - const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath) - ?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header); - if (!source) return []; - used.add(source.sourceColumnIndex); - return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }]; - }); -} - -function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined { - if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true }; - if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' }; - return undefined; -} - -function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined { - if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true }; - if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true }; - if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true }; - if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' }; - return undefined; -} - -function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); } -function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; } -function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); } -function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); } -function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); } -function dateRange(startAt?: string, endAt?: string) { - const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined; - const end = endAt ? new Date(`${endAt}T23:59:59.999+08:00`) : undefined; - if (start && Number.isNaN(start.getTime())) throw new BadRequestException('开始日期无效'); - if (end && Number.isNaN(end.getTime())) throw new BadRequestException('结束日期无效'); - return start || end ? { gte: start, lte: end } : undefined; -} - -function cellText(cell: ExcelJS.Cell) { - const value = cell.value; - if (value === null || value === undefined) return ''; - if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value); - if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim(); - if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim(); - if ('richText' in value) return value.richText.map((item) => item.text).join('').trim(); - if ('text' in value) return String(value.text).trim(); - return cell.text.trim(); -} - -function transformValue(value: string, transform?: string) { - if (!transform || transform === 'trim') return value.trim(); - if (transform === 'digits') return value.replace(/\D/g, ''); - if (transform === 'uppercase') return value.trim().toUpperCase(); - if (transform === 'lowercase') return value.trim().toLowerCase(); - return value.trim(); -} - -function mappedCoreValue(mappings: ImportMapping[], values: Record, kind: ImportMapping['targetKind']) { - const mapping = mappings.find((item) => item.targetKind === kind); - return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : ''; -} - -function dynamicValues(mappings: ImportMapping[], values: Record) { - return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]])); -} - -function jsonRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } -function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; } -function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record).fileObjectId === 'string'; } - -function resolveExportValue(snapshot: Record, code: string, name?: string) { - const values = jsonRecord(snapshot.values); - if (hasValue(values[code])) return values[code]; - const signature = jsonRecord(snapshot.signature); - const drainage = jsonRecord(snapshot.drainage); - const aliases: Record = { - signature_name: signature.name, sign_name: signature.name, signatureName: signature.name, - purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName, - application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark, - }; - if (hasValue(aliases[code])) return aliases[code]; - const semantic = normalizeHeader(`${code}/${name ?? ''}`); - if (/短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)) return signature.name; - if (/签名用途|签名依据|purpose/.test(semantic)) return signature.purpose; - if (/企业名称|公司名称|enterprisename|companyname/.test(semantic)) return signature.tenantName; - if (/应用名称|applicationname|appname/.test(semantic)) return signature.applicationName; - if (/站点名称|网站名称|sitename/.test(semantic)) return drainage.siteName; - if (/引流地址|网址|链接|url/.test(semantic)) return drainage.url; - if (/备注|说明|remark/.test(semantic)) return drainage.remark; - return undefined; -} - -function applyExportTransform(value: unknown, transform?: string | null) { - const text = value === null || value === undefined ? '' : String(value); - return transformValue(text, transform ?? undefined); -} - -function styleHeader(row: ExcelJS.Row) { - row.height = 28; - row.eachCell((cell) => { - cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; - cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } }; - cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; - cell.border = { bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } } }; - }); -} - -function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; } -function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; } -function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; } - -function normalizeBatchIdempotencyKey(value?: string) { - const key = value?.trim(); - if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' }); - return key; -} - -function jsonStringArray(value: unknown) { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; -} - -function jsonSafe(value: unknown): Prisma.InputJsonValue { - return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; -} diff --git a/api/src/risk-review/admin-risk-review.controller.ts b/api/src/risk-review/admin-risk-review.controller.ts index d7b0682..8852ab5 100644 --- a/api/src/risk-review/admin-risk-review.controller.ts +++ b/api/src/risk-review/admin-risk-review.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { SendChainService } from '../send-chain/send-chain.service'; @@ -8,11 +8,20 @@ import { ReviewSmsTaskDto, RiskReviewService, } from './risk-review.service'; +import { + CreatePhoneFrequencyWhitelistDto, + PhoneFrequencyService, + UpdatePhoneFrequencyWhitelistDto, +} from './phone-frequency.service'; @ApiTags('risk-review') @Controller('admin/risk-review') export class AdminRiskReviewController { - constructor(private readonly riskReview: RiskReviewService, private readonly sendChain: SendChainService) {} + constructor( + private readonly riskReview: RiskReviewService, + private readonly sendChain: SendChainService, + private readonly phoneFrequency: PhoneFrequencyService, + ) {} @Get('rules') listRules(@Query('applicationId') applicationId?: string) { @@ -34,6 +43,85 @@ export class AdminRiskReviewController { return this.riskReview.listHits(tenantId, taskId); } + @Get('phone-frequency-hits') + listPhoneFrequencyHits( + @Query('tenantId') tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('phoneNumber') phoneNumber?: string, + @Query('status') status?: 'active' | 'expired' | 'released', + @Query('createdAtFrom') createdAtFrom?: string, + @Query('createdAtTo') createdAtTo?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.phoneFrequency.listHits({ + tenantId, + applicationId, + phoneNumber, + status, + createdAtFrom, + createdAtTo, + page: Number(page ?? 1), + pageSize: Number(pageSize ?? 20), + }); + } + + @Post('phone-frequency-hits/:id/release') + releasePhoneFrequencyHit( + @Param('id') hitId: string, + @Body() body: { reason?: string }, + @CurrentSessionUserId() reviewerId?: string, + ) { + return this.phoneFrequency.releaseHit(hitId, reviewerId, body.reason); + } + + @Get('phone-frequency-whitelist') + listPhoneFrequencyWhitelist( + @Query('phoneNumber') phoneNumber?: string, + @Query('keyword') keyword?: string, + @Query('status') status?: 'active' | 'inactive' | 'deleted', + @Query('updatedAtFrom') updatedAtFrom?: string, + @Query('updatedAtTo') updatedAtTo?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.phoneFrequency.listWhitelist({ + phoneNumber, + keyword, + status, + updatedAtFrom, + updatedAtTo, + page: Number(page ?? 1), + pageSize: Number(pageSize ?? 20), + }); + } + + @Post('phone-frequency-whitelist') + createPhoneFrequencyWhitelist( + @Body() body: CreatePhoneFrequencyWhitelistDto, + @CurrentSessionUserId() operatorId?: string, + ) { + return this.phoneFrequency.createWhitelist(body, operatorId); + } + + @Put('phone-frequency-whitelist/:id') + updatePhoneFrequencyWhitelist( + @Param('id') id: string, + @Body() body: UpdatePhoneFrequencyWhitelistDto, + @CurrentSessionUserId() operatorId?: string, + ) { + return this.phoneFrequency.updateWhitelist(id, body, operatorId); + } + + @Delete('phone-frequency-whitelist/:id') + deletePhoneFrequencyWhitelist( + @Param('id') id: string, + @Body() body: { reason?: string }, + @CurrentSessionUserId() operatorId?: string, + ) { + return this.phoneFrequency.deleteWhitelist(id, operatorId, body.reason); + } + @Get('tasks') listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) { return this.riskReview.listTasks(tenantId, status); diff --git a/api/src/risk-review/phone-frequency.service.spec.ts b/api/src/risk-review/phone-frequency.service.spec.ts new file mode 100644 index 0000000..657cc9b --- /dev/null +++ b/api/src/risk-review/phone-frequency.service.spec.ts @@ -0,0 +1,144 @@ +import { PhoneFrequencyService, fixedShanghaiWindow } from './phone-frequency.service'; + +describe('PhoneFrequencyService', () => { + it('aligns five-minute cycles and natural days in Asia/Shanghai', () => { + const requestedAt = new Date('2026-07-30T16:07:42.000Z'); + + expect(fixedShanghaiWindow(requestedAt, 300)).toEqual({ + startAt: new Date('2026-07-30T16:05:00.000Z'), + endAt: new Date('2026-07-30T16:10:00.000Z'), + }); + expect(fixedShanghaiWindow(requestedAt, 86400)).toEqual({ + startAt: new Date('2026-07-30T16:00:00.000Z'), + endAt: new Date('2026-07-31T16:00:00.000Z'), + }); + }); + + it('creates a persistent hit and rejects the threshold-exceeding phone only', async () => { + const tx = { + $queryRaw: jest.fn() + .mockResolvedValueOnce([{ + id: 'state-24h', + phoneNumber: '13800000001', + count: 2, + generation: 0, + activeHitId: null, + windowStartedAt: new Date('2026-07-29T16:00:00.000Z'), + windowEndsAt: new Date('2026-07-30T16:00:00.000Z'), + }]) + .mockResolvedValueOnce([{ + id: 'state-5m', + phoneNumber: '13800000001', + count: 6, + generation: 0, + activeHitId: null, + windowStartedAt: new Date('2026-07-30T01:00:00.000Z'), + windowEndsAt: new Date('2026-07-30T01:05:00.000Z'), + }]), + $executeRaw: jest.fn().mockResolvedValue(1), + phoneFrequencyHit: { + createMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + phoneFrequencyWhitelist: { + findMany: jest.fn().mockResolvedValue([]), + }, + }; + const prisma = { + riskRule: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'rule-24h', + applicationId: null, + code: 'PHONE_FREQUENCY_24H', + name: '单号码24小时发送频次', + thresholdValue: 10, + action: 'block', + priority: 40, + config: { periodSeconds: 86400 }, + }, + { + id: 'rule-5m', + applicationId: null, + code: 'PHONE_FREQUENCY_5M', + name: '单号码5分钟发送频次', + thresholdValue: 5, + action: 'block', + priority: 50, + config: { periodSeconds: 300 }, + }, + ]), + }, + $transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + }; + const riskReview = { ensureDefaultRules: jest.fn().mockResolvedValue(undefined) }; + const service = new PhoneFrequencyService(prisma as never, riskReview as never); + + const rejected = await service.reserve( + 'tenant-1', + 'application-1', + ['13800000001'], + 'client', + new Date('2026-07-30T01:03:00.000Z'), + ); + + expect(rejected.get('13800000001')).toEqual(expect.objectContaining({ + code: 'PHONE_FREQUENCY_LIMIT', + reason: expect.stringContaining('当前第6条'), + })); + expect(tx.phoneFrequencyHit.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ + applicationId: 'application-1', + phoneNumber: '13800000001', + ruleCode: 'PHONE_FREQUENCY_5M', + thresholdValue: 5, + actualValue: 6, + })], + }); + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it('bypasses both frequency rules for active platform-level whitelist phones', async () => { + const tx = { + phoneFrequencyWhitelist: { + findMany: jest.fn().mockResolvedValue([{ phoneNumber: '13800000001' }]), + }, + $queryRaw: jest.fn(), + }; + const prisma = { + riskRule: { + findMany: jest.fn().mockResolvedValue([{ + id: 'rule-5m', + applicationId: null, + code: 'PHONE_FREQUENCY_5M', + name: '单号码5分钟发送频次', + thresholdValue: 5, + action: 'block', + priority: 50, + config: { periodSeconds: 300 }, + }]), + }, + $transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + }; + const riskReview = { ensureDefaultRules: jest.fn().mockResolvedValue(undefined) }; + const service = new PhoneFrequencyService(prisma as never, riskReview as never); + + const rejected = await service.reserve( + 'tenant-1', + 'application-1', + ['13800000001'], + 'client', + new Date('2026-07-30T01:03:00.000Z'), + ); + + expect(rejected.size).toBe(0); + expect(tx.phoneFrequencyWhitelist.findMany).toHaveBeenCalledWith({ + where: { + phoneNumber: { in: ['13800000001'] }, + status: 'active', + deletedAt: null, + }, + select: { phoneNumber: true }, + }); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/risk-review/phone-frequency.service.ts b/api/src/risk-review/phone-frequency.service.ts new file mode 100644 index 0000000..608b98d --- /dev/null +++ b/api/src/risk-review/phone-frequency.service.ts @@ -0,0 +1,690 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { RiskReviewService } from './risk-review.service'; + +const PHONE_FREQUENCY_RULE_CODES = ['PHONE_FREQUENCY_24H', 'PHONE_FREQUENCY_5M'] as const; +const FREQUENCY_WRITE_CHUNK_SIZE = 1000; + +type FrequencyRule = { + id: string; + applicationId: string | null; + code: string; + name: string; + thresholdValue: number; + action: string; + priority: number; + config: unknown; +}; + +type FrequencyStateRow = { + id: string; + phoneNumber: string; + count: number; + generation: number; + activeHitId: string | null; + windowStartedAt: Date; + windowEndsAt: Date; +}; + +export interface PhoneFrequencyHitQuery { + tenantId?: string; + applicationId?: string; + phoneNumber?: string; + status?: 'active' | 'expired' | 'released'; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; +} + +export interface PhoneFrequencyWhitelistQuery { + phoneNumber?: string; + keyword?: string; + status?: 'active' | 'inactive' | 'deleted'; + updatedAtFrom?: string; + updatedAtTo?: string; + page?: number; + pageSize?: number; +} + +export interface CreatePhoneFrequencyWhitelistDto { + phoneNumber: string; + reason: string; + remark?: string; + status?: 'active' | 'inactive'; +} + +export type UpdatePhoneFrequencyWhitelistDto = Partial; + +export interface PhoneFrequencyRejection { + code: 'PHONE_FREQUENCY_LIMIT'; + reason: string; +} + +@Injectable() +export class PhoneFrequencyService { + constructor( + private readonly prisma: PrismaService, + private readonly riskReview: RiskReviewService, + ) {} + + /** + * 为一次初始业务短信提交占用频次。调用方只传尚未被格式或黑名单拒绝的号码; + * 长短信分片、通道重试和补发不会进入这里,因此同一业务号码只计一次。 + */ + async reserve( + tenantId: string, + applicationId: string | undefined, + phones: string[], + sourceType?: string, + requestedAt = new Date(), + ) { + if (!applicationId) return new Map(); + const normalizedPhones = [...new Set(phones.map((phone) => phone.trim()).filter(Boolean))].sort(); + if (normalizedPhones.length === 0) return new Map(); + + await this.riskReview.ensureDefaultRules(); + const rules = await this.effectiveRules(applicationId); + if (rules.length === 0) return new Map(); + + return this.prisma.$transaction(async (tx) => { + const rejected = new Map(); + // 平台级白名单只截断号码频控链路;调用 reserve 之前已执行的格式、黑名单等校验不受影响。 + const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, normalizedPhones); + const controlledPhones = normalizedPhones.filter((phone) => !whitelistedPhones.has(phone)); + if (controlledPhones.length === 0) return rejected; + for (const rule of rules) { + const window = fixedShanghaiWindow(requestedAt, readPeriodSeconds(rule)); + // 分块限制 SQL 参数数量,但两条规则的全部分块仍在同一事务中提交或回滚。 + for (const phoneChunk of chunks(controlledPhones, FREQUENCY_WRITE_CHUNK_SIZE)) { + const states = await this.upsertStates(tx, { + tenantId, + applicationId, + phones: phoneChunk, + rule, + window, + }); + const newTriggers = states.filter((state) => state.activeHitId === null && state.count > rule.thresholdValue); + const hitByStateId = new Map(); + if (newTriggers.length > 0) { + const hitRows = newTriggers.map((state) => { + const hitId = randomUUID(); + hitByStateId.set(state.id, hitId); + return { + id: hitId, + tenantId, + applicationId, + ruleId: rule.id, + ruleCode: rule.code, + ruleName: rule.name, + phoneNumber: state.phoneNumber, + thresholdValue: Math.floor(rule.thresholdValue), + actualValue: state.count, + windowStartedAt: state.windowStartedAt, + windowEndsAt: state.windowEndsAt, + generation: state.generation, + action: 'block', + sourceType, + }; + }); + await tx.phoneFrequencyHit.createMany({ data: hitRows }); + await this.attachActiveHits(tx, hitByStateId); + } + + for (const state of states) { + if (state.activeHitId === null && state.count <= rule.thresholdValue) continue; + const reason = `${rule.name}命中:本周期最多${Math.floor(rule.thresholdValue)}条,当前第${state.count}条,周期${formatWindow(state.windowStartedAt, state.windowEndsAt)}`; + const existing = rejected.get(state.phoneNumber); + rejected.set(state.phoneNumber, { + code: 'PHONE_FREQUENCY_LIMIT', + reason: existing ? `${existing.reason};${reason}` : reason, + }); + } + } + } + return rejected; + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + } + + async listHits(query: PhoneFrequencyHitQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20))); + const now = new Date(); + if (query.status && !['active', 'expired', 'released'].includes(query.status)) { + throw new BadRequestException('号码频次触发记录状态无效'); + } + const createdAtFrom = parseOptionalDate(query.createdAtFrom, '开始时间'); + const createdAtTo = parseOptionalDate(query.createdAtTo, '结束时间'); + if (createdAtFrom && createdAtTo && createdAtFrom > createdAtTo) { + throw new BadRequestException('开始时间不能晚于结束时间'); + } + const where: Prisma.PhoneFrequencyHitWhereInput = { + tenantId: query.tenantId, + applicationId: query.applicationId, + phoneNumber: query.phoneNumber?.trim() ? { contains: query.phoneNumber.trim() } : undefined, + createdAt: createdAtFrom || createdAtTo ? { + gte: createdAtFrom, + lte: createdAtTo, + } : undefined, + ...(query.status === 'active' ? { releasedAt: null, windowEndsAt: { gt: now } } : {}), + ...(query.status === 'expired' ? { releasedAt: null, windowEndsAt: { lte: now } } : {}), + ...(query.status === 'released' ? { releasedAt: { not: null } } : {}), + }; + const [items, total] = await Promise.all([ + this.prisma.phoneFrequencyHit.findMany({ + where, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + releasedBy: { select: { id: true, username: true, displayName: true } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.phoneFrequencyHit.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async releaseHit(hitId: string, reviewerId: string | undefined, reason?: string) { + const normalizedReason = reason?.trim(); + if (!reviewerId) throw new BadRequestException('解除操作需要有效的运营登录会话'); + if (!normalizedReason) throw new BadRequestException('解除并清零时必须填写原因'); + + return this.prisma.$transaction(async (tx) => { + // 与并发 reserve 串行:解除先锁住当前活跃状态,再同时清零计数和断开命中关联。 + const [lockedState] = await tx.$queryRaw>(Prisma.sql` + SELECT state.id + FROM "PhoneFrequencyState" state + WHERE state."activeHitId" = ${hitId} + FOR UPDATE + `); + const hit = await tx.phoneFrequencyHit.findUnique({ where: { id: hitId } }); + if (!hit) throw new NotFoundException('号码频次触发记录不存在'); + if (hit.releasedAt) { + return tx.phoneFrequencyHit.findUnique({ + where: { id: hitId }, + include: { tenant: true, application: true, releasedBy: true }, + }); + } + const releasedAt = new Date(); + if (lockedState) { + await tx.phoneFrequencyState.update({ + where: { id: lockedState.id }, + data: { + count: 0, + generation: { increment: 1 }, + activeHitId: null, + }, + }); + } + const released = await tx.phoneFrequencyHit.update({ + where: { id: hitId }, + data: { + releasedAt, + releasedById: reviewerId, + releaseReason: normalizedReason, + }, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + releasedBy: { select: { id: true, username: true, displayName: true } }, + }, + }); + await tx.operationLog.create({ + data: { + tenantId: hit.tenantId, + userId: reviewerId, + action: 'phone_frequency.release', + resource: 'phone_frequency_hit', + resourceId: hitId, + detail: { + applicationId: hit.applicationId, + phoneNumber: hit.phoneNumber, + ruleCode: hit.ruleCode, + countReset: Boolean(lockedState), + reason: normalizedReason, + } as Prisma.InputJsonValue, + }, + }); + return released; + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + } + + async listWhitelist(query: PhoneFrequencyWhitelistQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20))); + if (query.status && !['active', 'inactive', 'deleted'].includes(query.status)) { + throw new BadRequestException('号码频控白名单状态无效'); + } + const updatedAtFrom = parseOptionalDate(query.updatedAtFrom, '开始时间'); + const updatedAtTo = parseOptionalDate(query.updatedAtTo, '结束时间'); + if (updatedAtFrom && updatedAtTo && updatedAtFrom > updatedAtTo) { + throw new BadRequestException('开始时间不能晚于结束时间'); + } + const keyword = query.keyword?.trim(); + const phoneNumber = query.phoneNumber?.trim(); + const where: Prisma.PhoneFrequencyWhitelistWhereInput = { + status: query.status ?? { not: 'deleted' }, + phoneNumber: phoneNumber ? { contains: phoneNumber } : undefined, + updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, + OR: keyword ? [ + { phoneNumber: { contains: keyword } }, + { reason: { contains: keyword, mode: 'insensitive' } }, + { remark: { contains: keyword, mode: 'insensitive' } }, + ] : undefined, + }; + const include = { + createdBy: { select: { id: true, username: true, displayName: true } }, + updatedBy: { select: { id: true, username: true, displayName: true } }, + } as const; + const [items, total] = await Promise.all([ + this.prisma.phoneFrequencyWhitelist.findMany({ + where, + include, + orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.phoneFrequencyWhitelist.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async createWhitelist(data: CreatePhoneFrequencyWhitelistDto, operatorId: string | undefined) { + const normalized = normalizeWhitelistInput(data, false); + if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话'); + return this.prisma.$transaction(async (tx) => { + const existing = await tx.phoneFrequencyWhitelist.findUnique({ + where: { phoneNumber: normalized.phoneNumber }, + }); + if (existing && existing.status !== 'deleted') { + throw new BadRequestException('该号码已存在于号码频控白名单'); + } + const entry = existing + ? await tx.phoneFrequencyWhitelist.update({ + where: { id: existing.id }, + data: { + ...normalized, + deletedAt: null, + updatedById: operatorId, + }, + }) + : await tx.phoneFrequencyWhitelist.create({ + data: { + ...normalized, + createdById: operatorId, + updatedById: operatorId, + }, + }); + const reset = normalized.status === 'active' + ? await this.resetFrequencyStates(tx, [normalized.phoneNumber], operatorId, '号码加入平台级频控白名单') + : { stateCount: 0, hitCount: 0 }; + await tx.operationLog.create({ + data: { + userId: operatorId, + action: existing ? 'phone_frequency_whitelist.restore' : 'phone_frequency_whitelist.create', + resource: 'phone_frequency_whitelist', + resourceId: entry.id, + detail: { + after: normalized, + reset, + } as Prisma.InputJsonValue, + }, + }); + return tx.phoneFrequencyWhitelist.findUnique({ + where: { id: entry.id }, + include: whitelistUserInclude, + }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + } + + async updateWhitelist( + id: string, + data: UpdatePhoneFrequencyWhitelistDto, + operatorId: string | undefined, + ) { + if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话'); + if (!data || Object.keys(data).length === 0) throw new BadRequestException('没有需要修改的白名单字段'); + const normalized = normalizeWhitelistInput(data, true); + return this.prisma.$transaction(async (tx) => { + await tx.$queryRaw(Prisma.sql` + SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE + `); + const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } }); + if (!existing || existing.status === 'deleted') { + throw new NotFoundException('号码频控白名单记录不存在'); + } + const nextPhone = normalized.phoneNumber ?? existing.phoneNumber; + const nextStatus = normalized.status ?? existing.status; + if (nextPhone !== existing.phoneNumber) { + const duplicate = await tx.phoneFrequencyWhitelist.findUnique({ where: { phoneNumber: nextPhone } }); + if (duplicate && duplicate.id !== id) { + throw new BadRequestException( + duplicate.status === 'deleted' + ? '该号码存在已删除的白名单历史记录,请直接重新新增该号码以恢复记录' + : '该号码已存在于号码频控白名单', + ); + } + } + const shouldReset = nextPhone !== existing.phoneNumber || nextStatus !== existing.status; + const reset = shouldReset + ? await this.resetFrequencyStates( + tx, + [existing.phoneNumber, nextPhone], + operatorId, + '平台级频控白名单号码或状态发生变更', + ) + : { stateCount: 0, hitCount: 0 }; + const entry = await tx.phoneFrequencyWhitelist.update({ + where: { id }, + data: { + ...normalized, + updatedById: operatorId, + }, + include: whitelistUserInclude, + }); + await tx.operationLog.create({ + data: { + userId: operatorId, + action: 'phone_frequency_whitelist.update', + resource: 'phone_frequency_whitelist', + resourceId: id, + detail: { + before: whitelistAuditValue(existing), + after: whitelistAuditValue(entry), + reset, + } as Prisma.InputJsonValue, + }, + }); + return entry; + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + } + + async deleteWhitelist(id: string, operatorId: string | undefined, reason?: string) { + if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话'); + const normalizedReason = reason?.trim(); + if (!normalizedReason) throw new BadRequestException('删除白名单时必须填写原因'); + return this.prisma.$transaction(async (tx) => { + await tx.$queryRaw(Prisma.sql` + SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE + `); + const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } }); + if (!existing || existing.status === 'deleted') { + throw new NotFoundException('号码频控白名单记录不存在'); + } + const reset = await this.resetFrequencyStates( + tx, + [existing.phoneNumber], + operatorId, + `删除平台级频控白名单:${normalizedReason}`, + ); + const entry = await tx.phoneFrequencyWhitelist.update({ + where: { id }, + data: { + status: 'deleted', + deletedAt: new Date(), + updatedById: operatorId, + }, + include: whitelistUserInclude, + }); + await tx.operationLog.create({ + data: { + userId: operatorId, + action: 'phone_frequency_whitelist.delete', + resource: 'phone_frequency_whitelist', + resourceId: id, + detail: { + phoneNumber: existing.phoneNumber, + reason: normalizedReason, + reset, + } as Prisma.InputJsonValue, + }, + }); + return entry; + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + } + + private async findActiveWhitelistedPhones(tx: Prisma.TransactionClient, phones: string[]) { + const result = new Set(); + for (const phoneChunk of chunks(phones, FREQUENCY_WRITE_CHUNK_SIZE)) { + const rows = await tx.phoneFrequencyWhitelist.findMany({ + where: { phoneNumber: { in: phoneChunk }, status: 'active', deletedAt: null }, + select: { phoneNumber: true }, + }); + for (const row of rows) result.add(row.phoneNumber); + } + return result; + } + + private async resetFrequencyStates( + tx: Prisma.TransactionClient, + phones: string[], + operatorId: string, + releaseReason: string, + ) { + // 白名单状态变化按号码跨应用清零;历史命中保留,只解除当前仍与状态关联的活跃命中。 + const normalizedPhones = [...new Set(phones)].sort(); + const states = await tx.phoneFrequencyState.findMany({ + where: { phoneNumber: { in: normalizedPhones } }, + select: { id: true, activeHitId: true }, + }); + const activeHitIds = states.flatMap((state) => state.activeHitId ? [state.activeHitId] : []); + const releasedAt = new Date(); + const released = activeHitIds.length > 0 + ? await tx.phoneFrequencyHit.updateMany({ + where: { id: { in: activeHitIds }, releasedAt: null }, + data: { releasedAt, releasedById: operatorId, releaseReason }, + }) + : { count: 0 }; + const reset = states.length > 0 + ? await tx.phoneFrequencyState.updateMany({ + where: { id: { in: states.map((state) => state.id) } }, + data: { count: 0, generation: { increment: 1 }, activeHitId: null }, + }) + : { count: 0 }; + return { stateCount: reset.count, hitCount: released.count }; + } + + private async effectiveRules(applicationId: string): Promise { + const rules = await this.prisma.riskRule.findMany({ + where: { + status: 'active', + code: { in: [...PHONE_FREQUENCY_RULE_CODES] }, + OR: [{ applicationId: null }, { applicationId }], + }, + orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], + }); + const byCode = new Map(); + for (const rule of rules) { + if (rule.applicationId || !byCode.has(rule.code)) byCode.set(rule.code, rule); + } + return [...byCode.values()].sort((left, right) => left.priority - right.priority); + } + + private upsertStates( + tx: Prisma.TransactionClient, + input: { + tenantId: string; + applicationId: string; + phones: string[]; + rule: FrequencyRule; + window: { startAt: Date; endAt: Date }; + }, + ) { + const values = input.phones.map((phone) => Prisma.sql`(${randomUUID()}, ${phone})`); + // ON CONFLICT 对同一应用、规则、号码取得行锁,保证并发越过阈值时只有一个首次命中者。 + return tx.$queryRaw(Prisma.sql` + WITH input("id", "phoneNumber") AS ( + VALUES ${Prisma.join(values)} + ) + INSERT INTO "PhoneFrequencyState" ( + "id", "tenantId", "applicationId", "ruleId", "ruleCode", "phoneNumber", + "windowStartedAt", "windowEndsAt", "count", "generation", "createdAt", "updatedAt" + ) + SELECT + input.id, ${input.tenantId}, ${input.applicationId}, ${input.rule.id}, ${input.rule.code}, + input."phoneNumber", ${input.window.startAt}, ${input.window.endAt}, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM input + ON CONFLICT ("applicationId", "ruleCode", "phoneNumber") + DO UPDATE SET + "ruleId" = EXCLUDED."ruleId", + "windowStartedAt" = EXCLUDED."windowStartedAt", + "windowEndsAt" = EXCLUDED."windowEndsAt", + "count" = CASE + WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 1 + WHEN "PhoneFrequencyState"."activeHitId" IS NOT NULL THEN "PhoneFrequencyState"."count" + ELSE "PhoneFrequencyState"."count" + 1 + END, + "generation" = CASE + WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 0 + ELSE "PhoneFrequencyState"."generation" + END, + "activeHitId" = CASE + WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN NULL + ELSE "PhoneFrequencyState"."activeHitId" + END, + "updatedAt" = CURRENT_TIMESTAMP + RETURNING + "id", "phoneNumber", "count", "generation", "activeHitId", "windowStartedAt", "windowEndsAt" + `); + } + + private async attachActiveHits(tx: Prisma.TransactionClient, hitByStateId: Map) { + if (hitByStateId.size === 0) return; + const values = [...hitByStateId].map(([stateId, hitId]) => Prisma.sql`(${stateId}, ${hitId})`); + await tx.$executeRaw(Prisma.sql` + UPDATE "PhoneFrequencyState" state + SET "activeHitId" = updates."hitId", "updatedAt" = CURRENT_TIMESTAMP + FROM (VALUES ${Prisma.join(values)}) AS updates("stateId", "hitId") + WHERE state.id = updates."stateId" + AND state."activeHitId" IS NULL + `); + } +} + +const whitelistUserInclude = { + createdBy: { select: { id: true, username: true, displayName: true } }, + updatedBy: { select: { id: true, username: true, displayName: true } }, +} as const; + +function normalizeWhitelistInput( + input: CreatePhoneFrequencyWhitelistDto | UpdatePhoneFrequencyWhitelistDto, + partial: boolean, +) { + const result: { + phoneNumber?: string; + reason?: string; + remark?: string | null; + status?: 'active' | 'inactive'; + } = {}; + if (!partial || input.phoneNumber !== undefined) { + const phoneNumber = normalizeMainlandPhone(input.phoneNumber); + if (!phoneNumber) throw new BadRequestException('请输入有效的中国大陆11位手机号码'); + result.phoneNumber = phoneNumber; + } + if (!partial || input.reason !== undefined) { + const reason = input.reason?.trim(); + if (!reason) throw new BadRequestException('白名单用途说明不能为空'); + if (reason.length > 200) throw new BadRequestException('白名单用途说明不能超过200个字符'); + result.reason = reason; + } + if (input.remark !== undefined) { + const remark = input.remark?.trim() ?? ''; + if (remark.length > 500) throw new BadRequestException('白名单备注不能超过500个字符'); + result.remark = remark || null; + } + const status = input.status ?? (partial ? undefined : 'active'); + if (status !== undefined && !['active', 'inactive'].includes(status)) { + throw new BadRequestException('白名单状态无效'); + } + if (status) result.status = status; + return result as { + phoneNumber: string; + reason: string; + remark?: string | null; + status: 'active' | 'inactive'; + }; +} + +function normalizeMainlandPhone(value: string | undefined) { + const compact = value?.trim().replace(/[\s-]/g, '') ?? ''; + const withoutCountryCode = compact.startsWith('+86') + ? compact.slice(3) + : compact.startsWith('86') && compact.length === 13 + ? compact.slice(2) + : compact; + return /^1\d{10}$/.test(withoutCountryCode) ? withoutCountryCode : undefined; +} + +function whitelistAuditValue(entry: { + phoneNumber: string; + status: string; + reason: string; + remark: string | null; + deletedAt: Date | null; +}) { + return { + phoneNumber: entry.phoneNumber, + status: entry.status, + reason: entry.reason, + remark: entry.remark, + deletedAt: entry.deletedAt?.toISOString() ?? null, + }; +} + +function readPeriodSeconds(rule: FrequencyRule) { + const config = rule.config && typeof rule.config === 'object' && !Array.isArray(rule.config) + ? rule.config as Record + : {}; + const fallback = rule.code === 'PHONE_FREQUENCY_24H' ? 24 * 60 * 60 : 5 * 60; + const value = Number(config.periodSeconds ?? fallback); + return Number.isInteger(value) && value >= 60 && value <= 24 * 60 * 60 && 24 * 60 * 60 % value === 0 + ? value + : fallback; +} + +export function fixedShanghaiWindow(value: Date, periodSeconds: number) { + const shanghaiOffsetMs = 8 * 60 * 60 * 1000; + const shifted = value.getTime() + shanghaiOffsetMs; + const dayMs = 24 * 60 * 60 * 1000; + const localDayStart = Math.floor(shifted / dayMs) * dayMs; + const periodMs = periodSeconds * 1000; + const localWindowStart = localDayStart + Math.floor((shifted - localDayStart) / periodMs) * periodMs; + return { + startAt: new Date(localWindowStart - shanghaiOffsetMs), + endAt: new Date(localWindowStart - shanghaiOffsetMs + periodMs), + }; +} + +function formatWindow(startAt: Date, endAt: Date) { + const formatter = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + return `${formatter.format(startAt)}至${formatter.format(endAt)}`; +} + +function chunks(items: T[], size: number) { + const result: T[][] = []; + for (let index = 0; index < items.length; index += size) { + result.push(items.slice(index, index + size)); + } + return result; +} + +function parseOptionalDate(value: string | undefined, label: string) { + if (!value) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException(`${label}格式无效`); + } + return parsed; +} diff --git a/api/src/risk-review/risk-review.module.ts b/api/src/risk-review/risk-review.module.ts index 462166d..3302d14 100644 --- a/api/src/risk-review/risk-review.module.ts +++ b/api/src/risk-review/risk-review.module.ts @@ -3,12 +3,13 @@ import { PrismaModule } from '../prisma/prisma.module'; import { AdminRiskReviewController } from './admin-risk-review.controller'; import { ClientRiskReviewController } from './client-risk-review.controller'; import { RiskReviewService } from './risk-review.service'; +import { PhoneFrequencyService } from './phone-frequency.service'; import { SendChainModule } from '../send-chain/send-chain.module'; @Module({ imports: [PrismaModule, forwardRef(() => SendChainModule)], controllers: [AdminRiskReviewController, ClientRiskReviewController], - providers: [RiskReviewService], - exports: [RiskReviewService], + providers: [RiskReviewService, PhoneFrequencyService], + exports: [RiskReviewService, PhoneFrequencyService], }) export class RiskReviewModule {} diff --git a/api/src/risk-review/risk-review.service.spec.ts b/api/src/risk-review/risk-review.service.spec.ts index 77b2342..7a0d82a 100644 --- a/api/src/risk-review/risk-review.service.spec.ts +++ b/api/src/risk-review/risk-review.service.spec.ts @@ -61,6 +61,18 @@ function createPrismaMock(overrides: Record = {}) { } describe('RiskReviewService', () => { + it('keeps phone-frequency periods fixed and rejects manual-review actions', () => { + const service = new RiskReviewService(createPrismaMock() as never); + + expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 })) + .toThrow('号码频次周期首版固定为24小时自然日或5分钟,不允许修改'); + expect(() => service['validateRuleInput']({ + code: 'PHONE_FREQUENCY_24H', + thresholdValue: 10, + action: 'manual_review', + })).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝'); + }); + it('includes the sending enterprise and application in SMS review rows', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findMany.mockResolvedValue([]); diff --git a/api/src/risk-review/risk-review.service.ts b/api/src/risk-review/risk-review.service.ts index 7738519..35fdc15 100644 --- a/api/src/risk-review/risk-review.service.ts +++ b/api/src/risk-review/risk-review.service.ts @@ -87,6 +87,26 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [ action: 'manual_review', priority: 30, }, + { + code: 'PHONE_FREQUENCY_24H', + name: '单号码24小时发送频次', + description: '同一企业应用下,单个号码在北京时间自然日内最多允许10条业务短信。', + metric: 'phoneFrequencyCount', + thresholdValue: 10, + action: 'block', + priority: 40, + config: { periodSeconds: 24 * 60 * 60, timeZone: 'Asia/Shanghai', alignment: 'fixed' }, + }, + { + code: 'PHONE_FREQUENCY_5M', + name: '单号码5分钟发送频次', + description: '同一企业应用下,单个号码在固定5分钟周期内最多允许5条业务短信。', + metric: 'phoneFrequencyCount', + thresholdValue: 5, + action: 'block', + priority: 50, + config: { periodSeconds: 5 * 60, timeZone: 'Asia/Shanghai', alignment: 'fixed' }, + }, ]; const RULE_DEFINITIONS = new Map(DEFAULT_RULES.map((rule) => [rule.code, rule])); @@ -129,7 +149,7 @@ export class RiskReviewService { description: definition.description, metric: definition.metric!, thresholdValue: data.thresholdValue, - action: data.action ?? 'manual_review', + action: isPhoneFrequencyRule(data.code) ? 'block' : data.action ?? 'manual_review', status: data.status ?? 'active', priority: data.priority ?? definition.priority ?? 100, config: this.normalizeRuleConfig(data.code, data.config ?? definition.config) as Prisma.InputJsonValue | undefined, @@ -468,7 +488,7 @@ export class RiskReviewService { return rejected; } - private async ensureDefaultRules() { + async ensureDefaultRules() { for (const rule of DEFAULT_RULES) { const exists = await this.prisma.riskRule.findFirst({ where: { applicationId: null, code: rule.code, status: { not: 'deleted' } }, @@ -556,6 +576,12 @@ export class RiskReviewService { if (!Number.isFinite(data.thresholdValue) || data.thresholdValue < 0) { throw new BadRequestException('风控阈值必须是大于等于0的有效数字'); } + if ( + isPhoneFrequencyRule(data.code) + && (!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review') + ) { + throw new BadRequestException('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝'); + } if (data.action && !['block', 'manual_review'].includes(data.action)) { throw new BadRequestException('风控处理动作无效'); } @@ -581,6 +607,18 @@ export class RiskReviewService { } private normalizeRuleConfig(code: string, config?: Record | null) { + if (code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M') { + const defaultPeriodSeconds = code === 'PHONE_FREQUENCY_24H' ? 24 * 60 * 60 : 5 * 60; + const periodSeconds = Number(config?.periodSeconds ?? defaultPeriodSeconds); + if (periodSeconds !== defaultPeriodSeconds) { + throw new BadRequestException('号码频次周期首版固定为24小时自然日或5分钟,不允许修改'); + } + return { + periodSeconds: defaultPeriodSeconds, + timeZone: 'Asia/Shanghai', + alignment: 'fixed', + }; + } if (code !== 'NON_WORKING_MARKETING_BULK') { return config ?? undefined; } @@ -599,6 +637,10 @@ export class RiskReviewService { } } +function isPhoneFrequencyRule(code: string) { + return code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M'; +} + function ratio(count: number, total: number) { if (total <= 0) { return 0; diff --git a/api/src/send-chain/admin-send-chain.controller.ts b/api/src/send-chain/admin-send-chain.controller.ts index 69e9600..7402884 100644 --- a/api/src/send-chain/admin-send-chain.controller.ts +++ b/api/src/send-chain/admin-send-chain.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { SendChainService, TimeoutUnknownDto } from './send-chain.service'; +import { TimeoutUnknownDto } from './send-chain.contracts'; +import { SendChainService } from './send-chain.service'; @ApiTags('send-chain') @Controller('admin/send') diff --git a/api/src/send-chain/client-send-chain.controller.ts b/api/src/send-chain/client-send-chain.controller.ts index 55fa60e..5097b90 100644 --- a/api/src/send-chain/client-send-chain.controller.ts +++ b/api/src/send-chain/client-send-chain.controller.ts @@ -1,7 +1,8 @@ import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; -import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto, SendChainService } from './send-chain.service'; +import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts'; +import { SendChainService } from './send-chain.service'; @ApiTags('client-send-chain') @Controller('client/send') diff --git a/api/src/send-chain/gateway-events.controller.ts b/api/src/send-chain/gateway-events.controller.ts index 4646a29..60b867b 100644 --- a/api/src/send-chain/gateway-events.controller.ts +++ b/api/src/send-chain/gateway-events.controller.ts @@ -13,9 +13,10 @@ import { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayUplinkEventDto, - SendChainService, -} from './send-chain.service'; -import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service'; +} from './send-chain.contracts'; +import { SendChainService } from './send-chain.service'; +import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts'; +import { SmsConfigService } from '../sms-config/sms-config.service'; import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service'; @ApiTags('gateway-events') diff --git a/api/src/send-chain/send-accounting.service.ts b/api/src/send-chain/send-accounting.service.ts new file mode 100644 index 0000000..7a55bf4 --- /dev/null +++ b/api/src/send-chain/send-accounting.service.ts @@ -0,0 +1,142 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { BillingService } from '../billing/billing.service'; +import { moneyToNumber } from '../common/money'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; + + +/** + * R10 accounting implementation. + * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. + */ +export class SendAccountingService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly openApi: OpenApiService | undefined, + private readonly facade: SendCompletionFacade, + private readonly callbacks: SendCompletionCallbacks, + ) {} + + async chargeAcceptedMessage(message: { + tenantId: string; + applicationId?: string | null; + batchTaskId: string; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + unitPrice: number | bigint; + amountCents: number | bigint; + }) { + const amountCents = moneyToNumber(message.amountCents); + const unitPrice = moneyToNumber(message.unitPrice); + const billingUnits = message.billingUnits ?? 0; + const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } }); + if (exists?.billingStatus === 'charged') { + return; + } + if (amountCents > 0) { + await this.billing.release({ + tenantId: message.tenantId, + amountCents, + idempotencyKey: `sms-charge-release:${message.messageId}`, + relatedType: 'sms_batch_task', + relatedId: message.batchTaskId, + remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, + }); + } + const transaction = await this.billing.charge({ + tenantId: message.tenantId, + amountCents, + idempotencyKey: `sms-charge:${message.messageId}`, + relatedType: 'sms_message_record', + relatedId: message.messageId, + remark: '提交成功扣费', + }); + const data = { + tenantId: message.tenantId, + applicationId: message.applicationId ?? undefined, + taskId: message.batchTaskId, + messageId: message.messageId, + phoneNumber: message.phoneNumber, + contentLength: [...message.content].length, + billingUnits, + unitPrice, + amountCents, + billingStatus: 'charged', + transactionId: transaction.id, + }; + if (exists) { + await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data }); + return; + } + await this.prisma.smsBillingRecord.create({ data }); + } + + async releaseMessageReservation( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + const amountCents = moneyToNumber(message.amountCents); + if (amountCents <= 0) { + return; + } + const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); + if (charged) { + return; + } + const released = await this.prisma.accountTransaction.findFirst({ + where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' }, + }); + if (released) { + return; + } + await this.billing.release({ + tenantId: message.tenantId, + amountCents, + idempotencyKey: `sms-reservation-release:${message.messageId}`, + relatedType: 'sms_message_record', + relatedId: message.messageId, + remark: `${remark}: ${message.messageId}`, + }); + } + + async refundMessage( + message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + const amountCents = moneyToNumber(message.amountCents); + if (amountCents <= 0) { + return; + } + const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } }); + if (refunded) { + return; + } + const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); + if (!charged) { + return; + } + const transaction = await this.billing.refund({ + tenantId: message.tenantId, + amountCents, + idempotencyKey: `sms-refund:${message.messageId}`, + relatedType: 'sms_message_record', + relatedId: message.messageId, + remark, + }); + await this.prisma.smsBillingRecord.updateMany({ + where: { messageId: message.messageId }, + data: { billingStatus: 'refunded', transactionId: transaction.id }, + }); + } +} diff --git a/api/src/send-chain/send-batch-entry.service.ts b/api/src/send-chain/send-batch-entry.service.ts new file mode 100644 index 0000000..240133c --- /dev/null +++ b/api/src/send-chain/send-batch-entry.service.ts @@ -0,0 +1,552 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Queue, Worker } from 'bullmq'; +import IORedis from 'ioredis'; +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { BillingService } from '../billing/billing.service'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { moneyToNumber } from '../common/money'; +import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RiskReviewService } from '../risk-review/risk-review.service'; +import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; +import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; + +/** + * R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam. + */ +export class SendBatchEntryService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly riskReview: RiskReviewService, + private readonly phoneFrequency: PhoneFrequencyService, + private readonly phoneRouting: PhoneRoutingLookupService, + private readonly facade: SendSubmissionService, + private readonly callbacks: SendSubmissionCallbacks, + ) {} + + private releaseMessageReservation( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + return this.callbacks.releaseMessageReservation(message, remark); + } + + private recordCmppFailureReceipt( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) { + return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); + } + + +async createBatchTask(data: CreateBatchTaskDto) { + const phones = [...new Set(data.phones ?? [])]; + const schedule = parseSchedule(data); + await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId); + const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones); + let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone)); + const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([ + this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content), + this.facade.resolveUnitPrice(data.tenantId, data.applicationId), + this.facade.resolveQueuePriority(data.tenantId, data.applicationId), + this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId), + ]); + const risk = messageClassification.rejectionReason + ? { status: 'rejected', reason: messageClassification.rejectionReason, task: null } + : await this.riskReview.evaluateTask({ + tenantId: data.tenantId, + applicationId: data.applicationId, + templateId: data.templateId, + content: data.content, + category: data.category, + phones, + variables: messageClassification.variables ?? data.variables, + createdById: data.createdById, + sourceType: data.sourceType ?? 'client', + }); + let frequencyRejectedAll = false; + let frequencyBatchReason: string | undefined; + if (risk.status !== 'rejected' && sendablePhones.length > 0) { + const frequencyRejections = await this.phoneFrequency.reserve( + data.tenantId, + data.applicationId, + sendablePhones, + data.sourceType ?? 'client', + ); + for (const [phone, rejection] of frequencyRejections) { + phoneRejections.set(phone, rejection); + } + sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone)); + frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0; + frequencyBatchReason = frequencyRejectedAll + ? [...frequencyRejections.values()][0]?.reason + : undefined; + } + if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) { + await this.prisma.smsSendTask.update({ + where: { id: risk.task.id }, + data: { + status: 'rejected', + riskDecision: 'block', + reviewReason: null, + rejectReason: frequencyBatchReason, + }, + }); + } + const billing = this.billing.estimateSmsCost({ + tenantId: data.tenantId, + applicationId: data.applicationId, + taskId: risk.task?.id, + content: data.content, + phoneCount: sendablePhones.length, + unitPrice, + }); + const batchStatus = frequencyRejectedAll + ? 'rejected' + : risk.status === 'approved' && sendablePhones.length === 0 + ? 'failed' + : statusFromRisk(risk.status, Boolean(schedule.scheduledAt)); + const shouldReserveBalance = batchStatus === 'ready'; + if (risk.status === 'approved') { + const accountCheck = await this.billing.checkAccount({ + tenantId: data.tenantId, + amountCents: billing.amountCents, + }); + if (!accountCheck.canSend) { + throw new BadRequestException('企业账户余额不足'); + } + } + if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) { + await this.facade.reserveDailySendQuota(data.applicationId, sendablePhones.length); + } + const task = await this.prisma.smsBatchTask.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + templateId: data.templateId, + taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`, + sourceType: data.sourceType ?? 'client', + content: data.content, + category: data.category, + phoneTotal: phones.length, + status: batchStatus, + riskTaskId: risk.task?.id, + auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved', + reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null, + rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null, + progressTotal: phones.length, + scheduledAt: schedule.scheduledAt, + createdById: data.createdById, + }, + }); + if (shouldReserveBalance && billing.amountCents > 0) { + await this.billing.freeze({ + tenantId: data.tenantId, + amountCents: billing.amountCents, + relatedType: 'sms_batch_task', + relatedId: task.id, + remark: '发送任务创建冻结', + }); + } + await this.prisma.smsApiRequest.create({ + data: { + tenantId: data.tenantId, + batchTaskId: task.id, + requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`, + sourceIp: data.sourceIp, + userAgent: data.userAgent, + payloadSummary: { + phoneTotal: phones.length, + contentLength: [...data.content].length, + category: data.category, + sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate', + scheduledAt: schedule.scheduledAt?.toISOString(), + }, + status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted', + }, + }); + if (phones.length > 0) { + await this.prisma.smsMessageRecord.createMany({ + data: phones.map((phone) => { + const rejection = phoneRejections.get(phone); + const status = rejection + ? 'submit_failed' + : batchStatus === 'ready' + ? 'queued' + : batchStatus === 'scheduled' + ? 'scheduled' + : batchStatus; + return { + tenantId: data.tenantId, + batchTaskId: task.id, + applicationId: data.applicationId, + templateId: data.templateId, + signatureId: messageClassification.signatureId, + drainageInfoId: messageClassification.drainageInfoId, + reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined, + messageId: `MSG-${randomUUID()}`, + clientMessageId: data.clientMessageId, + phoneNumber: phone, + content: data.content, + billingUnits: billing.billingUnitsPerMessage, + unitPrice: rejection ? 0 : billing.unitPrice, + amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice, + queuePriority, + clientSrcId: accessNumber.clientSrcId, + applicationExtension: accessNumber.applicationExtension, + status, + submitStatus: rejection ? 'rejected' : undefined, + errorCode: rejection?.code, + errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined), + }; + }), + }); + } + if (batchStatus === 'ready' && sendablePhones.length > 0) { + await this.facade.enqueueBatchTask(task.id); + } else if (batchStatus === 'failed') { + await this.facade.refreshTaskProgress(task.id); + } + return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client'); + } + +async createHttpBatchTask(data: CreateHttpBatchTaskDto) { + if (!data.applicationId) { + throw new BadRequestException('公开 HTTP 发送必须关联企业应用'); + } + const template = await this.facade.resolveInboundTemplateCandidate(data.applicationId, data.content); + if (!template || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { + throw new BadRequestException('短信内容未匹配当前应用已审核通过的签名和模板'); + } + const variables = matchTemplateContent(template.content, data.content); + if (variables === null) { + throw new BadRequestException('短信内容与已审核模板不匹配'); + } + return this.facade.createBatchTask({ + ...data, + templateId: template.id, + variables, + sourceType: 'api', + }); + } + +async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { + const task = await this.prisma.smsBatchTask.findFirst({ + where: { id: taskId, tenantId, sourceType }, + include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } }, + }); + if (!task) { + throw new NotFoundException('SMS batch task not found'); + } + return task; + } + +async previewImport(data: ImportPreviewDto) { + const sizeBytes = Buffer.byteLength(data.content, 'utf8'); + if (sizeBytes > 20 * 1024 * 1024) { + throw new BadRequestException('导入文件不能超过 20MB'); + } + const rows = parseImportRows(data.content, data.delimiter); + const phones: string[] = []; + const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = []; + const requiredVariables = data.requiredVariables ?? []; + const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({ + where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' }, + select: { phoneNumber: true }, + }) : []; + const globalBlacklist = await this.prisma.globalBlacklist.findMany({ + where: { status: 'active' }, + select: { phoneNumber: true }, + }); + const blacklist = new Set([...enterpriseBlacklist, ...globalBlacklist].map((item) => item.phoneNumber)); + const seen = new Set(); + for (const row of rows) { + if (!row.phoneNumber) { + errors.push({ rowNumber: row.rowNumber, reason: '缺少手机号' }); + continue; + } + if (!/^1[3-9]\d{9}$/.test(row.phoneNumber)) { + errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '手机号格式非法' }); + continue; + } + if (seen.has(row.phoneNumber)) { + errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '重复号码' }); + continue; + } + if (blacklist.has(row.phoneNumber)) { + errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '命中黑名单' }); + continue; + } + const missingVariables = requiredVariables.filter((name) => !row.variables[name]); + if (missingVariables.length > 0) { + errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` }); + continue; + } + seen.add(row.phoneNumber); + phones.push(row.phoneNumber); + } + return { + fileName: data.fileName, + encoding: data.encoding ?? 'utf8', + totalRows: rows.length, + validCount: phones.length, + errorCount: errors.length, + phones, + errors, + }; + } + +async confirmImport(data: ConfirmImportDto) { + const preview = await this.facade.previewImport({ + tenantId: data.tenantId, + applicationId: data.applicationId, + content: data.importContent, + requiredVariables: data.requiredVariables, + }); + if (preview.validCount === 0) { + throw new BadRequestException('导入文件没有可发送号码'); + } + return this.facade.createBatchTask({ ...data, phones: preview.phones }); + } + +async resolveUnitPrice(tenantId: string, applicationId?: string) { + if (!applicationId) { + return 0; + } + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { tenantId: true, customerUnitPrice: true }, + }); + if (!application || application.tenantId !== tenantId) { + return 0; + } + return moneyToNumber(application.customerUnitPrice); + } + +async resolveQueuePriority(tenantId: string, applicationId?: string): Promise { + if (!applicationId) { + return 'normal'; + } + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { tenantId: true, queuePriority: true }, + }); + if (!application || application.tenantId !== tenantId) { + return 'normal'; + } + return normalizeQueuePriority(application.queuePriority); + } + +async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) { + if (!applicationId) { + return { clientSrcId: null, applicationExtension: null }; + } + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true }, + }); + if (!application || application.tenantId !== tenantId) { + return { clientSrcId: null, applicationExtension: null }; + } + return { + clientSrcId: application.cmppClientSrcId, + applicationExtension: application.cmppApplicationExtension, + }; + } + +async resolveTemplateMessageClassification( + tenantId: string, + applicationId: string | undefined, + templateId: string | undefined, + content: string, + ) { + if (templateId) { + const template = await this.prisma.smsTemplate.findUnique({ + where: { id: templateId }, + include: { signature: true }, + }); + if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId + || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { + throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); + } + const variables = matchTemplateContent(template.content, content); + if (variables === null) { + throw new BadRequestException('短信内容与选定的审核模板不匹配'); + } + const drainage = await this.facade.resolveDrainageInfoMatch(template.signatureId, content); + return { + signatureId: template.signatureId, + drainageInfoId: drainage?.id, + variables, + rejectionReason: drainageRejectionReason(drainage), + }; + } + + if (!applicationId) { + throw new BadRequestException('自由内容短信必须关联企业应用'); + } + const [application, signature] = await Promise.all([ + this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { tenantId: true, templateMismatchMode: true }, + }), + this.facade.resolveInboundSignatureCandidate(applicationId, content), + ]); + if (!application || application.tenantId !== tenantId) { + throw new BadRequestException('短信应用不存在或不属于当前企业'); + } + if (!signature) { + throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头'); + } + if (application.templateMismatchMode !== 'direct_send') { + throw new BadRequestException('当前应用未允许无模板自由内容直接发送'); + } + const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, content); + return { + signatureId: signature.id, + drainageInfoId: drainage?.id, + variables: undefined, + rejectionReason: drainageRejectionReason(drainage), + }; + } + +async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { + const rejected = new Map(); + for (const phone of phones) { + if (!/^1\d{10}$/.test(phone)) { + rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' }); + } + } + const validPhones = phones.filter((phone) => !rejected.has(phone)); + if (validPhones.length === 0) { + return rejected; + } + const [globalHits, enterpriseHits] = await Promise.all([ + this.prisma.globalBlacklist.findMany({ + where: { phoneNumber: { in: validPhones }, status: 'active' }, + select: { phoneNumber: true, reason: true }, + }), + applicationId + ? this.prisma.enterpriseBlacklist.findMany({ + where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' }, + select: { phoneNumber: true, reason: true }, + }) + : Promise.resolve([]), + ]); + for (const hit of globalHits) { + rejected.set(hit.phoneNumber, { + code: 'GLOBAL_BLACKLIST', + reason: hit.reason?.trim() || '号码命中平台黑名单', + }); + } + for (const hit of enterpriseHits) { + rejected.set(hit.phoneNumber, { + code: 'ENTERPRISE_BLACKLIST', + reason: hit.reason?.trim() || '号码命中企业应用黑名单', + }); + } + return rejected; + } + +async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { + const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); + if (!tenant || tenant.status !== 'active') { + throw new BadRequestException('企业客户不存在或已停用'); + } + if (tenant.certificationStatus !== 'approved') { + throw new BadRequestException('企业认证未通过,不能发送短信'); + } + if (!applicationId) { + return; + } + const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); + if (!application || application.tenantId !== tenantId || application.status !== 'active') { + throw new BadRequestException('短信应用不存在或已停用'); + } + if (!application.interfaceEnabled) { + throw new BadRequestException('短信应用接口未开通,不能发送短信'); + } + if (!templateId) { + return; + } + const template = await this.prisma.smsTemplate.findUnique({ + where: { id: templateId }, + include: { signature: true }, + }); + if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') { + throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); + } + if (!template.signature || template.signature.auditStatus !== 'approved') { + throw new BadRequestException('短信签名未审核通过'); + } + } + +async reserveDailySendQuota(applicationId: string, requestedCount: number) { + const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount); + if (!result.reserved) { + throw new HttpException({ + code: 'DAILY_SEND_LIMIT_EXCEEDED', + message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`, + dailyLimit: result.dailyLimit, + requestedCount, + }, HttpStatus.TOO_MANY_REQUESTS); + } + return result; + } + +async tryReserveDailySendQuota(applicationId: string, requestedCount: number) { + if (!Number.isInteger(requestedCount) || requestedCount <= 0) { + throw new BadRequestException('发送号码数量必须为正整数'); + } + const usageDate = shanghaiDateKey(); + const reservationId = randomUUID(); + const rows = await this.prisma.$queryRaw>(Prisma.sql` + WITH application_limit AS ( + SELECT id, COALESCE("dailyLimit", 100000)::integer AS "dailyLimit" + FROM "SmsApplication" + WHERE id = ${applicationId} + ), reservation AS ( + INSERT INTO "SmsApplicationDailyUsage" ( + id, "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt" + ) + SELECT ${reservationId}, id, ${usageDate}::date, ${requestedCount}, NOW(), NOW() + FROM application_limit + WHERE ${requestedCount} <= "dailyLimit" + ON CONFLICT ("applicationId", "usageDate") DO UPDATE + SET "usedCount" = "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount", + "updatedAt" = NOW() + WHERE "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount" + <= (SELECT "dailyLimit" FROM application_limit) + RETURNING "usedCount" + ) + SELECT application_limit."dailyLimit", reservation."usedCount" + FROM application_limit + LEFT JOIN reservation ON TRUE + `); + if (rows.length === 0) { + throw new NotFoundException('短信应用不存在'); + } + return { + dailyLimit: Number(rows[0].dailyLimit), + usedCount: rows[0].usedCount == null ? null : Number(rows[0].usedCount), + reserved: rows[0].usedCount != null, + }; + } +} diff --git a/api/src/send-chain/send-chain.contracts.ts b/api/src/send-chain/send-chain.contracts.ts new file mode 100644 index 0000000..d2135a6 --- /dev/null +++ b/api/src/send-chain/send-chain.contracts.ts @@ -0,0 +1,257 @@ +// R8 contract-only declarations. Runtime behavior remains in SendChainService. + +export interface CreateBatchTaskDto { + tenantId: string; + applicationId?: string; + templateId?: string; + content: string; + category?: string; + phones: string[]; + sendMode?: 'immediate' | 'scheduled'; + scheduledAt?: string; + variables?: Record; + createdById?: string; + sourceIp?: string; + userAgent?: string; + sourceType?: 'client' | 'api' | 'cmpp'; + clientMessageId?: string; +} + +export type CreateHttpBatchTaskDto = Omit; + +export interface GatewayInboundAuthDto { + account: string; + password?: string; + authSource?: string; + timestamp?: number; + remoteIp?: string; +} + +export interface GatewayInboundSubmitDto { + account: string; + phoneNumber?: string; + phoneNumbers?: string[]; + content: string; + srcId?: string; + destId?: string; + sequenceId?: number; + remoteIp?: string; + longMessage?: { + reference: number; + total: number; + index: number; + format: number; + }; +} + +export interface GatewayInboundSingleSubmitResult { + accepted: boolean; + tenantId: string; + applicationId: string; + taskId: string; + messageId: string; + messageRecordId: string; + status: string; +} + +export interface GatewaySubmitResultDto { + traceId?: string; + messageId: string; + channelId: string; + submitId?: string; + sequenceId?: number; + gatewayMessageId: string; + submitStatus: 'accepted' | 'rejected' | 'timeout'; + errorCode?: string; + errorMessage?: string; + submittedAt?: string; + segments?: Array<{ + segmentTotal?: number; + segmentIndex?: number; + sequenceId?: number; + gatewayMessageId?: string; + submitStatus?: 'accepted' | 'rejected' | 'timeout' | string; + errorCode?: string; + errorMessage?: string; + submittedAt?: string; + }>; +} + +export interface GatewaySubmitSegmentResultDto { + traceId?: string; + messageId: string; + channelId: string; + submitId?: string; + segmentTotal: number; + segmentIndex: number; + sequenceId?: number; + gatewayMessageId?: string; + submitStatus: 'accepted' | 'rejected' | 'timeout' | string; + errorCode?: string; + errorMessage?: string; + submittedAt?: string; +} + +export interface GatewayReceiptEventDto { + traceId?: string; + messageId?: string; + channelId: string; + sequenceId?: number; + gatewayMessageId: string; + phoneNumber?: string; + receiptStatus: 'delivered' | 'undelivered' | 'unknown'; + rawStatus: string; + errorCode?: string; + errorMessage?: string; + deliveredAt?: string; + connectionId?: string; +} + +export interface GatewayUplinkEventDto { + traceId?: string; + messageId?: string; + channelId: string; + sequenceId?: number; + phoneNumber: string; + destId: string; + content: string; + receivedAt?: string; +} + +export type UplinkMatchCandidateInput = { + tenantId: string; + applicationId: string; + messageRecordId?: string; + matchSource: 'access_number' | 'phone_window'; + confidence: number; + reason: string; +}; + +export interface GatewayPendingDeliveryQueryDto { + account: string; + limit?: number; +} + +export interface GatewayDownstreamSentDto { + id: string; + connectionId?: string; + sequenceId?: string; + messageId?: string; + sentAt?: string; + ackDeadlineAt?: string; +} + +export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentDto { + result: number; + acknowledgedAt?: string; +} + +export type GatewayDownstreamFailureType = + | 'send_failed' + | 'ack_timeout' + | 'ack_rejected' + | 'ack_invalid' + | 'connection_lost' + | 'unrecoverable' + | 'queue_timeout'; + +export type GatewayControlDeliveryResult = { + sent?: boolean; + delivered?: boolean; + retryable?: boolean; + reasonCode?: string; + errorMessage?: string; + connectionId?: string; + sequenceId?: string; + messageId?: string; + sentAt?: string; + ackDeadlineAt?: string; +}; + +export interface GatewaySubmitDeadLetterDto { + streamMessageId: string; + traceId?: string; + messageId?: string; + channelId?: string; + tenantId?: string; + applicationId?: string; + submitId?: string; + failureCode: string; + failureMessage: string; + attempts: number; + maxAttempts: number; + commandPayload?: Record; + rawPayload?: string; + deadLetteredAt?: string; +} + +export interface RequeueGatewaySubmitExceptionDto { + confirmedNotSubmitted?: boolean; + reason?: string; + operatorId?: string; +} + +export interface GatewayDownstreamRecoveryStatusDto { + account: string; + gatewayInstanceId?: string; + state: string; + lockOwner?: string; + lockExpiresAt?: string; + lastAttemptAt?: string; + lastSuccessAt?: string; + lastFailureAt?: string; + nextRetryAt?: string; + attemptCount?: number; + failureCategory?: string; + lastError?: string; + lastSkipReason?: string; +} + +export interface TimeoutUnknownDto { + olderThanHours?: number; +} + +export interface ImportPreviewDto { + tenantId: string; + applicationId?: string; + content: string; + fileName?: string; + encoding?: 'utf8' | 'gbk'; + delimiter?: ',' | '\t'; + requiredVariables?: string[]; +} + +export interface ConfirmImportDto extends CreateBatchTaskDto { + importContent: string; + requiredVariables?: string[]; +} + +export interface SendJob { + messageRecordId: string; +} + +export type QueuePriority = 'normal' | 'priority'; + +export type RoutedChannel = { + channel: { + id: string; + code: string; + account: string; + srcId: string; + rateLimitPerSecond: number; + unitPrice: number; + status: string; + carrier?: string | null; + sendRegion: string; + gatewayHost: string; + gatewayPort: number; + passwordCipher: string; + cmppVersion: string; + config?: unknown; + }; + carrier: string; + province?: string | null; + groupId: string; + groupName: string; + routeScope: 'province' | 'national'; +}; diff --git a/api/src/send-chain/send-chain.helpers.spec.ts b/api/src/send-chain/send-chain.helpers.spec.ts new file mode 100644 index 0000000..b8eb1b3 --- /dev/null +++ b/api/src/send-chain/send-chain.helpers.spec.ts @@ -0,0 +1,111 @@ +import { + aggregateReceiptSegmentState, + isSameUpstreamEndpointIdentity, + receiptEventKey, + selectChannelCandidate, +} from './send-chain.helpers'; + +const connected = [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }]; + +describe('send-chain pure policies', () => { + it('prefers an approved online province channel while preserving priority order', () => { + const items = [ + { channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } }, + { channelId: 'province', carrier: 'mobile', province: '安徽省', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: connected } }, + ]; + + expect(selectChannelCandidate(items, { + carrier: 'mobile', + province: '安徽省', + excludedChannelIds: new Set(), + approvedChannelIds: new Set(['national', 'province']), + })?.channelId).toBe('province'); + }); + + it('falls back to an approved online national channel', () => { + const items = [ + { channelId: 'offline', carrier: 'mobile', province: '安徽', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: [] } }, + { channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'all', sendRegion: '全国', status: 'active', connectionStates: connected } }, + ]; + + expect(selectChannelCandidate(items, { + carrier: 'mobile', + province: '安徽', + excludedChannelIds: new Set(), + approvedChannelIds: new Set(['offline', 'national']), + })?.channelId).toBe('national'); + }); + + it('does not select excluded or unreported channels', () => { + const items = [ + { channelId: 'excluded', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } }, + { channelId: 'unreported', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } }, + ]; + + expect(selectChannelCandidate(items, { + carrier: 'mobile', + excludedChannelIds: new Set(['excluded']), + approvedChannelIds: new Set(['excluded']), + })).toBeUndefined(); + }); + + it('keeps a segmented message non-terminal until all receipts arrive', () => { + const result = aggregateReceiptSegmentState( + [{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:00:00Z') }], + 2, + { channelId: 'channel-1', gatewayMessageId: 'gw-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD' }, + new Date('2026-07-31T00:01:00Z'), + ); + expect(result).toMatchObject({ terminal: false, segmentTotal: 2, status: 'submitted' }); + }); + + it('marks all delivered segments successful at the latest receipt time', () => { + const latest = new Date('2026-07-31T00:02:00Z'); + const result = aggregateReceiptSegmentState( + [ + { segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:01:00Z') }, + { segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: latest }, + ], + 2, + { channelId: 'channel-1', gatewayMessageId: 'gw-2', receiptStatus: 'delivered', rawStatus: 'DELIVRD' }, + latest, + ); + expect(result).toMatchObject({ terminal: true, segmentTotal: 2, status: 'delivered', deliveredAt: latest }); + }); + + it('lets a failed segment decide the terminal message result', () => { + const result = aggregateReceiptSegmentState( + [ + { segmentTotal: 2, receiptStatus: 'delivered' }, + { segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'ERR' }, + ], + 2, + { channelId: 'channel-1', gatewayMessageId: 'gw-3', receiptStatus: 'undelivered', rawStatus: 'REJECTD' }, + new Date('2026-07-31T00:03:00Z'), + ); + expect(result).toMatchObject({ terminal: true, status: 'failed', receiptStatus: 'undelivered', errorCode: 'ERR' }); + }); + + it('normalizes upstream endpoint identity without weakening port or version equality', () => { + expect(isSameUpstreamEndpointIdentity( + { account: ' acct ', gatewayHost: 'SMSC.EXAMPLE', gatewayPort: 7890, protocol: 'cmpp', cmppVersion: '2.0' }, + { account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' }, + )).toBe(true); + expect(isSameUpstreamEndpointIdentity( + { account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' }, + { account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7891, protocol: 'CMPP', cmppVersion: '2.0' }, + )).toBe(false); + }); + + it('generates a stable receipt event key and changes it with logical channel identity', () => { + const event = { + channelId: 'physical', + gatewayMessageId: 'gw-4', + phoneNumber: '13800000000', + receiptStatus: 'delivered' as const, + rawStatus: 'DELIVRD', + }; + expect(receiptEventKey(event, 'logical')).toBe(receiptEventKey({ ...event }, 'logical')); + expect(receiptEventKey(event, 'logical')).not.toBe(receiptEventKey(event, 'other')); + }); +}); diff --git a/api/src/send-chain/send-chain.helpers.ts b/api/src/send-chain/send-chain.helpers.ts new file mode 100644 index 0000000..ee92b12 --- /dev/null +++ b/api/src/send-chain/send-chain.helpers.ts @@ -0,0 +1,602 @@ +import { BadRequestException } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import type { CreateBatchTaskDto, GatewayControlDeliveryResult, GatewayDownstreamRecoveryStatusDto, GatewayDownstreamSentDto, GatewayInboundAuthDto, GatewayReceiptEventDto, GatewaySubmitResultDto, QueuePriority } from './send-chain.contracts'; + +// R8 pure policies and deterministic key/status helpers. No database, queue or network access. + +export const SEND_QUEUE = 'sms.send.queue'; + +export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue'; + +export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands'; + +export const DEFAULT_DOWNSTREAM_RETRY_DELAY_MS = 60_000; + +export const DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS = 30 * 60_000; + +export const DEFAULT_DOWNSTREAM_MAX_RETRIES = 10; + +export const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72; + +export const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72; + +export const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000; + +export const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000; + +export const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000; + +export const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000; + +export const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000; + +export const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000; + +export const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000; + +export const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000; + +export const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000; + +export const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30; + +export const DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS = 5_000; + +export const UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS = 1_000; + +export const DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS = 2 * 60_000; + +export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS = 30; + +export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS = 72; + +export const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; + +export const BULLMQ_PRIORITY: Record = { + priority: 1, + normal: 100, +}; + +export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) { + return `gateway:submit:requeue:${deadLetterId}:${attempt}`; +} + +export function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) { + if (!drainage || drainage.auditStatus === 'approved') return undefined; + return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`; +} + +export function statusFromRisk(status: string, scheduled: boolean) { + if (status === 'rejected') { + return 'rejected'; + } + if (status === 'pending_review') { + return 'pending_review'; + } + if (scheduled) { + return 'scheduled'; + } + return 'ready'; +} + +export function parseSchedule(data: CreateBatchTaskDto) { + if (data.sendMode !== 'scheduled' && !data.scheduledAt) { + return { scheduledAt: null }; + } + if (!data.scheduledAt) { + throw new BadRequestException('定时发送必须提供 scheduledAt'); + } + const scheduledAt = new Date(data.scheduledAt); + if (Number.isNaN(scheduledAt.getTime())) { + throw new BadRequestException('scheduledAt 时间格式无效'); + } + if (scheduledAt.getTime() <= Date.now()) { + throw new BadRequestException('scheduledAt 必须晚于当前时间'); + } + return { scheduledAt }; +} + +export function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function asDateOrNull(value?: string | null) { + if (!value) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function downstreamRetryDelayMs(retryCount = 1) { + const base = downstreamRetryBaseDelayMs(); + const max = downstreamRetryMaxDelayMs(); + const attempt = Math.max(1, Math.floor(retryCount)); + const delay = base * Math.pow(2, Math.max(0, attempt - 1)); + return Math.min(delay, max); +} + +export function downstreamAckTimeoutMs() { + const configured = Number(process.env.CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS ?? 30); + return Math.max(5, Number.isFinite(configured) ? configured : 30) * 1000; +} + +export function downstreamRetryBaseDelayMs() { + const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_DELAY_MS); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_DELAY_MS; +} + +export function downstreamRetryMaxDelayMs() { + const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS; +} + +export function downstreamMaxRetries() { + const value = Number(process.env.CMPP_DOWNSTREAM_MAX_RETRIES ?? DEFAULT_DOWNSTREAM_MAX_RETRIES); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_DOWNSTREAM_MAX_RETRIES; +} + +export function downstreamPendingTimeoutHours() { + const value = Number(process.env.CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS ?? DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS; +} + +export function downstreamControlFailureMessage(result: GatewayControlDeliveryResult) { + const reason = String(result.errorMessage ?? '').trim(); + const code = String(result.reasonCode ?? '').trim(); + if (reason && code) return `${reason} (${code})`; + if (reason) return reason; + if (code) return `Gateway 未完成下游投递 (${code})`; + return 'Gateway 未完成下游投递,等待自动重试'; +} + +export function parseImportRows(content: string, delimiter?: ',' | '\t') { + const normalized = content.replace(/^\uFEFF/, ''); + const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return []; + } + const firstDelimiter = delimiter ?? (lines[0].includes(',') ? ',' : '\t'); + const firstCells = splitImportLine(lines[0], firstDelimiter); + const hasHeader = firstCells.some((cell) => ['phone', 'phoneNumber', 'mobile', '手机号'].includes(cell)); + const headers = hasHeader ? firstCells : ['phoneNumber']; + const dataLines = hasHeader ? lines.slice(1) : lines; + return dataLines.map((line, index) => { + const cells = splitImportLine(line, firstDelimiter); + const row: { rowNumber: number; phoneNumber?: string; variables: Record } = { + rowNumber: (hasHeader ? index + 2 : index + 1), + phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0], + variables: {}, + }; + headers.forEach((header, cellIndex) => { + if (!['phone', 'phoneNumber', 'mobile', '手机号'].includes(header)) { + row.variables[header] = cells[cellIndex] ?? ''; + } + }); + return row; + }); +} + +export function splitImportLine(line: string, delimiter: ',' | '\t') { + return line.split(delimiter).map((cell) => cell.trim().replace(/^"|"$/g, '')); +} + +export function cellByHeader(headers: string[], cells: string[], candidates: string[]) { + const index = headers.findIndex((header) => candidates.includes(header)); + return index >= 0 ? cells[index] : undefined; +} + +export function normalizeCarrier(carrier?: string | null) { + const value = String(carrier ?? '').trim().toLowerCase(); + if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; + if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; + if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; + if (['all', 'tri', '三网', '全网'].includes(value)) return 'all'; + return value || 'mobile'; +} + +export function normalizeQueuePriority(queuePriority?: string | null): QueuePriority { + return queuePriority === 'priority' ? 'priority' : 'normal'; +} + +export function getPositiveConfigInteger(config: unknown, key: string, fallback: number) { + if (config && typeof config === 'object' && !Array.isArray(config) && key in config) { + const value = Number((config as Record)[key]); + if (Number.isInteger(value) && value > 0) { + return value; + } + } + return fallback; +} + +export function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) { + if (config && typeof config === 'object' && !Array.isArray(config) && key in config) { + const value = Number((config as Record)[key]); + if (Number.isInteger(value) && value >= 0) { + return value; + } + } + return fallback; +} + +export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) { + const normalized = normalizeCarrier(channelCarrier); + return normalized === 'all' || normalized === targetCarrier; +} + +export function normalizeRegion(region?: string | null) { + return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); +} + +export function matchTemplateContent(templateContent: string, actualContent: string) { + if (templateContent === actualContent) { + return {} as Record; + } + const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g; + const names: string[] = []; + let cursor = 0; + let pattern = '^'; + for (const match of templateContent.matchAll(tokenPattern)) { + const index = match.index ?? 0; + pattern += escapeRegularExpression(templateContent.slice(cursor, index)); + pattern += '([\\s\\S]+?)'; + names.push(match[1]); + cursor = index + match[0].length; + } + if (names.length === 0) { + return null; + } + pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`; + const matched = new RegExp(pattern, 'u').exec(actualContent); + if (!matched) { + return null; + } + const variables: Record = {}; + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + const value = matched[index + 1]; + if (variables[name] !== undefined && variables[name] !== value) { + return null; + } + variables[name] = value; + } + return variables; +} + +export function escapeRegularExpression(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) { + const itemProvince = normalizeRegion(item.province); + const sendRegion = normalizeRegion(item.channel.sendRegion); + return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国'; +} + +export function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) { + if (!province) { + return false; + } + const target = normalizeRegion(province); + const itemProvince = normalizeRegion(item.province); + const sendRegion = normalizeRegion(item.channel.sendRegion); + return itemProvince === target || sendRegion === target; +} + +export function validateInboundApplicationSrcId( + srcId: string | undefined, + application: { + cmppApplicationExtension?: string | null; + cmppAccessNumberFillEnabled?: boolean | null; + cmppAccessNumberFillPrefix?: string | null; + cmppClientSrcId?: string | null; + }, +) { + const submittedSrcId = srcId?.trim() ?? ''; + const applicationExtension = application.cmppApplicationExtension?.trim() ?? ''; + if (!applicationExtension) { + return submittedSrcId || null; + } + + const fillPrefix = application.cmppAccessNumberFillEnabled + ? application.cmppAccessNumberFillPrefix?.trim() ?? '' + : ''; + const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`; + if (!submittedSrcId || submittedSrcId !== expectedSrcId) { + throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`); + } + return submittedSrcId; +} + +export function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) { + const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`; + if (upstreamSrcId.length > 21) { + throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits'); + } + return upstreamSrcId; +} + +export function positiveInteger(value: string | undefined, fallback: number) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export function parseOptionalSequenceId(value: string | null | undefined) { + if (!value) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined; +} + +export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] { + return value === 'accepted' || value === 'rejected' || value === 'timeout' ? value : 'timeout'; +} + +export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['receiptStatus'] { + return value === 'delivered' || value === 'undelivered' || value === 'unknown' ? value : 'unknown'; +} + +export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) { + return createHash('sha256').update([ + data.id, + data.connectionId ?? '', + data.sequenceId ?? '', + data.messageId ?? '', + data.sequenceId ? '' : data.sentAt ?? '', + ].join('\u0000')).digest('hex'); +} + +export function shanghaiDateKey(now = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${values.year}-${values.month}-${values.day}`; +} + +export function bullmqConnection() { + const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); + return { + host: redisUrl.hostname, + port: Number(redisUrl.port || 6379), + username: redisUrl.username || undefined, + password: redisUrl.password || undefined, + maxRetriesPerRequest: null, + }; +} + +export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) { + if (data.authSource && data.timestamp !== undefined) { + const expected = createHash('md5') + .update(Buffer.concat([ + Buffer.from(octetString(data.account, 6), 'binary'), + Buffer.alloc(9), + Buffer.from(secretHash), + Buffer.from(String(data.timestamp).padStart(10, '0')), + ])) + .digest('base64'); + return expected === data.authSource; + } + if (!data.password) { + return false; + } + return data.password === secretHash || createHash('sha256').update(data.password).digest('hex') === secretHash; +} + +export function octetString(value: string, fixedLength: number) { + if (value.length === fixedLength) { + return value; + } + if (value.length > fixedLength) { + return value.slice(value.length - fixedLength); + } + return value + '\0'.repeat(fixedLength - value.length); +} + +export function hasRecoveryAuditStateChanged( + previous: Record | null, + current: Record, +) { + if (!previous) { + return true; + } + return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'] + .some((key) => (previous[key] ?? null) !== (current[key] ?? null)); +} + +export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) { + const explicit = String(data.failureCategory ?? '').trim(); + if (explicit) { + return explicit; + } + if (data.state === 'success' || data.state === 'running') { + return null; + } + if (data.lastSkipReason === 'backoff') { + return 'backoff'; + } + if (data.lastSkipReason === 'locked') { + return 'lock_contended'; + } + if (data.lastSkipReason === 'lock_lost') { + return 'lock_lost'; + } + if (data.state === 'waiting_connection') { + return 'client_disconnected'; + } + if (data.state === 'partial') { + return 'partial_delivery_failed'; + } + if (data.state === 'failed' && data.lastError) { + return 'flush_failed'; + } + return data.state ? 'unknown' : null; +} + +export type ChannelCandidate = { + channelId: string; + carrier?: string | null; + province?: string | null; + channel: { + carrier?: string | null; + sendRegion?: string | null; + status: string; + connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>; + }; +}; + +export function isChannelSendAvailable(channel: ChannelCandidate['channel']) { + if (channel.status !== 'active') { + return false; + } + return (channel.connectionStates ?? []).some((connection) => + connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected', + ); +} + +/** + * Preserve database priority order while preferring matching province routes + * over national fallbacks. Filtering remains deterministic and side-effect free. + */ +export function selectChannelCandidate( + items: T[], + options: { + carrier: string; + province?: string | null; + forceNational?: boolean; + excludedChannelIds: ReadonlySet; + approvedChannelIds: ReadonlySet; + }, +) { + const eligible = items.filter((item) => + !options.excludedChannelIds.has(item.channelId) + && options.approvedChannelIds.has(item.channelId) + && normalizeCarrier(item.carrier) === options.carrier + && isCarrierCompatible(item.channel.carrier, options.carrier), + ); + const provinceCandidates = options.forceNational + ? [] + : eligible.filter((item) => isProvinceChannel(item, options.province)); + const nationalCandidates = eligible.filter((item) => isNationalChannel(item)); + return [...provinceCandidates, ...nationalCandidates].find((item) => isChannelSendAvailable(item.channel)); +} + +export type ReceiptSegmentAudit = { + segmentTotal?: number | null; + receiptStatus?: string | null; + rawStatus?: string | null; + errorCode?: string | null; + errorMessage?: string | null; + deliveredAt?: Date | null; +}; + +/** + * Calculate one message's terminal state without reading or writing storage. + * A failed segment wins; success requires every expected segment to be delivered. + */ +export function aggregateReceiptSegmentState( + audits: ReceiptSegmentAudit[], + billingUnits: number | null | undefined, + data: GatewayReceiptEventDto, + deliveredAt: Date, +) { + if (audits.length === 0) { + const status = data.receiptStatus === 'delivered' + ? 'delivered' + : data.receiptStatus === 'unknown' + ? 'unknown' + : 'failed'; + return { + terminal: true, + segmentTotal: 1, + status, + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + deliveredAt, + }; + } + + const segmentTotal = Math.max( + 1, + Number(billingUnits ?? 1), + ...audits.map((audit) => Number(audit.segmentTotal ?? 1)), + ); + const received = audits.filter((audit) => Boolean(audit.receiptStatus)); + const failed = received.find((audit) => !['delivered', 'unknown'].includes(audit.receiptStatus ?? '')); + if (failed) { + return { + terminal: true, + segmentTotal, + status: 'failed', + receiptStatus: failed.receiptStatus ?? 'undelivered', + rawStatus: failed.rawStatus ?? data.rawStatus, + errorCode: failed.errorCode ?? data.errorCode, + errorMessage: failed.errorMessage ?? data.errorMessage, + deliveredAt: failed.deliveredAt ?? deliveredAt, + }; + } + const delivered = received.filter((audit) => audit.receiptStatus === 'delivered'); + if (delivered.length >= segmentTotal) { + const latest = delivered.reduce((current, audit) => + (audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current); + return { + terminal: true, + segmentTotal, + status: 'delivered', + receiptStatus: 'delivered', + rawStatus: latest.rawStatus ?? data.rawStatus, + errorCode: latest.errorCode ?? undefined, + errorMessage: undefined, + deliveredAt: latest.deliveredAt ?? deliveredAt, + }; + } + if (received.length >= segmentTotal) { + const latest = received[received.length - 1]; + return { + terminal: true, + segmentTotal, + status: 'unknown', + receiptStatus: 'unknown', + rawStatus: latest.rawStatus ?? data.rawStatus, + errorCode: latest.errorCode ?? data.errorCode, + errorMessage: latest.errorMessage ?? data.errorMessage, + deliveredAt: latest.deliveredAt ?? deliveredAt, + }; + } + return { + terminal: false, + segmentTotal, + status: 'submitted', + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + deliveredAt, + }; +} + +export function isSameUpstreamEndpointIdentity( + left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, +) { + return left.account.trim() === right.account.trim() + && left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() + && left.gatewayPort === right.gatewayPort + && left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() + && left.cmppVersion.trim() === right.cmppVersion.trim(); +} + +export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) { + return createHash('sha256').update([ + channelId, + data.gatewayMessageId, + data.phoneNumber?.trim() ?? '', + data.receiptStatus, + data.rawStatus.trim(), + data.errorCode ?? '', + ].join('\u0000')).digest('hex'); +} diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 70545ad..7d1dec9 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -111,6 +111,7 @@ function createPrismaMock() { }, smsSendTask: { findUnique: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }), }, smsBatchTask: { create: jest.fn().mockResolvedValue(task), @@ -374,10 +375,19 @@ function createService( reviewReason: '企业应用已配置模板不匹配进入人工审核', }), } as unknown as RiskReviewService; - const service = new SendChainService(prisma as never, billing, riskReview, openApi as never); + const phoneFrequency = { + reserve: jest.fn().mockResolvedValue(new Map()), + }; + const service = new SendChainService( + prisma as never, + billing, + riskReview, + phoneFrequency as never, + openApi as never, + ); service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true }); service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined); - return { service, prisma, billing, riskReview }; + return { service, prisma, billing, riskReview, phoneFrequency }; } describe('SendChainService', () => { @@ -451,6 +461,94 @@ describe('SendChainService', () => { expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); + it('rejects only phones that hit application frequency rules and excludes them from billing', async () => { + const { service, prisma, billing, phoneFrequency } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + phoneFrequency.reserve.mockResolvedValue(new Map([ + ['13800000002', { + code: 'PHONE_FREQUENCY_LIMIT', + reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条', + }], + ])); + (billing.estimateSmsCost as jest.Mock).mockReturnValue({ + billingUnitsPerMessage: 1, + totalBillingUnits: 1, + unitPrice: 3, + amountCents: 3, + }); + + await service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: 'hello', + phones: ['13800000001', '13800000002'], + }); + + expect(phoneFrequency.reserve).toHaveBeenCalledWith( + 'tenant-1', + 'app-1', + ['13800000001', '13800000002'], + 'client', + ); + expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 })); + expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }), + expect.objectContaining({ + phoneNumber: '13800000002', + status: 'submit_failed', + submitStatus: 'rejected', + errorCode: 'PHONE_FREQUENCY_LIMIT', + amountCents: 0, + }), + ]), + }); + expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 })); + expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); + }); + + it('marks a batch and its pending review task rejected when every phone hits frequency rules', async () => { + const { service, prisma, riskReview, phoneFrequency } = createService(); + (riskReview.evaluateTask as jest.Mock).mockResolvedValue({ + status: 'pending_review', + reason: '命中人工审核规则', + task: { id: 'review-task-1' }, + }); + phoneFrequency.reserve.mockResolvedValue(new Map([ + ['13800000001', { + code: 'PHONE_FREQUENCY_LIMIT', + reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条', + }], + ])); + + await service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: 'hello', + phones: ['13800000001'], + }); + + expect(prisma.smsSendTask.update).toHaveBeenCalledWith({ + where: { id: 'review-task-1' }, + data: expect.objectContaining({ + status: 'rejected', + riskDecision: 'block', + reviewReason: null, + rejectReason: expect.stringContaining('单号码5分钟发送频次命中'), + }), + }); + expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + status: 'rejected', + auditStatus: 'rejected', + reviewReason: null, + rejectReason: expect.stringContaining('单号码5分钟发送频次命中'), + }), + }); + }); + it('persists the review task id on every message waiting for manual review', async () => { const { service, prisma, riskReview } = createService(); (riskReview.evaluateTask as jest.Mock).mockResolvedValue({ diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 24b75f8..ce44582 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -11,292 +11,13 @@ import { moneyToNumber } from '../common/money'; import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; +import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import { OpenApiService } from '../open-api/open-api.service'; - -export interface CreateBatchTaskDto { - tenantId: string; - applicationId?: string; - templateId?: string; - content: string; - category?: string; - phones: string[]; - sendMode?: 'immediate' | 'scheduled'; - scheduledAt?: string; - variables?: Record; - createdById?: string; - sourceIp?: string; - userAgent?: string; - sourceType?: 'client' | 'api' | 'cmpp'; - clientMessageId?: string; -} - -export type CreateHttpBatchTaskDto = Omit; - -export interface GatewayInboundAuthDto { - account: string; - password?: string; - authSource?: string; - timestamp?: number; - remoteIp?: string; -} - -export interface GatewayInboundSubmitDto { - account: string; - phoneNumber?: string; - phoneNumbers?: string[]; - content: string; - srcId?: string; - destId?: string; - sequenceId?: number; - remoteIp?: string; - longMessage?: { - reference: number; - total: number; - index: number; - format: number; - }; -} - -interface GatewayInboundSingleSubmitResult { - accepted: boolean; - tenantId: string; - applicationId: string; - taskId: string; - messageId: string; - messageRecordId: string; - status: string; -} - -export interface GatewaySubmitResultDto { - traceId?: string; - messageId: string; - channelId: string; - submitId?: string; - sequenceId?: number; - gatewayMessageId: string; - submitStatus: 'accepted' | 'rejected' | 'timeout'; - errorCode?: string; - errorMessage?: string; - submittedAt?: string; - segments?: Array<{ - segmentTotal?: number; - segmentIndex?: number; - sequenceId?: number; - gatewayMessageId?: string; - submitStatus?: 'accepted' | 'rejected' | 'timeout' | string; - errorCode?: string; - errorMessage?: string; - submittedAt?: string; - }>; -} - -export interface GatewaySubmitSegmentResultDto { - traceId?: string; - messageId: string; - channelId: string; - submitId?: string; - segmentTotal: number; - segmentIndex: number; - sequenceId?: number; - gatewayMessageId?: string; - submitStatus: 'accepted' | 'rejected' | 'timeout' | string; - errorCode?: string; - errorMessage?: string; - submittedAt?: string; -} - -export interface GatewayReceiptEventDto { - traceId?: string; - messageId?: string; - channelId: string; - sequenceId?: number; - gatewayMessageId: string; - phoneNumber?: string; - receiptStatus: 'delivered' | 'undelivered' | 'unknown'; - rawStatus: string; - errorCode?: string; - errorMessage?: string; - deliveredAt?: string; - connectionId?: string; -} - -export interface GatewayUplinkEventDto { - traceId?: string; - messageId?: string; - channelId: string; - sequenceId?: number; - phoneNumber: string; - destId: string; - content: string; - receivedAt?: string; -} - -type UplinkMatchCandidateInput = { - tenantId: string; - applicationId: string; - messageRecordId?: string; - matchSource: 'access_number' | 'phone_window'; - confidence: number; - reason: string; -}; - -export interface GatewayPendingDeliveryQueryDto { - account: string; - limit?: number; -} - -export interface GatewayDownstreamSentDto { - id: string; - connectionId?: string; - sequenceId?: string; - messageId?: string; - sentAt?: string; - ackDeadlineAt?: string; -} - -export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentDto { - result: number; - acknowledgedAt?: string; -} - -export type GatewayDownstreamFailureType = - | 'send_failed' - | 'ack_timeout' - | 'ack_rejected' - | 'ack_invalid' - | 'connection_lost' - | 'unrecoverable' - | 'queue_timeout'; - -type GatewayControlDeliveryResult = { - sent?: boolean; - delivered?: boolean; - retryable?: boolean; - reasonCode?: string; - errorMessage?: string; - connectionId?: string; - sequenceId?: string; - messageId?: string; - sentAt?: string; - ackDeadlineAt?: string; -}; - -export interface GatewaySubmitDeadLetterDto { - streamMessageId: string; - traceId?: string; - messageId?: string; - channelId?: string; - tenantId?: string; - applicationId?: string; - submitId?: string; - failureCode: string; - failureMessage: string; - attempts: number; - maxAttempts: number; - commandPayload?: Record; - rawPayload?: string; - deadLetteredAt?: string; -} - -export interface RequeueGatewaySubmitExceptionDto { - confirmedNotSubmitted?: boolean; - reason?: string; - operatorId?: string; -} - -export interface GatewayDownstreamRecoveryStatusDto { - account: string; - gatewayInstanceId?: string; - state: string; - lockOwner?: string; - lockExpiresAt?: string; - lastAttemptAt?: string; - lastSuccessAt?: string; - lastFailureAt?: string; - nextRetryAt?: string; - attemptCount?: number; - failureCategory?: string; - lastError?: string; - lastSkipReason?: string; -} - -export interface TimeoutUnknownDto { - olderThanHours?: number; -} - -export interface ImportPreviewDto { - tenantId: string; - applicationId?: string; - content: string; - fileName?: string; - encoding?: 'utf8' | 'gbk'; - delimiter?: ',' | '\t'; - requiredVariables?: string[]; -} - -export interface ConfirmImportDto extends CreateBatchTaskDto { - importContent: string; - requiredVariables?: string[]; -} - -interface SendJob { - messageRecordId: string; -} - -type QueuePriority = 'normal' | 'priority'; - -type RoutedChannel = { - channel: { - id: string; - code: string; - account: string; - srcId: string; - rateLimitPerSecond: number; - unitPrice: number; - status: string; - carrier?: string | null; - sendRegion: string; - gatewayHost: string; - gatewayPort: number; - passwordCipher: string; - cmppVersion: string; - config?: unknown; - }; - carrier: string; - province?: string | null; - groupId: string; - groupName: string; - routeScope: 'province' | 'national'; -}; - -const SEND_QUEUE = 'sms.send.queue'; -const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue'; -const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands'; -const DEFAULT_DOWNSTREAM_RETRY_DELAY_MS = 60_000; -const DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS = 30 * 60_000; -const DEFAULT_DOWNSTREAM_MAX_RETRIES = 10; -const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72; -const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72; -const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000; -const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000; -const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000; -const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000; -const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000; -const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000; -const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000; -const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000; -const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000; -const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30; -const DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS = 5_000; -const UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS = 1_000; -const DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS = 2 * 60_000; -const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS = 30; -const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS = 72; -const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; -const BULLMQ_PRIORITY: Record = { - priority: 1, - normal: 100, -}; +import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers'; +import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import { SendSubmissionService } from './send-submission.service'; +import { SendCompletionService, type SendCompletionFacade } from './send-completion.service'; @Injectable() export class SendChainService implements OnModuleInit, OnModuleDestroy { @@ -315,17 +36,37 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { private inboundLongMessageIntervalTimer?: ReturnType; private upstreamReceiptInboxInitialTimer?: ReturnType; private upstreamReceiptInboxIntervalTimer?: ReturnType; - private upstreamReceiptInboxScanRunning = false; - private readonly phoneRouting: PhoneRoutingLookupService; + private readonly submission: SendSubmissionService; + private readonly completion: SendCompletionService; constructor( private readonly prisma: PrismaService, private readonly billing: BillingService, private readonly riskReview: RiskReviewService, + private readonly phoneFrequency: PhoneFrequencyService, @Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService, @Optional() phoneRouting?: PhoneRoutingLookupService, ) { - this.phoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma); + const resolvedPhoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma); + this.submission = new SendSubmissionService( + prisma, + billing, + riskReview, + phoneFrequency, + resolvedPhoneRouting, + this as unknown as SendSubmissionService, + { + releaseMessageReservation: (message, remark) => this.releaseMessageReservation(message, remark), + recordCmppFailureReceipt: (message, errorCode, reason) => + this.recordCmppFailureReceipt(message, errorCode, reason), + }, + ); + this.completion = new SendCompletionService( + prisma, + billing, + openApi, + this as unknown as SendCompletionFacade, + ); } onModuleInit() { @@ -405,163 +146,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async createBatchTask(data: CreateBatchTaskDto) { - const phones = [...new Set(data.phones ?? [])]; - const schedule = parseSchedule(data); - await this.validateSendResources(data.tenantId, data.applicationId, data.templateId); - const phoneRejections = await this.classifyRejectedPhones(data.tenantId, data.applicationId, phones); - const sendablePhones = phones.filter((phone) => !phoneRejections.has(phone)); - const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([ - this.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content), - this.resolveUnitPrice(data.tenantId, data.applicationId), - this.resolveQueuePriority(data.tenantId, data.applicationId), - this.resolveApplicationAccessNumber(data.tenantId, data.applicationId), - ]); - const risk = messageClassification.rejectionReason - ? { status: 'rejected', reason: messageClassification.rejectionReason, task: null } - : await this.riskReview.evaluateTask({ - tenantId: data.tenantId, - applicationId: data.applicationId, - templateId: data.templateId, - content: data.content, - category: data.category, - phones, - variables: messageClassification.variables ?? data.variables, - createdById: data.createdById, - sourceType: data.sourceType ?? 'client', - }); - const billing = this.billing.estimateSmsCost({ - tenantId: data.tenantId, - applicationId: data.applicationId, - taskId: risk.task?.id, - content: data.content, - phoneCount: sendablePhones.length, - unitPrice, - }); - const batchStatus = risk.status === 'approved' && sendablePhones.length === 0 - ? 'failed' - : statusFromRisk(risk.status, Boolean(schedule.scheduledAt)); - const shouldReserveBalance = batchStatus === 'ready'; - if (risk.status === 'approved') { - const accountCheck = await this.billing.checkAccount({ - tenantId: data.tenantId, - amountCents: billing.amountCents, - }); - if (!accountCheck.canSend) { - throw new BadRequestException('企业账户余额不足'); - } - } - if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) { - await this.reserveDailySendQuota(data.applicationId, sendablePhones.length); - } - const task = await this.prisma.smsBatchTask.create({ - data: { - tenantId: data.tenantId, - applicationId: data.applicationId, - templateId: data.templateId, - taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`, - sourceType: data.sourceType ?? 'client', - content: data.content, - category: data.category, - phoneTotal: phones.length, - status: batchStatus, - riskTaskId: risk.task?.id, - auditStatus: risk.status === 'pending_review' ? 'pending' : risk.status === 'rejected' ? 'rejected' : 'approved', - reviewReason: risk.status === 'pending_review' ? risk.reason : null, - rejectReason: risk.status === 'rejected' ? risk.reason : null, - progressTotal: phones.length, - scheduledAt: schedule.scheduledAt, - createdById: data.createdById, - }, - }); - if (shouldReserveBalance && billing.amountCents > 0) { - await this.billing.freeze({ - tenantId: data.tenantId, - amountCents: billing.amountCents, - relatedType: 'sms_batch_task', - relatedId: task.id, - remark: '发送任务创建冻结', - }); - } - await this.prisma.smsApiRequest.create({ - data: { - tenantId: data.tenantId, - batchTaskId: task.id, - requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`, - sourceIp: data.sourceIp, - userAgent: data.userAgent, - payloadSummary: { - phoneTotal: phones.length, - contentLength: [...data.content].length, - category: data.category, - sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate', - scheduledAt: schedule.scheduledAt?.toISOString(), - }, - status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted', - }, - }); - if (phones.length > 0) { - await this.prisma.smsMessageRecord.createMany({ - data: phones.map((phone) => { - const rejection = phoneRejections.get(phone); - const status = rejection - ? 'submit_failed' - : batchStatus === 'ready' - ? 'queued' - : batchStatus === 'scheduled' - ? 'scheduled' - : batchStatus; - return { - tenantId: data.tenantId, - batchTaskId: task.id, - applicationId: data.applicationId, - templateId: data.templateId, - signatureId: messageClassification.signatureId, - drainageInfoId: messageClassification.drainageInfoId, - reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined, - messageId: `MSG-${randomUUID()}`, - clientMessageId: data.clientMessageId, - phoneNumber: phone, - content: data.content, - billingUnits: billing.billingUnitsPerMessage, - unitPrice: rejection ? 0 : billing.unitPrice, - amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice, - queuePriority, - clientSrcId: accessNumber.clientSrcId, - applicationExtension: accessNumber.applicationExtension, - status, - submitStatus: rejection ? 'rejected' : undefined, - errorCode: rejection?.code, - errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined), - }; - }), - }); - } - if (batchStatus === 'ready' && sendablePhones.length > 0) { - await this.enqueueBatchTask(task.id); - } else if (batchStatus === 'failed') { - await this.refreshTaskProgress(task.id); - } - return this.getBatchTask(task.id, undefined, data.sourceType ?? 'client'); + return this.submission.createBatchTask(data); } async createHttpBatchTask(data: CreateHttpBatchTaskDto) { - if (!data.applicationId) { - throw new BadRequestException('公开 HTTP 发送必须关联企业应用'); - } - const template = await this.resolveInboundTemplateCandidate(data.applicationId, data.content); - if (!template || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { - throw new BadRequestException('短信内容未匹配当前应用已审核通过的签名和模板'); - } - const variables = matchTemplateContent(template.content, data.content); - if (variables === null) { - throw new BadRequestException('短信内容与已审核模板不匹配'); - } - return this.createBatchTask({ - ...data, - templateId: template.id, - variables, - sourceType: 'api', - }); + return this.submission.createHttpBatchTask(data); } async listBatchTasks(tenantId?: string, status?: string, sourceType = 'client') { @@ -589,14 +178,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { - const task = await this.prisma.smsBatchTask.findFirst({ - where: { id: taskId, tenantId, sourceType }, - include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } }, - }); - if (!task) { - throw new NotFoundException('SMS batch task not found'); - } - return task; + return this.submission.getBatchTask(taskId, tenantId, sourceType); } async listClientTaskMessages(taskId: string, tenantId: string) { @@ -747,97 +329,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async previewImport(data: ImportPreviewDto) { - const sizeBytes = Buffer.byteLength(data.content, 'utf8'); - if (sizeBytes > 20 * 1024 * 1024) { - throw new BadRequestException('导入文件不能超过 20MB'); - } - const rows = parseImportRows(data.content, data.delimiter); - const phones: string[] = []; - const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = []; - const requiredVariables = data.requiredVariables ?? []; - const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({ - where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' }, - select: { phoneNumber: true }, - }) : []; - const globalBlacklist = await this.prisma.globalBlacklist.findMany({ - where: { status: 'active' }, - select: { phoneNumber: true }, - }); - const blacklist = new Set([...enterpriseBlacklist, ...globalBlacklist].map((item) => item.phoneNumber)); - const seen = new Set(); - for (const row of rows) { - if (!row.phoneNumber) { - errors.push({ rowNumber: row.rowNumber, reason: '缺少手机号' }); - continue; - } - if (!/^1[3-9]\d{9}$/.test(row.phoneNumber)) { - errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '手机号格式非法' }); - continue; - } - if (seen.has(row.phoneNumber)) { - errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '重复号码' }); - continue; - } - if (blacklist.has(row.phoneNumber)) { - errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '命中黑名单' }); - continue; - } - const missingVariables = requiredVariables.filter((name) => !row.variables[name]); - if (missingVariables.length > 0) { - errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` }); - continue; - } - seen.add(row.phoneNumber); - phones.push(row.phoneNumber); - } - return { - fileName: data.fileName, - encoding: data.encoding ?? 'utf8', - totalRows: rows.length, - validCount: phones.length, - errorCount: errors.length, - phones, - errors, - }; + return this.submission.previewImport(data); } async confirmImport(data: ConfirmImportDto) { - const preview = await this.previewImport({ - tenantId: data.tenantId, - applicationId: data.applicationId, - content: data.importContent, - requiredVariables: data.requiredVariables, - }); - if (preview.validCount === 0) { - throw new BadRequestException('导入文件没有可发送号码'); - } - return this.createBatchTask({ ...data, phones: preview.phones }); + return this.submission.confirmImport(data); } async enqueueBatchTask(taskId: string) { - const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } }); - if (!task) { - throw new NotFoundException('SMS batch task not found'); - } - if (task.status === 'canceled') { - throw new BadRequestException('SMS batch task is canceled'); - } - const messages = await this.prisma.smsMessageRecord.findMany({ - where: { batchTaskId: taskId, status: 'queued' }, - select: { id: true, queuePriority: true }, - take: 100000, - }); - const queue = this.getSendQueue(); - for (const message of messages) { - const queuePriority = normalizeQueuePriority(message.queuePriority); - await queue.add('send-message', { messageRecordId: message.id }, { - jobId: message.id, - attempts: 3, - priority: BULLMQ_PRIORITY[queuePriority], - }); - } - await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } }); - return { taskId, enqueued: messages.length }; + return this.submission.enqueueBatchTask(taskId); } async cancelBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { @@ -859,54 +359,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { - const reviewTask = await this.prisma.smsSendTask.findUnique({ - where: { id: reviewTaskId }, - }); - if (!reviewTask) { - return { reviewTaskId, decision, affected: 0 }; - } - const messageRecords = await this.prisma.smsMessageRecord.findMany({ - where: { - status: 'pending_review', - OR: [ - { reviewTaskId }, - { batchTask: { riskTaskId: reviewTaskId } }, - ], - }, - include: { batchTask: true }, - }); - if (messageRecords.length === 0) { - return { reviewTaskId, decision, affected: 0 }; - } - const batchTaskIds = new Set(); - for (const message of messageRecords) { - if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue; - if (decision === 'approved') { - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { status: 'queued', errorCode: null, errorMessage: null }, - }); - await this.prisma.smsBatchTask.update({ - where: { id: message.batchTaskId }, - data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null }, - }); - batchTaskIds.add(message.batchTaskId); - } else { - await this.releaseMessageReservation( - message as typeof message & { tenantId: string; batchTaskId: string }, - '模板不匹配人工审核驳回释放冻结', - ); - await this.prisma.smsBatchTask.update({ - where: { id: message.batchTaskId }, - data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason }, - }); - await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason); - } - } - for (const batchTaskId of batchTaskIds) { - await this.enqueueBatchTask(batchTaskId); - } - return { reviewTaskId, decision, affected: messageRecords.length }; + return this.submission.handleReviewDecision(reviewTaskId, decision, reason); } async terminateBatchTask(taskId: string) { @@ -929,907 +382,81 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async dispatchDueScheduledTasks(now = new Date()) { - const staleCutoff = new Date(now.getTime() - positiveInteger( - process.env.SMS_SCHEDULED_DISPATCH_STALE_MS, - DEFAULT_SCHEDULED_DISPATCH_STALE_MS, - )); - const tasks = await this.prisma.smsBatchTask.findMany({ - where: { - OR: [ - { status: 'scheduled', scheduledAt: { lte: now } }, - { status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } }, - ], - }, - orderBy: { scheduledAt: 'asc' }, - }); - const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = []; - for (const task of tasks) { - const candidateStatus = task.status || 'scheduled'; - const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching'; - const claimed = await this.prisma.smsBatchTask.updateMany({ - where: { - id: task.id, - status: candidateStatus, - ...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }), - }, - data: { status: claimedStatus }, - }); - if (claimed.count !== 1) continue; - let reservationEstablished = false; - let dispatchPrepared = false; - try { - await this.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined); - const messages = await this.prisma.smsMessageRecord.findMany({ - where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } }, - select: { id: true, amountCents: true, billingUnits: true }, - take: 100000, - }); - const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0); - const existingReservation = await this.prisma.accountTransaction.findFirst({ - where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id }, - select: { id: true }, - }); - reservationEstablished = Boolean(existingReservation); - if (!reservationEstablished) { - const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents }); - if (!accountCheck.canSend) { - throw new BadRequestException('定时任务到点时企业账户余额不足'); - } - if (amountCents > 0) { - await this.billing.freeze({ - tenantId: task.tenantId, - amountCents, - relatedType: 'sms_batch_task', - relatedId: task.id, - remark: '定时任务到点冻结', - }); - reservationEstablished = true; - } - } - dispatchPrepared = true; - await this.prisma.smsMessageRecord.updateMany({ - where: { batchTaskId: task.id, status: 'scheduled' }, - data: { status: 'queued' }, - }); - const enqueued = await this.enqueueBatchTask(task.id); - results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued }); - } catch (error) { - const reason = error instanceof Error ? error.message : '定时任务到点执行失败'; - if (reservationEstablished || dispatchPrepared) { - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` }, - }); - results.push({ taskId: task.id, status: 'retrying', reason }); - continue; - } - await this.prisma.smsMessageRecord.updateMany({ - where: { batchTaskId: task.id, status: 'scheduled' }, - data: { status: 'rejected', errorMessage: reason }, - }); - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { status: 'failed', rejectReason: reason }, - }); - results.push({ taskId: task.id, status: 'failed', reason }); - } - } - return { dispatched: results.filter((result) => result.status === 'queued').length, results }; + return this.submission.dispatchDueScheduledTasks(now); } private async runScheduledDispatchScan() { - if (this.scheduledDispatchScanRunning) return; - this.scheduledDispatchScanRunning = true; - try { - await this.dispatchDueScheduledTasks(); - } catch (error) { - this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`); - } finally { - this.scheduledDispatchScanRunning = false; - } + return this.submission.runScheduledDispatchScan(); } startWorker() { - if (this.worker) { - return { status: 'already_started' }; - } - const connection = bullmqConnection(); - this.worker = new Worker( - SEND_QUEUE, - async (job) => this.processSendJob(job.data), - { connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) }, - ); - return { status: 'started' }; + return this.submission.startWorker(); } async processSendJob(job: SendJob) { - const message = await this.prisma.smsMessageRecord.findUnique({ - where: { id: job.messageRecordId }, - include: { batchTask: true, template: { include: { signature: true } }, signature: true }, - }); - if (!message || message.status !== 'queued') { - return { skipped: true }; - } - if (!message.tenantId || !message.batchTaskId) { - return { skipped: true, reason: 'standalone channel test message' }; - } - const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; - try { - const routed = await this.selectChannelForMessage(businessMessage); - return await this.submitMessageToGateway(businessMessage, routed, 0); - } catch (error) { - const reason = error instanceof Error ? error.message : '无可用通道组或通道'; - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { status: 'failed', errorMessage: reason }, - }); - await this.releaseMessageReservation(businessMessage, reason); - if (message.batchTask?.sourceType === 'cmpp') { - await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason); - } else { - await this.refreshTaskProgress(businessMessage.batchTaskId); - } - return { submitted: false, messageRecordId: message.id, status: 'failed', reason }; - } + return this.submission.processSendJob(job); } async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { - const message = await this.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); - const submitRecord = await this.resolveSubmitRecordForGatewaySegmentResult(message.id, data); - const effectiveSubmitId = submitRecord.submitId; - const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date(); - await this.recordSubmitSegments(message, { - messageId: data.messageId, - channelId: data.channelId, - submitId: effectiveSubmitId, - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId ?? '', - submitStatus: normalizeSubmitStatus(data.submitStatus), - errorCode: data.errorCode, - errorMessage: data.errorMessage, - submittedAt: submittedAt.toISOString(), - segments: [{ - segmentTotal: data.segmentTotal, - segmentIndex: data.segmentIndex, - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId, - submitStatus: data.submitStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - submittedAt: submittedAt.toISOString(), - }], - }, submittedAt); - if (data.gatewayMessageId) { - await this.prisma.smsSubmitRecord.updateMany({ - where: { - id: submitRecord.id, - gatewayMessageId: null, - }, - data: { - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId, - submittedAt, - }, - }); - } - return { accepted: true }; + return this.completion.handleSubmitSegmentResult(data); } private async resolveSubmitRecordForGatewaySegmentResult( messageRecordId: string, data: GatewaySubmitSegmentResultDto, ) { - if (data.submitId) { - const exact = await this.prisma.smsSubmitRecord.findUnique({ - where: { submitId: data.submitId }, - }); - if ( - !exact || - (exact.messageRecordId && exact.messageRecordId !== messageRecordId) || - (exact.channelId && exact.channelId !== data.channelId) - ) { - this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - submitId: data.submitId, - channelId: data.channelId, - segmentIndex: data.segmentIndex, - })}`); - throw new BadRequestException( - 'Gateway SubmitSegmentResult submitId does not match the SMS message and channel', - ); - } - return exact; - } - - const candidates = await this.prisma.smsSubmitRecord.findMany({ - where: { - messageRecordId, - channelId: data.channelId, - }, - orderBy: { createdAt: 'desc' }, - take: 2, - }); - if (candidates.length !== 1) { - this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - channelId: data.channelId, - segmentIndex: data.segmentIndex, - candidateCount: candidates.length, - })}`); - throw new BadRequestException( - 'Gateway SubmitSegmentResult without submitId cannot be matched uniquely', - ); - } - this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - channelId: data.channelId, - segmentIndex: data.segmentIndex, - submitId: candidates[0].submitId, - })}`); - return candidates[0]; + return this.completion.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data); } async handleSubmitResult(data: GatewaySubmitResultDto) { - const message = await this.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); - const submitRecord = await this.resolveSubmitRecordForGatewayResult(message.id, data); - const effectiveData = { ...data, submitId: submitRecord.submitId }; - const batchTask = message.batchTaskId - ? await this.prisma.smsBatchTask.findUnique({ where: { id: message.batchTaskId }, select: { sourceType: true } }) - : null; - const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date(); - await this.prisma.smsSubmitRecord.updateMany({ - where: { id: submitRecord.id }, - data: { - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId, - submitStatus: data.submitStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - submittedAt, - }, - }); - await this.recordSubmitSegments(message, effectiveData, submittedAt); - const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId; - if (message.submitId && effectiveData.submitId !== message.submitId) { - return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); - } - const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed'; - if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { - const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; - await this.chargeAcceptedMessage(businessMessage); - const latest = await this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); - if (latest?.status === 'failed') { - await this.refundMessage(businessMessage, '先到失败回执补偿退款'); - } - } else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { - const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; - const retried = await this.retryMessageIfAllowed( - businessMessage, - data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发', - submitRecord.id, - ); - if (retried) { - await this.refreshTaskProgress(businessMessage.batchTaskId); - return retried; - } - await this.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结'); - } - const protectedTerminalStatuses = ['delivered', 'failed', 'unknown']; - const updated = await this.prisma.smsMessageRecord.updateMany({ - where: data.submitStatus === 'accepted' - ? { id: message.id, status: { notIn: protectedTerminalStatuses } } - : { id: message.id, status: { not: 'delivered' } }, - data: { - gatewayMessageId: data.gatewayMessageId, - submitStatus: data.submitStatus, - status, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - submittedAt, - timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined, - }, - }); - if (updated.count === 0 && data.submitStatus === 'accepted') { - await this.prisma.smsMessageRecord.updateMany({ - where: { id: message.id, gatewayMessageId: null }, - data: { - gatewayMessageId: data.gatewayMessageId, - submittedAt, - }, - }); - } - if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) { - await this.recordCmppFailureReceipt( - message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string }, - data.errorCode || 'SUBMIT', - data.errorMessage || (data.submitStatus === 'timeout' ? '上游提交超时' : '上游拒绝短信'), - ); - } - await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { - status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] }, - OR: [ - { submitId: effectiveData.submitId }, - data.messageId ? { messageId: data.messageId } : undefined, - ].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>, - }, - data: { - status: 'resolved', - resolvedAt: submittedAt, - resolvedStatus: data.submitStatus, - }, - }); - if (message.batchTaskId) { - await this.refreshTaskProgress(message.batchTaskId); - } - return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + return this.completion.handleSubmitResult(data); } private async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) { - if (data.submitId) { - const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } }); - if (!exact - || (exact.messageRecordId && exact.messageRecordId !== messageRecordId) - || (exact.channelId && exact.channelId !== data.channelId)) { - throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel'); - } - return exact; - } - const candidates = await this.prisma.smsSubmitRecord.findMany({ - where: { - messageRecordId, - channelId: data.channelId, - OR: [ - data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined, - { gatewayMessageId: null }, - ].filter(Boolean) as Array<{ gatewayMessageId?: string | null }>, - }, - orderBy: { createdAt: 'desc' }, - take: 2, - }); - if (candidates.length !== 1) { - this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - channelId: data.channelId, - gatewayMessageId: data.gatewayMessageId, - candidateCount: candidates.length, - })}`); - throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely'); - } - this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - channelId: data.channelId, - gatewayMessageId: data.gatewayMessageId, - submitId: candidates[0].submitId, - })}`); - return candidates[0]; + return this.completion.resolveSubmitRecordForGatewayResult(messageRecordId, data); } async intakeReceipt(data: GatewayReceiptEventDto) { - const channel = await this.prisma.smsChannel.findUnique({ - where: { id: data.channelId }, - select: { - id: true, - account: true, - gatewayHost: true, - gatewayPort: true, - protocol: true, - cmppVersion: true, - }, - }); - if (!channel) { - throw new NotFoundException('SMS channel not found'); - } - const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); - const receiptKey = this.receiptEventKey(data, data.channelId); - const inbox = await this.prisma.upstreamReceiptInbox.upsert({ - where: { receiptKey }, - update: { - incomingConnectionId: data.connectionId, - }, - create: { - receiptKey, - incomingChannelId: data.channelId, - incomingConnectionId: data.connectionId, - upstreamAccount: channel.account, - upstreamHost: channel.gatewayHost, - upstreamPort: channel.gatewayPort, - protocol: channel.protocol, - protocolVersion: channel.cmppVersion, - provisionalMessageId: data.messageId, - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId, - phoneNumber: data.phoneNumber?.trim() || null, - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - deliveredAt, - status: 'pending', - nextRetryAt: new Date(), - }, - }); - if (['pending', 'retrying'].includes(inbox.status)) { - setImmediate(() => void this.processUpstreamReceiptInboxRecord(inbox.id)); - } - return { accepted: true, inboxId: inbox.id, status: inbox.status }; + return this.completion.intakeReceipt(data); } async processPendingUpstreamReceiptInbox(limit = 100) { - const now = new Date(); - const staleBefore = new Date( - now.getTime() - - positiveInteger( - process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, - DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, - ), - ); - const candidates = await this.prisma.upstreamReceiptInbox.findMany({ - where: { - OR: [ - { - status: { in: ['pending', 'retrying'] }, - OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }], - }, - { status: 'processing', updatedAt: { lte: staleBefore } }, - ], - }, - orderBy: [{ receivedAt: 'asc' }, { id: 'asc' }], - take: Math.min(Math.max(limit, 1), 500), - select: { id: true }, - }); - let processed = 0; - for (const candidate of candidates) { - if (await this.processUpstreamReceiptInboxRecord(candidate.id)) processed += 1; - } - return { scanned: candidates.length, processed }; + return this.completion.processPendingUpstreamReceiptInbox(limit); } private async processUpstreamReceiptInboxRecord(id: string) { - const staleBefore = new Date( - Date.now() - - positiveInteger( - process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, - DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, - ), - ); - const claimed = await this.prisma.upstreamReceiptInbox.updateMany({ - where: { - id, - OR: [ - { status: { in: ['pending', 'retrying'] } }, - { status: 'processing', updatedAt: { lte: staleBefore } }, - ], - }, - data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null }, - }); - if (claimed.count !== 1) return false; - const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } }); - if (!inbox) return false; - try { - const message = await this.handleReceipt({ - messageId: inbox.provisionalMessageId ?? undefined, - channelId: inbox.incomingChannelId, - connectionId: inbox.incomingConnectionId ?? undefined, - sequenceId: inbox.sequenceId ?? undefined, - gatewayMessageId: inbox.gatewayMessageId, - phoneNumber: inbox.phoneNumber ?? undefined, - receiptStatus: normalizeReceiptStatus(inbox.receiptStatus), - rawStatus: inbox.rawStatus, - errorCode: inbox.errorCode ?? undefined, - errorMessage: inbox.errorMessage ?? undefined, - deliveredAt: inbox.deliveredAt.toISOString(), - }, { - account: inbox.upstreamAccount, - gatewayHost: inbox.upstreamHost, - gatewayPort: inbox.upstreamPort, - protocol: inbox.protocol, - cmppVersion: inbox.protocolVersion, - }); - const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId; - const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined; - await this.prisma.upstreamReceiptInbox.update({ - where: { id }, - data: { - status: 'matched', - matchedMessageRecordId: matchedMessageRecordId ?? null, - matchedChannelId: matchedChannelId ?? null, - lastError: null, - processedAt: new Date(), - }, - }); - return true; - } catch (error) { - const maxAttempts = positiveInteger( - process.env.UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, - DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, - ); - const maxAgeHours = positiveInteger( - process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, - DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, - ); - const exhausted = inbox.attemptCount >= maxAttempts - || inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000; - const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8)); - await this.prisma.upstreamReceiptInbox.update({ - where: { id }, - data: { - status: exhausted ? 'unmatched' : 'retrying', - nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs), - lastError: error instanceof Error ? error.message : String(error), - processedAt: exhausted ? new Date() : null, - }, - }); - return false; - } + return this.completion.processUpstreamReceiptInboxRecord(id); } private async runUpstreamReceiptInboxScan() { - if (this.upstreamReceiptInboxScanRunning) return; - this.upstreamReceiptInboxScanRunning = true; - try { - const result = await this.processPendingUpstreamReceiptInbox(); - if (result.processed > 0) { - this.logger.log(`Matched ${result.processed}/${result.scanned} pending upstream receipts`); - } - } catch (error) { - this.logger.error('Upstream receipt inbox scan failed', error instanceof Error ? error.stack : String(error)); - } finally { - this.upstreamReceiptInboxScanRunning = false; - } + return this.completion.runUpstreamReceiptInboxScan(); } async handleReceipt( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, ) { - const resolved = await this.resolveReceiptMessage(data, incomingIdentity); - const logicalChannelId = resolved.channelId ?? data.channelId; - const receiptKey = this.receiptEventKey(data, logicalChannelId); - const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({ - where: { receiptKey }, - include: { messageRecord: true }, - }); - if (existingReceipt?.messageRecord) { - return existingReceipt.messageRecord; - } - const message = resolved.message; - const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); - if (resolved.submitRecordId) { - await this.prisma.smsSubmitRecord.updateMany({ - where: { - id: resolved.submitRecordId, - gatewayMessageId: null, - }, - data: { - gatewayMessageId: data.gatewayMessageId, - sequenceId: data.sequenceId, - }, - }); - } - try { - await this.prisma.smsReceiptRecord.create({ - data: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - receiptKey, - channelId: logicalChannelId, - messageId: resolved.messageId, - gatewayMessageId: data.gatewayMessageId, - phoneNumber: data.phoneNumber?.trim() || message.phoneNumber, - sequenceId: data.sequenceId, - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - deliveredAt, - }, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { - const duplicate = await this.prisma.smsReceiptRecord.findUnique({ - where: { receiptKey }, - include: { messageRecord: true }, - }); - if (duplicate?.messageRecord) return duplicate.messageRecord; - } - throw error; - } - const logicalReceipt = { ...data, channelId: logicalChannelId }; - await this.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId); - const aggregate = await this.aggregateReceiptSegments( - message, - logicalReceipt, - deliveredAt, - resolved.submitRecordId, - resolved.submitId, - ); - if (!aggregate.terminal) { - return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); - } - const status = aggregate.status; - const isCurrentAttempt = - (!message.channelId || message.channelId === logicalChannelId) - && ( - !message.gatewayMessageId - || message.gatewayMessageId === data.gatewayMessageId - || (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId)) - ); - if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) { - return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); - } - const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId; - if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { - const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; - const retried = await this.retryMessageIfAllowed( - businessMessage, - '回执失败补发', - resolved.submitRecordId, - ); - if (retried) { - await this.refreshTaskProgress(businessMessage.batchTaskId); - return retried; - } - await this.refundMessage(businessMessage, '最终失败退款'); - } - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { - channelId: logicalChannelId, - gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId, - receiptStatus: aggregate.receiptStatus, - receiptRawStatus: aggregate.rawStatus, - status, - errorCode: aggregate.errorCode, - errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus), - deliveredAt: aggregate.deliveredAt, - }, - }); - if (!isStandaloneChannelTest && message.tenantId && message.applicationId) { - await this.queueAndTryDownstreamDelivery({ - tenantId: message.tenantId, - applicationId: message.applicationId, - messageRecordId: message.id, - messageId: message.messageId, - deliveryType: 'receipt', - payload: { - messageId: message.messageId, - gatewayMessageId: data.gatewayMessageId, - phoneNumber: message.phoneNumber, - receiptStatus: aggregate.receiptStatus, - rawStatus: aggregate.rawStatus, - errorCode: aggregate.errorCode, - submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, - submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, - deliveredAt: aggregate.deliveredAt.toISOString(), - }, - }); - } - if (message.batchTaskId) { - await this.refreshTaskProgress(message.batchTaskId); - } - return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + return this.completion.handleReceipt(data, incomingIdentity); } async handleUplink(data: GatewayUplinkEventDto) { - const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); - if (!channel) { - throw new NotFoundException('SMS channel not found'); - } - const match = await this.resolveUplinkMatch(data, channel); - const record = await this.prisma.smsUplinkMessage.create({ - data: { - tenantId: match.tenantId, - applicationId: match.applicationId, - messageRecordId: match.messageRecordId, - channelId: data.channelId, - messageId: data.messageId, - sequenceId: data.sequenceId, - phoneNumber: data.phoneNumber, - destId: data.destId, - content: data.content, - matchStatus: match.matchStatus, - matchReason: match.matchReason, - receivedAt: data.receivedAt ? new Date(data.receivedAt) : new Date(), - }, - }); - if (match.candidates.length > 0) { - await this.prisma.smsUplinkMatchCandidate.createMany({ - data: match.candidates.map((candidate) => ({ - uplinkMessageId: record.id, - tenantId: candidate.tenantId, - applicationId: candidate.applicationId, - messageRecordId: candidate.messageRecordId, - matchSource: candidate.matchSource, - confidence: candidate.confidence, - reason: candidate.reason, - })), - skipDuplicates: true, - }); - } - if (match.tenantId && match.applicationId) { - await this.queueAndTryDownstreamDelivery({ - tenantId: match.tenantId, - applicationId: match.applicationId, - messageRecordId: match.messageRecordId, - messageId: data.messageId, - deliveryType: 'uplink', - payload: { - messageId: data.messageId, - applicationId: match.applicationId, - phoneNumber: data.phoneNumber, - destId: data.destId, - content: data.content, - receivedAt: record.receivedAt.toISOString(), - uplinkMessageId: record.id, - }, - }); - } - return record; + return this.completion.handleUplink(data); } async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) { - const application = await this.findInboundApplication(data.account); - if (!application) { - throw new BadRequestException('CMPP account is invalid'); - } - const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({ - where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } }, - select: { id: true }, - take: 500, - }); - for (const expired of expiredAcknowledgements) { - await this.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout'); - } - return this.prisma.cmppDownstreamDelivery.findMany({ - where: { - applicationId: application.id, - status: 'pending', - OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }], - }, - orderBy: { createdAt: 'asc' }, - take: Math.min(Math.max(data.limit ?? 100, 1), 500), - }); + return this.completion.listPendingDownstreamDeliveries(data); } async markDownstreamDeliveryDelivered(id: string) { - return this.prisma.cmppDownstreamDelivery.update({ - where: { id }, - data: { - status: 'delivered', - deliveredAt: new Date(), - lastError: null, - }, - }); + return this.completion.markDownstreamDeliveryDelivered(id); } async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) { - const sentAt = asDateOrNull(data.sentAt) ?? new Date(); - const ackDeadlineAt = asDateOrNull(data.ackDeadlineAt) ?? new Date(sentAt.getTime() + downstreamAckTimeoutMs()); - const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); - if (!delivery) { - throw new NotFoundException('Downstream delivery not found'); - } - const attemptKey = downstreamDeliveryAttemptKey(data); - await this.prisma.cmppDownstreamDeliveryAttempt.upsert({ - where: { attemptKey }, - update: { - connectionId: data.connectionId, - sequenceId: data.sequenceId, - messageId: data.messageId, - sentAt, - ackDeadlineAt, - }, - create: { - deliveryId: data.id, - attemptKey, - attemptNo: delivery.retryCount + 1, - connectionId: data.connectionId, - sequenceId: data.sequenceId, - messageId: data.messageId, - status: 'awaiting_ack', - sentAt, - ackDeadlineAt, - }, - }); - await this.prisma.cmppDownstreamDelivery.updateMany({ - where: { id: data.id, status: { not: 'delivered' } }, - data: { - status: 'awaiting_ack', - sentAt, - ackDeadlineAt, - ackSequenceId: data.sequenceId, - ackMessageId: data.messageId, - connectionId: data.connectionId, - nextRetryAt: null, - lastError: null, - }, - }); - return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); + return this.completion.markDownstreamDeliverySent(data); } async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) { - const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date(); - const acknowledgedMessageId = String(data.messageId ?? '').trim(); - const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); - if (!delivery) { - throw new NotFoundException('Downstream delivery not found'); - } - const attemptKey = downstreamDeliveryAttemptKey(data); - const acknowledgementAccepted = data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0'; - await this.prisma.cmppDownstreamDeliveryAttempt.upsert({ - where: { attemptKey }, - update: { - status: acknowledgementAccepted ? 'acknowledged' : 'rejected', - connectionId: data.connectionId, - sequenceId: data.sequenceId, - messageId: data.messageId, - acknowledgedAt, - ackResult: data.result, - ackDeadlineAt: null, - failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', - errorMessage: acknowledgementAccepted - ? null - : data.result === 0 - ? 'CMPP_DELIVER_RESP Msg_Id=0' - : `CMPP_DELIVER_RESP result=${data.result}`, - }, - create: { - deliveryId: data.id, - attemptKey, - attemptNo: delivery.retryCount + 1, - connectionId: data.connectionId, - sequenceId: data.sequenceId, - messageId: data.messageId, - status: acknowledgementAccepted ? 'acknowledged' : 'rejected', - acknowledgedAt, - ackResult: data.result, - failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', - errorMessage: acknowledgementAccepted - ? null - : data.result === 0 - ? 'CMPP_DELIVER_RESP Msg_Id=0' - : `CMPP_DELIVER_RESP result=${data.result}`, - }, - }); - if (acknowledgementAccepted) { - await this.prisma.cmppDownstreamDelivery.updateMany({ - where: { id: data.id, status: { not: 'delivered' } }, - data: { - status: 'delivered', - acknowledgedAt, - deliveredAt: acknowledgedAt, - ackDeadlineAt: null, - ackResult: data.result, - ackSequenceId: data.sequenceId, - ackMessageId: data.messageId, - connectionId: data.connectionId, - nextRetryAt: null, - lastError: null, - }, - }); - return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); - } - await this.prisma.cmppDownstreamDelivery.updateMany({ - where: { id: data.id, status: { not: 'delivered' } }, - data: { - acknowledgedAt, - ackResult: data.result, - ackSequenceId: data.sequenceId, - ackMessageId: data.messageId, - connectionId: data.connectionId, - }, - }); - if (data.result === 0) { - return this.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid'); - } - return this.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected'); + return this.completion.acknowledgeDownstreamDelivery(data); } async markDownstreamDeliveryFailed( @@ -1838,630 +465,39 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { failureType: GatewayDownstreamFailureType = 'send_failed', attempt?: GatewayDownstreamSentDto, ) { - const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id } }); - if (!delivery) { - throw new NotFoundException('Downstream delivery not found'); - } - if (delivery.status === 'delivered') { - return delivery; - } - if (failureType === 'queue_timeout' && delivery.status !== 'pending') { - return delivery; - } - const retryCount = (delivery.retryCount ?? 0) + 1; - const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost'; - const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false; - const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout'; - const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries(); - const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed'; - if (attempt && (attempt.connectionId || attempt.sequenceId || attempt.messageId)) { - const attemptKey = downstreamDeliveryAttemptKey({ ...attempt, id }); - await this.prisma.cmppDownstreamDeliveryAttempt.upsert({ - where: { attemptKey }, - update: { - status: 'failed', - connectionId: attempt.connectionId, - sequenceId: attempt.sequenceId, - messageId: attempt.messageId, - failureType, - errorMessage: errorMessage ?? 'downstream delivery failed', - ackDeadlineAt: null, - }, - create: { - deliveryId: id, - attemptKey, - attemptNo: delivery.retryCount + 1, - connectionId: attempt.connectionId, - sequenceId: attempt.sequenceId, - messageId: attempt.messageId, - status: 'failed', - sentAt: asDateOrNull(attempt.sentAt), - failureType, - errorMessage: errorMessage ?? 'downstream delivery failed', - }, - }); - } - const updated = await this.prisma.cmppDownstreamDelivery.update({ - where: { id }, - data: { - status: finalFailure ? finalStatus : 'pending', - retryCount, - nextRetryAt: finalFailure ? null : new Date(Date.now() + downstreamRetryDelayMs(retryCount)), - ackDeadlineAt: null, - lastError: errorMessage ?? 'downstream delivery failed', - }, - }); - if (finalFailure) { - await this.prisma.operationLog.create({ - data: { - tenantId: updated.tenantId, - action: 'gateway.downstream_delivery_failed', - resource: 'cmpp_downstream_delivery', - resourceId: updated.id, - detail: { - deliveryType: updated.deliveryType, - applicationId: updated.applicationId, - messageId: updated.messageId, - retryCount, - failureType, - retryEnabled: updated.retryEnabled, - errorMessage: updated.lastError, - }, - }, - }); - } - return updated; + return this.completion.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt); } async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) { - const createdAt = data.deadLetteredAt ? new Date(data.deadLetteredAt) : new Date(); - return this.prisma.gatewaySubmitDeadLetter.upsert({ - where: { streamMessageId: data.streamMessageId }, - update: { - tenantId: data.tenantId, - applicationId: data.applicationId, - channelId: data.channelId, - traceId: data.traceId, - messageId: data.messageId, - submitId: data.submitId, - failureCode: data.failureCode, - failureMessage: data.failureMessage, - attempts: data.attempts, - maxAttempts: data.maxAttempts, - commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined, - rawPayload: data.rawPayload, - }, - create: { - streamMessageId: data.streamMessageId, - tenantId: data.tenantId, - applicationId: data.applicationId, - channelId: data.channelId, - traceId: data.traceId, - messageId: data.messageId, - submitId: data.submitId, - failureCode: data.failureCode, - failureMessage: data.failureMessage, - attempts: data.attempts, - maxAttempts: data.maxAttempts, - commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined, - rawPayload: data.rawPayload, - createdAt, - }, - }); + return this.completion.recordGatewaySubmitDeadLetter(data); } async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) { - const account = String(data.account ?? '').trim(); - if (!account) { - throw new BadRequestException('account is required'); - } - const recoveryStatuses = (this.prisma as PrismaService & { - gatewayDownstreamRecoveryStatus: { - findUnique: (args: Record) => Promise; - upsert: (args: Record) => Promise; - }; - }).gatewayDownstreamRecoveryStatus; - const previous = await recoveryStatuses.findUnique({ - where: { account }, - select: { - state: true, - gatewayInstanceId: true, - lockOwner: true, - failureCategory: true, - lastError: true, - lastSkipReason: true, - }, - }); - const application = await this.prisma.smsApplication.findUnique({ - where: { cmppAccount: account }, - select: { id: true, tenantId: true, name: true }, - }); - const failureCategory = normalizeRecoveryFailureCategory(data); - const updated = await recoveryStatuses.upsert({ - where: { account }, - update: { - tenantId: application?.tenantId ?? null, - applicationId: application?.id ?? null, - gatewayInstanceId: data.gatewayInstanceId ?? null, - state: data.state, - lockOwner: data.lockOwner ?? null, - lockExpiresAt: asDateOrNull(data.lockExpiresAt), - lastAttemptAt: asDateOrNull(data.lastAttemptAt), - lastSuccessAt: asDateOrNull(data.lastSuccessAt), - lastFailureAt: asDateOrNull(data.lastFailureAt), - nextRetryAt: asDateOrNull(data.nextRetryAt), - attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0, - failureCategory, - lastError: data.lastError ?? null, - lastSkipReason: data.lastSkipReason ?? null, - }, - create: { - account, - tenantId: application?.tenantId, - applicationId: application?.id, - gatewayInstanceId: data.gatewayInstanceId, - state: data.state, - lockOwner: data.lockOwner, - lockExpiresAt: asDateOrNull(data.lockExpiresAt), - lastAttemptAt: asDateOrNull(data.lastAttemptAt), - lastSuccessAt: asDateOrNull(data.lastSuccessAt), - lastFailureAt: asDateOrNull(data.lastFailureAt), - nextRetryAt: asDateOrNull(data.nextRetryAt), - attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0, - failureCategory, - lastError: data.lastError, - lastSkipReason: data.lastSkipReason, - }, - include: { - tenant: true, - application: true, - }, - }); - const normalizedUpdated = updated as typeof updated & { - failureCategory?: string | null; - lockOwner?: string | null; - lockExpiresAt?: Date | null; - }; - if (hasRecoveryAuditStateChanged(previous, updated)) { - await this.prisma.operationLog.create({ - data: { - tenantId: updated.tenantId ?? undefined, - action: 'gateway.downstream_recovery_status_changed', - resource: 'gateway_downstream_recovery_status', - resourceId: updated.id, - detail: { - account, - previousState: previous?.state ?? null, - state: updated.state, - gatewayInstanceId: updated.gatewayInstanceId, - lockOwner: normalizedUpdated.lockOwner, - attemptCount: updated.attemptCount, - nextRetryAt: updated.nextRetryAt, - failureCategory: normalizedUpdated.failureCategory, - applicationId: updated.applicationId, - applicationName: application?.name, - lastError: updated.lastError, - lastSkipReason: updated.lastSkipReason, - }, - }, - }); - } - return updated; + return this.completion.recordGatewayDownstreamRecoveryStatus(data); } async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) { - const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } }); - if (!deadLetter) { - throw new NotFoundException('Gateway提交异常记录不存在'); - } - if (deadLetter.status !== 'pending') { - throw new BadRequestException('该提交异常当前状态不允许重新入队'); - } - if (!data.confirmedNotSubmitted) { - throw new BadRequestException('请确认运营商未接收该短信后再重新入队'); - } - const reason = String(data.reason ?? '').trim(); - if (reason.length < 5 || reason.length > 500) { - throw new BadRequestException('请填写5至500字的重新入队原因'); - } - if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) { - throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand'); - } - if (deadLetter.manualRetryCount >= 3) { - throw new BadRequestException('该提交异常已达到人工重新入队次数上限'); - } - const message = deadLetter.messageId - ? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } }) - : null; - if (message && ( - message.submitStatus === 'accepted' - || ['submitted', 'delivered', 'unknown'].includes(message.status) - || ['delivered', 'unknown'].includes(message.receiptStatus ?? '') - )) { - throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队'); - } - const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim(); - if (!commandChannelId) { - throw new BadRequestException('该提交异常缺少通道信息'); - } - const channel = await this.prisma.smsChannel.findUnique({ - where: { id: commandChannelId }, - include: { connectionStates: true }, - }); - if (!channel || channel.status !== 'active') { - throw new BadRequestException('原通道不存在或已停用,不能重新入队'); - } - if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) { - throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道'); - } - const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { id, status: 'pending' }, - data: { status: 'requeueing' }, - }); - if (claimed.count !== 1) { - throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试'); - } - const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1); - let retryStreamMessageId: string; - try { - const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey); - if (!publishedStreamMessageId) { - throw new Error('Gateway提交异常重新入队未返回Stream消息编号'); - } - retryStreamMessageId = publishedStreamMessageId; - } catch (error) { - await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { id, status: 'requeueing' }, - data: { status: 'pending' }, - }); - throw error; - } - const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { id, status: 'requeueing' }, - data: { - status: 'requeued', - manualRetryCount: { increment: 1 }, - lastRetryStreamId: retryStreamMessageId, - lastRetriedAt: new Date(), - }, - }); - const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } }); - if (!updated) { - throw new NotFoundException('Gateway提交异常记录不存在'); - } - if (finalized.count !== 1 && updated.status !== 'resolved') { - throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果'); - } - await this.prisma.operationLog.create({ - data: { - tenantId: updated.tenantId ?? undefined, - userId: data.operatorId, - action: 'gateway.submit_dead_letter_requeue', - resource: 'gateway_submit_dead_letter', - resourceId: updated.id, - detail: { - streamMessageId: updated.streamMessageId, - retryStreamMessageId, - submitId: updated.submitId, - messageId: updated.messageId, - reason, - confirmedNotSubmitted: true, - }, - }, - }); - return updated; + return this.completion.requeueGatewaySubmitDeadLetter(id, data); } async recoverStaleGatewaySubmitRequeues(now = new Date()) { - const staleCutoff = new Date(now.getTime() - positiveInteger( - process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS, - DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, - )); - const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({ - where: { status: 'requeueing', updatedAt: { lt: staleCutoff } }, - orderBy: { updatedAt: 'asc' }, - take: 100, - }); - let recovered = 0; - let failed = 0; - for (const deadLetter of stale) { - if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) { - await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt }, - data: { status: 'pending' }, - }); - failed += 1; - continue; - } - const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt }, - data: { status: 'requeue_recovering' }, - }); - if (claimed.count !== 1) continue; - try { - const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1); - const retryStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey); - if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号'); - const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { id: deadLetter.id, status: 'requeue_recovering' }, - data: { - status: 'requeued', - manualRetryCount: { increment: 1 }, - lastRetryStreamId: retryStreamMessageId, - lastRetriedAt: new Date(), - }, - }); - if (finalized.count === 1) { - recovered += 1; - await this.prisma.operationLog.create({ - data: { - tenantId: deadLetter.tenantId ?? undefined, - action: 'gateway.submit_dead_letter_requeue_recovered', - resource: 'gateway_submit_dead_letter', - resourceId: deadLetter.id, - detail: { retryStreamMessageId, requeueKey }, - }, - }); - } - } catch (error) { - failed += 1; - await this.prisma.gatewaySubmitDeadLetter.updateMany({ - where: { id: deadLetter.id, status: 'requeue_recovering' }, - data: { status: 'requeueing' }, - }); - this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`); - } - } - return { recovered, failed }; + return this.completion.recoverStaleGatewaySubmitRequeues(now); } async requeueDownstreamDelivery(id: string) { - const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ - where: { id }, - include: { application: { select: { cmppAccount: true } } }, - }); - if (!delivery) { - throw new NotFoundException('Downstream delivery not found'); - } - if (delivery.status === 'awaiting_ack') { - throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投'); - } - const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null; - if (!payload) { - throw new BadRequestException('下游投递记录缺少可重放 payload'); - } - const path = - delivery.deliveryType === 'receipt' - ? '/downstream/receipt' - : delivery.deliveryType === 'uplink' - ? '/downstream/uplink' - : null; - if (!path) { - throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`); - } - - const requestPayload = { - deliveryId: delivery.id, - account: String(payload.account ?? delivery.application?.cmppAccount ?? ''), - ...payload, - }; - const retriedAt = new Date(); - const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({ - where: { - id: delivery.id, - status: delivery.status, - updatedAt: delivery.updatedAt, - }, - data: { - status: 'manual_requeueing', - retryCount: 0, - manualRetryCount: { increment: 1 }, - lastRetriedAt: retriedAt, - nextRetryAt: null, - sentAt: null, - acknowledgedAt: null, - ackDeadlineAt: null, - ackResult: null, - ackSequenceId: null, - ackMessageId: null, - connectionId: null, - deliveredAt: null, - lastError: null, - }, - }); - if (claimed.count !== 1) { - throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试'); - } - await this.prisma.operationLog.create({ - data: { - tenantId: delivery.tenantId, - action: 'gateway.downstream_delivery_requeue', - resource: 'cmpp_downstream_delivery', - resourceId: delivery.id, - detail: { - deliveryType: delivery.deliveryType, - applicationId: delivery.applicationId, - messageId: delivery.messageId, - previousStatus: delivery.status, - previousRetryCount: delivery.retryCount, - manualRetryCount: (delivery.manualRetryCount ?? 0) + 1, - lastRetriedAt: retriedAt, - }, - }, - }); - try { - const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult; - if (result.sent || result.delivered) { - return this.markDownstreamDeliverySent({ id: delivery.id, ...result }); - } - return this.markDownstreamDeliveryFailed( - delivery.id, - downstreamControlFailureMessage(result), - result.retryable === false ? 'unrecoverable' : 'send_failed', - ); - } catch (error) { - return this.markDownstreamDeliveryFailed( - delivery.id, - error instanceof Error ? error.message : 'Gateway control delivery failed', - ); - } + return this.completion.requeueDownstreamDelivery(id); } async recoverStaleDownstreamManualRequeues(now = new Date()) { - const staleCutoff = new Date(now.getTime() - positiveInteger( - process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, - DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, - )); - const stale = await this.prisma.cmppDownstreamDelivery.findMany({ - where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } }, - select: { id: true, updatedAt: true }, - orderBy: { updatedAt: 'asc' }, - take: 500, - }); - let recovered = 0; - for (const delivery of stale) { - const updated = await this.prisma.cmppDownstreamDelivery.updateMany({ - where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt }, - data: { - status: 'pending', - nextRetryAt: null, - lastError: '人工重投进程中断,已恢复为待投递', - }, - }); - recovered += updated.count; - } - return { recovered }; + return this.completion.recoverStaleDownstreamManualRequeues(now); } async batchRequeueDownstreamDeliveries(ids: string[]) { - const uniqueIds = [...new Set(ids.filter(Boolean))]; - if (uniqueIds.length === 0) { - throw new BadRequestException('请选择至少一条下游投递记录'); - } - const results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }> = []; - for (const id of uniqueIds) { - try { - await this.requeueDownstreamDelivery(id); - results.push({ id, status: 'success' }); - } catch (error) { - results.push({ - id, - status: 'failed', - errorMessage: error instanceof Error ? error.message : '批量重投失败', - }); - } - } - return { - total: uniqueIds.length, - successCount: results.filter((item) => item.status === 'success').length, - failedCount: results.filter((item) => item.status === 'failed').length, - results, - }; + return this.completion.batchRequeueDownstreamDeliveries(ids); } async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) { - const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({ - where: { id: candidateId, uplinkMessageId }, - include: { - application: { select: { id: true, name: true, cmppAccount: true } }, - messageRecord: { select: { id: true, messageId: true, content: true } }, - uplinkMessage: true, - }, - }); - if (!candidate) { - throw new NotFoundException('Uplink match candidate not found'); - } - if (candidate.status === 'rejected') { - throw new BadRequestException('该候选已被排除,不能认领'); - } - if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') { - throw new BadRequestException('该上行记录已完成匹配,不能重复认领'); - } - const claimedAt = new Date(); - const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null; - const [updatedUplink] = await this.prisma.$transaction([ - this.prisma.smsUplinkMessage.update({ - where: { id: uplinkMessageId }, - data: { - tenantId: candidate.tenantId, - applicationId: candidate.applicationId, - messageRecordId: candidate.messageRecordId, - messageId, - matchStatus: 'matched', - matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`, - }, - }), - this.prisma.smsUplinkMatchCandidate.updateMany({ - where: { - uplinkMessageId, - id: { not: candidate.id }, - status: 'pending', - }, - data: { status: 'rejected' }, - }), - this.prisma.smsUplinkMatchCandidate.update({ - where: { id: candidate.id }, - data: { - status: 'claimed', - claimedAt, - claimedById: operatorId, - }, - }), - this.prisma.operationLog.create({ - data: { - tenantId: candidate.tenantId, - userId: operatorId, - action: 'gateway.uplink_manual_claim', - resource: 'sms_uplink_message', - resourceId: uplinkMessageId, - detail: { - candidateId: candidate.id, - applicationId: candidate.applicationId, - applicationName: candidate.application.name, - messageRecordId: candidate.messageRecordId, - messageId, - matchSource: candidate.matchSource, - phoneNumber: candidate.uplinkMessage.phoneNumber, - destId: candidate.uplinkMessage.destId, - }, - }, - }), - ]); - - await this.queueAndTryDownstreamDelivery({ - tenantId: candidate.tenantId, - applicationId: candidate.applicationId, - messageRecordId: candidate.messageRecordId, - messageId, - deliveryType: 'uplink', - payload: { - messageId, - applicationId: candidate.applicationId, - phoneNumber: candidate.uplinkMessage.phoneNumber, - destId: candidate.uplinkMessage.destId, - content: candidate.uplinkMessage.content, - receivedAt: candidate.uplinkMessage.receivedAt.toISOString(), - manualClaim: true, - uplinkMessageId, - }, - }); - return this.prisma.smsUplinkMessage.findUnique({ - where: { id: updatedUplink.id }, - include: { - tenant: true, - application: true, - channel: true, - messageRecord: { include: { application: true } }, - matchCandidates: { - include: { - tenant: true, - application: true, - messageRecord: { include: { application: true, tenant: true, channel: true } }, - }, - orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }], - }, - }, - }); + return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId); } private async queueAndTryDownstreamDelivery(data: { @@ -2472,109 +508,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { deliveryType: 'receipt' | 'uplink'; payload: Record; }) { - if (!data.applicationId) { - return null; - } - const application = await this.prisma.smsApplication.findUnique({ - where: { id: data.applicationId }, - select: { - cmppAccount: true, - interfaceEnabled: true, - status: true, - downstreamReceiptRetryEnabled: true, - downstreamUplinkRetryEnabled: true, - httpConfig: true, - }, - }); - const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling'; - if (deliveryAllowed) { - 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)}`); - } - } - if (application?.interfaceEnabled !== true) { - return null; - } - const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; - const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId - ? `receipt:${data.messageRecordId}` - : data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string' - ? `uplink:${data.payload.uplinkMessageId}` - : null; - let delivery; - try { - delivery = await this.prisma.cmppDownstreamDelivery.create({ - data: { - tenantId: data.tenantId, - applicationId: data.applicationId, - messageRecordId: data.messageRecordId, - messageId: data.messageId, - dedupeKey, - deliveryType: data.deliveryType, - payload, - retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink' - ? application?.downstreamUplinkRetryEnabled ?? true - : application?.downstreamReceiptRetryEnabled ?? true), - status: deliveryAllowed ? 'pending' : 'abandoned', - lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送', - }, - }); - } catch (error) { - if ( - dedupeKey - && error instanceof Prisma.PrismaClientKnownRequestError - && error.code === 'P2002' - ) { - const existing = await this.prisma.cmppDownstreamDelivery.findUnique({ - where: { dedupeKey }, - }); - if (existing) { - this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({ - deliveryType: data.deliveryType, - messageRecordId: data.messageRecordId, - messageId: data.messageId, - dedupeKey, - deliveryId: existing.id, - })}`); - return existing; - } - } - throw error; - } - if (!deliveryAllowed) { - return delivery; - } - try { - const result = await this.postGatewayControl( - data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink', - { deliveryId: delivery.id, ...payload }, - ) as GatewayControlDeliveryResult; - if (result.sent || result.delivered) { - await this.markDownstreamDeliverySent({ id: delivery.id, ...result }); - } else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') { - return delivery; - } else { - await this.markDownstreamDeliveryFailed( - delivery.id, - downstreamControlFailureMessage(result), - result.retryable === false ? 'unrecoverable' : 'send_failed', - { id: delivery.id, ...result }, - ); - } - } catch (error) { - await this.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed'); - } - return delivery; + return this.completion.queueAndTryDownstreamDelivery(data); } private async resolveUplinkMatch( @@ -2588,251 +522,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { matchReason: string; candidates: UplinkMatchCandidateInput[]; }> { - if (data.messageId) { - const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }); - if (message?.tenantId) { - return { - tenantId: message.tenantId, - applicationId: message.applicationId ?? undefined, - messageRecordId: message.id, - matchStatus: message.applicationId ? 'matched' : 'unmatched', - matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用', - candidates: [], - }; - } - } - - const accessNumber = data.destId || channel.srcId || ''; - const accessRoutes = accessNumber - ? await this.prisma.channelRouteRule.findMany({ - where: { - applicationId: { not: null }, - status: 'active', - group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } }, - }, - select: { applicationId: true }, - take: 10, - }) - : []; - const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))]; - const accessApplications = accessApplicationIds.length > 0 - ? await this.prisma.smsApplication.findMany({ - where: { id: { in: accessApplicationIds }, status: 'active' }, - select: { id: true, tenantId: true, name: true }, - }) - : []; - if (accessApplications.length === 1) { - return { - tenantId: accessApplications[0].tenantId, - applicationId: accessApplications[0].id, - matchStatus: 'matched', - matchReason: '接入号唯一匹配应用', - candidates: [], - }; - } - if (accessApplications.length > 1) { - return { - matchStatus: 'ambiguous', - matchReason: '接入号匹配多个应用', - candidates: accessApplications.map((application) => ({ - tenantId: application.tenantId, - applicationId: application.id, - matchSource: 'access_number', - confidence: 70, - reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`, - })), - }; - } - - const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72); - const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000); - const recentMessages = await this.prisma.smsMessageRecord.findMany({ - where: { - phoneNumber: data.phoneNumber, - tenantId: { not: null }, - applicationId: { not: null }, - submittedAt: { gte: since }, - }, - orderBy: { submittedAt: 'desc' }, - take: 2, - }); - const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId); - if (matchableRecentMessages.length === 1) { - return { - tenantId: matchableRecentMessages[0].tenantId ?? undefined, - applicationId: matchableRecentMessages[0].applicationId ?? undefined, - messageRecordId: matchableRecentMessages[0].id, - matchStatus: 'matched', - matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`, - candidates: [], - }; - } - if (matchableRecentMessages.length > 1) { - return { - matchStatus: 'ambiguous', - matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`, - candidates: matchableRecentMessages - .map((message) => ({ - tenantId: String(message.tenantId), - applicationId: String(message.applicationId), - messageRecordId: message.id, - matchSource: 'phone_window', - confidence: 55, - reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`, - })), - }; - } - return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] }; + return this.completion.resolveUplinkMatch(data, channel); } async authenticateInboundApplication(data: GatewayInboundAuthDto) { - const application = await this.findInboundApplication(data.account); - if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') { - throw new BadRequestException('CMPP account is invalid or disabled'); - } - if (!application.interfaceEnabled) { - throw new BadRequestException('CMPP interface is disabled for this application'); - } - if (application.tenant.certificationStatus !== 'approved') { - throw new BadRequestException('Enterprise certification is not approved'); - } - if (!matchesApplicationSecret(data, application.secretHash)) { - throw new BadRequestException('CMPP account or password is invalid'); - } - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { - throw new BadRequestException('CMPP source IP is not in application allowlist'); - } - return { - applicationId: application.id, - tenantId: application.tenantId, - account: application.cmppAccount, - enterpriseCode: application.cmppEnterpriseCode, - passwordCipher: application.secretHash, - maxConnections: application.cmppMaxConnections, - status: 'authenticated', - }; + return this.submission.authenticateInboundApplication(data); } async submitInboundMessage(data: GatewayInboundSubmitDto) { - const phoneNumbers = data.phoneNumbers?.length - ? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim()) - : data.phoneNumber - ? [data.phoneNumber.trim()] - : []; - if (phoneNumbers.length === 0) { - throw new BadRequestException('CMPP submit phone number is invalid'); - } - - const application = await this.findInboundApplication(data.account); - if (!application) { - throw new BadRequestException('CMPP account is invalid'); - } - if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) { - throw new BadRequestException('CMPP account is disabled for new submissions'); - } - if (data.longMessage) { - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { - throw new BadRequestException('CMPP source IP is not in application allowlist'); - } - validateInboundApplicationSrcId(data.srcId, application); - const collection = await this.collectInboundLongMessageFragment(data, application, phoneNumbers); - if (collection.response) { - return collection.response; - } - if (!collection.complete) { - return { - accepted: true, - tenantId: application.tenantId, - applicationId: application.id, - messageId: collection.messageId, - status: 'fragment_pending', - fragmentPending: true, - receivedSegments: collection.receivedSegments, - segmentTotal: data.longMessage.total, - phoneCount: phoneNumbers.length, - messages: phoneNumbers.map((phoneNumber) => ({ - phoneNumber, - messageId: collection.messageId, - status: 'fragment_pending', - })), - }; - } - try { - const response = await this.recoverCompletedInboundLongMessageResponse( - collection.messageId, - phoneNumbers, - ) ?? await this.submitCompleteInboundMessage({ - ...data, - content: collection.content, - sequenceId: collection.sequenceId, - longMessage: undefined, - }, phoneNumbers, application, collection.messageId); - await this.prisma.cmppInboundLongMessage.update({ - where: { id: collection.groupId }, - data: { - status: 'completed', - response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue, - completedAt: new Date(), - }, - }); - return response; - } catch (error) { - await this.prisma.cmppInboundLongMessage.update({ - where: { id: collection.groupId }, - data: { - status: 'rejected', - completedAt: new Date(), - }, - }).catch(() => undefined); - throw error; - } - } - return this.submitCompleteInboundMessage(data, phoneNumbers, application); + return this.submission.submitInboundMessage(data); } private async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) { - const existing = await this.prisma.smsMessageRecord.findMany({ - where: { - cmppSubmitGroupMessageId: messageId, - phoneNumber: { in: phoneNumbers }, - }, - select: { - id: true, - tenantId: true, - applicationId: true, - batchTaskId: true, - messageId: true, - phoneNumber: true, - status: true, - errorCode: true, - }, - }); - const byPhone = new Map(existing.map((item) => [item.phoneNumber, item])); - const ordered = phoneNumbers.map((phoneNumber) => byPhone.get(phoneNumber)); - if (ordered.some((item) => !item)) { - return null; - } - const messages = ordered.map((item, index) => ({ - phoneNumber: phoneNumbers[index], - messageId: item!.messageId, - messageRecordId: item!.id, - taskId: item!.batchTaskId ?? '', - status: item!.status, - })); - const first = ordered[0]!; - const dailyLimitRejected = ordered.every((item) => item!.errorCode === 'DAILY_LIMIT'); - return { - accepted: !dailyLimitRejected, - tenantId: first.tenantId ?? '', - applicationId: first.applicationId ?? '', - taskId: first.batchTaskId ?? '', - messageId: first.messageId, - messageRecordId: first.id, - status: dailyLimitRejected ? 'rejected' : 'accepted', - result: dailyLimitRejected ? 8 : undefined, - phoneCount: messages.length, - messages, - }; + return this.submission.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers); } private async submitCompleteInboundMessage( @@ -2841,83 +543,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { application: Awaited>, requestedGroupMessageId?: string, ) { - if (!application) { - throw new BadRequestException('CMPP account is invalid'); - } - const persisted = requestedGroupMessageId - ? await this.prisma.smsMessageRecord.findMany({ - where: { - cmppSubmitGroupMessageId: requestedGroupMessageId, - phoneNumber: { in: phoneNumbers }, - }, - select: { - id: true, - tenantId: true, - applicationId: true, - batchTaskId: true, - messageId: true, - phoneNumber: true, - status: true, - errorCode: true, - }, - }) - : []; - const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item])); - const phoneRejections = await this.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers); - const missingPhoneCount = phoneNumbers.filter((phoneNumber) => ( - !persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber) - )).length; - const dailyQuota = missingPhoneCount > 0 - ? await this.tryReserveDailySendQuota(application.id, missingPhoneCount) - : { reserved: true, dailyLimit: application.dailyLimit ?? 100000 }; - const dailyLimitRejection = dailyQuota.reserved - ? undefined - : { - code: 'DAILY_LIMIT', - reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`, - }; - - const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`; - const submissions = phoneNumbers.map((phoneNumber, index) => ({ - phoneNumber, - persisted: persistedByPhone.get(phoneNumber), - receiptRejection: phoneRejections.get(phoneNumber), - messageId: persistedByPhone.get(phoneNumber)?.messageId - ?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`), - })); - const results: GatewayInboundSingleSubmitResult[] = []; - const concurrency = 10; - for (let offset = 0; offset < submissions.length; offset += concurrency) { - const batch = submissions.slice(offset, offset + concurrency); - results.push(...await Promise.all(batch.map((submission) => submission.persisted - ? Promise.resolve({ - accepted: submission.persisted.errorCode !== 'DAILY_LIMIT', - tenantId: submission.persisted.tenantId ?? application.tenantId, - applicationId: submission.persisted.applicationId ?? application.id, - taskId: submission.persisted.batchTaskId ?? '', - messageId: submission.persisted.messageId, - messageRecordId: submission.persisted.id, - status: submission.persisted.status, - }) - : this.submitInboundSingleMessage({ - ...data, - phoneNumber: submission.phoneNumber, - phoneNumbers: undefined, - }, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection)))); - } - const first = results[0]; - return { - ...first, - result: dailyLimitRejection ? 8 : undefined, - phoneCount: results.length, - messages: results.map((result, index) => ({ - phoneNumber: phoneNumbers[index], - messageId: result.messageId, - messageRecordId: result.messageRecordId, - taskId: result.taskId, - status: result.status, - })), - }; + return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId); } private async collectInboundLongMessageFragment( @@ -2925,166 +551,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { application: NonNullable>>, phoneNumbers: string[], ) { - const fragment = data.longMessage; - if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535 - || !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255 - || !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total - || !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) { - throw new BadRequestException('CMPP long message fragment metadata is invalid'); - } - const groupKey = createHash('sha256').update(JSON.stringify({ - applicationId: application.id, - account: data.account, - srcId: data.srcId?.trim() ?? '', - phoneNumbers, - reference: fragment.reference, - total: fragment.total, - format: fragment.format, - })).digest('hex'); - const contentHash = createHash('sha256').update(data.content).digest('hex'); - const now = new Date(); - const expiresAt = new Date(now.getTime() + positiveInteger( - process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS, - 300, - ) * 1000); - - return this.prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`; - await tx.cmppInboundLongMessage.updateMany({ - where: { - groupKey, - status: { in: ['collecting', 'processing'] }, - expiresAt: { lte: now }, - }, - data: { status: 'expired', completedAt: now }, - }); - - const recent = await tx.cmppInboundLongMessage.findFirst({ - where: { - groupKey, - expiresAt: { gt: now }, - }, - include: { segments: { orderBy: { segmentIndex: 'asc' } } }, - orderBy: { createdAt: 'desc' }, - }); - const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index); - if (recent && ['completed', 'rejected'].includes(recent.status) - && matchingRecentSegment?.contentHash === contentHash - && matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) { - return { - complete: recent.status === 'completed', - groupId: recent.id, - messageId: recent.messageId, - receivedSegments: recent.segments.length, - response: recent.response as any, - content: recent.segments.map((item) => item.content).join(''), - sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId), - }; - } - - let group = recent && ['collecting', 'processing'].includes(recent.status) ? recent : null; - if (!group) { - group = await tx.cmppInboundLongMessage.create({ - data: { - tenantId: application.tenantId, - applicationId: application.id, - groupKey, - account: data.account, - srcId: data.srcId?.trim() || null, - phoneNumbers, - concatReference: fragment.reference, - segmentTotal: fragment.total, - msgFmt: fragment.format, - messageId: `MSG-${randomUUID()}`, - expiresAt, - }, - include: { segments: { orderBy: { segmentIndex: 'asc' } } }, - }); - } - if (group.status === 'processing') { - const processingStaleMs = positiveInteger( - process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, - DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, - ) * 1000; - const complete = group.segments.length === fragment.total - && group.segments.every((item, index) => item.segmentIndex === index + 1); - if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) { - await tx.cmppInboundLongMessage.update({ - where: { id: group.id }, - data: { status: 'processing', expiresAt }, - }); - return { - complete: true, - groupId: group.id, - messageId: group.messageId, - receivedSegments: group.segments.length, - response: null, - content: group.segments.map((item) => item.content).join(''), - sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId), - }; - } - return { - complete: false, - groupId: group.id, - messageId: group.messageId, - receivedSegments: group.segments.length, - response: group.response as any, - content: '', - sequenceId: undefined, - }; - } - - const existing = group.segments.find((item) => item.segmentIndex === fragment.index); - if (existing && (existing.contentHash !== contentHash - || existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) { - throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`); - } - if (!existing) { - await tx.cmppInboundLongMessageSegment.create({ - data: { - groupId: group.id, - segmentIndex: fragment.index, - sequenceId: data.sequenceId == null ? null : String(data.sequenceId), - content: data.content, - contentHash, - }, - }); - } - const segments = await tx.cmppInboundLongMessageSegment.findMany({ - where: { groupId: group.id }, - orderBy: { segmentIndex: 'asc' }, - }); - const complete = segments.length === fragment.total - && segments.every((item, index) => item.segmentIndex === index + 1); - if (complete) { - await tx.cmppInboundLongMessage.update({ - where: { id: group.id }, - data: { status: 'processing', expiresAt }, - }); - } - return { - complete, - groupId: group.id, - messageId: group.messageId, - receivedSegments: segments.length, - response: null, - content: complete ? segments.map((item) => item.content).join('') : '', - sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId), - }; - }); + return this.submission.collectInboundLongMessageFragment(data, application, phoneNumbers); } async expireInboundLongMessages(now = new Date()) { - return this.prisma.cmppInboundLongMessage.updateMany({ - where: { - status: { in: ['collecting', 'processing'] }, - expiresAt: { lte: now }, - }, - data: { - status: 'expired', - completedAt: now, - }, - }); + return this.submission.expireInboundLongMessages(now); } private async submitInboundSingleMessage( @@ -3094,340 +565,35 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { synchronousRejection?: { code: string; reason: string }, receiptRejection?: { code: string; reason: string }, ) { - const application = await this.findInboundApplication(data.account); - if (!application) { - throw new BadRequestException('CMPP account is invalid'); - } - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { - throw new BadRequestException('CMPP source IP is not in application allowlist'); - } - const clientSrcId = validateInboundApplicationSrcId(data.srcId, application); - const template = await this.resolveInboundTemplateCandidate(application.id, data.content); - const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {}; - const unitPrice = moneyToNumber(application.customerUnitPrice); - const queuePriority = normalizeQueuePriority(application.queuePriority); - const billing = this.billing.estimateSmsCost({ - tenantId: application.tenantId, - applicationId: application.id, - content: data.content, - phoneCount: 1, - unitPrice, - }); - const task = await this.prisma.smsBatchTask.create({ - data: { - tenantId: application.tenantId, - applicationId: application.id, - templateId: template?.id, - taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`, - sourceType: 'cmpp', - content: data.content, - phoneTotal: 1, - status: synchronousRejection ? 'rejected' : 'validating', - auditStatus: synchronousRejection ? 'rejected' : undefined, - rejectReason: synchronousRejection?.reason, - progressTotal: 1, - }, - }); - await this.prisma.smsApiRequest.create({ - data: { - tenantId: application.tenantId, - batchTaskId: task.id, - requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`, - sourceIp: data.remoteIp, - userAgent: 'cmpp-gateway', - payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account }, - status: synchronousRejection ? 'rejected' : 'accepted', - }, - }); - const message = await this.prisma.smsMessageRecord.create({ - data: { - tenantId: application.tenantId, - batchTaskId: task.id, - applicationId: application.id, - templateId: template?.id, - messageId, - phoneNumber: data.phoneNumber, - content: data.content, - billingUnits: billing.billingUnitsPerMessage, - unitPrice: receiptRejection ? 0 : billing.unitPrice, - amountCents: receiptRejection ? 0 : billing.amountCents, - queuePriority, - cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), - cmppSubmitGroupMessageId: submitGroupMessageId, - clientSrcId, - applicationExtension: application.cmppApplicationExtension, - status: synchronousRejection ? 'rejected' : 'validating', - errorCode: synchronousRejection?.code, - errorMessage: synchronousRejection?.reason, - }, - }); + return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection); + } - if (synchronousRejection) { - return { - accepted: false, - tenantId: application.tenantId, - applicationId: application.id, - taskId: task.id, - messageId: message.messageId, - messageRecordId: message.id, - status: 'rejected', - }; - } - - const reject = async (code: string, reason: string) => { - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason }, - }); - await this.recordCmppFailureReceipt(message, code, reason); - }; - const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => { - const drainage = await this.resolveDrainageInfoMatch(options.signatureId, data.content); - const drainageInfoId = drainage?.id; - const drainageReason = drainageRejectionReason(drainage); - if (drainageReason) { - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { drainageInfoId, signatureId: options.signatureId }, - }); - await reject('DRAINAGE_NOT_APPROVED', drainageReason); - return; - } - const risk = await this.riskReview.evaluateTask({ - tenantId: application.tenantId, - applicationId: application.id, - templateId: options.templateId, - content: data.content, - variables: options.templateId ? templateVariables : undefined, - phones: [data.phoneNumber], - sourceType: 'cmpp', - }); - if (risk.status === 'rejected') { - await reject('RISK', risk.reason || '短信被风控拒绝'); - return; - } - if (risk.status === 'pending_review') { - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { - status: 'pending_review', - reviewTaskId: risk.task?.id, - signatureId: options.signatureId, - drainageInfoId, - }, - }); - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason }, - }); - return; - } - const accountCheck = await this.billing.checkAccount({ - tenantId: application.tenantId, - amountCents: billing.amountCents, - }); - if (!accountCheck.canSend) { - await reject('BALANCE', '企业账户余额不足'); - return; - } - if (billing.amountCents > 0) { - await this.billing.freeze({ - tenantId: application.tenantId, - amountCents: billing.amountCents, - relatedType: 'sms_batch_task', - relatedId: task.id, - remark: 'CMPP 入站短信冻结', - }); - } - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { status: 'queued', signatureId: options.signatureId, drainageInfoId }, - }); - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' }, - }); - await this.enqueueBatchTask(task.id); - }; - if (receiptRejection) { - await reject(receiptRejection.code, receiptRejection.reason); - } else if (application.status !== 'active' || application.tenant.status !== 'active') { - await reject('ACCOUNT', '企业或短信应用已停用'); - } else if (!application.interfaceEnabled) { - await reject('INTERFACE', '短信应用 CMPP 接口已停用'); - } else if (application.tenant.certificationStatus !== 'approved') { - await reject('CERT', '企业认证未通过'); - } else if (!template && application.templateMismatchMode === 'manual_review') { - const signature = await this.resolveInboundSignatureCandidate(application.id, data.content); - if (!signature) { - await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); - } else { - const drainage = await this.resolveDrainageInfoMatch(signature.id, data.content); - const drainageReason = drainageRejectionReason(drainage); - if (drainageReason) { - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { drainageInfoId: drainage?.id, signatureId: signature.id }, - }); - await reject('DRAINAGE_NOT_APPROVED', drainageReason); - return { - accepted: true, - tenantId: application.tenantId, - applicationId: application.id, - messageId, - messageRecordId: message.id, - taskId: task.id, - status: 'rejected', - }; - } - const risk = await this.riskReview.evaluateTask({ - tenantId: application.tenantId, - applicationId: application.id, - content: data.content, - phones: [data.phoneNumber], - sourceType: 'cmpp', - }); - if (risk.status === 'rejected') { - await reject('RISK', risk.reason || '短信被风控拒绝'); - } else { - const accountCheck = await this.billing.checkAccount({ - tenantId: application.tenantId, - amountCents: billing.amountCents, - }); - if (!accountCheck.canSend) { - await reject('BALANCE', '企业账户余额不足'); - } else { - if (billing.amountCents > 0) { - await this.billing.freeze({ - tenantId: application.tenantId, - amountCents: billing.amountCents, - relatedType: 'sms_batch_task', - relatedId: task.id, - remark: 'CMPP 模板不匹配待审核短信冻结', - }); - } - const reviewTask = risk.status === 'pending_review' && risk.task - ? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id) - : await this.riskReview.aggregateTemplateMismatch({ - tenantId: application.tenantId, - applicationId: application.id, - account: data.account, - messageRecordId: message.id, - signatureId: signature.id, - content: data.content, - }); - await this.prisma.smsBatchTask.update({ - where: { id: task.id }, - data: { - status: 'pending_review', - riskTaskId: reviewTask?.id, - auditStatus: 'pending', - reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核', - }, - }); - } - } - } - } else if (!template && application.templateMismatchMode === 'direct_send') { - const signature = await this.resolveInboundSignatureCandidate(application.id, data.content); - if (!signature) { - await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); - } else { - await queueAfterRiskChecks({ signatureId: signature.id }); - } - } else if (!template) { - await reject('TEMPLATE', '短信内容未匹配到已报备模板'); - } else if (template.auditStatus !== 'approved') { - await reject('TEMPLATE', '短信模板尚未审核通过'); - } else if (!template.signature || template.signature.auditStatus !== 'approved') { - await reject('SIGNATURE', '短信签名尚未审核通过'); - } else { - await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id }); - } - return { - accepted: true, - tenantId: application.tenantId, - applicationId: application.id, - taskId: task.id, - messageId: message.messageId, - messageRecordId: message.id, - status: 'accepted', - }; + /** + * CMPP 单号码提交必须在生成最终入队决定前原子占用号码频次。 + * 频次规则是直接拒绝,因此优先级高于其他规则产生的待人工审核结果。 + */ + private async evaluateRiskWithPhoneFrequency(input: { + tenantId: string; + applicationId: string; + templateId?: string; + content: string; + variables?: Record; + phoneNumber: string; + sourceType: 'cmpp'; + }) { + return this.submission.evaluateRiskWithPhoneFrequency(input); } async markUnknownTimeout(data: TimeoutUnknownDto) { - const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS); - const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000); - const candidates = await this.prisma.smsMessageRecord.findMany({ - where: { - tenantId: { not: null }, - status: { in: ['submitted', 'unknown'] }, - submittedAt: { lte: cutoff }, - }, - select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true }, - take: 10000, - }); - const timedOutTaskIds = new Set(); - let timeout = 0; - for (const candidate of candidates) { - if (!candidate.tenantId) continue; - const transitioned = await this.prisma.smsMessageRecord.updateMany({ - where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } }, - data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` }, - }); - if (transitioned.count !== 1) continue; - timeout += 1; - await this.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`); - if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId); - } - for (const batchTaskId of timedOutTaskIds) { - await this.refreshTaskProgress(batchTaskId); - } - return { timeout }; + return this.completion.markUnknownTimeout(data); } async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) { - const cutoff = new Date(Date.now() - olderThanHours * 60 * 60_000); - const expired = await this.prisma.cmppDownstreamDelivery.findMany({ - where: { - status: 'pending', - OR: [ - { lastRetriedAt: null, createdAt: { lte: cutoff } }, - { lastRetriedAt: { lte: cutoff } }, - ], - }, - select: { id: true }, - take: 500, - }); - for (const delivery of expired) { - await this.markDownstreamDeliveryFailed( - delivery.id, - `下游投递排队超过 ${olderThanHours} 小时,系统自动终止重试`, - 'queue_timeout', - ); - } - return { failed: expired.length }; + return this.completion.markExpiredDownstreamDeliveries(olderThanHours); } private async runReceiptTimeoutScan() { - if (this.receiptTimeoutScanRunning) return; - this.receiptTimeoutScanRunning = true; - try { - const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([ - this.markUnknownTimeout({}), - this.markExpiredDownstreamDeliveries(), - this.recoverStaleGatewaySubmitRequeues(), - this.recoverStaleDownstreamManualRequeues(), - ]); - if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`); - if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`); - if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`); - if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`); - } catch (error) { - this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error)); - } finally { - this.receiptTimeoutScanRunning = false; - } + return this.completion.runReceiptTimeoutScan(); } private async submitMessageToGateway( @@ -3453,140 +619,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { attempt: number, retryOfSubmitRecordId?: string, ) { - const channel = routed.channel; - const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension); - await this.ensureSignatureReportedForChannel(message, channel.id); - await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond); - const submitId = `SUB-${randomUUID()}`; - try { - await this.prisma.$transaction(async (tx) => { - const session = await tx.cmppSubmitSession.upsert({ - where: { sessionNo: `OPEN-${channel.id}` }, - update: { submitTotal: { increment: 1 } }, - create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 }, - }); - await tx.smsSubmitRecord.create({ - data: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - channelId: channel.id, - channelGroupId: routed.groupId, - channelGroupName: routed.groupName, - sessionId: session.id, - retryOfSubmitRecordId, - submitId, - submitStatus: 'queued', - costUnitPrice: channel.unitPrice ?? 0, - costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1), - }, - }); - await tx.smsMessageRecord.update({ - where: { id: message.id }, - data: { - channelId: channel.id, - carrier: routed.carrier, - province: routed.province, - submitId, - status: 'submit_queued', - submitStatus: 'queued', - receiptStatus: null, - errorCode: null, - errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined, - }, - }); - }); - if (retryOfSubmitRecordId) { - this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - retryOfSubmitRecordId, - submitId, - channelId: channel.id, - })}`); - } - } catch (error) { - if ( - retryOfSubmitRecordId - && error instanceof Prisma.PrismaClientKnownRequestError - && error.code === 'P2002' - ) { - const existingRetry = await this.prisma.smsSubmitRecord.findUnique({ - where: { retryOfSubmitRecordId }, - }); - if (existingRetry) { - this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - retryOfSubmitRecordId, - submitId: existingRetry.submitId, - channelId: existingRetry.channelId, - })}`); - return { - submitted: false, - duplicateRetry: true, - messageRecordId: message.id, - channelId: existingRetry.channelId, - attempt, - submitId: existingRetry.submitId, - }; - } - } - throw error; - } - const command = { - schemaVersion: 'v1', - messageType: 'SubmitCommand', - traceId: randomUUID(), - messageId: message.messageId, - channelId: channel.id, - createdAt: new Date().toISOString(), - tenantId: message.tenantId, - applicationId: message.applicationId ?? 'unknown', - taskId: message.batchTaskId, - submitId, - queuePriority: normalizeQueuePriority(message.queuePriority), - phoneNumber: message.phoneNumber, - content: message.content, - signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS', - templateId: message.templateId ?? 'unknown', - billingUnits: message.billingUnits, - route: { - channelCode: channel.code, - cmppAccountCode: channel.account, - priority: attempt, - rateLimitPerSecond: channel.rateLimitPerSecond, - carrier: routed.carrier, - province: routed.province ?? undefined, - scope: routed.routeScope, - groupId: routed.groupId, - }, - cmpp: { - serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config - ? String(channel.config.serviceId) - : 'SMS', - srcId: upstreamSrcId, - extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0), - registeredDelivery: 1, - msgFmt: 8, - }, - upstream: { - gatewayHost: channel.gatewayHost, - gatewayPort: channel.gatewayPort, - account: channel.account, - passwordCipher: channel.passwordCipher, - cmppVersion: channel.cmppVersion, - desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1), - windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16), - heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30), - heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3), - }, - retry: { attempt, maxAttempts: 1 }, - }; - await this.getGatewayQueue().add('submit-command', command); - await this.publishGatewaySubmitCommand(command); - await this.refreshTaskProgress(message.batchTaskId); - return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt }; + return this.submission.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId); } private async retryMessageIfAllowed( @@ -3611,296 +644,50 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { reason: string, sourceSubmitRecordId?: string, ) { - const attempts = await this.prisma.smsSubmitRecord.findMany({ - where: { messageRecordId: message.id }, - orderBy: { createdAt: 'asc' }, - take: 200, - }); - const attemptedChannelIds = attempts.map((attempt) => attempt.channelId); - let sourceAttempt = sourceSubmitRecordId - ? attempts.find((attempt) => attempt.id === sourceSubmitRecordId) - : attempts.find((attempt) => attempt.submitId === message.submitId) ?? attempts[attempts.length - 1]; - if (!sourceAttempt && sourceSubmitRecordId) { - sourceAttempt = await this.prisma.smsSubmitRecord.findUnique({ - where: { id: sourceSubmitRecordId }, - }) ?? undefined; - } - if (!sourceAttempt || sourceAttempt.messageRecordId !== message.id) { - this.logger.error(`sms_retry_route_failed ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - reason, - sourceSubmitRecordId, - sourceMessageRecordId: sourceAttempt?.messageRecordId, - error: sourceAttempt ? 'retry_source_submit_record_mismatch' : 'retry_source_submit_record_missing', - })}`); - return null; - } - const existingRetry = await this.prisma.smsSubmitRecord.findUnique({ - where: { retryOfSubmitRecordId: sourceAttempt.id }, - }); - if (existingRetry) { - this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - retryOfSubmitRecordId: sourceAttempt.id, - submitId: existingRetry.submitId, - channelId: existingRetry.channelId, - })}`); - return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); - } - const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000; - this.logger.log(`sms_retry_route_started ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - reason, - attemptedChannelIds, - ageMinutes: Math.round(ageMinutes * 100) / 100, - })}`); - if (ageMinutes >= 72 * 60) { - this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - reason: 'maximum_message_age_exceeded', - ageMinutes: Math.round(ageMinutes * 100) / 100, - })}`); - return null; - } - const retryCarrier = message.carrier - ? normalizeCarrier(message.carrier) - : await this.identifyCarrier(message.phoneNumber); - const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier); - const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60); - if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) { - this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - groupId: route.groupId, - reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded', - ageMinutes: Math.round(ageMinutes * 100) / 100, - retryTimeLimitMinutes, - })}`); - return null; - } - try { - const routed = await this.selectChannelForMessage({ ...message, carrier: retryCarrier }, { - forceNational: true, - excludeChannelIds: attemptedChannelIds, - }); - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { errorMessage: reason }, - }); - const retried = await this.submitMessageToGateway( - message, - routed, - attempts.length, - sourceAttempt.id, - ); - this.logger.log(`sms_retry_route_selected ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - groupId: routed.groupId, - channelId: routed.channel.id, - attempt: attempts.length, - })}`); - return retried; - } catch (error) { - this.logger.error(`sms_retry_route_failed ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - reason, - attemptedChannelIds, - error: error instanceof Error ? error.message : String(error), - })}`); - return null; - } + return this.completion.retryMessageIfAllowed(message, reason, sourceSubmitRecordId); } private async selectChannelForMessage( message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, ): Promise { - if (!message.applicationId) { - throw new BadRequestException('短信应用未配置,无法选择通道组'); - } - const hasPersistedRouting = Boolean(message.carrier); - const [carrier, province] = hasPersistedRouting - ? [normalizeCarrier(message.carrier), message.province ?? null] - : await Promise.all([ - this.identifyCarrier(message.phoneNumber), - this.identifyProvince(message.phoneNumber), - ]); - if (!hasPersistedRouting) { - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { carrier, province }, - }); - } - const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier); - const excluded = new Set(options.excludeChannelIds ?? []); - const signatureId = await this.resolveMessageSignatureId(message); - if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道'); - const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({ - where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } }, - select: { channelId: true }, - }); - const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId)); - const items = route.group.items.filter((item) => - !excluded.has(item.channelId) - && approvedChannelIds.has(item.channelId) - && normalizeCarrier(item.carrier) === carrier - && isCarrierCompatible(item.channel.carrier, carrier), - ); - const provinceCandidates = options.forceNational ? [] : items.filter((item) => isProvinceChannel(item, province)); - const nationalCandidates = items.filter((item) => isNationalChannel(item)); - const selected = [...provinceCandidates, ...nationalCandidates].find((item) => this.isChannelSendAvailable(item.channel)); - if (!selected) { - throw new NotFoundException('无已报备通过且在线的可用通道'); - } - return { - channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, - carrier, - province, - groupId: route.groupId, - groupName: route.group.name, - routeScope: isNationalChannel(selected) ? 'national' : 'province', - }; + return this.submission.selectChannelForMessage(message, options); } private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) { - const route = await this.prisma.channelRouteRule.findFirst({ - where: { - status: 'active', - tenantId, - applicationId, - carrier, - channelId: null, - province: null, - }, - include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } }, - orderBy: { priority: 'asc' }, - }); - if (!route) { - throw new NotFoundException('企业应用未配置对应运营商通道组'); - } - if (route.group.status !== 'active') { - throw new BadRequestException('企业应用绑定的通道组已停用'); - } - if (normalizeCarrier(route.group.carrier) !== carrier) { - throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致'); - } - return route; + return this.submission.findApplicationRoute(tenantId, applicationId, carrier); } private async identifyCarrier(phoneNumber: string) { - return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber)); + return this.submission.identifyCarrier(phoneNumber); } private async identifyProvince(phoneNumber: string) { - return this.phoneRouting.identifyProvince(phoneNumber); - } - - private isChannelSendAvailable(channel: { status: string; connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }> }) { - if (channel.status !== 'active') { - return false; - } - return (channel.connectionStates ?? []).some((connection) => - connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected', - ); + return this.submission.identifyProvince(phoneNumber); } private async resolveUnitPrice(tenantId: string, applicationId?: string) { - if (!applicationId) { - return 0; - } - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - select: { tenantId: true, customerUnitPrice: true }, - }); - if (!application || application.tenantId !== tenantId) { - return 0; - } - return moneyToNumber(application.customerUnitPrice); + return this.submission.resolveUnitPrice(tenantId, applicationId); } private async resolveQueuePriority(tenantId: string, applicationId?: string): Promise { - if (!applicationId) { - return 'normal'; - } - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - select: { tenantId: true, queuePriority: true }, - }); - if (!application || application.tenantId !== tenantId) { - return 'normal'; - } - return normalizeQueuePriority(application.queuePriority); + return this.submission.resolveQueuePriority(tenantId, applicationId); } private async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) { - if (!applicationId) { - return { clientSrcId: null, applicationExtension: null }; - } - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true }, - }); - if (!application || application.tenantId !== tenantId) { - return { clientSrcId: null, applicationExtension: null }; - } - return { - clientSrcId: application.cmppClientSrcId, - applicationExtension: application.cmppApplicationExtension, - }; + return this.submission.resolveApplicationAccessNumber(tenantId, applicationId); } private findInboundApplication(account: string) { - return this.prisma.smsApplication.findFirst({ - where: { cmppAccount: account }, - include: { - tenant: true, - ipAllowlist: true, - }, - }); + return this.submission.findInboundApplication(account); } private async resolveInboundTemplateCandidate(applicationId: string, content: string) { - const exact = await this.prisma.smsTemplate.findFirst({ - where: { - applicationId, - content, - auditStatus: 'approved', - signature: { auditStatus: 'approved' }, - }, - include: { signature: true }, - orderBy: { updatedAt: 'desc' }, - }); - if (exact) return exact; - const variableTemplates = await this.prisma.smsTemplate.findMany({ - where: { - applicationId, - content: { contains: '${' }, - auditStatus: 'approved', - signature: { auditStatus: 'approved' }, - }, - include: { signature: true }, - orderBy: { updatedAt: 'desc' }, - }); - return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null; + return this.submission.resolveInboundTemplateCandidate(applicationId, content); } private resolveInboundSignatureCandidate(applicationId: string, content: string) { - const match = content.match(/^【[^】]+】/); - if (!match?.[0]) return null; - return this.prisma.smsSignature.findFirst({ - where: { - applicationId, - name: match[0], - auditStatus: 'approved', - }, - orderBy: { updatedAt: 'desc' }, - }); + return this.submission.resolveInboundSignatureCandidate(applicationId, content); } private async resolveTemplateMessageClassification( @@ -3909,87 +696,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { templateId: string | undefined, content: string, ) { - if (templateId) { - const template = await this.prisma.smsTemplate.findUnique({ - where: { id: templateId }, - include: { signature: true }, - }); - if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId - || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { - throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); - } - const variables = matchTemplateContent(template.content, content); - if (variables === null) { - throw new BadRequestException('短信内容与选定的审核模板不匹配'); - } - const drainage = await this.resolveDrainageInfoMatch(template.signatureId, content); - return { - signatureId: template.signatureId, - drainageInfoId: drainage?.id, - variables, - rejectionReason: drainageRejectionReason(drainage), - }; - } - - if (!applicationId) { - throw new BadRequestException('自由内容短信必须关联企业应用'); - } - const [application, signature] = await Promise.all([ - this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - select: { tenantId: true, templateMismatchMode: true }, - }), - this.resolveInboundSignatureCandidate(applicationId, content), - ]); - if (!application || application.tenantId !== tenantId) { - throw new BadRequestException('短信应用不存在或不属于当前企业'); - } - if (!signature) { - throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头'); - } - if (application.templateMismatchMode !== 'direct_send') { - throw new BadRequestException('当前应用未允许无模板自由内容直接发送'); - } - const drainage = await this.resolveDrainageInfoMatch(signature.id, content); - return { - signatureId: signature.id, - drainageInfoId: drainage?.id, - variables: undefined, - rejectionReason: drainageRejectionReason(drainage), - }; + return this.submission.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content); } private async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { - if (!signatureId) return undefined; - const candidates = await this.prisma.smsDrainageInfo.findMany({ - where: { signatureId, auditStatus: { not: 'deleted' } }, - select: { id: true, url: true, auditStatus: true, updatedAt: true }, - orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], - }); - const matches = candidates - .map((item) => ({ ...item, normalizedUrl: item.url.trim() })) - .filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl)) - .sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime()); - if (matches.length === 0) return undefined; - const longestLength = matches[0].normalizedUrl.length; - const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength); - if (longestMatches.length !== 1) { - throw new BadRequestException({ - code: 'DRAINAGE_MATCH_AMBIGUOUS', - message: '短信内容同时匹配多条等长引流地址,无法确定报备资料', - drainageInfoIds: longestMatches.map((item) => item.id), - }); - } - const matched = longestMatches[0]; - return { id: matched.id, auditStatus: matched.auditStatus }; + return this.submission.resolveDrainageInfoMatch(signatureId, content); } private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { - await this.prisma.smsMessageRecord.update({ - where: { id: messageRecordId }, - data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' }, - }); - return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } }); + return this.submission.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId); } private async recordCmppFailureReceipt( @@ -4006,177 +721,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { errorCode: string, reason: string, ) { - if (!message.tenantId || !message.applicationId) return null; - const existing = await this.prisma.smsReceiptRecord.findFirst({ - where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` }, - }); - if (existing) return existing; - const deliveredAt = new Date(); - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt }, - }); - const gatewayMessageId = `PLATFORM:${message.messageId}`; - const receipt = await this.prisma.smsReceiptRecord.create({ - data: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'), - messageId: message.messageId, - gatewayMessageId, - phoneNumber: message.phoneNumber, - receiptStatus: 'undelivered', - rawStatus: 'REJECTD', - errorCode, - errorMessage: reason, - deliveredAt, - }, - }); - await this.queueAndTryDownstreamDelivery({ - tenantId: message.tenantId, - applicationId: message.applicationId, - messageRecordId: message.id, - messageId: message.messageId, - deliveryType: 'receipt', - payload: { - messageId: message.messageId, - gatewayMessageId: `PLATFORM:${message.messageId}`, - phoneNumber: message.phoneNumber, - receiptStatus: 'undelivered', - rawStatus: 'REJECTD', - errorCode, - errorMessage: reason, - submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, - submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, - deliveredAt: deliveredAt.toISOString(), - }, - }); - if (message.batchTaskId) await this.refreshTaskProgress(message.batchTaskId); - return receipt; + return this.completion.recordCmppFailureReceipt(message, errorCode, reason); } private async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { - const rejected = new Map(); - for (const phone of phones) { - if (!/^1\d{10}$/.test(phone)) { - rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' }); - } - } - const validPhones = phones.filter((phone) => !rejected.has(phone)); - if (validPhones.length === 0) { - return rejected; - } - const [globalHits, enterpriseHits] = await Promise.all([ - this.prisma.globalBlacklist.findMany({ - where: { phoneNumber: { in: validPhones }, status: 'active' }, - select: { phoneNumber: true, reason: true }, - }), - applicationId - ? this.prisma.enterpriseBlacklist.findMany({ - where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' }, - select: { phoneNumber: true, reason: true }, - }) - : Promise.resolve([]), - ]); - for (const hit of globalHits) { - rejected.set(hit.phoneNumber, { - code: 'GLOBAL_BLACKLIST', - reason: hit.reason?.trim() || '号码命中平台黑名单', - }); - } - for (const hit of enterpriseHits) { - rejected.set(hit.phoneNumber, { - code: 'ENTERPRISE_BLACKLIST', - reason: hit.reason?.trim() || '号码命中企业应用黑名单', - }); - } - return rejected; + return this.submission.classifyRejectedPhones(tenantId, applicationId, phones); } private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { - const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); - if (!tenant || tenant.status !== 'active') { - throw new BadRequestException('企业客户不存在或已停用'); - } - if (tenant.certificationStatus !== 'approved') { - throw new BadRequestException('企业认证未通过,不能发送短信'); - } - if (!applicationId) { - return; - } - const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); - if (!application || application.tenantId !== tenantId || application.status !== 'active') { - throw new BadRequestException('短信应用不存在或已停用'); - } - if (!application.interfaceEnabled) { - throw new BadRequestException('短信应用接口未开通,不能发送短信'); - } - if (!templateId) { - return; - } - const template = await this.prisma.smsTemplate.findUnique({ - where: { id: templateId }, - include: { signature: true }, - }); - if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') { - throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); - } - if (!template.signature || template.signature.auditStatus !== 'approved') { - throw new BadRequestException('短信签名未审核通过'); - } + return this.submission.validateSendResources(tenantId, applicationId, templateId); } private async reserveDailySendQuota(applicationId: string, requestedCount: number) { - const result = await this.tryReserveDailySendQuota(applicationId, requestedCount); - if (!result.reserved) { - throw new HttpException({ - code: 'DAILY_SEND_LIMIT_EXCEEDED', - message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`, - dailyLimit: result.dailyLimit, - requestedCount, - }, HttpStatus.TOO_MANY_REQUESTS); - } - return result; + return this.submission.reserveDailySendQuota(applicationId, requestedCount); } private async tryReserveDailySendQuota(applicationId: string, requestedCount: number) { - if (!Number.isInteger(requestedCount) || requestedCount <= 0) { - throw new BadRequestException('发送号码数量必须为正整数'); - } - const usageDate = shanghaiDateKey(); - const reservationId = randomUUID(); - const rows = await this.prisma.$queryRaw>(Prisma.sql` - WITH application_limit AS ( - SELECT id, COALESCE("dailyLimit", 100000)::integer AS "dailyLimit" - FROM "SmsApplication" - WHERE id = ${applicationId} - ), reservation AS ( - INSERT INTO "SmsApplicationDailyUsage" ( - id, "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt" - ) - SELECT ${reservationId}, id, ${usageDate}::date, ${requestedCount}, NOW(), NOW() - FROM application_limit - WHERE ${requestedCount} <= "dailyLimit" - ON CONFLICT ("applicationId", "usageDate") DO UPDATE - SET "usedCount" = "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount", - "updatedAt" = NOW() - WHERE "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount" - <= (SELECT "dailyLimit" FROM application_limit) - RETURNING "usedCount" - ) - SELECT application_limit."dailyLimit", reservation."usedCount" - FROM application_limit - LEFT JOIN reservation ON TRUE - `); - if (rows.length === 0) { - throw new NotFoundException('短信应用不存在'); - } - return { - dailyLimit: Number(rows[0].dailyLimit), - usedCount: rows[0].usedCount == null ? null : Number(rows[0].usedCount), - reserved: rows[0].usedCount != null, - }; + return this.submission.tryReserveDailySendQuota(applicationId, requestedCount); } private async chargeAcceptedMessage(message: { @@ -4190,107 +751,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { unitPrice: number | bigint; amountCents: number | bigint; }) { - const amountCents = moneyToNumber(message.amountCents); - const unitPrice = moneyToNumber(message.unitPrice); - const billingUnits = message.billingUnits ?? 0; - const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } }); - if (exists?.billingStatus === 'charged') { - return; - } - if (amountCents > 0) { - await this.billing.release({ - tenantId: message.tenantId, - amountCents, - idempotencyKey: `sms-charge-release:${message.messageId}`, - relatedType: 'sms_batch_task', - relatedId: message.batchTaskId, - remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, - }); - } - const transaction = await this.billing.charge({ - tenantId: message.tenantId, - amountCents, - idempotencyKey: `sms-charge:${message.messageId}`, - relatedType: 'sms_message_record', - relatedId: message.messageId, - remark: '提交成功扣费', - }); - const data = { - tenantId: message.tenantId, - applicationId: message.applicationId ?? undefined, - taskId: message.batchTaskId, - messageId: message.messageId, - phoneNumber: message.phoneNumber, - contentLength: [...message.content].length, - billingUnits, - unitPrice, - amountCents, - billingStatus: 'charged', - transactionId: transaction.id, - }; - if (exists) { - await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data }); - return; - } - await this.prisma.smsBillingRecord.create({ data }); + return this.completion.chargeAcceptedMessage(message); } private async releaseMessageReservation( message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, remark: string, ) { - const amountCents = moneyToNumber(message.amountCents); - if (amountCents <= 0) { - return; - } - const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); - if (charged) { - return; - } - const released = await this.prisma.accountTransaction.findFirst({ - where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' }, - }); - if (released) { - return; - } - await this.billing.release({ - tenantId: message.tenantId, - amountCents, - idempotencyKey: `sms-reservation-release:${message.messageId}`, - relatedType: 'sms_message_record', - relatedId: message.messageId, - remark: `${remark}: ${message.messageId}`, - }); + return this.completion.releaseMessageReservation(message, remark); } private async refundMessage( message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, remark: string, ) { - const amountCents = moneyToNumber(message.amountCents); - if (amountCents <= 0) { - return; - } - const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } }); - if (refunded) { - return; - } - const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); - if (!charged) { - return; - } - const transaction = await this.billing.refund({ - tenantId: message.tenantId, - amountCents, - idempotencyKey: `sms-refund:${message.messageId}`, - relatedType: 'sms_message_record', - relatedId: message.messageId, - remark, - }); - await this.prisma.smsBillingRecord.updateMany({ - where: { messageId: message.messageId }, - data: { billingStatus: 'refunded', transactionId: transaction.id }, - }); + return this.completion.refundMessage(message, remark); } private async ensureSignatureReportedForChannel( @@ -4302,72 +777,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }, channelId: string, ) { - const signatureId = await this.resolveMessageSignatureId(message); - if (!signatureId) { - throw new BadRequestException('短信签名未配置,不能提交到通道'); - } - const reportTask = await this.prisma.channelSignatureReportTask.findFirst({ - where: { signatureId, channelId, reportType: 'signature', status: 'approved' }, - select: { id: true }, - }); - if (!reportTask) { - throw new BadRequestException('短信签名未在最终通道报备通过'); - } + return this.submission.ensureSignatureReportedForChannel(message, channelId); } private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { - const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null; - if (direct || !message.templateId) return direct; - const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } }); - return template?.signature?.id ?? null; + return this.submission.resolveMessageSignatureId(message); } private async waitForChannelRateLimit(channelId: string, tps: number) { - const redis = this.getRedis(); - for (;;) { - const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`; - const count = await redis.incr(bucket); - if (count === 1) { - await redis.expire(bucket, 2); - } - if (count <= Math.max(1, tps)) { - return; - } - await sleep(100); - } + return this.submission.waitForChannelRateLimit(channelId, tps); } private async refreshTaskProgress(batchTaskId: string) { - const groups = await this.prisma.smsMessageRecord.groupBy({ - by: ['status'], - where: { batchTaskId }, - _count: { _all: true }, - }); - const count = (statuses: string[]) => - groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0); - const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0); - const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']); - const successTotal = count(['delivered']); - const failedTotal = count(['submit_failed', 'failed']); - const unknownTotal = count(['unknown']); - const timeoutTotal = count(['timeout']); - const doneTotal = successTotal + failedTotal + timeoutTotal; - const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued'; - await this.prisma.smsBatchTask.update({ - where: { id: batchTaskId }, - data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status }, - }); + return this.submission.refreshTaskProgress(batchTaskId); } private smsMessageSegmentAuditDelegate() { - return (this.prisma as PrismaService & { - smsMessageSegmentAudit: { - upsert: (args: Record) => Promise; - updateMany: (args: Record) => Promise<{ count: number }>; - findFirst: (args: Record) => Promise; - findMany: (args: Record) => Promise; - }; - }).smsMessageSegmentAudit; + return this.completion.smsMessageSegmentAuditDelegate(); } private async recordSubmitSegments( @@ -4382,81 +808,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { data: GatewaySubmitResultDto, submittedAt: Date, ) { - const segmentAudits = this.smsMessageSegmentAuditDelegate(); - const submitRecord = await this.prisma.smsSubmitRecord.findFirst({ - where: { - messageRecordId: message.id, - OR: [ - data.submitId ? { submitId: data.submitId } : undefined, - data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined, - ].filter(Boolean) as Array<{ submitId?: string; gatewayMessageId?: string }>, - }, - orderBy: { createdAt: 'desc' }, - }); - const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`; - const attempt = submitRecord - ? Math.max(0, await this.prisma.smsSubmitRecord.count({ - where: { - messageRecordId: message.id, - createdAt: { lte: submitRecord.createdAt }, - }, - }) - 1) - : 0; - const fallbackSegments = [{ - segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), - segmentIndex: 1, - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId, - submitStatus: data.submitStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - submittedAt: data.submittedAt, - }]; - const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments; - const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1))); - await Promise.all(segments.map((segment, index) => { - const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1)); - const status = segment.submitStatus ?? data.submitStatus; - return segmentAudits.upsert({ - where: { - messageRecordId_submitId_segmentIndex: { - messageRecordId: message.id, - submitId, - segmentIndex, - }, - }, - update: { - submitRecordId: submitRecord?.id ?? null, - channelId: data.channelId ?? message.channelId ?? null, - attempt, - segmentTotal, - sequenceId: segment.sequenceId ?? data.sequenceId ?? null, - gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, - submitStatus: status, - errorCode: segment.errorCode ?? data.errorCode ?? null, - errorMessage: segment.errorMessage ?? data.errorMessage ?? null, - submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, - }, - create: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - submitRecordId: submitRecord?.id ?? null, - channelId: data.channelId ?? message.channelId ?? null, - submitId, - attempt, - segmentTotal, - segmentIndex, - sequenceId: segment.sequenceId ?? data.sequenceId ?? null, - gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, - submitStatus: status, - compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null, - errorCode: segment.errorCode ?? data.errorCode ?? null, - errorMessage: segment.errorMessage ?? data.errorMessage ?? null, - submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, - }, - }); - })); + return this.completion.recordSubmitSegments(message, data, submittedAt); } private async recordReceiptSegment( @@ -4472,66 +824,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { deliveredAt: Date, submitRecordId?: string, ) { - const segmentAudits = this.smsMessageSegmentAuditDelegate(); - const updated = await segmentAudits.updateMany({ - where: { - messageRecordId: message.id, - gatewayMessageId: data.gatewayMessageId, - }, - data: { - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - errorCode: data.errorCode ?? null, - deliveredAt, - }, - }); - if (updated.count > 0) { - return; - } - const submitRecord = submitRecordId - ? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } }) - : await this.prisma.smsSubmitRecord.findFirst({ - where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, - orderBy: { createdAt: 'desc' }, - }); - await segmentAudits.upsert({ - where: { - messageRecordId_submitId_segmentIndex: { - messageRecordId: message.id, - submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`, - segmentIndex: 1, - }, - }, - update: { - submitRecordId: submitRecord?.id ?? submitRecordId ?? null, - channelId: data.channelId ?? message.channelId ?? null, - sequenceId: data.sequenceId ?? null, - gatewayMessageId: data.gatewayMessageId, - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - errorCode: data.errorCode ?? null, - deliveredAt, - }, - create: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - submitRecordId: submitRecord?.id ?? submitRecordId ?? null, - channelId: data.channelId ?? message.channelId ?? null, - submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`, - attempt: 0, - segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), - segmentIndex: 1, - sequenceId: data.sequenceId ?? null, - gatewayMessageId: data.gatewayMessageId, - submitStatus: submitRecord?.submitStatus ?? 'accepted', - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - compensationType: 'receipt_recovered', - errorCode: data.errorCode ?? null, - deliveredAt, - }, - }); + return this.completion.recordReceiptSegment(message, data, deliveredAt, submitRecordId); } private async aggregateReceiptSegments( @@ -4544,734 +837,42 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { submitRecordId?: string, submitId?: string, ) { - const audits = await this.smsMessageSegmentAuditDelegate().findMany({ - where: submitRecordId - ? { messageRecordId: message.id, submitRecordId } - : submitId - ? { messageRecordId: message.id, submitId } - : { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, - orderBy: { segmentIndex: 'asc' }, - }); - if (audits.length === 0) { - const status = data.receiptStatus === 'delivered' - ? 'delivered' - : data.receiptStatus === 'unknown' - ? 'unknown' - : 'failed'; - return { - terminal: true, - segmentTotal: 1, - status, - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - deliveredAt, - }; - } - - const segmentTotal = Math.max( - 1, - Number(message.billingUnits ?? 1), - ...audits.map((audit) => Number(audit.segmentTotal ?? 1)), - ); - const received = audits.filter((audit) => Boolean(audit.receiptStatus)); - const failed = received.find((audit) => !['delivered', 'unknown'].includes(audit.receiptStatus ?? '')); - if (failed) { - return { - terminal: true, - segmentTotal, - status: 'failed', - receiptStatus: failed.receiptStatus ?? 'undelivered', - rawStatus: failed.rawStatus ?? data.rawStatus, - errorCode: failed.errorCode ?? data.errorCode, - errorMessage: failed.errorMessage ?? data.errorMessage, - deliveredAt: failed.deliveredAt ?? deliveredAt, - }; - } - const delivered = received.filter((audit) => audit.receiptStatus === 'delivered'); - if (delivered.length >= segmentTotal) { - const latest = delivered.reduce((current, audit) => - (audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current); - return { - terminal: true, - segmentTotal, - status: 'delivered', - receiptStatus: 'delivered', - rawStatus: latest.rawStatus ?? data.rawStatus, - errorCode: latest.errorCode ?? undefined, - errorMessage: undefined, - deliveredAt: latest.deliveredAt ?? deliveredAt, - }; - } - if (received.length >= segmentTotal) { - const latest = received[received.length - 1]; - return { - terminal: true, - segmentTotal, - status: 'unknown', - receiptStatus: 'unknown', - rawStatus: latest.rawStatus ?? data.rawStatus, - errorCode: latest.errorCode ?? data.errorCode, - errorMessage: latest.errorMessage ?? data.errorMessage, - deliveredAt: latest.deliveredAt ?? deliveredAt, - }; - } - return { - terminal: false, - segmentTotal, - status: 'submitted', - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - deliveredAt, - }; + return this.completion.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId); } private async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { - const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter( - Boolean, - ) as Array<{ - messageId?: string; - gatewayMessageId?: string; - }>; - if (conditions.length === 0) { - return null; - } - return this.prisma.smsMessageRecord.findFirst({ - where: { - OR: conditions, - }, - }); + return this.completion.findMessageByGatewayEvent(messageId, gatewayMessageId); } private async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { - const message = await this.findMessageByGatewayEvent(messageId, gatewayMessageId); - if (!message) { - throw new NotFoundException('SMS message record not found'); - } - return message; + return this.completion.requireMessageByGatewayEvent(messageId, gatewayMessageId); } private async resolveReceiptMessage( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, ) { - const exactMessage = data.messageId - ? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }) - : null; - if (exactMessage) { - const segmentAudit = data.gatewayMessageId - ? await this.smsMessageSegmentAuditDelegate().findFirst({ - where: { - messageRecordId: exactMessage.id, - gatewayMessageId: data.gatewayMessageId, - }, - orderBy: { updatedAt: 'desc' }, - }) - : null; - if (segmentAudit) { - return { - message: exactMessage, - messageId: exactMessage.messageId, - submitRecordId: segmentAudit.submitRecordId ?? undefined, - submitId: segmentAudit.submitId, - channelId: segmentAudit.channelId ?? data.channelId, - }; - } - const submitRecord = await this.prisma.smsSubmitRecord.findFirst({ - where: { - messageRecordId: exactMessage.id, - channelId: data.channelId, - gatewayMessageId: data.gatewayMessageId, - }, - orderBy: { createdAt: 'desc' }, - }); - return { - message: exactMessage, - messageId: exactMessage.messageId, - submitRecordId: submitRecord?.id, - submitId: submitRecord?.submitId, - channelId: submitRecord?.channelId ?? data.channelId, - }; - } - - const phoneNumber = data.phoneNumber?.trim(); - const exactSubmits = await this.prisma.smsSubmitRecord.findMany({ - where: { - channelId: data.channelId, - gatewayMessageId: data.gatewayMessageId, - ...(phoneNumber ? { messageRecord: { phoneNumber } } : {}), - }, - include: { messageRecord: true }, - orderBy: { createdAt: 'desc' }, - take: 2, - }); - if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) { - return { - message: exactSubmits[0].messageRecord, - messageId: exactSubmits[0].messageRecord.messageId, - submitRecordId: exactSubmits[0].id, - submitId: exactSubmits[0].submitId, - channelId: exactSubmits[0].channelId, - }; - } - - if (!phoneNumber) { - throw new NotFoundException('SMS message record not found'); - } - - const incomingChannel = incomingIdentity - ?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); - if (!incomingChannel) { - throw new NotFoundException('SMS message record not found'); - } - const segmentMatches = await this.smsMessageSegmentAuditDelegate().findMany({ - where: { - gatewayMessageId: data.gatewayMessageId, - messageRecord: { phoneNumber }, - }, - include: { messageRecord: true, submitRecord: true, channel: true }, - orderBy: { createdAt: 'desc' }, - take: 10, - }); - const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId); - if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) { - return { - message: exactSegmentMatches[0].messageRecord, - messageId: exactSegmentMatches[0].messageRecord.messageId, - submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined, - submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId, - channelId: exactSegmentMatches[0].channelId, - }; - } - const sameSupplierSegments = segmentMatches.filter((candidate) => - candidate.channel && this.isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel)); - if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) { - return { - message: sameSupplierSegments[0].messageRecord, - messageId: sameSupplierSegments[0].messageRecord.messageId, - submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined, - submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId, - channelId: sameSupplierSegments[0].channelId, - }; - } - const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({ - where: { - gatewayMessageId: data.gatewayMessageId, - messageRecord: { phoneNumber }, - }, - include: { messageRecord: true, channel: true }, - orderBy: { createdAt: 'desc' }, - take: 10, - }); - const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) => - candidate.channel && this.isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel)); - if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) { - return { - message: sameSupplierSubmits[0].messageRecord, - messageId: sameSupplierSubmits[0].messageRecord.messageId, - submitRecordId: sameSupplierSubmits[0].id, - submitId: sameSupplierSubmits[0].submitId, - channelId: sameSupplierSubmits[0].channelId, - }; - } - - const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); - const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000); - const candidates = await this.prisma.smsSubmitRecord.findMany({ - where: { - channelId: data.channelId, - gatewayMessageId: null, - submitStatus: 'timeout', - submittedAt: { - gte: submittedAfter, - lte: deliveredAt, - }, - messageRecord: { - phoneNumber, - }, - }, - include: { - messageRecord: true, - }, - orderBy: { - submittedAt: 'desc', - }, - take: 10, - }); - - if (candidates.length !== 1 || !candidates[0]?.messageRecord) { - throw new NotFoundException('SMS message record not found'); - } - - return { - message: candidates[0].messageRecord, - messageId: candidates[0].messageRecord.messageId, - submitRecordId: candidates[0].id, - submitId: candidates[0].submitId, - channelId: candidates[0].channelId, - }; - } - - private isSameUpstreamEndpointIdentity( - left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, - right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, - ) { - return left.account.trim() === right.account.trim() - && left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() - && left.gatewayPort === right.gatewayPort - && left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() - && left.cmppVersion.trim() === right.cmppVersion.trim(); - } - - private receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) { - return createHash('sha256').update([ - channelId, - data.gatewayMessageId, - data.phoneNumber?.trim() ?? '', - data.receiptStatus, - data.rawStatus.trim(), - data.errorCode ?? '', - ].join('\u0000')).digest('hex'); + return this.completion.resolveReceiptMessage(data, incomingIdentity); } private getSendQueue(): Queue { - if (!this.sendQueue) { - this.sendQueue = new Queue(SEND_QUEUE, { connection: bullmqConnection() }); - } - return this.sendQueue; + return this.submission.getSendQueue(); } private getGatewayQueue(): Queue { - if (!this.gatewayQueue) { - this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() }); - } - return this.gatewayQueue; + return this.submission.getGatewayQueue(); } private getRedis() { - if (!this.redis) { - this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { - maxRetriesPerRequest: null, - }); - } - return this.redis; + return this.submission.getRedis(); } private async postGatewayControl(path: string, payload: unknown) { - const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? 'http://127.0.0.1:8090').replace(/\/$/, ''); - const response = await fetch(`${baseUrl}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new Error(`Gateway control ${path} returned ${response.status}${body ? `: ${body}` : ''}`); - } - return response.json().catch(() => ({})); + return this.completion.postGatewayControl(path, payload); } private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { - const redis = this.getRedis(); - const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM; - const payload = JSON.stringify(command); - if (!idempotencyKey) { - return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload); - } - const result = await redis.eval( - `local existing = redis.call('GET', KEYS[2]) -if existing then return existing end -local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1]) -redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2]) -return streamId`, - 2, - stream, - idempotencyKey, - payload, - String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS), - ); - return typeof result === 'string' ? result : String(result ?? ''); + return this.submission.publishGatewaySubmitCommand(command, idempotencyKey); } } - -function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) { - return `gateway:submit:requeue:${deadLetterId}:${attempt}`; -} - -function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) { - if (!drainage || drainage.auditStatus === 'approved') return undefined; - return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`; -} - -function statusFromRisk(status: string, scheduled: boolean) { - if (status === 'rejected') { - return 'rejected'; - } - if (status === 'pending_review') { - return 'pending_review'; - } - if (scheduled) { - return 'scheduled'; - } - return 'ready'; -} - -function parseSchedule(data: CreateBatchTaskDto) { - if (data.sendMode !== 'scheduled' && !data.scheduledAt) { - return { scheduledAt: null }; - } - if (!data.scheduledAt) { - throw new BadRequestException('定时发送必须提供 scheduledAt'); - } - const scheduledAt = new Date(data.scheduledAt); - if (Number.isNaN(scheduledAt.getTime())) { - throw new BadRequestException('scheduledAt 时间格式无效'); - } - if (scheduledAt.getTime() <= Date.now()) { - throw new BadRequestException('scheduledAt 必须晚于当前时间'); - } - return { scheduledAt }; -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function asDateOrNull(value?: string | null) { - if (!value) { - return null; - } - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -function downstreamRetryDelayMs(retryCount = 1) { - const base = downstreamRetryBaseDelayMs(); - const max = downstreamRetryMaxDelayMs(); - const attempt = Math.max(1, Math.floor(retryCount)); - const delay = base * Math.pow(2, Math.max(0, attempt - 1)); - return Math.min(delay, max); -} - -function downstreamAckTimeoutMs() { - const configured = Number(process.env.CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS ?? 30); - return Math.max(5, Number.isFinite(configured) ? configured : 30) * 1000; -} - -function downstreamRetryBaseDelayMs() { - const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_DELAY_MS); - return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_DELAY_MS; -} - -function downstreamRetryMaxDelayMs() { - const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS); - return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS; -} - -function downstreamMaxRetries() { - const value = Number(process.env.CMPP_DOWNSTREAM_MAX_RETRIES ?? DEFAULT_DOWNSTREAM_MAX_RETRIES); - return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_DOWNSTREAM_MAX_RETRIES; -} - -function downstreamPendingTimeoutHours() { - const value = Number(process.env.CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS ?? DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS); - return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS; -} - -function downstreamControlFailureMessage(result: GatewayControlDeliveryResult) { - const reason = String(result.errorMessage ?? '').trim(); - const code = String(result.reasonCode ?? '').trim(); - if (reason && code) return `${reason} (${code})`; - if (reason) return reason; - if (code) return `Gateway 未完成下游投递 (${code})`; - return 'Gateway 未完成下游投递,等待自动重试'; -} - -function parseImportRows(content: string, delimiter?: ',' | '\t') { - const normalized = content.replace(/^\uFEFF/, ''); - const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0); - if (lines.length === 0) { - return []; - } - const firstDelimiter = delimiter ?? (lines[0].includes(',') ? ',' : '\t'); - const firstCells = splitImportLine(lines[0], firstDelimiter); - const hasHeader = firstCells.some((cell) => ['phone', 'phoneNumber', 'mobile', '手机号'].includes(cell)); - const headers = hasHeader ? firstCells : ['phoneNumber']; - const dataLines = hasHeader ? lines.slice(1) : lines; - return dataLines.map((line, index) => { - const cells = splitImportLine(line, firstDelimiter); - const row: { rowNumber: number; phoneNumber?: string; variables: Record } = { - rowNumber: (hasHeader ? index + 2 : index + 1), - phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0], - variables: {}, - }; - headers.forEach((header, cellIndex) => { - if (!['phone', 'phoneNumber', 'mobile', '手机号'].includes(header)) { - row.variables[header] = cells[cellIndex] ?? ''; - } - }); - return row; - }); -} - -function splitImportLine(line: string, delimiter: ',' | '\t') { - return line.split(delimiter).map((cell) => cell.trim().replace(/^"|"$/g, '')); -} - -function cellByHeader(headers: string[], cells: string[], candidates: string[]) { - const index = headers.findIndex((header) => candidates.includes(header)); - return index >= 0 ? cells[index] : undefined; -} - -function normalizeCarrier(carrier?: string | null) { - const value = String(carrier ?? '').trim().toLowerCase(); - if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; - if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; - if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; - if (['all', 'tri', '三网', '全网'].includes(value)) return 'all'; - return value || 'mobile'; -} - -function normalizeQueuePriority(queuePriority?: string | null): QueuePriority { - return queuePriority === 'priority' ? 'priority' : 'normal'; -} - -function getPositiveConfigInteger(config: unknown, key: string, fallback: number) { - if (config && typeof config === 'object' && !Array.isArray(config) && key in config) { - const value = Number((config as Record)[key]); - if (Number.isInteger(value) && value > 0) { - return value; - } - } - return fallback; -} - -function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) { - if (config && typeof config === 'object' && !Array.isArray(config) && key in config) { - const value = Number((config as Record)[key]); - if (Number.isInteger(value) && value >= 0) { - return value; - } - } - return fallback; -} - -function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) { - const normalized = normalizeCarrier(channelCarrier); - return normalized === 'all' || normalized === targetCarrier; -} - -function normalizeRegion(region?: string | null) { - return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); -} - -function matchTemplateContent(templateContent: string, actualContent: string) { - if (templateContent === actualContent) { - return {} as Record; - } - const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g; - const names: string[] = []; - let cursor = 0; - let pattern = '^'; - for (const match of templateContent.matchAll(tokenPattern)) { - const index = match.index ?? 0; - pattern += escapeRegularExpression(templateContent.slice(cursor, index)); - pattern += '([\\s\\S]+?)'; - names.push(match[1]); - cursor = index + match[0].length; - } - if (names.length === 0) { - return null; - } - pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`; - const matched = new RegExp(pattern, 'u').exec(actualContent); - if (!matched) { - return null; - } - const variables: Record = {}; - for (let index = 0; index < names.length; index += 1) { - const name = names[index]; - const value = matched[index + 1]; - if (variables[name] !== undefined && variables[name] !== value) { - return null; - } - variables[name] = value; - } - return variables; -} - -function escapeRegularExpression(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) { - const itemProvince = normalizeRegion(item.province); - const sendRegion = normalizeRegion(item.channel.sendRegion); - return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国'; -} - -function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) { - if (!province) { - return false; - } - const target = normalizeRegion(province); - const itemProvince = normalizeRegion(item.province); - const sendRegion = normalizeRegion(item.channel.sendRegion); - return itemProvince === target || sendRegion === target; -} - -function validateInboundApplicationSrcId( - srcId: string | undefined, - application: { - cmppApplicationExtension?: string | null; - cmppAccessNumberFillEnabled?: boolean | null; - cmppAccessNumberFillPrefix?: string | null; - cmppClientSrcId?: string | null; - }, -) { - const submittedSrcId = srcId?.trim() ?? ''; - const applicationExtension = application.cmppApplicationExtension?.trim() ?? ''; - if (!applicationExtension) { - return submittedSrcId || null; - } - - const fillPrefix = application.cmppAccessNumberFillEnabled - ? application.cmppAccessNumberFillPrefix?.trim() ?? '' - : ''; - const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`; - if (!submittedSrcId || submittedSrcId !== expectedSrcId) { - throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`); - } - return submittedSrcId; -} - -function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) { - const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`; - if (upstreamSrcId.length > 21) { - throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits'); - } - return upstreamSrcId; -} - -function positiveInteger(value: string | undefined, fallback: number) { - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; -} - -function parseOptionalSequenceId(value: string | null | undefined) { - if (!value) return undefined; - const parsed = Number(value); - return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined; -} - -function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] { - return value === 'accepted' || value === 'rejected' || value === 'timeout' ? value : 'timeout'; -} - -function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['receiptStatus'] { - return value === 'delivered' || value === 'undelivered' || value === 'unknown' ? value : 'unknown'; -} - -function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) { - return createHash('sha256').update([ - data.id, - data.connectionId ?? '', - data.sequenceId ?? '', - data.messageId ?? '', - data.sequenceId ? '' : data.sentAt ?? '', - ].join('\u0000')).digest('hex'); -} - -function shanghaiDateKey(now = new Date()) { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(now); - const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); - return `${values.year}-${values.month}-${values.day}`; -} - -function bullmqConnection() { - const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); - return { - host: redisUrl.hostname, - port: Number(redisUrl.port || 6379), - username: redisUrl.username || undefined, - password: redisUrl.password || undefined, - maxRetriesPerRequest: null, - }; -} - -function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) { - if (data.authSource && data.timestamp !== undefined) { - const expected = createHash('md5') - .update(Buffer.concat([ - Buffer.from(octetString(data.account, 6), 'binary'), - Buffer.alloc(9), - Buffer.from(secretHash), - Buffer.from(String(data.timestamp).padStart(10, '0')), - ])) - .digest('base64'); - return expected === data.authSource; - } - if (!data.password) { - return false; - } - return data.password === secretHash || createHash('sha256').update(data.password).digest('hex') === secretHash; -} - -function octetString(value: string, fixedLength: number) { - if (value.length === fixedLength) { - return value; - } - if (value.length > fixedLength) { - return value.slice(value.length - fixedLength); - } - return value + '\0'.repeat(fixedLength - value.length); -} - -function hasRecoveryAuditStateChanged( - previous: Record | null, - current: Record, -) { - if (!previous) { - return true; - } - return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'] - .some((key) => (previous[key] ?? null) !== (current[key] ?? null)); -} - -function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) { - const explicit = String(data.failureCategory ?? '').trim(); - if (explicit) { - return explicit; - } - if (data.state === 'success' || data.state === 'running') { - return null; - } - if (data.lastSkipReason === 'backoff') { - return 'backoff'; - } - if (data.lastSkipReason === 'locked') { - return 'lock_contended'; - } - if (data.lastSkipReason === 'lock_lost') { - return 'lock_lost'; - } - if (data.state === 'waiting_connection') { - return 'client_disconnected'; - } - if (data.state === 'partial') { - return 'partial_delivery_failed'; - } - if (data.state === 'failed' && data.lastError) { - return 'flush_failed'; - } - return data.state ? 'unknown' : null; -} diff --git a/api/src/send-chain/send-completion.service.ts b/api/src/send-chain/send-completion.service.ts new file mode 100644 index 0000000..4301cb2 --- /dev/null +++ b/api/src/send-chain/send-completion.service.ts @@ -0,0 +1,323 @@ +import { BillingService } from '../billing/billing.service'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { downstreamPendingTimeoutHours } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import { SendAccountingService } from './send-accounting.service'; +import { SendDownstreamDeliveryService } from './send-downstream-delivery.service'; +import { SendDownstreamStateService } from './send-downstream-state.service'; +import { SendGatewayResultService } from './send-gateway-result.service'; +import { SendReceiptService } from './send-receipt.service'; +import { SendRetryService } from './send-retry.service'; +import { SendTimeoutService } from './send-timeout.service'; + + +export type SendCompletionCallbacks = Record; +export type SendCompletionFacade = SendCompletionService & SendSubmissionService; + +/** + * R10 internal compatibility facade. SendChainService remains the only public NestJS provider. + */ +export class SendCompletionService { + private readonly gatewayResult: SendGatewayResultService; + private readonly receipt: SendReceiptService; + private readonly retry: SendRetryService; + private readonly accounting: SendAccountingService; + private readonly downstreamState: SendDownstreamStateService; + private readonly downstreamDelivery: SendDownstreamDeliveryService; + private readonly timeout: SendTimeoutService; + + constructor( + prisma: PrismaService, + billing: BillingService, + openApi: OpenApiService | undefined, + facade: SendCompletionFacade, + callbacks: SendCompletionCallbacks = {}, + ) { + this.gatewayResult = new SendGatewayResultService(prisma, billing, openApi, facade, callbacks); + this.receipt = new SendReceiptService(prisma, billing, openApi, facade, callbacks); + this.retry = new SendRetryService(prisma, billing, openApi, facade, callbacks); + this.accounting = new SendAccountingService(prisma, billing, openApi, facade, callbacks); + this.downstreamState = new SendDownstreamStateService(prisma, billing, openApi, facade, callbacks); + this.downstreamDelivery = new SendDownstreamDeliveryService(prisma, billing, openApi, facade, callbacks); + this.timeout = new SendTimeoutService(prisma, billing, openApi, facade, callbacks); + } + + async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { + return this.gatewayResult.handleSubmitSegmentResult(data); + } + + async resolveSubmitRecordForGatewaySegmentResult( + messageRecordId: string, + data: GatewaySubmitSegmentResultDto, + ) { + return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data); + } + + async handleSubmitResult(data: GatewaySubmitResultDto) { + return this.gatewayResult.handleSubmitResult(data); + } + + async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) { + return this.gatewayResult.resolveSubmitRecordForGatewayResult(messageRecordId, data); + } + + smsMessageSegmentAuditDelegate() { + return this.gatewayResult.smsMessageSegmentAuditDelegate(); + } + + async recordSubmitSegments( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + channelId?: string | null; + submitId?: string | null; + billingUnits?: number | null; + }, + data: GatewaySubmitResultDto, + submittedAt: Date, + ) { + return this.gatewayResult.recordSubmitSegments(message, data, submittedAt); + } + + async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { + return this.gatewayResult.findMessageByGatewayEvent(messageId, gatewayMessageId); + } + + async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { + return this.gatewayResult.requireMessageByGatewayEvent(messageId, gatewayMessageId); + } + + async intakeReceipt(data: GatewayReceiptEventDto) { + return this.receipt.intakeReceipt(data); + } + + async processPendingUpstreamReceiptInbox(limit = 100) { + return this.receipt.processPendingUpstreamReceiptInbox(limit); + } + + async processUpstreamReceiptInboxRecord(id: string) { + return this.receipt.processUpstreamReceiptInboxRecord(id); + } + + async runUpstreamReceiptInboxScan() { + return this.receipt.runUpstreamReceiptInboxScan(); + } + + async handleReceipt( + data: GatewayReceiptEventDto, + incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + ) { + return this.receipt.handleReceipt(data, incomingIdentity); + } + + async recordReceiptSegment( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + channelId?: string | null; + submitId?: string | null; + billingUnits?: number | null; + }, + data: GatewayReceiptEventDto, + deliveredAt: Date, + submitRecordId?: string, + ) { + return this.receipt.recordReceiptSegment(message, data, deliveredAt, submitRecordId); + } + + async aggregateReceiptSegments( + message: { + id: string; + billingUnits?: number | null; + }, + data: GatewayReceiptEventDto, + deliveredAt: Date, + submitRecordId?: string, + submitId?: string, + ) { + return this.receipt.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId); + } + + async resolveReceiptMessage( + data: GatewayReceiptEventDto, + incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + ) { + return this.receipt.resolveReceiptMessage(data, incomingIdentity); + } + + async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) { + return this.retry.recordGatewaySubmitDeadLetter(data); + } + + async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) { + return this.retry.requeueGatewaySubmitDeadLetter(id, data); + } + + async recoverStaleGatewaySubmitRequeues(now = new Date()) { + return this.retry.recoverStaleGatewaySubmitRequeues(now); + } + + async retryMessageIfAllowed( + message: { + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + templateId?: string | null; + signatureId?: string | null; + submitId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + queuedAt?: Date; + clientSrcId?: string | null; + applicationExtension?: string | null; + carrier?: string | null; + province?: string | null; + }, + reason: string, + sourceSubmitRecordId?: string, + ) { + return this.retry.retryMessageIfAllowed(message, reason, sourceSubmitRecordId); + } + + async chargeAcceptedMessage(message: { + tenantId: string; + applicationId?: string | null; + batchTaskId: string; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + unitPrice: number | bigint; + amountCents: number | bigint; + }) { + return this.accounting.chargeAcceptedMessage(message); + } + + async releaseMessageReservation( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + return this.accounting.releaseMessageReservation(message, remark); + } + + async refundMessage( + message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + return this.accounting.refundMessage(message, remark); + } + + async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) { + return this.downstreamState.listPendingDownstreamDeliveries(data); + } + + async markDownstreamDeliveryDelivered(id: string) { + return this.downstreamState.markDownstreamDeliveryDelivered(id); + } + + async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) { + return this.downstreamState.markDownstreamDeliverySent(data); + } + + async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) { + return this.downstreamState.acknowledgeDownstreamDelivery(data); + } + + async markDownstreamDeliveryFailed( + id: string, + errorMessage?: string, + failureType: GatewayDownstreamFailureType = 'send_failed', + attempt?: GatewayDownstreamSentDto, + ) { + return this.downstreamState.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt); + } + + async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) { + return this.downstreamState.recordGatewayDownstreamRecoveryStatus(data); + } + + async requeueDownstreamDelivery(id: string) { + return this.downstreamState.requeueDownstreamDelivery(id); + } + + async recoverStaleDownstreamManualRequeues(now = new Date()) { + return this.downstreamState.recoverStaleDownstreamManualRequeues(now); + } + + async batchRequeueDownstreamDeliveries(ids: string[]) { + return this.downstreamState.batchRequeueDownstreamDeliveries(ids); + } + + async handleUplink(data: GatewayUplinkEventDto) { + return this.downstreamDelivery.handleUplink(data); + } + + async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) { + return this.downstreamDelivery.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId); + } + + async queueAndTryDownstreamDelivery(data: { + tenantId: string; + applicationId?: string | null; + messageRecordId?: string | null; + messageId?: string | null; + deliveryType: 'receipt' | 'uplink'; + payload: Record; + }) { + return this.downstreamDelivery.queueAndTryDownstreamDelivery(data); + } + + async resolveUplinkMatch( + data: GatewayUplinkEventDto, + channel: { id: string; srcId?: string | null }, + ): Promise<{ + tenantId?: string; + applicationId?: string; + messageRecordId?: string; + matchStatus: string; + matchReason: string; + candidates: UplinkMatchCandidateInput[]; + }> { + return this.downstreamDelivery.resolveUplinkMatch(data, channel); + } + + async recordCmppFailureReceipt( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) { + return this.downstreamDelivery.recordCmppFailureReceipt(message, errorCode, reason); + } + + async postGatewayControl(path: string, payload: unknown) { + return this.downstreamDelivery.postGatewayControl(path, payload); + } + + async markUnknownTimeout(data: TimeoutUnknownDto) { + return this.timeout.markUnknownTimeout(data); + } + + async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) { + return this.timeout.markExpiredDownstreamDeliveries(olderThanHours); + } + + async runReceiptTimeoutScan() { + return this.timeout.runReceiptTimeoutScan(); + } +} diff --git a/api/src/send-chain/send-downstream-delivery.service.ts b/api/src/send-chain/send-downstream-delivery.service.ts new file mode 100644 index 0000000..6206cb9 --- /dev/null +++ b/api/src/send-chain/send-downstream-delivery.service.ts @@ -0,0 +1,489 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { BillingService } from '../billing/billing.service'; +import { moneyToNumber } from '../common/money'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; + + +/** + * R10 downstreamDelivery implementation. + * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. + */ +export class SendDownstreamDeliveryService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly openApi: OpenApiService | undefined, + private readonly facade: SendCompletionFacade, + private readonly callbacks: SendCompletionCallbacks, + ) {} + + async handleUplink(data: GatewayUplinkEventDto) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); + if (!channel) { + throw new NotFoundException('SMS channel not found'); + } + const match = await this.facade.resolveUplinkMatch(data, channel); + const record = await this.prisma.smsUplinkMessage.create({ + data: { + tenantId: match.tenantId, + applicationId: match.applicationId, + messageRecordId: match.messageRecordId, + channelId: data.channelId, + messageId: data.messageId, + sequenceId: data.sequenceId, + phoneNumber: data.phoneNumber, + destId: data.destId, + content: data.content, + matchStatus: match.matchStatus, + matchReason: match.matchReason, + receivedAt: data.receivedAt ? new Date(data.receivedAt) : new Date(), + }, + }); + if (match.candidates.length > 0) { + await this.prisma.smsUplinkMatchCandidate.createMany({ + data: match.candidates.map((candidate) => ({ + uplinkMessageId: record.id, + tenantId: candidate.tenantId, + applicationId: candidate.applicationId, + messageRecordId: candidate.messageRecordId, + matchSource: candidate.matchSource, + confidence: candidate.confidence, + reason: candidate.reason, + })), + skipDuplicates: true, + }); + } + if (match.tenantId && match.applicationId) { + await this.facade.queueAndTryDownstreamDelivery({ + tenantId: match.tenantId, + applicationId: match.applicationId, + messageRecordId: match.messageRecordId, + messageId: data.messageId, + deliveryType: 'uplink', + payload: { + messageId: data.messageId, + applicationId: match.applicationId, + phoneNumber: data.phoneNumber, + destId: data.destId, + content: data.content, + receivedAt: record.receivedAt.toISOString(), + uplinkMessageId: record.id, + }, + }); + } + return record; + } + + async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) { + const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({ + where: { id: candidateId, uplinkMessageId }, + include: { + application: { select: { id: true, name: true, cmppAccount: true } }, + messageRecord: { select: { id: true, messageId: true, content: true } }, + uplinkMessage: true, + }, + }); + if (!candidate) { + throw new NotFoundException('Uplink match candidate not found'); + } + if (candidate.status === 'rejected') { + throw new BadRequestException('该候选已被排除,不能认领'); + } + if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') { + throw new BadRequestException('该上行记录已完成匹配,不能重复认领'); + } + const claimedAt = new Date(); + const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null; + const [updatedUplink] = await this.prisma.$transaction([ + this.prisma.smsUplinkMessage.update({ + where: { id: uplinkMessageId }, + data: { + tenantId: candidate.tenantId, + applicationId: candidate.applicationId, + messageRecordId: candidate.messageRecordId, + messageId, + matchStatus: 'matched', + matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`, + }, + }), + this.prisma.smsUplinkMatchCandidate.updateMany({ + where: { + uplinkMessageId, + id: { not: candidate.id }, + status: 'pending', + }, + data: { status: 'rejected' }, + }), + this.prisma.smsUplinkMatchCandidate.update({ + where: { id: candidate.id }, + data: { + status: 'claimed', + claimedAt, + claimedById: operatorId, + }, + }), + this.prisma.operationLog.create({ + data: { + tenantId: candidate.tenantId, + userId: operatorId, + action: 'gateway.uplink_manual_claim', + resource: 'sms_uplink_message', + resourceId: uplinkMessageId, + detail: { + candidateId: candidate.id, + applicationId: candidate.applicationId, + applicationName: candidate.application.name, + messageRecordId: candidate.messageRecordId, + messageId, + matchSource: candidate.matchSource, + phoneNumber: candidate.uplinkMessage.phoneNumber, + destId: candidate.uplinkMessage.destId, + }, + }, + }), + ]); + + await this.facade.queueAndTryDownstreamDelivery({ + tenantId: candidate.tenantId, + applicationId: candidate.applicationId, + messageRecordId: candidate.messageRecordId, + messageId, + deliveryType: 'uplink', + payload: { + messageId, + applicationId: candidate.applicationId, + phoneNumber: candidate.uplinkMessage.phoneNumber, + destId: candidate.uplinkMessage.destId, + content: candidate.uplinkMessage.content, + receivedAt: candidate.uplinkMessage.receivedAt.toISOString(), + manualClaim: true, + uplinkMessageId, + }, + }); + return this.prisma.smsUplinkMessage.findUnique({ + where: { id: updatedUplink.id }, + include: { + tenant: true, + application: true, + channel: true, + messageRecord: { include: { application: true } }, + matchCandidates: { + include: { + tenant: true, + application: true, + messageRecord: { include: { application: true, tenant: true, channel: true } }, + }, + orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }], + }, + }, + }); + } + + async queueAndTryDownstreamDelivery(data: { + tenantId: string; + applicationId?: string | null; + messageRecordId?: string | null; + messageId?: string | null; + deliveryType: 'receipt' | 'uplink'; + payload: Record; + }) { + if (!data.applicationId) { + return null; + } + const application = await this.prisma.smsApplication.findUnique({ + where: { id: data.applicationId }, + select: { + cmppAccount: true, + interfaceEnabled: true, + status: true, + downstreamReceiptRetryEnabled: true, + downstreamUplinkRetryEnabled: true, + httpConfig: true, + }, + }); + const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling'; + if (deliveryAllowed) { + 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)}`); + } + } + if (application?.interfaceEnabled !== true) { + return null; + } + const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; + const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId + ? `receipt:${data.messageRecordId}` + : data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string' + ? `uplink:${data.payload.uplinkMessageId}` + : null; + let delivery; + try { + delivery = await this.prisma.cmppDownstreamDelivery.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + messageRecordId: data.messageRecordId, + messageId: data.messageId, + dedupeKey, + deliveryType: data.deliveryType, + payload, + retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink' + ? application?.downstreamUplinkRetryEnabled ?? true + : application?.downstreamReceiptRetryEnabled ?? true), + status: deliveryAllowed ? 'pending' : 'abandoned', + lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送', + }, + }); + } catch (error) { + if ( + dedupeKey + && error instanceof Prisma.PrismaClientKnownRequestError + && error.code === 'P2002' + ) { + const existing = await this.prisma.cmppDownstreamDelivery.findUnique({ + where: { dedupeKey }, + }); + if (existing) { + this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({ + deliveryType: data.deliveryType, + messageRecordId: data.messageRecordId, + messageId: data.messageId, + dedupeKey, + deliveryId: existing.id, + })}`); + return existing; + } + } + throw error; + } + if (!deliveryAllowed) { + return delivery; + } + try { + const result = await this.facade.postGatewayControl( + data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink', + { deliveryId: delivery.id, ...payload }, + ) as GatewayControlDeliveryResult; + if (result.sent || result.delivered) { + await this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result }); + } else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') { + return delivery; + } else { + await this.facade.markDownstreamDeliveryFailed( + delivery.id, + downstreamControlFailureMessage(result), + result.retryable === false ? 'unrecoverable' : 'send_failed', + { id: delivery.id, ...result }, + ); + } + } catch (error) { + await this.facade.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed'); + } + return delivery; + } + + async resolveUplinkMatch( + data: GatewayUplinkEventDto, + channel: { id: string; srcId?: string | null }, + ): Promise<{ + tenantId?: string; + applicationId?: string; + messageRecordId?: string; + matchStatus: string; + matchReason: string; + candidates: UplinkMatchCandidateInput[]; + }> { + if (data.messageId) { + const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }); + if (message?.tenantId) { + return { + tenantId: message.tenantId, + applicationId: message.applicationId ?? undefined, + messageRecordId: message.id, + matchStatus: message.applicationId ? 'matched' : 'unmatched', + matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用', + candidates: [], + }; + } + } + + const accessNumber = data.destId || channel.srcId || ''; + const accessRoutes = accessNumber + ? await this.prisma.channelRouteRule.findMany({ + where: { + applicationId: { not: null }, + status: 'active', + group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } }, + }, + select: { applicationId: true }, + take: 10, + }) + : []; + const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))]; + const accessApplications = accessApplicationIds.length > 0 + ? await this.prisma.smsApplication.findMany({ + where: { id: { in: accessApplicationIds }, status: 'active' }, + select: { id: true, tenantId: true, name: true }, + }) + : []; + if (accessApplications.length === 1) { + return { + tenantId: accessApplications[0].tenantId, + applicationId: accessApplications[0].id, + matchStatus: 'matched', + matchReason: '接入号唯一匹配应用', + candidates: [], + }; + } + if (accessApplications.length > 1) { + return { + matchStatus: 'ambiguous', + matchReason: '接入号匹配多个应用', + candidates: accessApplications.map((application) => ({ + tenantId: application.tenantId, + applicationId: application.id, + matchSource: 'access_number', + confidence: 70, + reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`, + })), + }; + } + + const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72); + const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000); + const recentMessages = await this.prisma.smsMessageRecord.findMany({ + where: { + phoneNumber: data.phoneNumber, + tenantId: { not: null }, + applicationId: { not: null }, + submittedAt: { gte: since }, + }, + orderBy: { submittedAt: 'desc' }, + take: 2, + }); + const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId); + if (matchableRecentMessages.length === 1) { + return { + tenantId: matchableRecentMessages[0].tenantId ?? undefined, + applicationId: matchableRecentMessages[0].applicationId ?? undefined, + messageRecordId: matchableRecentMessages[0].id, + matchStatus: 'matched', + matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`, + candidates: [], + }; + } + if (matchableRecentMessages.length > 1) { + return { + matchStatus: 'ambiguous', + matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`, + candidates: matchableRecentMessages + .map((message) => ({ + tenantId: String(message.tenantId), + applicationId: String(message.applicationId), + messageRecordId: message.id, + matchSource: 'phone_window', + confidence: 55, + reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`, + })), + }; + } + return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] }; + } + + async recordCmppFailureReceipt( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) { + if (!message.tenantId || !message.applicationId) return null; + const existing = await this.prisma.smsReceiptRecord.findFirst({ + where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` }, + }); + if (existing) return existing; + const deliveredAt = new Date(); + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt }, + }); + const gatewayMessageId = `PLATFORM:${message.messageId}`; + const receipt = await this.prisma.smsReceiptRecord.create({ + data: { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'), + messageId: message.messageId, + gatewayMessageId, + phoneNumber: message.phoneNumber, + receiptStatus: 'undelivered', + rawStatus: 'REJECTD', + errorCode, + errorMessage: reason, + deliveredAt, + }, + }); + await this.facade.queueAndTryDownstreamDelivery({ + tenantId: message.tenantId, + applicationId: message.applicationId, + messageRecordId: message.id, + messageId: message.messageId, + deliveryType: 'receipt', + payload: { + messageId: message.messageId, + gatewayMessageId: `PLATFORM:${message.messageId}`, + phoneNumber: message.phoneNumber, + receiptStatus: 'undelivered', + rawStatus: 'REJECTD', + errorCode, + errorMessage: reason, + submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, + submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, + deliveredAt: deliveredAt.toISOString(), + }, + }); + if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId); + return receipt; + } + + async postGatewayControl(path: string, payload: unknown) { + const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? 'http://127.0.0.1:8090').replace(/\/$/, ''); + const response = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Gateway control ${path} returned ${response.status}${body ? `: ${body}` : ''}`); + } + return response.json().catch(() => ({})); + } +} diff --git a/api/src/send-chain/send-downstream-state.service.ts b/api/src/send-chain/send-downstream-state.service.ts new file mode 100644 index 0000000..a2ab15f --- /dev/null +++ b/api/src/send-chain/send-downstream-state.service.ts @@ -0,0 +1,510 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { BillingService } from '../billing/billing.service'; +import { moneyToNumber } from '../common/money'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; + + +/** + * R10 downstreamState implementation. + * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. + */ +export class SendDownstreamStateService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly openApi: OpenApiService | undefined, + private readonly facade: SendCompletionFacade, + private readonly callbacks: SendCompletionCallbacks, + ) {} + + async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) { + const application = await this.facade.findInboundApplication(data.account); + if (!application) { + throw new BadRequestException('CMPP account is invalid'); + } + const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({ + where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } }, + select: { id: true }, + take: 500, + }); + for (const expired of expiredAcknowledgements) { + await this.facade.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout'); + } + return this.prisma.cmppDownstreamDelivery.findMany({ + where: { + applicationId: application.id, + status: 'pending', + OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }], + }, + orderBy: { createdAt: 'asc' }, + take: Math.min(Math.max(data.limit ?? 100, 1), 500), + }); + } + + async markDownstreamDeliveryDelivered(id: string) { + return this.prisma.cmppDownstreamDelivery.update({ + where: { id }, + data: { + status: 'delivered', + deliveredAt: new Date(), + lastError: null, + }, + }); + } + + async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) { + const sentAt = asDateOrNull(data.sentAt) ?? new Date(); + const ackDeadlineAt = asDateOrNull(data.ackDeadlineAt) ?? new Date(sentAt.getTime() + downstreamAckTimeoutMs()); + const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); + if (!delivery) { + throw new NotFoundException('Downstream delivery not found'); + } + const attemptKey = downstreamDeliveryAttemptKey(data); + await this.prisma.cmppDownstreamDeliveryAttempt.upsert({ + where: { attemptKey }, + update: { + connectionId: data.connectionId, + sequenceId: data.sequenceId, + messageId: data.messageId, + sentAt, + ackDeadlineAt, + }, + create: { + deliveryId: data.id, + attemptKey, + attemptNo: delivery.retryCount + 1, + connectionId: data.connectionId, + sequenceId: data.sequenceId, + messageId: data.messageId, + status: 'awaiting_ack', + sentAt, + ackDeadlineAt, + }, + }); + await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { id: data.id, status: { not: 'delivered' } }, + data: { + status: 'awaiting_ack', + sentAt, + ackDeadlineAt, + ackSequenceId: data.sequenceId, + ackMessageId: data.messageId, + connectionId: data.connectionId, + nextRetryAt: null, + lastError: null, + }, + }); + return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); + } + + async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) { + const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date(); + const acknowledgedMessageId = String(data.messageId ?? '').trim(); + const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); + if (!delivery) { + throw new NotFoundException('Downstream delivery not found'); + } + const attemptKey = downstreamDeliveryAttemptKey(data); + const acknowledgementAccepted = data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0'; + await this.prisma.cmppDownstreamDeliveryAttempt.upsert({ + where: { attemptKey }, + update: { + status: acknowledgementAccepted ? 'acknowledged' : 'rejected', + connectionId: data.connectionId, + sequenceId: data.sequenceId, + messageId: data.messageId, + acknowledgedAt, + ackResult: data.result, + ackDeadlineAt: null, + failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', + errorMessage: acknowledgementAccepted + ? null + : data.result === 0 + ? 'CMPP_DELIVER_RESP Msg_Id=0' + : `CMPP_DELIVER_RESP result=${data.result}`, + }, + create: { + deliveryId: data.id, + attemptKey, + attemptNo: delivery.retryCount + 1, + connectionId: data.connectionId, + sequenceId: data.sequenceId, + messageId: data.messageId, + status: acknowledgementAccepted ? 'acknowledged' : 'rejected', + acknowledgedAt, + ackResult: data.result, + failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', + errorMessage: acknowledgementAccepted + ? null + : data.result === 0 + ? 'CMPP_DELIVER_RESP Msg_Id=0' + : `CMPP_DELIVER_RESP result=${data.result}`, + }, + }); + if (acknowledgementAccepted) { + await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { id: data.id, status: { not: 'delivered' } }, + data: { + status: 'delivered', + acknowledgedAt, + deliveredAt: acknowledgedAt, + ackDeadlineAt: null, + ackResult: data.result, + ackSequenceId: data.sequenceId, + ackMessageId: data.messageId, + connectionId: data.connectionId, + nextRetryAt: null, + lastError: null, + }, + }); + return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); + } + await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { id: data.id, status: { not: 'delivered' } }, + data: { + acknowledgedAt, + ackResult: data.result, + ackSequenceId: data.sequenceId, + ackMessageId: data.messageId, + connectionId: data.connectionId, + }, + }); + if (data.result === 0) { + return this.facade.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid'); + } + return this.facade.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected'); + } + + async markDownstreamDeliveryFailed( + id: string, + errorMessage?: string, + failureType: GatewayDownstreamFailureType = 'send_failed', + attempt?: GatewayDownstreamSentDto, + ) { + const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id } }); + if (!delivery) { + throw new NotFoundException('Downstream delivery not found'); + } + if (delivery.status === 'delivered') { + return delivery; + } + if (failureType === 'queue_timeout' && delivery.status !== 'pending') { + return delivery; + } + const retryCount = (delivery.retryCount ?? 0) + 1; + const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost'; + const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false; + const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout'; + const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries(); + const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed'; + if (attempt && (attempt.connectionId || attempt.sequenceId || attempt.messageId)) { + const attemptKey = downstreamDeliveryAttemptKey({ ...attempt, id }); + await this.prisma.cmppDownstreamDeliveryAttempt.upsert({ + where: { attemptKey }, + update: { + status: 'failed', + connectionId: attempt.connectionId, + sequenceId: attempt.sequenceId, + messageId: attempt.messageId, + failureType, + errorMessage: errorMessage ?? 'downstream delivery failed', + ackDeadlineAt: null, + }, + create: { + deliveryId: id, + attemptKey, + attemptNo: delivery.retryCount + 1, + connectionId: attempt.connectionId, + sequenceId: attempt.sequenceId, + messageId: attempt.messageId, + status: 'failed', + sentAt: asDateOrNull(attempt.sentAt), + failureType, + errorMessage: errorMessage ?? 'downstream delivery failed', + }, + }); + } + const updated = await this.prisma.cmppDownstreamDelivery.update({ + where: { id }, + data: { + status: finalFailure ? finalStatus : 'pending', + retryCount, + nextRetryAt: finalFailure ? null : new Date(Date.now() + downstreamRetryDelayMs(retryCount)), + ackDeadlineAt: null, + lastError: errorMessage ?? 'downstream delivery failed', + }, + }); + if (finalFailure) { + await this.prisma.operationLog.create({ + data: { + tenantId: updated.tenantId, + action: 'gateway.downstream_delivery_failed', + resource: 'cmpp_downstream_delivery', + resourceId: updated.id, + detail: { + deliveryType: updated.deliveryType, + applicationId: updated.applicationId, + messageId: updated.messageId, + retryCount, + failureType, + retryEnabled: updated.retryEnabled, + errorMessage: updated.lastError, + }, + }, + }); + } + return updated; + } + + async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) { + const account = String(data.account ?? '').trim(); + if (!account) { + throw new BadRequestException('account is required'); + } + const recoveryStatuses = (this.prisma as PrismaService & { + gatewayDownstreamRecoveryStatus: { + findUnique: (args: Record) => Promise; + upsert: (args: Record) => Promise; + }; + }).gatewayDownstreamRecoveryStatus; + const previous = await recoveryStatuses.findUnique({ + where: { account }, + select: { + state: true, + gatewayInstanceId: true, + lockOwner: true, + failureCategory: true, + lastError: true, + lastSkipReason: true, + }, + }); + const application = await this.prisma.smsApplication.findUnique({ + where: { cmppAccount: account }, + select: { id: true, tenantId: true, name: true }, + }); + const failureCategory = normalizeRecoveryFailureCategory(data); + const updated = await recoveryStatuses.upsert({ + where: { account }, + update: { + tenantId: application?.tenantId ?? null, + applicationId: application?.id ?? null, + gatewayInstanceId: data.gatewayInstanceId ?? null, + state: data.state, + lockOwner: data.lockOwner ?? null, + lockExpiresAt: asDateOrNull(data.lockExpiresAt), + lastAttemptAt: asDateOrNull(data.lastAttemptAt), + lastSuccessAt: asDateOrNull(data.lastSuccessAt), + lastFailureAt: asDateOrNull(data.lastFailureAt), + nextRetryAt: asDateOrNull(data.nextRetryAt), + attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0, + failureCategory, + lastError: data.lastError ?? null, + lastSkipReason: data.lastSkipReason ?? null, + }, + create: { + account, + tenantId: application?.tenantId, + applicationId: application?.id, + gatewayInstanceId: data.gatewayInstanceId, + state: data.state, + lockOwner: data.lockOwner, + lockExpiresAt: asDateOrNull(data.lockExpiresAt), + lastAttemptAt: asDateOrNull(data.lastAttemptAt), + lastSuccessAt: asDateOrNull(data.lastSuccessAt), + lastFailureAt: asDateOrNull(data.lastFailureAt), + nextRetryAt: asDateOrNull(data.nextRetryAt), + attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0, + failureCategory, + lastError: data.lastError, + lastSkipReason: data.lastSkipReason, + }, + include: { + tenant: true, + application: true, + }, + }); + const normalizedUpdated = updated as typeof updated & { + failureCategory?: string | null; + lockOwner?: string | null; + lockExpiresAt?: Date | null; + }; + if (hasRecoveryAuditStateChanged(previous, updated)) { + await this.prisma.operationLog.create({ + data: { + tenantId: updated.tenantId ?? undefined, + action: 'gateway.downstream_recovery_status_changed', + resource: 'gateway_downstream_recovery_status', + resourceId: updated.id, + detail: { + account, + previousState: previous?.state ?? null, + state: updated.state, + gatewayInstanceId: updated.gatewayInstanceId, + lockOwner: normalizedUpdated.lockOwner, + attemptCount: updated.attemptCount, + nextRetryAt: updated.nextRetryAt, + failureCategory: normalizedUpdated.failureCategory, + applicationId: updated.applicationId, + applicationName: application?.name, + lastError: updated.lastError, + lastSkipReason: updated.lastSkipReason, + }, + }, + }); + } + return updated; + } + + async requeueDownstreamDelivery(id: string) { + const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ + where: { id }, + include: { application: { select: { cmppAccount: true } } }, + }); + if (!delivery) { + throw new NotFoundException('Downstream delivery not found'); + } + if (delivery.status === 'awaiting_ack') { + throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投'); + } + const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null; + if (!payload) { + throw new BadRequestException('下游投递记录缺少可重放 payload'); + } + const path = + delivery.deliveryType === 'receipt' + ? '/downstream/receipt' + : delivery.deliveryType === 'uplink' + ? '/downstream/uplink' + : null; + if (!path) { + throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`); + } + + const requestPayload = { + deliveryId: delivery.id, + account: String(payload.account ?? delivery.application?.cmppAccount ?? ''), + ...payload, + }; + const retriedAt = new Date(); + const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { + id: delivery.id, + status: delivery.status, + updatedAt: delivery.updatedAt, + }, + data: { + status: 'manual_requeueing', + retryCount: 0, + manualRetryCount: { increment: 1 }, + lastRetriedAt: retriedAt, + nextRetryAt: null, + sentAt: null, + acknowledgedAt: null, + ackDeadlineAt: null, + ackResult: null, + ackSequenceId: null, + ackMessageId: null, + connectionId: null, + deliveredAt: null, + lastError: null, + }, + }); + if (claimed.count !== 1) { + throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试'); + } + await this.prisma.operationLog.create({ + data: { + tenantId: delivery.tenantId, + action: 'gateway.downstream_delivery_requeue', + resource: 'cmpp_downstream_delivery', + resourceId: delivery.id, + detail: { + deliveryType: delivery.deliveryType, + applicationId: delivery.applicationId, + messageId: delivery.messageId, + previousStatus: delivery.status, + previousRetryCount: delivery.retryCount, + manualRetryCount: (delivery.manualRetryCount ?? 0) + 1, + lastRetriedAt: retriedAt, + }, + }, + }); + try { + const result = await this.facade.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult; + if (result.sent || result.delivered) { + return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result }); + } + return this.facade.markDownstreamDeliveryFailed( + delivery.id, + downstreamControlFailureMessage(result), + result.retryable === false ? 'unrecoverable' : 'send_failed', + ); + } catch (error) { + return this.facade.markDownstreamDeliveryFailed( + delivery.id, + error instanceof Error ? error.message : 'Gateway control delivery failed', + ); + } + } + + async recoverStaleDownstreamManualRequeues(now = new Date()) { + const staleCutoff = new Date(now.getTime() - positiveInteger( + process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, + DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, + )); + const stale = await this.prisma.cmppDownstreamDelivery.findMany({ + where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } }, + select: { id: true, updatedAt: true }, + orderBy: { updatedAt: 'asc' }, + take: 500, + }); + let recovered = 0; + for (const delivery of stale) { + const updated = await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt }, + data: { + status: 'pending', + nextRetryAt: null, + lastError: '人工重投进程中断,已恢复为待投递', + }, + }); + recovered += updated.count; + } + return { recovered }; + } + + async batchRequeueDownstreamDeliveries(ids: string[]) { + const uniqueIds = [...new Set(ids.filter(Boolean))]; + if (uniqueIds.length === 0) { + throw new BadRequestException('请选择至少一条下游投递记录'); + } + const results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }> = []; + for (const id of uniqueIds) { + try { + await this.facade.requeueDownstreamDelivery(id); + results.push({ id, status: 'success' }); + } catch (error) { + results.push({ + id, + status: 'failed', + errorMessage: error instanceof Error ? error.message : '批量重投失败', + }); + } + } + return { + total: uniqueIds.length, + successCount: results.filter((item) => item.status === 'success').length, + failedCount: results.filter((item) => item.status === 'failed').length, + results, + }; + } +} diff --git a/api/src/send-chain/send-gateway-result.service.ts b/api/src/send-chain/send-gateway-result.service.ts new file mode 100644 index 0000000..b28dc4f --- /dev/null +++ b/api/src/send-chain/send-gateway-result.service.ts @@ -0,0 +1,390 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { BillingService } from '../billing/billing.service'; +import { moneyToNumber } from '../common/money'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; + + +/** + * R10 gatewayResult implementation. + * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. + */ +export class SendGatewayResultService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly openApi: OpenApiService | undefined, + private readonly facade: SendCompletionFacade, + private readonly callbacks: SendCompletionCallbacks, + ) {} + + async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { + const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); + const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data); + const effectiveSubmitId = submitRecord.submitId; + const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date(); + await this.facade.recordSubmitSegments(message, { + messageId: data.messageId, + channelId: data.channelId, + submitId: effectiveSubmitId, + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId ?? '', + submitStatus: normalizeSubmitStatus(data.submitStatus), + errorCode: data.errorCode, + errorMessage: data.errorMessage, + submittedAt: submittedAt.toISOString(), + segments: [{ + segmentTotal: data.segmentTotal, + segmentIndex: data.segmentIndex, + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId, + submitStatus: data.submitStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + submittedAt: submittedAt.toISOString(), + }], + }, submittedAt); + if (data.gatewayMessageId) { + await this.prisma.smsSubmitRecord.updateMany({ + where: { + id: submitRecord.id, + gatewayMessageId: null, + }, + data: { + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId, + submittedAt, + }, + }); + } + return { accepted: true }; + } + + async resolveSubmitRecordForGatewaySegmentResult( + messageRecordId: string, + data: GatewaySubmitSegmentResultDto, + ) { + if (data.submitId) { + const exact = await this.prisma.smsSubmitRecord.findUnique({ + where: { submitId: data.submitId }, + }); + if ( + !exact || + (exact.messageRecordId && exact.messageRecordId !== messageRecordId) || + (exact.channelId && exact.channelId !== data.channelId) + ) { + this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + submitId: data.submitId, + channelId: data.channelId, + segmentIndex: data.segmentIndex, + })}`); + throw new BadRequestException( + 'Gateway SubmitSegmentResult submitId does not match the SMS message and channel', + ); + } + return exact; + } + + const candidates = await this.prisma.smsSubmitRecord.findMany({ + where: { + messageRecordId, + channelId: data.channelId, + }, + orderBy: { createdAt: 'desc' }, + take: 2, + }); + if (candidates.length !== 1) { + this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + channelId: data.channelId, + segmentIndex: data.segmentIndex, + candidateCount: candidates.length, + })}`); + throw new BadRequestException( + 'Gateway SubmitSegmentResult without submitId cannot be matched uniquely', + ); + } + this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + channelId: data.channelId, + segmentIndex: data.segmentIndex, + submitId: candidates[0].submitId, + })}`); + return candidates[0]; + } + + async handleSubmitResult(data: GatewaySubmitResultDto) { + const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); + const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data); + const effectiveData = { ...data, submitId: submitRecord.submitId }; + const batchTask = message.batchTaskId + ? await this.prisma.smsBatchTask.findUnique({ where: { id: message.batchTaskId }, select: { sourceType: true } }) + : null; + const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date(); + await this.prisma.smsSubmitRecord.updateMany({ + where: { id: submitRecord.id }, + data: { + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId, + submitStatus: data.submitStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + submittedAt, + }, + }); + await this.facade.recordSubmitSegments(message, effectiveData, submittedAt); + const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId; + if (message.submitId && effectiveData.submitId !== message.submitId) { + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } + const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed'; + if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { + const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; + await this.facade.chargeAcceptedMessage(businessMessage); + const latest = await this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + if (latest?.status === 'failed') { + await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款'); + } + } else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { + const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; + const retried = await this.facade.retryMessageIfAllowed( + businessMessage, + data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发', + submitRecord.id, + ); + if (retried) { + await this.facade.refreshTaskProgress(businessMessage.batchTaskId); + return retried; + } + await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结'); + } + const protectedTerminalStatuses = ['delivered', 'failed', 'unknown']; + const updated = await this.prisma.smsMessageRecord.updateMany({ + where: data.submitStatus === 'accepted' + ? { id: message.id, status: { notIn: protectedTerminalStatuses } } + : { id: message.id, status: { not: 'delivered' } }, + data: { + gatewayMessageId: data.gatewayMessageId, + submitStatus: data.submitStatus, + status, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + submittedAt, + timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined, + }, + }); + if (updated.count === 0 && data.submitStatus === 'accepted') { + await this.prisma.smsMessageRecord.updateMany({ + where: { id: message.id, gatewayMessageId: null }, + data: { + gatewayMessageId: data.gatewayMessageId, + submittedAt, + }, + }); + } + if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) { + await this.facade.recordCmppFailureReceipt( + message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string }, + data.errorCode || 'SUBMIT', + data.errorMessage || (data.submitStatus === 'timeout' ? '上游提交超时' : '上游拒绝短信'), + ); + } + await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { + status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] }, + OR: [ + { submitId: effectiveData.submitId }, + data.messageId ? { messageId: data.messageId } : undefined, + ].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>, + }, + data: { + status: 'resolved', + resolvedAt: submittedAt, + resolvedStatus: data.submitStatus, + }, + }); + if (message.batchTaskId) { + await this.facade.refreshTaskProgress(message.batchTaskId); + } + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } + + async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) { + if (data.submitId) { + const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } }); + if (!exact + || (exact.messageRecordId && exact.messageRecordId !== messageRecordId) + || (exact.channelId && exact.channelId !== data.channelId)) { + throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel'); + } + return exact; + } + const candidates = await this.prisma.smsSubmitRecord.findMany({ + where: { + messageRecordId, + channelId: data.channelId, + OR: [ + data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined, + { gatewayMessageId: null }, + ].filter(Boolean) as Array<{ gatewayMessageId?: string | null }>, + }, + orderBy: { createdAt: 'desc' }, + take: 2, + }); + if (candidates.length !== 1) { + this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, + candidateCount: candidates.length, + })}`); + throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely'); + } + this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, + submitId: candidates[0].submitId, + })}`); + return candidates[0]; + } + + smsMessageSegmentAuditDelegate() { + return (this.prisma as PrismaService & { + smsMessageSegmentAudit: { + upsert: (args: Record) => Promise; + updateMany: (args: Record) => Promise<{ count: number }>; + findFirst: (args: Record) => Promise; + findMany: (args: Record) => Promise; + }; + }).smsMessageSegmentAudit; + } + + async recordSubmitSegments( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + channelId?: string | null; + submitId?: string | null; + billingUnits?: number | null; + }, + data: GatewaySubmitResultDto, + submittedAt: Date, + ) { + const segmentAudits = this.facade.smsMessageSegmentAuditDelegate(); + const submitRecord = await this.prisma.smsSubmitRecord.findFirst({ + where: { + messageRecordId: message.id, + OR: [ + data.submitId ? { submitId: data.submitId } : undefined, + data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined, + ].filter(Boolean) as Array<{ submitId?: string; gatewayMessageId?: string }>, + }, + orderBy: { createdAt: 'desc' }, + }); + const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`; + const attempt = submitRecord + ? Math.max(0, await this.prisma.smsSubmitRecord.count({ + where: { + messageRecordId: message.id, + createdAt: { lte: submitRecord.createdAt }, + }, + }) - 1) + : 0; + const fallbackSegments = [{ + segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), + segmentIndex: 1, + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId, + submitStatus: data.submitStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + submittedAt: data.submittedAt, + }]; + const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments; + const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1))); + await Promise.all(segments.map((segment, index) => { + const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1)); + const status = segment.submitStatus ?? data.submitStatus; + return segmentAudits.upsert({ + where: { + messageRecordId_submitId_segmentIndex: { + messageRecordId: message.id, + submitId, + segmentIndex, + }, + }, + update: { + submitRecordId: submitRecord?.id ?? null, + channelId: data.channelId ?? message.channelId ?? null, + attempt, + segmentTotal, + sequenceId: segment.sequenceId ?? data.sequenceId ?? null, + gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, + submitStatus: status, + errorCode: segment.errorCode ?? data.errorCode ?? null, + errorMessage: segment.errorMessage ?? data.errorMessage ?? null, + submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, + }, + create: { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + submitRecordId: submitRecord?.id ?? null, + channelId: data.channelId ?? message.channelId ?? null, + submitId, + attempt, + segmentTotal, + segmentIndex, + sequenceId: segment.sequenceId ?? data.sequenceId ?? null, + gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, + submitStatus: status, + compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null, + errorCode: segment.errorCode ?? data.errorCode ?? null, + errorMessage: segment.errorMessage ?? data.errorMessage ?? null, + submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, + }, + }); + })); + } + + async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { + const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter( + Boolean, + ) as Array<{ + messageId?: string; + gatewayMessageId?: string; + }>; + if (conditions.length === 0) { + return null; + } + return this.prisma.smsMessageRecord.findFirst({ + where: { + OR: conditions, + }, + }); + } + + async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { + const message = await this.facade.findMessageByGatewayEvent(messageId, gatewayMessageId); + if (!message) { + throw new NotFoundException('SMS message record not found'); + } + return message; + } +} diff --git a/api/src/send-chain/send-gateway-submit.service.ts b/api/src/send-chain/send-gateway-submit.service.ts new file mode 100644 index 0000000..f3bf62b --- /dev/null +++ b/api/src/send-chain/send-gateway-submit.service.ts @@ -0,0 +1,491 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Queue, Worker } from 'bullmq'; +import IORedis from 'ioredis'; +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { BillingService } from '../billing/billing.service'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { moneyToNumber } from '../common/money'; +import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RiskReviewService } from '../risk-review/risk-review.service'; +import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; +import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; + +/** + * R9 gatewaySubmit implementation. Cross-method calls return through the stable SendChainService seam. + */ +export class SendGatewaySubmitService { + private readonly logger = new Logger('SendChainService'); + private redis?: IORedis; + private sendQueue?: Queue; + private gatewayQueue?: Queue; + private worker?: Worker; + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly riskReview: RiskReviewService, + private readonly phoneFrequency: PhoneFrequencyService, + private readonly phoneRouting: PhoneRoutingLookupService, + private readonly facade: SendSubmissionService, + private readonly callbacks: SendSubmissionCallbacks, + ) {} + + async onModuleDestroy() { + await this.worker?.close(); + await this.sendQueue?.close(); + await this.gatewayQueue?.close(); + this.redis?.disconnect(); + } + + private releaseMessageReservation( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + return this.callbacks.releaseMessageReservation(message, remark); + } + + private recordCmppFailureReceipt( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) { + return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); + } + + +async enqueueBatchTask(taskId: string) { + const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } }); + if (!task) { + throw new NotFoundException('SMS batch task not found'); + } + if (task.status === 'canceled') { + throw new BadRequestException('SMS batch task is canceled'); + } + const messages = await this.prisma.smsMessageRecord.findMany({ + where: { batchTaskId: taskId, status: 'queued' }, + select: { id: true, queuePriority: true }, + take: 100000, + }); + const queue = this.facade.getSendQueue(); + for (const message of messages) { + const queuePriority = normalizeQueuePriority(message.queuePriority); + await queue.add('send-message', { messageRecordId: message.id }, { + jobId: message.id, + attempts: 3, + priority: BULLMQ_PRIORITY[queuePriority], + }); + } + await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } }); + return { taskId, enqueued: messages.length }; + } + +startWorker() { + if (this.worker) { + return { status: 'already_started' }; + } + const connection = bullmqConnection(); + this.worker = new Worker( + SEND_QUEUE, + async (job) => this.facade.processSendJob(job.data), + { connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) }, + ); + return { status: 'started' }; + } + +async processSendJob(job: SendJob) { + const message = await this.prisma.smsMessageRecord.findUnique({ + where: { id: job.messageRecordId }, + include: { batchTask: true, template: { include: { signature: true } }, signature: true }, + }); + if (!message || message.status !== 'queued') { + return { skipped: true }; + } + if (!message.tenantId || !message.batchTaskId) { + return { skipped: true, reason: 'standalone channel test message' }; + } + const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; + try { + const routed = await this.facade.selectChannelForMessage(businessMessage); + return await this.facade.submitMessageToGateway(businessMessage, routed, 0); + } catch (error) { + const reason = error instanceof Error ? error.message : '无可用通道组或通道'; + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { status: 'failed', errorMessage: reason }, + }); + await this.releaseMessageReservation(businessMessage, reason); + if (message.batchTask?.sourceType === 'cmpp') { + await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason); + } else { + await this.facade.refreshTaskProgress(businessMessage.batchTaskId); + } + return { submitted: false, messageRecordId: message.id, status: 'failed', reason }; + } + } + +async submitMessageToGateway( + message: { + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + templateId?: string | null; + signatureId?: string | null; + submitId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + queuePriority?: string | null; + clientSrcId?: string | null; + applicationExtension?: string | null; + template?: { signature?: { id?: string | null; name?: string | null } | null } | null; + signature?: { id?: string | null; name?: string | null } | null; + }, + routed: RoutedChannel, + attempt: number, + retryOfSubmitRecordId?: string, + ) { + const channel = routed.channel; + const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension); + await this.facade.ensureSignatureReportedForChannel(message, channel.id); + await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond); + const submitId = `SUB-${randomUUID()}`; + try { + await this.prisma.$transaction(async (tx) => { + const session = await tx.cmppSubmitSession.upsert({ + where: { sessionNo: `OPEN-${channel.id}` }, + update: { submitTotal: { increment: 1 } }, + create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 }, + }); + await tx.smsSubmitRecord.create({ + data: { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + channelId: channel.id, + channelGroupId: routed.groupId, + channelGroupName: routed.groupName, + sessionId: session.id, + retryOfSubmitRecordId, + submitId, + submitStatus: 'queued', + costUnitPrice: channel.unitPrice ?? 0, + costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1), + }, + }); + await tx.smsMessageRecord.update({ + where: { id: message.id }, + data: { + channelId: channel.id, + carrier: routed.carrier, + province: routed.province, + submitId, + status: 'submit_queued', + submitStatus: 'queued', + receiptStatus: null, + errorCode: null, + errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined, + }, + }); + }); + if (retryOfSubmitRecordId) { + this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + retryOfSubmitRecordId, + submitId, + channelId: channel.id, + })}`); + } + } catch (error) { + if ( + retryOfSubmitRecordId + && error instanceof Prisma.PrismaClientKnownRequestError + && error.code === 'P2002' + ) { + const existingRetry = await this.prisma.smsSubmitRecord.findUnique({ + where: { retryOfSubmitRecordId }, + }); + if (existingRetry) { + this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + retryOfSubmitRecordId, + submitId: existingRetry.submitId, + channelId: existingRetry.channelId, + })}`); + return { + submitted: false, + duplicateRetry: true, + messageRecordId: message.id, + channelId: existingRetry.channelId, + attempt, + submitId: existingRetry.submitId, + }; + } + } + throw error; + } + const command = { + schemaVersion: 'v1', + messageType: 'SubmitCommand', + traceId: randomUUID(), + messageId: message.messageId, + channelId: channel.id, + createdAt: new Date().toISOString(), + tenantId: message.tenantId, + applicationId: message.applicationId ?? 'unknown', + taskId: message.batchTaskId, + submitId, + queuePriority: normalizeQueuePriority(message.queuePriority), + phoneNumber: message.phoneNumber, + content: message.content, + signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS', + templateId: message.templateId ?? 'unknown', + billingUnits: message.billingUnits, + route: { + channelCode: channel.code, + cmppAccountCode: channel.account, + priority: attempt, + rateLimitPerSecond: channel.rateLimitPerSecond, + carrier: routed.carrier, + province: routed.province ?? undefined, + scope: routed.routeScope, + groupId: routed.groupId, + }, + cmpp: { + serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config + ? String(channel.config.serviceId) + : 'SMS', + srcId: upstreamSrcId, + extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0), + registeredDelivery: 1, + msgFmt: 8, + }, + upstream: { + gatewayHost: channel.gatewayHost, + gatewayPort: channel.gatewayPort, + account: channel.account, + passwordCipher: channel.passwordCipher, + cmppVersion: channel.cmppVersion, + desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1), + windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16), + heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30), + heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3), + }, + retry: { attempt, maxAttempts: 1 }, + }; + await this.facade.getGatewayQueue().add('submit-command', command); + await this.facade.publishGatewaySubmitCommand(command); + await this.facade.refreshTaskProgress(message.batchTaskId); + return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt }; + } + +async selectChannelForMessage( + message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }, + options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, + ): Promise { + if (!message.applicationId) { + throw new BadRequestException('短信应用未配置,无法选择通道组'); + } + const hasPersistedRouting = Boolean(message.carrier); + const [carrier, province] = hasPersistedRouting + ? [normalizeCarrier(message.carrier), message.province ?? null] + : await Promise.all([ + this.facade.identifyCarrier(message.phoneNumber), + this.facade.identifyProvince(message.phoneNumber), + ]); + if (!hasPersistedRouting) { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { carrier, province }, + }); + } + const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId, carrier); + const excluded = new Set(options.excludeChannelIds ?? []); + const signatureId = await this.facade.resolveMessageSignatureId(message); + if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道'); + const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({ + where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } }, + select: { channelId: true }, + }); + const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId)); + const selected = selectChannelCandidate(route.group.items, { + carrier, + province, + forceNational: options.forceNational, + excludedChannelIds: excluded, + approvedChannelIds, + }); + if (!selected) { + throw new NotFoundException('无已报备通过且在线的可用通道'); + } + return { + channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, + carrier, + province, + groupId: route.groupId, + groupName: route.group.name, + routeScope: isNationalChannel(selected) ? 'national' : 'province', + }; + } + +async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) { + const route = await this.prisma.channelRouteRule.findFirst({ + where: { + status: 'active', + tenantId, + applicationId, + carrier, + channelId: null, + province: null, + }, + include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } }, + orderBy: { priority: 'asc' }, + }); + if (!route) { + throw new NotFoundException('企业应用未配置对应运营商通道组'); + } + if (route.group.status !== 'active') { + throw new BadRequestException('企业应用绑定的通道组已停用'); + } + if (normalizeCarrier(route.group.carrier) !== carrier) { + throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致'); + } + return route; + } + +async identifyCarrier(phoneNumber: string) { + return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber)); + } + +async identifyProvince(phoneNumber: string) { + return this.phoneRouting.identifyProvince(phoneNumber); + } + +async ensureSignatureReportedForChannel( + message: { + id: string; + templateId?: string | null; + template?: { signature?: { id?: string | null; name?: string | null } | null } | null; + signature?: { id?: string | null; name?: string | null } | null; + }, + channelId: string, + ) { + const signatureId = await this.facade.resolveMessageSignatureId(message); + if (!signatureId) { + throw new BadRequestException('短信签名未配置,不能提交到通道'); + } + const reportTask = await this.prisma.channelSignatureReportTask.findFirst({ + where: { signatureId, channelId, reportType: 'signature', status: 'approved' }, + select: { id: true }, + }); + if (!reportTask) { + throw new BadRequestException('短信签名未在最终通道报备通过'); + } + } + +async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { + const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null; + if (direct || !message.templateId) return direct; + const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } }); + return template?.signature?.id ?? null; + } + +async waitForChannelRateLimit(channelId: string, tps: number) { + const redis = this.facade.getRedis(); + for (;;) { + const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`; + const count = await redis.incr(bucket); + if (count === 1) { + await redis.expire(bucket, 2); + } + if (count <= Math.max(1, tps)) { + return; + } + await sleep(100); + } + } + +async refreshTaskProgress(batchTaskId: string) { + const groups = await this.prisma.smsMessageRecord.groupBy({ + by: ['status'], + where: { batchTaskId }, + _count: { _all: true }, + }); + const count = (statuses: string[]) => + groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0); + const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0); + const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']); + const successTotal = count(['delivered']); + const failedTotal = count(['submit_failed', 'failed']); + const unknownTotal = count(['unknown']); + const timeoutTotal = count(['timeout']); + const doneTotal = successTotal + failedTotal + timeoutTotal; + const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued'; + await this.prisma.smsBatchTask.update({ + where: { id: batchTaskId }, + data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status }, + }); + } + +getSendQueue(): Queue { + if (!this.sendQueue) { + this.sendQueue = new Queue(SEND_QUEUE, { connection: bullmqConnection() }); + } + return this.sendQueue; + } + +getGatewayQueue(): Queue { + if (!this.gatewayQueue) { + this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() }); + } + return this.gatewayQueue; + } + +getRedis() { + if (!this.redis) { + this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { + maxRetriesPerRequest: null, + }); + } + return this.redis; + } + +async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { + const redis = this.facade.getRedis(); + const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM; + const payload = JSON.stringify(command); + if (!idempotencyKey) { + return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload); + } + const result = await redis.eval( + `local existing = redis.call('GET', KEYS[2]) +if existing then return existing end +local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1]) +redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2]) +return streamId`, + 2, + stream, + idempotencyKey, + payload, + String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS), + ); + return typeof result === 'string' ? result : String(result ?? ''); + } +} diff --git a/api/src/send-chain/send-inbound-entry.service.ts b/api/src/send-chain/send-inbound-entry.service.ts new file mode 100644 index 0000000..29f0c55 --- /dev/null +++ b/api/src/send-chain/send-inbound-entry.service.ts @@ -0,0 +1,840 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Queue, Worker } from 'bullmq'; +import IORedis from 'ioredis'; +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { BillingService } from '../billing/billing.service'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { moneyToNumber } from '../common/money'; +import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RiskReviewService } from '../risk-review/risk-review.service'; +import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; +import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; + +/** + * R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam. + */ +export class SendInboundEntryService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly riskReview: RiskReviewService, + private readonly phoneFrequency: PhoneFrequencyService, + private readonly phoneRouting: PhoneRoutingLookupService, + private readonly facade: SendSubmissionService, + private readonly callbacks: SendSubmissionCallbacks, + ) {} + + private releaseMessageReservation( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + return this.callbacks.releaseMessageReservation(message, remark); + } + + private recordCmppFailureReceipt( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) { + return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); + } + + +async authenticateInboundApplication(data: GatewayInboundAuthDto) { + const application = await this.facade.findInboundApplication(data.account); + if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') { + throw new BadRequestException('CMPP account is invalid or disabled'); + } + if (!application.interfaceEnabled) { + throw new BadRequestException('CMPP interface is disabled for this application'); + } + if (application.tenant.certificationStatus !== 'approved') { + throw new BadRequestException('Enterprise certification is not approved'); + } + if (!matchesApplicationSecret(data, application.secretHash)) { + throw new BadRequestException('CMPP account or password is invalid'); + } + if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + throw new BadRequestException('CMPP source IP is not in application allowlist'); + } + return { + applicationId: application.id, + tenantId: application.tenantId, + account: application.cmppAccount, + enterpriseCode: application.cmppEnterpriseCode, + passwordCipher: application.secretHash, + maxConnections: application.cmppMaxConnections, + status: 'authenticated', + }; + } + +async submitInboundMessage(data: GatewayInboundSubmitDto) { + const phoneNumbers = data.phoneNumbers?.length + ? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim()) + : data.phoneNumber + ? [data.phoneNumber.trim()] + : []; + if (phoneNumbers.length === 0) { + throw new BadRequestException('CMPP submit phone number is invalid'); + } + + const application = await this.facade.findInboundApplication(data.account); + if (!application) { + throw new BadRequestException('CMPP account is invalid'); + } + if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) { + throw new BadRequestException('CMPP account is disabled for new submissions'); + } + if (data.longMessage) { + if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + throw new BadRequestException('CMPP source IP is not in application allowlist'); + } + validateInboundApplicationSrcId(data.srcId, application); + const collection = await this.facade.collectInboundLongMessageFragment(data, application, phoneNumbers); + if (collection.response) { + return collection.response; + } + if (!collection.complete) { + return { + accepted: true, + tenantId: application.tenantId, + applicationId: application.id, + messageId: collection.messageId, + status: 'fragment_pending', + fragmentPending: true, + receivedSegments: collection.receivedSegments, + segmentTotal: data.longMessage.total, + phoneCount: phoneNumbers.length, + messages: phoneNumbers.map((phoneNumber) => ({ + phoneNumber, + messageId: collection.messageId, + status: 'fragment_pending', + })), + }; + } + try { + const response = await this.facade.recoverCompletedInboundLongMessageResponse( + collection.messageId, + phoneNumbers, + ) ?? await this.facade.submitCompleteInboundMessage({ + ...data, + content: collection.content, + sequenceId: collection.sequenceId, + longMessage: undefined, + }, phoneNumbers, application, collection.messageId); + await this.prisma.cmppInboundLongMessage.update({ + where: { id: collection.groupId }, + data: { + status: 'completed', + response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue, + completedAt: new Date(), + }, + }); + return response; + } catch (error) { + await this.prisma.cmppInboundLongMessage.update({ + where: { id: collection.groupId }, + data: { + status: 'rejected', + completedAt: new Date(), + }, + }).catch(() => undefined); + throw error; + } + } + return this.facade.submitCompleteInboundMessage(data, phoneNumbers, application); + } + +async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) { + const existing = await this.prisma.smsMessageRecord.findMany({ + where: { + cmppSubmitGroupMessageId: messageId, + phoneNumber: { in: phoneNumbers }, + }, + select: { + id: true, + tenantId: true, + applicationId: true, + batchTaskId: true, + messageId: true, + phoneNumber: true, + status: true, + errorCode: true, + }, + }); + const byPhone = new Map(existing.map((item) => [item.phoneNumber, item])); + const ordered = phoneNumbers.map((phoneNumber) => byPhone.get(phoneNumber)); + if (ordered.some((item) => !item)) { + return null; + } + const messages = ordered.map((item, index) => ({ + phoneNumber: phoneNumbers[index], + messageId: item!.messageId, + messageRecordId: item!.id, + taskId: item!.batchTaskId ?? '', + status: item!.status, + })); + const first = ordered[0]!; + const dailyLimitRejected = ordered.every((item) => item!.errorCode === 'DAILY_LIMIT'); + return { + accepted: !dailyLimitRejected, + tenantId: first.tenantId ?? '', + applicationId: first.applicationId ?? '', + taskId: first.batchTaskId ?? '', + messageId: first.messageId, + messageRecordId: first.id, + status: dailyLimitRejected ? 'rejected' : 'accepted', + result: dailyLimitRejected ? 8 : undefined, + phoneCount: messages.length, + messages, + }; + } + +async submitCompleteInboundMessage( + data: GatewayInboundSubmitDto, + phoneNumbers: string[], + application: Awaited>, + requestedGroupMessageId?: string, + ) { + if (!application) { + throw new BadRequestException('CMPP account is invalid'); + } + const persisted = requestedGroupMessageId + ? await this.prisma.smsMessageRecord.findMany({ + where: { + cmppSubmitGroupMessageId: requestedGroupMessageId, + phoneNumber: { in: phoneNumbers }, + }, + select: { + id: true, + tenantId: true, + applicationId: true, + batchTaskId: true, + messageId: true, + phoneNumber: true, + status: true, + errorCode: true, + }, + }) + : []; + const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item])); + const phoneRejections = await this.facade.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers); + const missingPhoneCount = phoneNumbers.filter((phoneNumber) => ( + !persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber) + )).length; + const dailyQuota = missingPhoneCount > 0 + ? await this.facade.tryReserveDailySendQuota(application.id, missingPhoneCount) + : { reserved: true, dailyLimit: application.dailyLimit ?? 100000 }; + const dailyLimitRejection = dailyQuota.reserved + ? undefined + : { + code: 'DAILY_LIMIT', + reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`, + }; + + const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`; + const submissions = phoneNumbers.map((phoneNumber, index) => ({ + phoneNumber, + persisted: persistedByPhone.get(phoneNumber), + receiptRejection: phoneRejections.get(phoneNumber), + messageId: persistedByPhone.get(phoneNumber)?.messageId + ?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`), + })); + const results: GatewayInboundSingleSubmitResult[] = []; + const concurrency = 10; + for (let offset = 0; offset < submissions.length; offset += concurrency) { + const batch = submissions.slice(offset, offset + concurrency); + results.push(...await Promise.all(batch.map((submission) => submission.persisted + ? Promise.resolve({ + accepted: submission.persisted.errorCode !== 'DAILY_LIMIT', + tenantId: submission.persisted.tenantId ?? application.tenantId, + applicationId: submission.persisted.applicationId ?? application.id, + taskId: submission.persisted.batchTaskId ?? '', + messageId: submission.persisted.messageId, + messageRecordId: submission.persisted.id, + status: submission.persisted.status, + }) + : this.facade.submitInboundSingleMessage({ + ...data, + phoneNumber: submission.phoneNumber, + phoneNumbers: undefined, + }, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection)))); + } + const first = results[0]; + return { + ...first, + result: dailyLimitRejection ? 8 : undefined, + phoneCount: results.length, + messages: results.map((result, index) => ({ + phoneNumber: phoneNumbers[index], + messageId: result.messageId, + messageRecordId: result.messageRecordId, + taskId: result.taskId, + status: result.status, + })), + }; + } + +async collectInboundLongMessageFragment( + data: GatewayInboundSubmitDto, + application: NonNullable>>, + phoneNumbers: string[], + ) { + const fragment = data.longMessage; + if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535 + || !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255 + || !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total + || !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) { + throw new BadRequestException('CMPP long message fragment metadata is invalid'); + } + const groupKey = createHash('sha256').update(JSON.stringify({ + applicationId: application.id, + account: data.account, + srcId: data.srcId?.trim() ?? '', + phoneNumbers, + reference: fragment.reference, + total: fragment.total, + format: fragment.format, + })).digest('hex'); + const contentHash = createHash('sha256').update(data.content).digest('hex'); + const now = new Date(); + const expiresAt = new Date(now.getTime() + positiveInteger( + process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS, + 300, + ) * 1000); + + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`; + await tx.cmppInboundLongMessage.updateMany({ + where: { + groupKey, + status: { in: ['collecting', 'processing'] }, + expiresAt: { lte: now }, + }, + data: { status: 'expired', completedAt: now }, + }); + + const recent = await tx.cmppInboundLongMessage.findFirst({ + where: { + groupKey, + expiresAt: { gt: now }, + }, + include: { segments: { orderBy: { segmentIndex: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + }); + const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index); + if (recent && ['completed', 'rejected'].includes(recent.status) + && matchingRecentSegment?.contentHash === contentHash + && matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) { + return { + complete: recent.status === 'completed', + groupId: recent.id, + messageId: recent.messageId, + receivedSegments: recent.segments.length, + response: recent.response as any, + content: recent.segments.map((item) => item.content).join(''), + sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId), + }; + } + + let group = recent && ['collecting', 'processing'].includes(recent.status) ? recent : null; + if (!group) { + group = await tx.cmppInboundLongMessage.create({ + data: { + tenantId: application.tenantId, + applicationId: application.id, + groupKey, + account: data.account, + srcId: data.srcId?.trim() || null, + phoneNumbers, + concatReference: fragment.reference, + segmentTotal: fragment.total, + msgFmt: fragment.format, + messageId: `MSG-${randomUUID()}`, + expiresAt, + }, + include: { segments: { orderBy: { segmentIndex: 'asc' } } }, + }); + } + if (group.status === 'processing') { + const processingStaleMs = positiveInteger( + process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, + DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, + ) * 1000; + const complete = group.segments.length === fragment.total + && group.segments.every((item, index) => item.segmentIndex === index + 1); + if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) { + await tx.cmppInboundLongMessage.update({ + where: { id: group.id }, + data: { status: 'processing', expiresAt }, + }); + return { + complete: true, + groupId: group.id, + messageId: group.messageId, + receivedSegments: group.segments.length, + response: null, + content: group.segments.map((item) => item.content).join(''), + sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId), + }; + } + return { + complete: false, + groupId: group.id, + messageId: group.messageId, + receivedSegments: group.segments.length, + response: group.response as any, + content: '', + sequenceId: undefined, + }; + } + + const existing = group.segments.find((item) => item.segmentIndex === fragment.index); + if (existing && (existing.contentHash !== contentHash + || existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) { + throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`); + } + if (!existing) { + await tx.cmppInboundLongMessageSegment.create({ + data: { + groupId: group.id, + segmentIndex: fragment.index, + sequenceId: data.sequenceId == null ? null : String(data.sequenceId), + content: data.content, + contentHash, + }, + }); + } + const segments = await tx.cmppInboundLongMessageSegment.findMany({ + where: { groupId: group.id }, + orderBy: { segmentIndex: 'asc' }, + }); + const complete = segments.length === fragment.total + && segments.every((item, index) => item.segmentIndex === index + 1); + if (complete) { + await tx.cmppInboundLongMessage.update({ + where: { id: group.id }, + data: { status: 'processing', expiresAt }, + }); + } + return { + complete, + groupId: group.id, + messageId: group.messageId, + receivedSegments: segments.length, + response: null, + content: complete ? segments.map((item) => item.content).join('') : '', + sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId), + }; + }); + } + +async expireInboundLongMessages(now = new Date()) { + return this.prisma.cmppInboundLongMessage.updateMany({ + where: { + status: { in: ['collecting', 'processing'] }, + expiresAt: { lte: now }, + }, + data: { + status: 'expired', + completedAt: now, + }, + }); + } + +async submitInboundSingleMessage( + data: GatewayInboundSubmitDto & { phoneNumber: string }, + messageId: string, + submitGroupMessageId: string, + synchronousRejection?: { code: string; reason: string }, + receiptRejection?: { code: string; reason: string }, + ) { + const application = await this.facade.findInboundApplication(data.account); + if (!application) { + throw new BadRequestException('CMPP account is invalid'); + } + if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + throw new BadRequestException('CMPP source IP is not in application allowlist'); + } + const clientSrcId = validateInboundApplicationSrcId(data.srcId, application); + const template = await this.facade.resolveInboundTemplateCandidate(application.id, data.content); + const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {}; + const unitPrice = moneyToNumber(application.customerUnitPrice); + const queuePriority = normalizeQueuePriority(application.queuePriority); + const billing = this.billing.estimateSmsCost({ + tenantId: application.tenantId, + applicationId: application.id, + content: data.content, + phoneCount: 1, + unitPrice, + }); + const task = await this.prisma.smsBatchTask.create({ + data: { + tenantId: application.tenantId, + applicationId: application.id, + templateId: template?.id, + taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`, + sourceType: 'cmpp', + content: data.content, + phoneTotal: 1, + status: synchronousRejection ? 'rejected' : 'validating', + auditStatus: synchronousRejection ? 'rejected' : undefined, + rejectReason: synchronousRejection?.reason, + progressTotal: 1, + }, + }); + await this.prisma.smsApiRequest.create({ + data: { + tenantId: application.tenantId, + batchTaskId: task.id, + requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`, + sourceIp: data.remoteIp, + userAgent: 'cmpp-gateway', + payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account }, + status: synchronousRejection ? 'rejected' : 'accepted', + }, + }); + const message = await this.prisma.smsMessageRecord.create({ + data: { + tenantId: application.tenantId, + batchTaskId: task.id, + applicationId: application.id, + templateId: template?.id, + messageId, + phoneNumber: data.phoneNumber, + content: data.content, + billingUnits: billing.billingUnitsPerMessage, + unitPrice: receiptRejection ? 0 : billing.unitPrice, + amountCents: receiptRejection ? 0 : billing.amountCents, + queuePriority, + cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), + cmppSubmitGroupMessageId: submitGroupMessageId, + clientSrcId, + applicationExtension: application.cmppApplicationExtension, + status: synchronousRejection ? 'rejected' : 'validating', + errorCode: synchronousRejection?.code, + errorMessage: synchronousRejection?.reason, + }, + }); + + if (synchronousRejection) { + return { + accepted: false, + tenantId: application.tenantId, + applicationId: application.id, + taskId: task.id, + messageId: message.messageId, + messageRecordId: message.id, + status: 'rejected', + }; + } + + const reject = async (code: string, reason: string) => { + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason }, + }); + await this.recordCmppFailureReceipt(message, code, reason); + }; + const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => { + const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content); + const drainageInfoId = drainage?.id; + const drainageReason = drainageRejectionReason(drainage); + if (drainageReason) { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { drainageInfoId, signatureId: options.signatureId }, + }); + await reject('DRAINAGE_NOT_APPROVED', drainageReason); + return; + } + const risk = await this.facade.evaluateRiskWithPhoneFrequency({ + tenantId: application.tenantId, + applicationId: application.id, + templateId: options.templateId, + content: data.content, + variables: options.templateId ? templateVariables : undefined, + phoneNumber: data.phoneNumber, + sourceType: 'cmpp', + }); + if (risk.status === 'rejected') { + await reject('RISK', risk.reason || '短信被风控拒绝'); + return; + } + if (risk.status === 'pending_review') { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { + status: 'pending_review', + reviewTaskId: risk.task?.id, + signatureId: options.signatureId, + drainageInfoId, + }, + }); + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason }, + }); + return; + } + const accountCheck = await this.billing.checkAccount({ + tenantId: application.tenantId, + amountCents: billing.amountCents, + }); + if (!accountCheck.canSend) { + await reject('BALANCE', '企业账户余额不足'); + return; + } + if (billing.amountCents > 0) { + await this.billing.freeze({ + tenantId: application.tenantId, + amountCents: billing.amountCents, + relatedType: 'sms_batch_task', + relatedId: task.id, + remark: 'CMPP 入站短信冻结', + }); + } + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { status: 'queued', signatureId: options.signatureId, drainageInfoId }, + }); + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' }, + }); + await this.facade.enqueueBatchTask(task.id); + }; + if (receiptRejection) { + await reject(receiptRejection.code, receiptRejection.reason); + } else if (application.status !== 'active' || application.tenant.status !== 'active') { + await reject('ACCOUNT', '企业或短信应用已停用'); + } else if (!application.interfaceEnabled) { + await reject('INTERFACE', '短信应用 CMPP 接口已停用'); + } else if (application.tenant.certificationStatus !== 'approved') { + await reject('CERT', '企业认证未通过'); + } else if (!template && application.templateMismatchMode === 'manual_review') { + const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content); + if (!signature) { + await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); + } else { + const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content); + const drainageReason = drainageRejectionReason(drainage); + if (drainageReason) { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { drainageInfoId: drainage?.id, signatureId: signature.id }, + }); + await reject('DRAINAGE_NOT_APPROVED', drainageReason); + return { + accepted: true, + tenantId: application.tenantId, + applicationId: application.id, + messageId, + messageRecordId: message.id, + taskId: task.id, + status: 'rejected', + }; + } + const risk = await this.facade.evaluateRiskWithPhoneFrequency({ + tenantId: application.tenantId, + applicationId: application.id, + content: data.content, + phoneNumber: data.phoneNumber, + sourceType: 'cmpp', + }); + if (risk.status === 'rejected') { + await reject('RISK', risk.reason || '短信被风控拒绝'); + } else { + const accountCheck = await this.billing.checkAccount({ + tenantId: application.tenantId, + amountCents: billing.amountCents, + }); + if (!accountCheck.canSend) { + await reject('BALANCE', '企业账户余额不足'); + } else { + if (billing.amountCents > 0) { + await this.billing.freeze({ + tenantId: application.tenantId, + amountCents: billing.amountCents, + relatedType: 'sms_batch_task', + relatedId: task.id, + remark: 'CMPP 模板不匹配待审核短信冻结', + }); + } + const reviewTask = risk.status === 'pending_review' && risk.task + ? await this.facade.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id) + : await this.riskReview.aggregateTemplateMismatch({ + tenantId: application.tenantId, + applicationId: application.id, + account: data.account, + messageRecordId: message.id, + signatureId: signature.id, + content: data.content, + }); + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { + status: 'pending_review', + riskTaskId: reviewTask?.id, + auditStatus: 'pending', + reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核', + }, + }); + } + } + } + } else if (!template && application.templateMismatchMode === 'direct_send') { + const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content); + if (!signature) { + await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); + } else { + await queueAfterRiskChecks({ signatureId: signature.id }); + } + } else if (!template) { + await reject('TEMPLATE', '短信内容未匹配到已报备模板'); + } else if (template.auditStatus !== 'approved') { + await reject('TEMPLATE', '短信模板尚未审核通过'); + } else if (!template.signature || template.signature.auditStatus !== 'approved') { + await reject('SIGNATURE', '短信签名尚未审核通过'); + } else { + await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id }); + } + return { + accepted: true, + tenantId: application.tenantId, + applicationId: application.id, + taskId: task.id, + messageId: message.messageId, + messageRecordId: message.id, + status: 'accepted', + }; + } + +async evaluateRiskWithPhoneFrequency(input: { + tenantId: string; + applicationId: string; + templateId?: string; + content: string; + variables?: Record; + phoneNumber: string; + sourceType: 'cmpp'; + }) { + const risk = await this.riskReview.evaluateTask({ + tenantId: input.tenantId, + applicationId: input.applicationId, + templateId: input.templateId, + content: input.content, + variables: input.variables, + phones: [input.phoneNumber], + sourceType: input.sourceType, + }); + if (risk.status === 'rejected') return risk; + const frequencyRejections = await this.phoneFrequency.reserve( + input.tenantId, + input.applicationId, + [input.phoneNumber], + input.sourceType, + ); + const rejection = frequencyRejections.get(input.phoneNumber); + return rejection + ? { ...risk, status: 'rejected' as const, reason: rejection.reason } + : risk; + } + +findInboundApplication(account: string) { + return this.prisma.smsApplication.findFirst({ + where: { cmppAccount: account }, + include: { + tenant: true, + ipAllowlist: true, + }, + }); + } + +async resolveInboundTemplateCandidate(applicationId: string, content: string) { + const exact = await this.prisma.smsTemplate.findFirst({ + where: { + applicationId, + content, + auditStatus: 'approved', + signature: { auditStatus: 'approved' }, + }, + include: { signature: true }, + orderBy: { updatedAt: 'desc' }, + }); + if (exact) return exact; + const variableTemplates = await this.prisma.smsTemplate.findMany({ + where: { + applicationId, + content: { contains: '${' }, + auditStatus: 'approved', + signature: { auditStatus: 'approved' }, + }, + include: { signature: true }, + orderBy: { updatedAt: 'desc' }, + }); + return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null; + } + +resolveInboundSignatureCandidate(applicationId: string, content: string) { + const match = content.match(/^【[^】]+】/); + if (!match?.[0]) return null; + return this.prisma.smsSignature.findFirst({ + where: { + applicationId, + name: match[0], + auditStatus: 'approved', + }, + orderBy: { updatedAt: 'desc' }, + }); + } + +async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { + if (!signatureId) return undefined; + const candidates = await this.prisma.smsDrainageInfo.findMany({ + where: { signatureId, auditStatus: { not: 'deleted' } }, + select: { id: true, url: true, auditStatus: true, updatedAt: true }, + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + }); + const matches = candidates + .map((item) => ({ ...item, normalizedUrl: item.url.trim() })) + .filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl)) + .sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime()); + if (matches.length === 0) return undefined; + const longestLength = matches[0].normalizedUrl.length; + const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength); + if (longestMatches.length !== 1) { + throw new BadRequestException({ + code: 'DRAINAGE_MATCH_AMBIGUOUS', + message: '短信内容同时匹配多条等长引流地址,无法确定报备资料', + drainageInfoIds: longestMatches.map((item) => item.id), + }); + } + const matched = longestMatches[0]; + return { id: matched.id, auditStatus: matched.auditStatus }; + } + +async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { + await this.prisma.smsMessageRecord.update({ + where: { id: messageRecordId }, + data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' }, + }); + return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } }); + } +} diff --git a/api/src/send-chain/send-receipt.service.ts b/api/src/send-chain/send-receipt.service.ts new file mode 100644 index 0000000..d855ce9 --- /dev/null +++ b/api/src/send-chain/send-receipt.service.ts @@ -0,0 +1,593 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { BillingService } from '../billing/billing.service'; +import { moneyToNumber } from '../common/money'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; + + +/** + * R10 receipt implementation. + * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. + */ +export class SendReceiptService { + private readonly logger = new Logger('SendChainService'); + private upstreamReceiptInboxScanRunning = false; + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly openApi: OpenApiService | undefined, + private readonly facade: SendCompletionFacade, + private readonly callbacks: SendCompletionCallbacks, + ) {} + + async intakeReceipt(data: GatewayReceiptEventDto) { + const channel = await this.prisma.smsChannel.findUnique({ + where: { id: data.channelId }, + select: { + id: true, + account: true, + gatewayHost: true, + gatewayPort: true, + protocol: true, + cmppVersion: true, + }, + }); + if (!channel) { + throw new NotFoundException('SMS channel not found'); + } + const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); + const receiptKey = receiptEventKey(data, data.channelId); + const inbox = await this.prisma.upstreamReceiptInbox.upsert({ + where: { receiptKey }, + update: { + incomingConnectionId: data.connectionId, + }, + create: { + receiptKey, + incomingChannelId: data.channelId, + incomingConnectionId: data.connectionId, + upstreamAccount: channel.account, + upstreamHost: channel.gatewayHost, + upstreamPort: channel.gatewayPort, + protocol: channel.protocol, + protocolVersion: channel.cmppVersion, + provisionalMessageId: data.messageId, + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId, + phoneNumber: data.phoneNumber?.trim() || null, + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + deliveredAt, + status: 'pending', + nextRetryAt: new Date(), + }, + }); + if (['pending', 'retrying'].includes(inbox.status)) { + setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id)); + } + return { accepted: true, inboxId: inbox.id, status: inbox.status }; + } + + async processPendingUpstreamReceiptInbox(limit = 100) { + const now = new Date(); + const staleBefore = new Date( + now.getTime() + - positiveInteger( + process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, + DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, + ), + ); + const candidates = await this.prisma.upstreamReceiptInbox.findMany({ + where: { + OR: [ + { + status: { in: ['pending', 'retrying'] }, + OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }], + }, + { status: 'processing', updatedAt: { lte: staleBefore } }, + ], + }, + orderBy: [{ receivedAt: 'asc' }, { id: 'asc' }], + take: Math.min(Math.max(limit, 1), 500), + select: { id: true }, + }); + let processed = 0; + for (const candidate of candidates) { + if (await this.facade.processUpstreamReceiptInboxRecord(candidate.id)) processed += 1; + } + return { scanned: candidates.length, processed }; + } + + async processUpstreamReceiptInboxRecord(id: string) { + const staleBefore = new Date( + Date.now() + - positiveInteger( + process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, + DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, + ), + ); + const claimed = await this.prisma.upstreamReceiptInbox.updateMany({ + where: { + id, + OR: [ + { status: { in: ['pending', 'retrying'] } }, + { status: 'processing', updatedAt: { lte: staleBefore } }, + ], + }, + data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null }, + }); + if (claimed.count !== 1) return false; + const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } }); + if (!inbox) return false; + try { + const message = await this.facade.handleReceipt({ + messageId: inbox.provisionalMessageId ?? undefined, + channelId: inbox.incomingChannelId, + connectionId: inbox.incomingConnectionId ?? undefined, + sequenceId: inbox.sequenceId ?? undefined, + gatewayMessageId: inbox.gatewayMessageId, + phoneNumber: inbox.phoneNumber ?? undefined, + receiptStatus: normalizeReceiptStatus(inbox.receiptStatus), + rawStatus: inbox.rawStatus, + errorCode: inbox.errorCode ?? undefined, + errorMessage: inbox.errorMessage ?? undefined, + deliveredAt: inbox.deliveredAt.toISOString(), + }, { + account: inbox.upstreamAccount, + gatewayHost: inbox.upstreamHost, + gatewayPort: inbox.upstreamPort, + protocol: inbox.protocol, + cmppVersion: inbox.protocolVersion, + }); + const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId; + const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined; + await this.prisma.upstreamReceiptInbox.update({ + where: { id }, + data: { + status: 'matched', + matchedMessageRecordId: matchedMessageRecordId ?? null, + matchedChannelId: matchedChannelId ?? null, + lastError: null, + processedAt: new Date(), + }, + }); + return true; + } catch (error) { + const maxAttempts = positiveInteger( + process.env.UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, + DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, + ); + const maxAgeHours = positiveInteger( + process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, + DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, + ); + const exhausted = inbox.attemptCount >= maxAttempts + || inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000; + const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8)); + await this.prisma.upstreamReceiptInbox.update({ + where: { id }, + data: { + status: exhausted ? 'unmatched' : 'retrying', + nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs), + lastError: error instanceof Error ? error.message : String(error), + processedAt: exhausted ? new Date() : null, + }, + }); + return false; + } + } + + async runUpstreamReceiptInboxScan() { + if (this.upstreamReceiptInboxScanRunning) return; + this.upstreamReceiptInboxScanRunning = true; + try { + const result = await this.facade.processPendingUpstreamReceiptInbox(); + if (result.processed > 0) { + this.logger.log(`Matched ${result.processed}/${result.scanned} pending upstream receipts`); + } + } catch (error) { + this.logger.error('Upstream receipt inbox scan failed', error instanceof Error ? error.stack : String(error)); + } finally { + this.upstreamReceiptInboxScanRunning = false; + } + } + + async handleReceipt( + data: GatewayReceiptEventDto, + incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + ) { + const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity); + const logicalChannelId = resolved.channelId ?? data.channelId; + const receiptKey = receiptEventKey(data, logicalChannelId); + const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({ + where: { receiptKey }, + include: { messageRecord: true }, + }); + if (existingReceipt?.messageRecord) { + return existingReceipt.messageRecord; + } + const message = resolved.message; + const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); + if (resolved.submitRecordId) { + await this.prisma.smsSubmitRecord.updateMany({ + where: { + id: resolved.submitRecordId, + gatewayMessageId: null, + }, + data: { + gatewayMessageId: data.gatewayMessageId, + sequenceId: data.sequenceId, + }, + }); + } + try { + await this.prisma.smsReceiptRecord.create({ + data: { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + receiptKey, + channelId: logicalChannelId, + messageId: resolved.messageId, + gatewayMessageId: data.gatewayMessageId, + phoneNumber: data.phoneNumber?.trim() || message.phoneNumber, + sequenceId: data.sequenceId, + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + deliveredAt, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + const duplicate = await this.prisma.smsReceiptRecord.findUnique({ + where: { receiptKey }, + include: { messageRecord: true }, + }); + if (duplicate?.messageRecord) return duplicate.messageRecord; + } + throw error; + } + const logicalReceipt = { ...data, channelId: logicalChannelId }; + await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId); + const aggregate = await this.facade.aggregateReceiptSegments( + message, + logicalReceipt, + deliveredAt, + resolved.submitRecordId, + resolved.submitId, + ); + if (!aggregate.terminal) { + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } + const status = aggregate.status; + const isCurrentAttempt = + (!message.channelId || message.channelId === logicalChannelId) + && ( + !message.gatewayMessageId + || message.gatewayMessageId === data.gatewayMessageId + || (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId)) + ); + if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) { + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } + const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId; + if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { + const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; + const retried = await this.facade.retryMessageIfAllowed( + businessMessage, + '回执失败补发', + resolved.submitRecordId, + ); + if (retried) { + await this.facade.refreshTaskProgress(businessMessage.batchTaskId); + return retried; + } + await this.facade.refundMessage(businessMessage, '最终失败退款'); + } + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { + channelId: logicalChannelId, + gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId, + receiptStatus: aggregate.receiptStatus, + receiptRawStatus: aggregate.rawStatus, + status, + errorCode: aggregate.errorCode, + errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus), + deliveredAt: aggregate.deliveredAt, + }, + }); + if (!isStandaloneChannelTest && message.tenantId && message.applicationId) { + await this.facade.queueAndTryDownstreamDelivery({ + tenantId: message.tenantId, + applicationId: message.applicationId, + messageRecordId: message.id, + messageId: message.messageId, + deliveryType: 'receipt', + payload: { + messageId: message.messageId, + gatewayMessageId: data.gatewayMessageId, + phoneNumber: message.phoneNumber, + receiptStatus: aggregate.receiptStatus, + rawStatus: aggregate.rawStatus, + errorCode: aggregate.errorCode, + submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, + submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, + deliveredAt: aggregate.deliveredAt.toISOString(), + }, + }); + } + if (message.batchTaskId) { + await this.facade.refreshTaskProgress(message.batchTaskId); + } + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } + + async recordReceiptSegment( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + channelId?: string | null; + submitId?: string | null; + billingUnits?: number | null; + }, + data: GatewayReceiptEventDto, + deliveredAt: Date, + submitRecordId?: string, + ) { + const segmentAudits = this.facade.smsMessageSegmentAuditDelegate(); + const updated = await segmentAudits.updateMany({ + where: { + messageRecordId: message.id, + gatewayMessageId: data.gatewayMessageId, + }, + data: { + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + errorCode: data.errorCode ?? null, + deliveredAt, + }, + }); + if (updated.count > 0) { + return; + } + const submitRecord = submitRecordId + ? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } }) + : await this.prisma.smsSubmitRecord.findFirst({ + where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, + orderBy: { createdAt: 'desc' }, + }); + await segmentAudits.upsert({ + where: { + messageRecordId_submitId_segmentIndex: { + messageRecordId: message.id, + submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`, + segmentIndex: 1, + }, + }, + update: { + submitRecordId: submitRecord?.id ?? submitRecordId ?? null, + channelId: data.channelId ?? message.channelId ?? null, + sequenceId: data.sequenceId ?? null, + gatewayMessageId: data.gatewayMessageId, + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + errorCode: data.errorCode ?? null, + deliveredAt, + }, + create: { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + submitRecordId: submitRecord?.id ?? submitRecordId ?? null, + channelId: data.channelId ?? message.channelId ?? null, + submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`, + attempt: 0, + segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), + segmentIndex: 1, + sequenceId: data.sequenceId ?? null, + gatewayMessageId: data.gatewayMessageId, + submitStatus: submitRecord?.submitStatus ?? 'accepted', + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + compensationType: 'receipt_recovered', + errorCode: data.errorCode ?? null, + deliveredAt, + }, + }); + } + + async aggregateReceiptSegments( + message: { + id: string; + billingUnits?: number | null; + }, + data: GatewayReceiptEventDto, + deliveredAt: Date, + submitRecordId?: string, + submitId?: string, + ) { + const audits = await this.facade.smsMessageSegmentAuditDelegate().findMany({ + where: submitRecordId + ? { messageRecordId: message.id, submitRecordId } + : submitId + ? { messageRecordId: message.id, submitId } + : { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, + orderBy: { segmentIndex: 'asc' }, + }); + return aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt); + } + + async resolveReceiptMessage( + data: GatewayReceiptEventDto, + incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + ) { + const exactMessage = data.messageId + ? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }) + : null; + if (exactMessage) { + const segmentAudit = data.gatewayMessageId + ? await this.facade.smsMessageSegmentAuditDelegate().findFirst({ + where: { + messageRecordId: exactMessage.id, + gatewayMessageId: data.gatewayMessageId, + }, + orderBy: { updatedAt: 'desc' }, + }) + : null; + if (segmentAudit) { + return { + message: exactMessage, + messageId: exactMessage.messageId, + submitRecordId: segmentAudit.submitRecordId ?? undefined, + submitId: segmentAudit.submitId, + channelId: segmentAudit.channelId ?? data.channelId, + }; + } + const submitRecord = await this.prisma.smsSubmitRecord.findFirst({ + where: { + messageRecordId: exactMessage.id, + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, + }, + orderBy: { createdAt: 'desc' }, + }); + return { + message: exactMessage, + messageId: exactMessage.messageId, + submitRecordId: submitRecord?.id, + submitId: submitRecord?.submitId, + channelId: submitRecord?.channelId ?? data.channelId, + }; + } + + const phoneNumber = data.phoneNumber?.trim(); + const exactSubmits = await this.prisma.smsSubmitRecord.findMany({ + where: { + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, + ...(phoneNumber ? { messageRecord: { phoneNumber } } : {}), + }, + include: { messageRecord: true }, + orderBy: { createdAt: 'desc' }, + take: 2, + }); + if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) { + return { + message: exactSubmits[0].messageRecord, + messageId: exactSubmits[0].messageRecord.messageId, + submitRecordId: exactSubmits[0].id, + submitId: exactSubmits[0].submitId, + channelId: exactSubmits[0].channelId, + }; + } + + if (!phoneNumber) { + throw new NotFoundException('SMS message record not found'); + } + + const incomingChannel = incomingIdentity + ?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); + if (!incomingChannel) { + throw new NotFoundException('SMS message record not found'); + } + const segmentMatches = await this.facade.smsMessageSegmentAuditDelegate().findMany({ + where: { + gatewayMessageId: data.gatewayMessageId, + messageRecord: { phoneNumber }, + }, + include: { messageRecord: true, submitRecord: true, channel: true }, + orderBy: { createdAt: 'desc' }, + take: 10, + }); + const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId); + if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) { + return { + message: exactSegmentMatches[0].messageRecord, + messageId: exactSegmentMatches[0].messageRecord.messageId, + submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined, + submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId, + channelId: exactSegmentMatches[0].channelId, + }; + } + const sameSupplierSegments = segmentMatches.filter((candidate) => + candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel)); + if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) { + return { + message: sameSupplierSegments[0].messageRecord, + messageId: sameSupplierSegments[0].messageRecord.messageId, + submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined, + submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId, + channelId: sameSupplierSegments[0].channelId, + }; + } + const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({ + where: { + gatewayMessageId: data.gatewayMessageId, + messageRecord: { phoneNumber }, + }, + include: { messageRecord: true, channel: true }, + orderBy: { createdAt: 'desc' }, + take: 10, + }); + const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) => + candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel)); + if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) { + return { + message: sameSupplierSubmits[0].messageRecord, + messageId: sameSupplierSubmits[0].messageRecord.messageId, + submitRecordId: sameSupplierSubmits[0].id, + submitId: sameSupplierSubmits[0].submitId, + channelId: sameSupplierSubmits[0].channelId, + }; + } + + const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); + const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000); + const candidates = await this.prisma.smsSubmitRecord.findMany({ + where: { + channelId: data.channelId, + gatewayMessageId: null, + submitStatus: 'timeout', + submittedAt: { + gte: submittedAfter, + lte: deliveredAt, + }, + messageRecord: { + phoneNumber, + }, + }, + include: { + messageRecord: true, + }, + orderBy: { + submittedAt: 'desc', + }, + take: 10, + }); + + if (candidates.length !== 1 || !candidates[0]?.messageRecord) { + throw new NotFoundException('SMS message record not found'); + } + + return { + message: candidates[0].messageRecord, + messageId: candidates[0].messageRecord.messageId, + submitRecordId: candidates[0].id, + submitId: candidates[0].submitId, + channelId: candidates[0].channelId, + }; + } +} diff --git a/api/src/send-chain/send-retry.service.ts b/api/src/send-chain/send-retry.service.ts new file mode 100644 index 0000000..c213a2e --- /dev/null +++ b/api/src/send-chain/send-retry.service.ts @@ -0,0 +1,359 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { BillingService } from '../billing/billing.service'; +import { moneyToNumber } from '../common/money'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; + + +/** + * R10 retry implementation. + * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. + */ +export class SendRetryService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly openApi: OpenApiService | undefined, + private readonly facade: SendCompletionFacade, + private readonly callbacks: SendCompletionCallbacks, + ) {} + + async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) { + const createdAt = data.deadLetteredAt ? new Date(data.deadLetteredAt) : new Date(); + return this.prisma.gatewaySubmitDeadLetter.upsert({ + where: { streamMessageId: data.streamMessageId }, + update: { + tenantId: data.tenantId, + applicationId: data.applicationId, + channelId: data.channelId, + traceId: data.traceId, + messageId: data.messageId, + submitId: data.submitId, + failureCode: data.failureCode, + failureMessage: data.failureMessage, + attempts: data.attempts, + maxAttempts: data.maxAttempts, + commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined, + rawPayload: data.rawPayload, + }, + create: { + streamMessageId: data.streamMessageId, + tenantId: data.tenantId, + applicationId: data.applicationId, + channelId: data.channelId, + traceId: data.traceId, + messageId: data.messageId, + submitId: data.submitId, + failureCode: data.failureCode, + failureMessage: data.failureMessage, + attempts: data.attempts, + maxAttempts: data.maxAttempts, + commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined, + rawPayload: data.rawPayload, + createdAt, + }, + }); + } + + async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) { + const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } }); + if (!deadLetter) { + throw new NotFoundException('Gateway提交异常记录不存在'); + } + if (deadLetter.status !== 'pending') { + throw new BadRequestException('该提交异常当前状态不允许重新入队'); + } + if (!data.confirmedNotSubmitted) { + throw new BadRequestException('请确认运营商未接收该短信后再重新入队'); + } + const reason = String(data.reason ?? '').trim(); + if (reason.length < 5 || reason.length > 500) { + throw new BadRequestException('请填写5至500字的重新入队原因'); + } + if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) { + throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand'); + } + if (deadLetter.manualRetryCount >= 3) { + throw new BadRequestException('该提交异常已达到人工重新入队次数上限'); + } + const message = deadLetter.messageId + ? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } }) + : null; + if (message && ( + message.submitStatus === 'accepted' + || ['submitted', 'delivered', 'unknown'].includes(message.status) + || ['delivered', 'unknown'].includes(message.receiptStatus ?? '') + )) { + throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队'); + } + const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim(); + if (!commandChannelId) { + throw new BadRequestException('该提交异常缺少通道信息'); + } + const channel = await this.prisma.smsChannel.findUnique({ + where: { id: commandChannelId }, + include: { connectionStates: true }, + }); + if (!channel || channel.status !== 'active') { + throw new BadRequestException('原通道不存在或已停用,不能重新入队'); + } + if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) { + throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道'); + } + const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id, status: 'pending' }, + data: { status: 'requeueing' }, + }); + if (claimed.count !== 1) { + throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试'); + } + const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1); + let retryStreamMessageId: string; + try { + const publishedStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey); + if (!publishedStreamMessageId) { + throw new Error('Gateway提交异常重新入队未返回Stream消息编号'); + } + retryStreamMessageId = publishedStreamMessageId; + } catch (error) { + await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id, status: 'requeueing' }, + data: { status: 'pending' }, + }); + throw error; + } + const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id, status: 'requeueing' }, + data: { + status: 'requeued', + manualRetryCount: { increment: 1 }, + lastRetryStreamId: retryStreamMessageId, + lastRetriedAt: new Date(), + }, + }); + const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } }); + if (!updated) { + throw new NotFoundException('Gateway提交异常记录不存在'); + } + if (finalized.count !== 1 && updated.status !== 'resolved') { + throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果'); + } + await this.prisma.operationLog.create({ + data: { + tenantId: updated.tenantId ?? undefined, + userId: data.operatorId, + action: 'gateway.submit_dead_letter_requeue', + resource: 'gateway_submit_dead_letter', + resourceId: updated.id, + detail: { + streamMessageId: updated.streamMessageId, + retryStreamMessageId, + submitId: updated.submitId, + messageId: updated.messageId, + reason, + confirmedNotSubmitted: true, + }, + }, + }); + return updated; + } + + async recoverStaleGatewaySubmitRequeues(now = new Date()) { + const staleCutoff = new Date(now.getTime() - positiveInteger( + process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS, + DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, + )); + const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({ + where: { status: 'requeueing', updatedAt: { lt: staleCutoff } }, + orderBy: { updatedAt: 'asc' }, + take: 100, + }); + let recovered = 0; + let failed = 0; + for (const deadLetter of stale) { + if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) { + await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt }, + data: { status: 'pending' }, + }); + failed += 1; + continue; + } + const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt }, + data: { status: 'requeue_recovering' }, + }); + if (claimed.count !== 1) continue; + try { + const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1); + const retryStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey); + if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号'); + const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeue_recovering' }, + data: { + status: 'requeued', + manualRetryCount: { increment: 1 }, + lastRetryStreamId: retryStreamMessageId, + lastRetriedAt: new Date(), + }, + }); + if (finalized.count === 1) { + recovered += 1; + await this.prisma.operationLog.create({ + data: { + tenantId: deadLetter.tenantId ?? undefined, + action: 'gateway.submit_dead_letter_requeue_recovered', + resource: 'gateway_submit_dead_letter', + resourceId: deadLetter.id, + detail: { retryStreamMessageId, requeueKey }, + }, + }); + } + } catch (error) { + failed += 1; + await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeue_recovering' }, + data: { status: 'requeueing' }, + }); + this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { recovered, failed }; + } + + async retryMessageIfAllowed( + message: { + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + templateId?: string | null; + signatureId?: string | null; + submitId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + queuedAt?: Date; + clientSrcId?: string | null; + applicationExtension?: string | null; + carrier?: string | null; + province?: string | null; + }, + reason: string, + sourceSubmitRecordId?: string, + ) { + const attempts = await this.prisma.smsSubmitRecord.findMany({ + where: { messageRecordId: message.id }, + orderBy: { createdAt: 'asc' }, + take: 200, + }); + const attemptedChannelIds = attempts.map((attempt) => attempt.channelId); + let sourceAttempt = sourceSubmitRecordId + ? attempts.find((attempt) => attempt.id === sourceSubmitRecordId) + : attempts.find((attempt) => attempt.submitId === message.submitId) ?? attempts[attempts.length - 1]; + if (!sourceAttempt && sourceSubmitRecordId) { + sourceAttempt = await this.prisma.smsSubmitRecord.findUnique({ + where: { id: sourceSubmitRecordId }, + }) ?? undefined; + } + if (!sourceAttempt || sourceAttempt.messageRecordId !== message.id) { + this.logger.error(`sms_retry_route_failed ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + reason, + sourceSubmitRecordId, + sourceMessageRecordId: sourceAttempt?.messageRecordId, + error: sourceAttempt ? 'retry_source_submit_record_mismatch' : 'retry_source_submit_record_missing', + })}`); + return null; + } + const existingRetry = await this.prisma.smsSubmitRecord.findUnique({ + where: { retryOfSubmitRecordId: sourceAttempt.id }, + }); + if (existingRetry) { + this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + retryOfSubmitRecordId: sourceAttempt.id, + submitId: existingRetry.submitId, + channelId: existingRetry.channelId, + })}`); + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } + const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000; + this.logger.log(`sms_retry_route_started ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + reason, + attemptedChannelIds, + ageMinutes: Math.round(ageMinutes * 100) / 100, + })}`); + if (ageMinutes >= 72 * 60) { + this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + reason: 'maximum_message_age_exceeded', + ageMinutes: Math.round(ageMinutes * 100) / 100, + })}`); + return null; + } + const retryCarrier = message.carrier + ? normalizeCarrier(message.carrier) + : await this.facade.identifyCarrier(message.phoneNumber); + const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier); + const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60); + if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) { + this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + groupId: route.groupId, + reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded', + ageMinutes: Math.round(ageMinutes * 100) / 100, + retryTimeLimitMinutes, + })}`); + return null; + } + try { + const routed = await this.facade.selectChannelForMessage({ ...message, carrier: retryCarrier }, { + forceNational: true, + excludeChannelIds: attemptedChannelIds, + }); + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { errorMessage: reason }, + }); + const retried = await this.facade.submitMessageToGateway( + message, + routed, + attempts.length, + sourceAttempt.id, + ); + this.logger.log(`sms_retry_route_selected ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + groupId: routed.groupId, + channelId: routed.channel.id, + attempt: attempts.length, + })}`); + return retried; + } catch (error) { + this.logger.error(`sms_retry_route_failed ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + reason, + attemptedChannelIds, + error: error instanceof Error ? error.message : String(error), + })}`); + return null; + } + } +} diff --git a/api/src/send-chain/send-review-continuation.service.ts b/api/src/send-chain/send-review-continuation.service.ts new file mode 100644 index 0000000..2e0ef27 --- /dev/null +++ b/api/src/send-chain/send-review-continuation.service.ts @@ -0,0 +1,109 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Queue, Worker } from 'bullmq'; +import IORedis from 'ioredis'; +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { BillingService } from '../billing/billing.service'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { moneyToNumber } from '../common/money'; +import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RiskReviewService } from '../risk-review/risk-review.service'; +import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; +import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; + +/** + * R9 reviewContinuation implementation. Cross-method calls return through the stable SendChainService seam. + */ +export class SendReviewContinuationService { + private readonly logger = new Logger('SendChainService'); + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly riskReview: RiskReviewService, + private readonly phoneFrequency: PhoneFrequencyService, + private readonly phoneRouting: PhoneRoutingLookupService, + private readonly facade: SendSubmissionService, + private readonly callbacks: SendSubmissionCallbacks, + ) {} + + private releaseMessageReservation( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + return this.callbacks.releaseMessageReservation(message, remark); + } + + private recordCmppFailureReceipt( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) { + return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); + } + + +async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { + const reviewTask = await this.prisma.smsSendTask.findUnique({ + where: { id: reviewTaskId }, + }); + if (!reviewTask) { + return { reviewTaskId, decision, affected: 0 }; + } + const messageRecords = await this.prisma.smsMessageRecord.findMany({ + where: { + status: 'pending_review', + OR: [ + { reviewTaskId }, + { batchTask: { riskTaskId: reviewTaskId } }, + ], + }, + include: { batchTask: true }, + }); + if (messageRecords.length === 0) { + return { reviewTaskId, decision, affected: 0 }; + } + const batchTaskIds = new Set(); + for (const message of messageRecords) { + if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue; + if (decision === 'approved') { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { status: 'queued', errorCode: null, errorMessage: null }, + }); + await this.prisma.smsBatchTask.update({ + where: { id: message.batchTaskId }, + data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null }, + }); + batchTaskIds.add(message.batchTaskId); + } else { + await this.releaseMessageReservation( + message as typeof message & { tenantId: string; batchTaskId: string }, + '模板不匹配人工审核驳回释放冻结', + ); + await this.prisma.smsBatchTask.update({ + where: { id: message.batchTaskId }, + data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason }, + }); + await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason); + } + } + for (const batchTaskId of batchTaskIds) { + await this.facade.enqueueBatchTask(batchTaskId); + } + return { reviewTaskId, decision, affected: messageRecords.length }; + } +} diff --git a/api/src/send-chain/send-scheduled-dispatch.service.ts b/api/src/send-chain/send-scheduled-dispatch.service.ts new file mode 100644 index 0000000..eb831d2 --- /dev/null +++ b/api/src/send-chain/send-scheduled-dispatch.service.ts @@ -0,0 +1,160 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Queue, Worker } from 'bullmq'; +import IORedis from 'ioredis'; +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { BillingService } from '../billing/billing.service'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { moneyToNumber } from '../common/money'; +import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RiskReviewService } from '../risk-review/risk-review.service'; +import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; +import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; +import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; + +/** + * R9 scheduledDispatch implementation. Cross-method calls return through the stable SendChainService seam. + */ +export class SendScheduledDispatchService { + private readonly logger = new Logger('SendChainService'); + private scheduledDispatchScanRunning = false; + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly riskReview: RiskReviewService, + private readonly phoneFrequency: PhoneFrequencyService, + private readonly phoneRouting: PhoneRoutingLookupService, + private readonly facade: SendSubmissionService, + private readonly callbacks: SendSubmissionCallbacks, + ) {} + + private releaseMessageReservation( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) { + return this.callbacks.releaseMessageReservation(message, remark); + } + + private recordCmppFailureReceipt( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) { + return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); + } + + +async dispatchDueScheduledTasks(now = new Date()) { + const staleCutoff = new Date(now.getTime() - positiveInteger( + process.env.SMS_SCHEDULED_DISPATCH_STALE_MS, + DEFAULT_SCHEDULED_DISPATCH_STALE_MS, + )); + const tasks = await this.prisma.smsBatchTask.findMany({ + where: { + OR: [ + { status: 'scheduled', scheduledAt: { lte: now } }, + { status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } }, + ], + }, + orderBy: { scheduledAt: 'asc' }, + }); + const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = []; + for (const task of tasks) { + const candidateStatus = task.status || 'scheduled'; + const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching'; + const claimed = await this.prisma.smsBatchTask.updateMany({ + where: { + id: task.id, + status: candidateStatus, + ...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }), + }, + data: { status: claimedStatus }, + }); + if (claimed.count !== 1) continue; + let reservationEstablished = false; + let dispatchPrepared = false; + try { + await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined); + const messages = await this.prisma.smsMessageRecord.findMany({ + where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } }, + select: { id: true, amountCents: true, billingUnits: true }, + take: 100000, + }); + const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0); + const existingReservation = await this.prisma.accountTransaction.findFirst({ + where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id }, + select: { id: true }, + }); + reservationEstablished = Boolean(existingReservation); + if (!reservationEstablished) { + const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents }); + if (!accountCheck.canSend) { + throw new BadRequestException('定时任务到点时企业账户余额不足'); + } + if (amountCents > 0) { + await this.billing.freeze({ + tenantId: task.tenantId, + amountCents, + relatedType: 'sms_batch_task', + relatedId: task.id, + remark: '定时任务到点冻结', + }); + reservationEstablished = true; + } + } + dispatchPrepared = true; + await this.prisma.smsMessageRecord.updateMany({ + where: { batchTaskId: task.id, status: 'scheduled' }, + data: { status: 'queued' }, + }); + const enqueued = await this.facade.enqueueBatchTask(task.id); + results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued }); + } catch (error) { + const reason = error instanceof Error ? error.message : '定时任务到点执行失败'; + if (reservationEstablished || dispatchPrepared) { + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` }, + }); + results.push({ taskId: task.id, status: 'retrying', reason }); + continue; + } + await this.prisma.smsMessageRecord.updateMany({ + where: { batchTaskId: task.id, status: 'scheduled' }, + data: { status: 'rejected', errorMessage: reason }, + }); + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: 'failed', rejectReason: reason }, + }); + results.push({ taskId: task.id, status: 'failed', reason }); + } + } + return { dispatched: results.filter((result) => result.status === 'queued').length, results }; + } + +async runScheduledDispatchScan() { + if (this.scheduledDispatchScanRunning) return; + this.scheduledDispatchScanRunning = true; + try { + await this.facade.dispatchDueScheduledTasks(); + } catch (error) { + this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.scheduledDispatchScanRunning = false; + } + } +} diff --git a/api/src/send-chain/send-submission.service.ts b/api/src/send-chain/send-submission.service.ts new file mode 100644 index 0000000..58b4f8a --- /dev/null +++ b/api/src/send-chain/send-submission.service.ts @@ -0,0 +1,306 @@ +import { BillingService } from '../billing/billing.service'; +import { Queue } from 'bullmq'; +import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RiskReviewService } from '../risk-review/risk-review.service'; +import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; +import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; +import { SendBatchEntryService } from './send-batch-entry.service'; +import { SendGatewaySubmitService } from './send-gateway-submit.service'; +import { SendInboundEntryService } from './send-inbound-entry.service'; +import { SendReviewContinuationService } from './send-review-continuation.service'; +import { SendScheduledDispatchService } from './send-scheduled-dispatch.service'; + + +export type SendSubmissionCallbacks = { + releaseMessageReservation: ( + message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + remark: string, + ) => Promise; + recordCmppFailureReceipt: ( + message: { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + }, + errorCode: string, + reason: string, + ) => Promise; +}; + +/** + * R9 internal compatibility facade. SendChainService remains the only public NestJS provider. + */ +export class SendSubmissionService { + private readonly batchEntry: SendBatchEntryService; + private readonly inboundEntry: SendInboundEntryService; + private readonly reviewContinuation: SendReviewContinuationService; + private readonly scheduledDispatch: SendScheduledDispatchService; + private readonly gatewaySubmit: SendGatewaySubmitService; + + constructor( + prisma: PrismaService, + billing: BillingService, + riskReview: RiskReviewService, + phoneFrequency: PhoneFrequencyService, + phoneRouting: PhoneRoutingLookupService, + facade: SendSubmissionService, + callbacks: SendSubmissionCallbacks, + ) { + this.batchEntry = new SendBatchEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks); + this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks); + this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks); + this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks); + this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks); + } + + onModuleDestroy() { + return this.gatewaySubmit.onModuleDestroy(); + } + +async createBatchTask(data: CreateBatchTaskDto) { + return this.batchEntry.createBatchTask(data); + } + +async createHttpBatchTask(data: CreateHttpBatchTaskDto) { + return this.batchEntry.createHttpBatchTask(data); + } + +async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { + return this.batchEntry.getBatchTask(taskId, tenantId, sourceType); + } + +async previewImport(data: ImportPreviewDto) { + return this.batchEntry.previewImport(data); + } + +async confirmImport(data: ConfirmImportDto) { + return this.batchEntry.confirmImport(data); + } + +async resolveUnitPrice(tenantId: string, applicationId?: string) { + return this.batchEntry.resolveUnitPrice(tenantId, applicationId); + } + +async resolveQueuePriority(tenantId: string, applicationId?: string): Promise { + return this.batchEntry.resolveQueuePriority(tenantId, applicationId); + } + +async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) { + return this.batchEntry.resolveApplicationAccessNumber(tenantId, applicationId); + } + +async resolveTemplateMessageClassification( + tenantId: string, + applicationId: string | undefined, + templateId: string | undefined, + content: string, + ) { + return this.batchEntry.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content); + } + +async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { + return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones); + } + +async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { + return this.batchEntry.validateSendResources(tenantId, applicationId, templateId); + } + +async reserveDailySendQuota(applicationId: string, requestedCount: number) { + return this.batchEntry.reserveDailySendQuota(applicationId, requestedCount); + } + +async tryReserveDailySendQuota(applicationId: string, requestedCount: number) { + return this.batchEntry.tryReserveDailySendQuota(applicationId, requestedCount); + } + +async authenticateInboundApplication(data: GatewayInboundAuthDto) { + return this.inboundEntry.authenticateInboundApplication(data); + } + +async submitInboundMessage(data: GatewayInboundSubmitDto) { + return this.inboundEntry.submitInboundMessage(data); + } + +async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) { + return this.inboundEntry.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers); + } + +async submitCompleteInboundMessage( + data: GatewayInboundSubmitDto, + phoneNumbers: string[], + application: Awaited>, + requestedGroupMessageId?: string, + ) { + return this.inboundEntry.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId); + } + +async collectInboundLongMessageFragment( + data: GatewayInboundSubmitDto, + application: NonNullable>>, + phoneNumbers: string[], + ) { + return this.inboundEntry.collectInboundLongMessageFragment(data, application, phoneNumbers); + } + +async expireInboundLongMessages(now = new Date()) { + return this.inboundEntry.expireInboundLongMessages(now); + } + +async submitInboundSingleMessage( + data: GatewayInboundSubmitDto & { phoneNumber: string }, + messageId: string, + submitGroupMessageId: string, + synchronousRejection?: { code: string; reason: string }, + receiptRejection?: { code: string; reason: string }, + ) { + return this.inboundEntry.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection); + } + +async evaluateRiskWithPhoneFrequency(input: { + tenantId: string; + applicationId: string; + templateId?: string; + content: string; + variables?: Record; + phoneNumber: string; + sourceType: 'cmpp'; + }) { + return this.inboundEntry.evaluateRiskWithPhoneFrequency(input); + } + +findInboundApplication(account: string) { + return this.inboundEntry.findInboundApplication(account); + } + +async resolveInboundTemplateCandidate(applicationId: string, content: string) { + return this.inboundEntry.resolveInboundTemplateCandidate(applicationId, content); + } + +resolveInboundSignatureCandidate(applicationId: string, content: string) { + return this.inboundEntry.resolveInboundSignatureCandidate(applicationId, content); + } + +async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { + return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content); + } + +async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { + return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId); + } + +async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { + return this.reviewContinuation.handleReviewDecision(reviewTaskId, decision, reason); + } + +async dispatchDueScheduledTasks(now = new Date()) { + return this.scheduledDispatch.dispatchDueScheduledTasks(now); + } + +async runScheduledDispatchScan() { + return this.scheduledDispatch.runScheduledDispatchScan(); + } + +async enqueueBatchTask(taskId: string) { + return this.gatewaySubmit.enqueueBatchTask(taskId); + } + +startWorker() { + return this.gatewaySubmit.startWorker(); + } + +async processSendJob(job: SendJob) { + return this.gatewaySubmit.processSendJob(job); + } + +async submitMessageToGateway( + message: { + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + templateId?: string | null; + signatureId?: string | null; + submitId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + queuePriority?: string | null; + clientSrcId?: string | null; + applicationExtension?: string | null; + template?: { signature?: { id?: string | null; name?: string | null } | null } | null; + signature?: { id?: string | null; name?: string | null } | null; + }, + routed: RoutedChannel, + attempt: number, + retryOfSubmitRecordId?: string, + ) { + return this.gatewaySubmit.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId); + } + +async selectChannelForMessage( + message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }, + options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, + ): Promise { + return this.gatewaySubmit.selectChannelForMessage(message, options); + } + +async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) { + return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier); + } + +async identifyCarrier(phoneNumber: string) { + return this.gatewaySubmit.identifyCarrier(phoneNumber); + } + +async identifyProvince(phoneNumber: string) { + return this.gatewaySubmit.identifyProvince(phoneNumber); + } + +async ensureSignatureReportedForChannel( + message: { + id: string; + templateId?: string | null; + template?: { signature?: { id?: string | null; name?: string | null } | null } | null; + signature?: { id?: string | null; name?: string | null } | null; + }, + channelId: string, + ) { + return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId); + } + +async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { + return this.gatewaySubmit.resolveMessageSignatureId(message); + } + +async waitForChannelRateLimit(channelId: string, tps: number) { + return this.gatewaySubmit.waitForChannelRateLimit(channelId, tps); + } + +async refreshTaskProgress(batchTaskId: string) { + return this.gatewaySubmit.refreshTaskProgress(batchTaskId); + } + +getSendQueue(): Queue { + return this.gatewaySubmit.getSendQueue(); + } + +getGatewayQueue(): Queue { + return this.gatewaySubmit.getGatewayQueue(); + } + +getRedis() { + return this.gatewaySubmit.getRedis(); + } + +async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { + return this.gatewaySubmit.publishGatewaySubmitCommand(command, idempotencyKey); + } +} diff --git a/api/src/send-chain/send-timeout.service.ts b/api/src/send-chain/send-timeout.service.ts new file mode 100644 index 0000000..22de455 --- /dev/null +++ b/api/src/send-chain/send-timeout.service.ts @@ -0,0 +1,104 @@ +import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { BillingService } from '../billing/billing.service'; +import { moneyToNumber } from '../common/money'; +import type { OpenApiService } from '../open-api/open-api.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; +import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { SendSubmissionService } from './send-submission.service'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; + + +/** + * R10 timeout implementation. + * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. + */ +export class SendTimeoutService { + private readonly logger = new Logger('SendChainService'); + private receiptTimeoutScanRunning = false; + + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + private readonly openApi: OpenApiService | undefined, + private readonly facade: SendCompletionFacade, + private readonly callbacks: SendCompletionCallbacks, + ) {} + + async markUnknownTimeout(data: TimeoutUnknownDto) { + const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS); + const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000); + const candidates = await this.prisma.smsMessageRecord.findMany({ + where: { + tenantId: { not: null }, + status: { in: ['submitted', 'unknown'] }, + submittedAt: { lte: cutoff }, + }, + select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true }, + take: 10000, + }); + const timedOutTaskIds = new Set(); + let timeout = 0; + for (const candidate of candidates) { + if (!candidate.tenantId) continue; + const transitioned = await this.prisma.smsMessageRecord.updateMany({ + where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } }, + data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` }, + }); + if (transitioned.count !== 1) continue; + timeout += 1; + await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`); + if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId); + } + for (const batchTaskId of timedOutTaskIds) { + await this.facade.refreshTaskProgress(batchTaskId); + } + return { timeout }; + } + + async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) { + const cutoff = new Date(Date.now() - olderThanHours * 60 * 60_000); + const expired = await this.prisma.cmppDownstreamDelivery.findMany({ + where: { + status: 'pending', + OR: [ + { lastRetriedAt: null, createdAt: { lte: cutoff } }, + { lastRetriedAt: { lte: cutoff } }, + ], + }, + select: { id: true }, + take: 500, + }); + for (const delivery of expired) { + await this.facade.markDownstreamDeliveryFailed( + delivery.id, + `下游投递排队超过 ${olderThanHours} 小时,系统自动终止重试`, + 'queue_timeout', + ); + } + return { failed: expired.length }; + } + + async runReceiptTimeoutScan() { + if (this.receiptTimeoutScanRunning) return; + this.receiptTimeoutScanRunning = true; + try { + const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([ + this.facade.markUnknownTimeout({}), + this.facade.markExpiredDownstreamDeliveries(), + this.facade.recoverStaleGatewaySubmitRequeues(), + this.facade.recoverStaleDownstreamManualRequeues(), + ]); + if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`); + if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`); + if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`); + if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`); + } catch (error) { + this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error)); + } finally { + this.receiptTimeoutScanRunning = false; + } + } +} diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index e9c150f..70f639a 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -4,7 +4,8 @@ import { RequireRecentAuthentication } from '../auth/require-recent-authenticati import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { ReviewDecisionDto, ReviewGovernanceService } from './review-governance.service'; import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service'; -import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service'; +import { SmsConfigService } from './sms-config.service'; +import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; @ApiTags('admin-sms-config') @Controller('admin') diff --git a/api/src/sms-config/application-config.service.ts b/api/src/sms-config/application-config.service.ts new file mode 100644 index 0000000..7c788fd --- /dev/null +++ b/api/src/sms-config/application-config.service.ts @@ -0,0 +1,513 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomInt, randomUUID } from 'node:crypto'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { assertMoneyUnits } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import { automaticDeliveryMode } from '../open-api/delivery-mode'; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import { SmsApplicationLifecycleService } from './application-lifecycle.service'; + +/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ +export class SmsApplicationConfigService { + constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService) {} + async listApplications(queryOrTenantId?: string | ApplicationListQuery) { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; + if (query.includeConnections) { + await this.lifecycle.markTimedOutDownstreamConnections(); + } + const applications = await this.prisma.smsApplication.findMany({ + where: { + tenantId: query.tenantId, + status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }, + include: { + tenant: true, + ipAllowlist: true, + httpConfig: true, + }, + omit: { + secretHash: true, + }, + orderBy: { createdAt: 'desc' }, + ...(query.page && query.pageSize ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } : {}), + }); + if (!query.includeConnections) { + return applications; + } + const applicationIds = applications.map((application) => application.id); + const [connections, messageStats] = await Promise.all([ + this.prisma.cmppDownstreamConnection.findMany({ + where: { applicationId: { in: applicationIds }, status: 'connected' }, + orderBy: { updatedAt: 'desc' }, + }), + this.prisma.smsMessageRecord.groupBy({ + by: ['applicationId', 'status'], + where: { applicationId: { in: applicationIds }, queuedAt: { gte: startOfToday() } }, + _count: { _all: true }, + }), + ]); + const disablingDetails = new Map((await Promise.all(applications + .filter((application) => application.status === 'disabling') + .map(async (application) => [application.id, await this.lifecycle.getApplicationDeactivationPreview(application.id)] as const)))); + return applications.map((application) => { + const appConnections = connections.filter((connection) => connection.applicationId === application.id); + const appStats = messageStats.filter((item) => item.applicationId === application.id); + const todayTotal = appStats.reduce((sum, item) => sum + item._count._all, 0); + const delivered = appStats.find((item) => item.status === 'delivered')?._count._all ?? 0; + return { + ...application, + cmppConnections: appConnections, + cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status), + sentToday: todayTotal, + deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0, + deactivation: disablingDetails.get(application.id) ?? null, + }; + }).sort((left, right) => right.sentToday - left.sentToday + || left.name.localeCompare(right.name, 'zh-CN') + || left.id.localeCompare(right.id)); + } + + async listApplicationsPage(query: ApplicationListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsApplicationWhereInput = { + tenantId: query.tenantId, + status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.listApplications({ ...query, page, pageSize }), + this.prisma.smsApplication.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + listApplicationOptions(tenantId?: string) { + return this.prisma.smsApplication.findMany({ + where: { tenantId, status: { not: 'deleted' } }, + select: { id: true, tenantId: true, name: true, status: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + } + + async getApplication(applicationId: string, tenantId?: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + include: { + tenant: true, + ipAllowlist: true, + httpConfig: true, + }, + }); + if (!application || (tenantId && application.tenantId !== tenantId)) { + throw new NotFoundException('Application not found'); + } + return application; + } + + async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') { + if (applicationId) await this.getApplication(applicationId); + const [commonFields, routes] = await Promise.all([ + this.prisma.commonReportField.findMany({ + where: { + status: 'active', + reportType, + drainageField: { status: 'active' }, + }, + include: { drainageField: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }), + applicationId ? this.prisma.channelRouteRule.findMany({ + where: { applicationId, status: 'active' }, + include: { + group: { + include: { + items: { + include: { + channel: { + include: { + reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } }, + }, + }, + }, + }, + }, + }, + }, + orderBy: { priority: 'asc' }, + }) : Promise.resolve([]), + ]); + type MergedReportField = { + id: string; + code: string; + name: string; + fieldType: string; + required: boolean; + description?: string | null; + reportTypes: string[]; + commonReportTypes: string[]; + channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>; + }; + const merged = new Map(); + const routeChannels = new Map(); + for (const route of routes) { + if (!route.group) continue; + for (const item of route.group.items) { + if (!routeChannels.has(item.channel.id)) { + routeChannels.set(item.channel.id, { + id: item.channel.id, + code: item.channel.code, + name: item.channel.name, + groupId: route.group.id, + groupName: route.group.name, + }); + } + } + } + for (const configured of commonFields) { + merged.set(configured.drainageField.id, { + id: configured.drainageField.id, + code: configured.drainageField.code, + name: configured.drainageField.name, + fieldType: configured.drainageField.fieldType, + required: configured.required, + description: configured.drainageField.description, + reportTypes: [configured.reportType], + commonReportTypes: [configured.reportType], + channels: Array.from(routeChannels.values()).map((channel) => ({ + ...channel, + required: configured.required, + reportType: configured.reportType, + source: 'common' as const, + })), + }); + } + for (const route of routes) { + if (!route.group) continue; + for (const item of route.group.items) { + for (const configured of item.channel.reportFields) { + if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue; + if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue; + const key = configured.drainageField.id; + const current: MergedReportField = merged.get(key) ?? { + id: configured.drainageField.id, + code: configured.drainageField.code, + name: configured.drainageField.name, + fieldType: configured.drainageField.fieldType, + required: false, + description: configured.drainageField.description, + reportTypes: [], + commonReportTypes: [], + channels: [], + }; + current.required = current.required || configured.required; + if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType); + const existingChannel = current.channels.find((channel) => channel.id === item.channel.id); + if (existingChannel) { + existingChannel.required = existingChannel.required || configured.required; + existingChannel.reportType = configured.reportType; + existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source; + } else { + current.channels.push({ + id: item.channel.id, + code: item.channel.code, + name: item.channel.name, + groupId: route.group.id, + groupName: route.group.name, + required: configured.required, + reportType: configured.reportType, + source: 'channel', + }); + } + merged.set(key, current); + } + } + } + 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) { + assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价'); + const secret = normalizeApplicationPassword(data.passwordCipher); + const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); + const interfaceType = normalizeApplicationInterfaceType(data.interfaceType); + const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount(); + const cmppEnterpriseCode = cmppAccount; + const accessNumber = normalizeCmppAccessNumberConfig(data); + await this.validateClientSrcIdAvailable(accessNumber.clientSrcId); + return this.prisma.smsApplication.create({ + data: { + tenantId: data.tenantId, + name: data.name, + scene: data.scene, + callbackUrl: data.callbackUrl, + cmppAccount, + cmppEnterpriseCode, + cmppApplicationExtension: accessNumber.applicationExtension, + cmppAccessNumberFillEnabled: accessNumber.fillEnabled, + cmppAccessNumberFillPrefix: accessNumber.fillPrefix, + cmppClientSrcId: accessNumber.clientSrcId, + secretHash: secret, + interfaceEnabled: data.interfaceEnabled ?? true, + interfaceType, + cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), + cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), + dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), + customerUnitPrice: data.customerUnitPrice ?? 0, + queuePriority, + templateMismatchMode: data.templateMismatchMode ?? 'reject', + downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true, + downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true, + ipAllowlist: { + create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })), + }, + }, + include: { ipAllowlist: true }, + }); + } + + async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + include: { httpConfig: true }, + }); + if (!application) { + throw new NotFoundException('Application not found'); + } + if (data.customerUnitPrice !== undefined) { + assertMoneyUnits(data.customerUnitPrice, '客户单价'); + } + const queuePriority = data.queuePriority === undefined + ? undefined + : normalizeApplicationQueuePriority(data.queuePriority); + const cmppAccount = data.cmppAccount === undefined + ? undefined + : await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId); + const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount; + const interfaceType = data.interfaceType === undefined + ? undefined + : normalizeApplicationInterfaceType(data.interfaceType); + const secretHash = data.passwordCipher === undefined + ? undefined + : normalizeApplicationPassword(data.passwordCipher); + const accessNumberChanged = data.cmppApplicationExtension !== undefined + || data.cmppAccessNumberFillEnabled !== undefined + || data.cmppAccessNumberFillPrefix !== undefined; + const accessNumber = accessNumberChanged + ? normalizeCmppAccessNumberConfig(data, application) + : undefined; + if (accessNumber?.clientSrcId && accessNumber.clientSrcId !== application.cmppClientSrcId) { + await this.validateClientSrcIdAvailable(accessNumber.clientSrcId, applicationId); + } + + return this.prisma.$transaction(async (tx) => { + if (data.ipAllowlist) { + await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } }); + } + const updated = await tx.smsApplication.update({ + where: { id: applicationId }, + data: { + name: data.name, + scene: data.scene, + callbackUrl: data.callbackUrl, + cmppAccount, + cmppEnterpriseCode, + cmppApplicationExtension: accessNumber?.applicationExtension, + cmppAccessNumberFillEnabled: accessNumber?.fillEnabled, + cmppAccessNumberFillPrefix: accessNumber?.fillPrefix, + cmppClientSrcId: accessNumber?.clientSrcId, + secretHash, + interfaceEnabled: data.interfaceEnabled, + interfaceType, + cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), + cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), + dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), + customerUnitPrice: data.customerUnitPrice, + queuePriority, + templateMismatchMode: data.templateMismatchMode, + downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled, + downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled, + status: data.status, + ipAllowlist: data.ipAllowlist ? { + create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })), + } : undefined, + }, + include: { tenant: true, ipAllowlist: true }, + }); + if (data.interfaceEnabled !== undefined && application.httpConfig) { + const deliveryMode = automaticDeliveryMode(data.interfaceEnabled, application.httpConfig.enabled); + await tx.smsApplicationHttpConfig.update({ + where: { applicationId }, + data: { + receiptDeliveryMode: deliveryMode, + uplinkDeliveryMode: deliveryMode, + }, + }); + } + return updated; + }); + } + + async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); + if (!application) { + throw new NotFoundException('Application not found'); + } + const routes = data.routes ?? []; + if (routes.length === 0) { + throw new BadRequestException('At least one carrier channel group is required'); + } + + const carriers = new Set(); + routes.forEach((route) => { + if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) { + throw new BadRequestException('carrier must be mobile, unicom or telecom'); + } + if (carriers.has(route.carrier)) { + throw new BadRequestException('Duplicate carrier route is not allowed'); + } + carriers.add(route.carrier); + }); + + const groups = await this.prisma.smsChannelGroup.findMany({ + where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } }, + select: { id: true, carrier: true }, + }); + const groupMap = new Map(groups.map((group) => [group.id, group])); + routes.forEach((route) => { + const group = groupMap.get(route.groupId); + if (!group) { + throw new BadRequestException(`channel group ${route.groupId} does not exist`); + } + if (group.carrier !== route.carrier) { + throw new BadRequestException('channel group carrier must match route carrier'); + } + }); + + return this.prisma.$transaction(async (tx) => { + await tx.channelRouteRule.deleteMany({ + where: { + applicationId, + channelId: null, + province: null, + }, + }); + await tx.channelRouteRule.createMany({ + data: routes.map((route, index) => ({ + tenantId: application.tenantId, + applicationId, + groupId: route.groupId, + carrier: route.carrier, + priority: route.priority ?? (index + 1) * 10, + status: route.status ?? 'active', + })), + }); + return tx.channelRouteRule.findMany({ + where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } }, + orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], + }); + }); + } + + async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); + if (!application) { + throw new NotFoundException('Application not found'); + } + const secret = generateApplicationPassword(); + const updated = await this.prisma.smsApplication.update({ + where: { id: applicationId }, + data: { secretHash: secret }, + }); + await this.lifecycle.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, { + reason: data.reason, + }); + return { ...updated, secret }; + } + + async getApplicationCmppParams(applicationId: string, tenantId?: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + include: { tenant: true }, + }); + if (!application || (tenantId && application.tenantId !== tenantId)) { + throw new NotFoundException('Application not found'); + } + if (tenantId && !application.interfaceEnabled) { + throw new ForbiddenException('该企业应用未开通 CMPP 接口'); + } + return { + applicationId: application.id, + applicationName: application.name, + tenantId: application.tenantId, + tenantName: application.tenant.name, + appCode: application.id, + gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1', + gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890), + enterpriseCode: application.cmppEnterpriseCode, + account: application.cmppAccount, + passwordCipher: application.secretHash, + srcId: application.cmppClientSrcId ?? '', + applicationExtension: application.cmppApplicationExtension, + accessNumberFillEnabled: application.cmppAccessNumberFillEnabled, + accessNumberFillPrefix: application.cmppAccessNumberFillPrefix, + interfaceEnabled: application.interfaceEnabled, + interfaceType: application.interfaceType, + maxConnections: application.cmppMaxConnections, + heartbeatSeconds: 30, + windowSize: application.cmppWindowSize, + protocolVersion: application.interfaceType === 'cmpp20' ? 'CMPP2.0' : application.interfaceType, + }; + } + + async validateAndReserveCmppAccount(cmppAccount: string, currentApplicationId?: string) { + if (!/^\d{6}$/.test(cmppAccount)) { + throw new BadRequestException('cmppAccount must be a 6-digit number'); + } + const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } }); + if (exists && exists.id !== currentApplicationId) { + throw new BadRequestException('cmppAccount already exists'); + } + return cmppAccount; + } + + async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string) { + if (!clientSrcId) return; + const exists = await this.prisma.smsApplication.findUnique({ where: { cmppClientSrcId: clientSrcId } }); + if (exists && exists.id !== currentApplicationId) { + throw new BadRequestException('client CMPP Src_Id already exists'); + } + } + + async generateCmppAccount() { + for (let attempt = 0; attempt < 20; attempt += 1) { + const cmppAccount = String(randomInt(100000, 1000000)); + const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } }); + if (!exists) { + return cmppAccount; + } + } + throw new BadRequestException('Unable to generate unique CMPP account'); + } +} diff --git a/api/src/sms-config/application-lifecycle.service.ts b/api/src/sms-config/application-lifecycle.service.ts new file mode 100644 index 0000000..df03e5c --- /dev/null +++ b/api/src/sms-config/application-lifecycle.service.ts @@ -0,0 +1,399 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomInt, randomUUID } from 'node:crypto'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { assertMoneyUnits } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import { automaticDeliveryMode } from '../open-api/delivery-mode'; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; + + +/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ +export class SmsApplicationLifecycleService { + private readonly logger = new Logger(SmsApplicationLifecycleService.name); + private applicationDisableTimer?: ReturnType; + private applicationDisableScanRunning = false; + constructor(private readonly prisma: PrismaService) {} + onModuleInit() { + this.applicationDisableTimer = setInterval( + () => void this.runApplicationDisableScan(), + getPositiveIntegerEnv('APPLICATION_DISABLE_SCAN_INTERVAL_MS', DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS), + ); + this.applicationDisableTimer.unref?.(); + void this.runApplicationDisableScan(); + } + + onModuleDestroy() { + if (this.applicationDisableTimer) clearInterval(this.applicationDisableTimer); + } + + async changeApplicationStatus(applicationId: string, data: StatusChangeDto) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); + if (!application) { + throw new NotFoundException('Application not found'); + } + const status = data.status ?? 'disabled'; + if (status === 'active') { + const updated = await this.prisma.smsApplication.update({ + where: { id: applicationId }, + data: { status: 'active', disablingAt: null, autoDisableAt: null, disableReason: null }, + }); + await this.writeApplicationStatusLog(application, data, 'active', {}); + return updated; + } + if (!['disabled', 'disabling', 'deleted'].includes(status)) { + throw new BadRequestException(`不支持的企业应用状态:${status}`); + } + + const preview = await this.getApplicationDeactivationPreview(applicationId); + if ((status === 'disabling' || status === 'disabled') && preview.totalOutstanding > 0 && !data.force) { + const disablingAt = new Date(); + const autoDisableAt = new Date(disablingAt.getTime() + APPLICATION_DISABLE_GRACE_MS); + const updated = await this.prisma.smsApplication.update({ + where: { id: applicationId }, + data: { + status: 'disabling', + disablingAt, + autoDisableAt, + disableReason: data.reason?.trim() || '等待未完成回执清算', + }, + }); + await this.writeApplicationStatusLog(application, data, 'disabling', { preview, disablingAt, autoDisableAt }); + return { ...updated, deactivation: { ...preview, disablingAt, autoDisableAt } }; + } + + const finalStatus = status === 'deleted' ? 'deleted' : 'disabled'; + const abandonReason = status === 'deleted' + ? '企业应用已删除,放弃剩余下游投递' + : data.force + ? '运营强制停用企业应用,放弃剩余下游投递' + : '企业应用无待清算数据,完成停用'; + const abandoned = await this.abandonApplicationDeliveries(applicationId, abandonReason); + const updated = await this.prisma.smsApplication.update({ + where: { id: applicationId }, + data: { + status: finalStatus, + disablingAt: null, + autoDisableAt: null, + disableReason: data.reason?.trim() || abandonReason, + }, + }); + const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, abandonReason); + await this.writeApplicationStatusLog(application, data, finalStatus, { preview, abandoned, disconnect }); + return { ...updated, deactivation: null, abandoned, disconnect }; + } + + async getApplicationDeactivationPreview(applicationId: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { + id: true, + status: true, + disablingAt: true, + autoDisableAt: true, + disableReason: true, + }, + }); + if (!application) throw new NotFoundException('Application not found'); + const [ + awaitingSupplierReceipt, + waitingToSend, + awaitingClientAck, + retryableFailures, + pendingUplinks, + activeConnections, + ] = await Promise.all([ + this.prisma.smsMessageRecord.count({ + where: { applicationId, status: { in: ['submitted', 'unknown'] }, receiptStatus: null }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'receipt', status: { in: ['pending', 'manual_requeueing'] } }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'receipt', status: 'awaiting_ack' }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'receipt', status: 'failed', retryEnabled: true }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'uplink', status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, + }), + this.prisma.cmppDownstreamConnection.count({ + where: { applicationId, status: 'connected' }, + }), + ]); + return { + status: application.status, + reason: application.disableReason, + disablingAt: application.disablingAt, + autoDisableAt: application.autoDisableAt, + awaitingSupplierReceipt, + waitingToSend, + awaitingClientAck, + retryableFailures, + pendingUplinks, + activeConnections, + totalOutstanding: awaitingSupplierReceipt + waitingToSend + awaitingClientAck + retryableFailures + pendingUplinks, + }; + } + + async listApplicationConnections(applicationId: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + include: { tenant: true }, + }); + if (!application) { + throw new NotFoundException('Application not found'); + } + await this.markTimedOutDownstreamConnections(); + const connections = await this.prisma.cmppDownstreamConnection.findMany({ + where: { applicationId }, + orderBy: { updatedAt: 'desc' }, + }); + return { + application, + connections, + summary: { + desiredConnections: application.cmppMaxConnections, + currentConnections: connections.filter((connection) => connection.status === 'connected').length, + status: normalizeApplicationCmppStatus(connections, application.status), + }, + }; + } + + async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) { + const application = await this.prisma.smsApplication.findUnique({ + where: { cmppAccount: data.account }, + include: { ipAllowlist: true }, + }); + if (!application) { + throw new BadRequestException('CMPP account does not reference an application'); + } + const observedAt = parseGatewayDate(data.observedAt) ?? new Date(); + const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt; + const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } }); + if (data.status === 'disconnected') { + if (existing) { + await this.prisma.cmppDownstreamConnection.delete({ where: { id: existing.id } }); + } + await this.writeOperationLog(application.tenantId, undefined, 'cmpp_downstream_connection.disconnected', 'cmpp_downstream_connection', data.connectionId, { + applicationId: application.id, + account: data.account, + remoteIp: data.remoteIp, + protocol: data.protocol, + status: 'disconnected', + errorMessage: data.errorMessage, + }); + return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) }; + } + if (!application.interfaceEnabled || !['active', 'disabling'].includes(application.status)) { + throw new ForbiddenException('CMPP interface is disabled for this application'); + } + if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + throw new ForbiddenException('CMPP source IP is not in application allowlist'); + } + // A Gateway process can disappear before it reports disconnect. Prune its + // expired rows here so a restarted process can reclaim connection slots + // without waiting for an operator to open the connection-list page. + await this.markTimedOutDownstreamConnections(observedAt); + const activeConnections = await this.prisma.cmppDownstreamConnection.findMany({ + where: { applicationId: application.id, status: 'connected' }, + select: { connectionId: true }, + orderBy: [{ connectedAt: 'asc' }, { connectionId: 'asc' }], + }); + const allowedConnectionIds = activeConnections.slice(0, application.cmppMaxConnections).map((item) => item.connectionId); + if ((!existing && activeConnections.length >= application.cmppMaxConnections) + || (existing && activeConnections.length > application.cmppMaxConnections && !allowedConnectionIds.includes(data.connectionId))) { + throw new ForbiddenException(`CMPP connection limit exceeded (${application.cmppMaxConnections})`); + } + const payload = { + tenantId: application.tenantId, + applicationId: application.id, + account: data.account, + enterpriseCode: application.cmppEnterpriseCode, + remoteIp: data.remoteIp, + protocol: data.protocol, + status: 'connected', + connectedAt: existing?.connectedAt ?? connectedAt, + lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt, + lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt, + lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt, + disconnectedAt: null, + lastError: null, + }; + const connection = existing + ? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload }) + : await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } }); + if (data.status === 'connected') { + await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, { + applicationId: application.id, + account: data.account, + remoteIp: data.remoteIp, + protocol: data.protocol, + status: connection.status, + }); + } + return connection; + } + + async markTimedOutDownstreamConnections(now = new Date()) { + const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS); + const cutoff = new Date(now.getTime() - timeoutMs); + return this.prisma.cmppDownstreamConnection.deleteMany({ + where: { + status: 'connected', + OR: [ + { lastHeartbeatAt: { lt: cutoff } }, + { lastHeartbeatAt: null, connectedAt: { lt: cutoff } }, + ], + }, + }); + } + + async abandonApplicationDeliveries(applicationId: string, reason: string) { + const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ + where: { applicationId, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, + select: { id: true }, + }); + const deliveryIds = deliveries.map((delivery) => delivery.id); + if (deliveryIds.length === 0) return 0; + await this.prisma.cmppDownstreamDeliveryAttempt.updateMany({ + where: { deliveryId: { in: deliveryIds }, status: { in: ['awaiting_ack', 'sent'] } }, + data: { + status: 'abandoned', + ackDeadlineAt: null, + failureType: 'application_disabled', + errorMessage: reason, + }, + }); + const updated = await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { id: { in: deliveryIds }, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, + data: { + status: 'abandoned', + retryEnabled: false, + nextRetryAt: null, + ackDeadlineAt: null, + lastError: reason, + }, + }); + return updated.count; + } + + async disconnectDownstreamAccount(account: string, reason: string) { + const baseUrl = process.env.GATEWAY_CONTROL_URL?.trim() || 'http://127.0.0.1:8090'; + try { + const response = await fetch(`${baseUrl}/downstream/connections/disconnect`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ account, reason }), + signal: AbortSignal.timeout(10_000), + }); + const responseText = await response.text(); + if (!response.ok) { + throw new Error(`Gateway returned ${response.status}: ${responseText}`); + } + return responseText ? JSON.parse(responseText) as { account: string; disconnected: number } : { account, disconnected: 0 }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to disconnect downstream CMPP account ${account}: ${message}`); + return { account, disconnected: 0, error: message }; + } + } + + writeApplicationStatusLog( + application: { id: string; tenantId: string; status: string }, + data: StatusChangeDto, + statusAfter: string, + detail: Record, + ) { + return this.writeOperationLog( + application.tenantId, + data.operatorId, + `sms_application.${statusAfter}`, + 'sms_application', + application.id, + { + statusBefore: application.status, + statusAfter, + reason: data.reason, + force: Boolean(data.force), + ...JSON.parse(JSON.stringify(detail)) as Record, + }, + ); + } + + async runApplicationDisableScan() { + if (this.applicationDisableScanRunning) return; + this.applicationDisableScanRunning = true; + try { + const applications = await this.prisma.smsApplication.findMany({ + where: { status: 'disabling' }, + select: { id: true, tenantId: true, cmppAccount: true, status: true, autoDisableAt: true }, + take: 500, + }); + const now = new Date(); + for (const application of applications) { + const preview = await this.getApplicationDeactivationPreview(application.id); + if (preview.totalOutstanding === 0) { + await this.finalizeDisablingApplication(application, false, '待处理回执已清算完成,系统自动停用', preview); + } else if (application.autoDisableAt && application.autoDisableAt <= now) { + await this.finalizeDisablingApplication(application, true, '进入停用中状态已满72小时,系统自动放弃剩余回执', preview); + } + } + } catch (error) { + this.logger.error(`Application disabling scan failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.applicationDisableScanRunning = false; + } + } + + async finalizeDisablingApplication( + application: { id: string; tenantId: string; cmppAccount: string; status: string }, + abandonOutstanding: boolean, + reason: string, + preview: Awaited>, + ) { + const claimed = await this.prisma.smsApplication.updateMany({ + where: { id: application.id, status: 'disabling' }, + data: { + status: 'disabled', + disablingAt: null, + autoDisableAt: null, + disableReason: reason, + }, + }); + if (claimed.count !== 1) return false; + const abandoned = abandonOutstanding + ? await this.abandonApplicationDeliveries(application.id, reason) + : 0; + const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, reason); + await this.writeApplicationStatusLog(application, { reason, force: abandonOutstanding }, 'disabled', { + preview, + abandoned, + disconnect, + automatic: true, + }); + return true; + } + + writeOperationLog( + tenantId: string, + userId: string | undefined, + action: string, + resource: string, + resourceId: string, + detail: Record, + ) { + return this.prisma.operationLog.create({ + data: { + tenantId, + userId, + action, + resource, + resourceId, + detail: detail as Prisma.InputJsonValue, + }, + }); + } +} diff --git a/api/src/sms-config/audit.service.ts b/api/src/sms-config/audit.service.ts new file mode 100644 index 0000000..96c0162 --- /dev/null +++ b/api/src/sms-config/audit.service.ts @@ -0,0 +1,177 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomInt, randomUUID } from 'node:crypto'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { assertMoneyUnits } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import { automaticDeliveryMode } from '../open-api/delivery-mode'; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import { SmsApplicationLifecycleService } from './application-lifecycle.service'; +import { SmsReportValidationService } from './report-validation.service'; + +/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ +export class SmsAuditService { + constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService, private readonly reportValidation: SmsReportValidationService) {} + listAuditRecords(targetType?: string, targetId?: string) { + return this.prisma.auditRecord.findMany({ + where: { + targetType, + targetId, + }, + include: { + reviewer: { select: { id: true, username: true, displayName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + approveSignature(signatureId: string, data: ReviewDto) { + return this.reviewSignature(signatureId, 'approved', 'approve', data); + } + + rejectSignature(signatureId: string, data: ReviewDto) { + return this.reviewSignature(signatureId, 'rejected', 'reject', data); + } + + approveTemplate(templateId: string, data: ReviewDto) { + return this.reviewTemplate(templateId, 'approved', 'approve', data); + } + + rejectTemplate(templateId: string, data: ReviewDto) { + return this.reviewTemplate(templateId, 'rejected', 'reject', data); + } + + async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature || (tenantId && signature.tenantId !== tenantId)) { + throw new NotFoundException('Signature not found'); + } + const status = data.status ?? 'deleted'; + const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } }); + await this.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, { + statusBefore: signature.auditStatus, + statusAfter: status, + reason: data.reason, + }); + return updated; + } + + async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) { + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template || (tenantId && template.tenantId !== tenantId)) { + throw new NotFoundException('Template not found'); + } + const status = data.status ?? 'deleted'; + const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } }); + await this.lifecycle.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, { + statusBefore: template.auditStatus, + statusAfter: status, + reason: data.reason, + }); + return updated; + } + + async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature) { + throw new NotFoundException('Signature not found'); + } + const reviewerId = await this.resolveReviewerId(data.reviewerId); + + const updated = await this.prisma.smsSignature.update({ + where: { id: signatureId }, + data: { + auditStatus: statusAfter, + rejectReason: statusAfter === 'rejected' ? data.reason : null, + }, + }); + await this.createAuditRecord({ + tenantId: signature.tenantId, + targetType: 'sms_signature', + targetId: signatureId, + action, + statusBefore: signature.auditStatus, + statusAfter, + reason: data.reason, + reviewerId, + }); + return updated; + } + + async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) { + const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); + if (!item) throw new NotFoundException('Drainage info not found'); + if (!['pending', 'rejected'].includes(item.auditStatus)) { + throw new BadRequestException('只有待审核或已驳回的引流信息可以审核'); + } + if (statusAfter === 'rejected' && !data.reason?.trim()) { + throw new BadRequestException('驳回引流信息时必须填写原因'); + } + const reviewerId = await this.resolveReviewerId(data.reviewerId); + const updated = await this.prisma.smsDrainageInfo.update({ + where: { id: itemId }, + data: { + auditStatus: statusAfter, + rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null, + reviewedAt: new Date(), + }, + include: { tenant: true, signature: true, application: true }, + }); + await this.createAuditRecord({ + tenantId: item.tenantId, + targetType: 'sms_drainage_info', + targetId: itemId, + action, + statusBefore: item.auditStatus, + statusAfter, + reason: data.reason, + reviewerId, + }); + if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId); + else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回'); + return updated; + } + + async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) { + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template) { + throw new NotFoundException('Template not found'); + } + const reviewerId = await this.resolveReviewerId(data.reviewerId); + + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: { + auditStatus: statusAfter, + rejectReason: statusAfter === 'rejected' ? data.reason : null, + }, + }); + await this.createAuditRecord({ + tenantId: template.tenantId, + targetType: 'sms_template', + targetId: templateId, + action, + statusBefore: template.auditStatus, + statusAfter, + reason: data.reason, + reviewerId, + }); + return updated; + } + + async resolveReviewerId(reviewerId?: string) { + if (!reviewerId) { + return undefined; + } + const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } }); + if (!reviewer) { + throw new BadRequestException('reviewerId does not reference an existing user'); + } + return reviewerId; + } + + createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) { + return this.prisma.auditRecord.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 662186d..52daba3 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -11,11 +11,11 @@ import { CreateSmsSignatureDto, CreateSmsTemplateDto, StatusChangeDto, - SmsConfigService, UpdateSmsTemplateDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, -} from './sms-config.service'; +} from './sms-config.contracts'; +import { SmsConfigService } from './sms-config.service'; @ApiTags('client-sms-config') @Controller('client') diff --git a/api/src/sms-config/drainage.service.ts b/api/src/sms-config/drainage.service.ts new file mode 100644 index 0000000..09d00e1 --- /dev/null +++ b/api/src/sms-config/drainage.service.ts @@ -0,0 +1,161 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomInt, randomUUID } from 'node:crypto'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { assertMoneyUnits } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import { automaticDeliveryMode } from '../open-api/delivery-mode'; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import { SmsReportValidationService } from './report-validation.service'; +import { SmsAuditService } from './audit.service'; + +/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ +export class SmsDrainageService { + constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {} + 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; + } + + listDrainageInfos(query: DrainageInfoListQuery = {}) { + return this.prisma.smsDrainageInfo.findMany({ + where: { + tenantId: query.tenantId, + signatureId: query.signatureId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + OR: query.keyword ? [ + { siteName: { contains: query.keyword } }, + { url: { contains: query.keyword } }, + { signature: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] : undefined, + }, + include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature) throw new NotFoundException('Signature not found'); + if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found'); + if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息'); + if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required'); + await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues); + const auditStatus = options.initialAuditStatus ?? 'pending'; + const item = await this.prisma.smsDrainageInfo.create({ + data: { + tenantId: signature.tenantId, + signatureId, + applicationId: signature.applicationId, + siteName: data.siteName.trim(), + url: data.url.trim(), + remark: data.remark, + reportValues: data.reportValues as Prisma.InputJsonValue | undefined, + auditStatus, + reviewedAt: auditStatus === 'approved' ? new Date() : undefined, + }, + include: { tenant: true, signature: true, application: true }, + }); + await this.audit.createAuditRecord({ + tenantId: item.tenantId, + targetType: 'sms_drainage_info', + targetId: item.id, + action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit', + statusAfter: auditStatus, + reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined, + }); + if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id); + return item; + } + + async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) { + const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); + if (!current) throw new NotFoundException('Drainage info not found'); + if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); + if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改'); + if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required'); + if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required'); + const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined; + await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {})); + const auditStatus = options.initialAuditStatus ?? 'pending'; + const updated = await this.prisma.smsDrainageInfo.update({ + where: { id: itemId }, + data: { + applicationId, + siteName: data.siteName?.trim(), + url: data.url?.trim(), + remark: data.remark, + reportValues: data.reportValues as Prisma.InputJsonValue | undefined, + auditStatus, + rejectReason: null, + submittedAt: new Date(), + reviewedAt: auditStatus === 'approved' ? new Date() : null, + materialVersion: { increment: 1 }, + pendingReport: true, + reportChangedAt: new Date(), + }, + include: { tenant: true, signature: true, application: true }, + }); + await this.audit.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_drainage_info', + targetId: itemId, + action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit', + statusBefore: current.auditStatus, + statusAfter: auditStatus, + reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined, + }); + if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId); + else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核'); + return updated; + } + + approveDrainageInfo(itemId: string, data: ReviewDto) { + return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data); + } + + rejectDrainageInfo(itemId: string, data: ReviewDto) { + return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data); + } + + async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) { + const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); + if (!current) throw new NotFoundException('Drainage info not found'); + if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); + const status = data.status ?? 'deleted'; + if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态'); + const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } }); + if (status === 'deleted') await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned'); + await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason }); + return updated; + } +} diff --git a/api/src/sms-config/report-validation.service.ts b/api/src/sms-config/report-validation.service.ts new file mode 100644 index 0000000..fb5ab9f --- /dev/null +++ b/api/src/sms-config/report-validation.service.ts @@ -0,0 +1,122 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomInt, randomUUID } from 'node:crypto'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { assertMoneyUnits } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import { automaticDeliveryMode } from '../open-api/delivery-mode'; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import { SmsApplicationConfigService } from './application-config.service'; + +/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ +export class SmsReportValidationService { + constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {} + async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record) { + if (!drainageInfo) return drainageInfo; + const fields = await this.applications.getApplicationReportFields(applicationId); + return { + ...drainageInfo, + reportRequirementSnapshot: { + capturedAt: new Date().toISOString(), + applicationId, + fields: fields.map((field) => ({ + id: field.id, + code: field.code, + name: field.name, + fieldType: field.fieldType, + required: field.required, + reportTypes: field.reportTypes, + commonReportTypes: field.commonReportTypes, + channels: field.channels, + })), + }, + }; + } + + async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record) { + if (!drainageInfo) return; + const fields = await this.applications.getApplicationReportFields(applicationId); + const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; + for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { + const value = reportValueParts(signatureValues[field.code]); + for (const channel of field.channels) { + await this.prisma.signatureReportMaterial.upsert({ + where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } }, + update: value, + create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value }, + }); + } + } + } + + async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record) { + const fields = await this.applications.getApplicationReportFields(applicationId, 'signature'); + const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {}; + const missingSignature = fields + .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')) + .filter((field) => !hasReportValue(signatureValues[field.code])); + if (missingSignature.length > 0) { + throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`); + } + } + + async validateDrainageReportValues(applicationId?: string, reportValues: Record = {}) { + const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage'); + const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code])); + if (missing.length > 0) { + throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`); + } + } + + async activateDrainageReporting(itemId: string) { + const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); + if (!item) throw new NotFoundException('Drainage info not found'); + if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); + const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined; + if (!applicationId) return; + const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage')) + .filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both')); + const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])); + const values = isRecord(item.reportValues) ? item.reportValues : {}; + await this.prisma.$transaction(async (tx) => { + await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); + for (const field of fields) { + const value = reportValueParts(values[field.code]); + for (const channel of field.channels) { + await tx.drainageReportMaterial.create({ + data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value }, + }); + } + } + const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); + const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task])); + for (const channel of channels.values()) { + const existing = existingByChannel.get(channel.id); + const task = existing + ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } }) + : await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } }); + await tx.channelSignatureReportRecord.create({ + data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' }, + }); + } + for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) { + await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } }); + await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } }); + } + }); + } + + async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') { + await this.prisma.$transaction(async (tx) => { + const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } }); + if (!item) throw new NotFoundException('Drainage info not found'); + await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); + const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); + for (const task of tasks.filter((current) => current.status !== statusAfter)) { + await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } }); + await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } }); + } + }); + } +} diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts new file mode 100644 index 0000000..73e6870 --- /dev/null +++ b/api/src/sms-config/signature.service.ts @@ -0,0 +1,404 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomInt, randomUUID } from 'node:crypto'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { assertMoneyUnits } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import { automaticDeliveryMode } from '../open-api/delivery-mode'; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import { SmsReportValidationService } from './report-validation.service'; +import { SmsAuditService } from './audit.service'; + +/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ +export class SmsSignatureService { + constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {} + async listSignatures(queryOrTenantId?: string | SignatureListQuery) { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; + const signatures = await this.prisma.smsSignature.findMany({ + where: { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, + drainageItems: query.drainageKeyword ? { + some: { + auditStatus: { not: 'deleted' }, + OR: [ + { siteName: { contains: query.drainageKeyword } }, + { url: { contains: query.drainageKeyword } }, + { remark: { contains: query.drainageKeyword } }, + ], + }, + } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { purpose: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] : undefined, + }, + include: { + materials: true, + tenant: true, + application: true, + drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, + reportTasks: { include: { channel: true, drainageInfo: true } }, + }, + orderBy: { createdAt: 'desc' }, + ...(query.page && query.pageSize ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } : {}), + }); + const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); + const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ + where: { applicationId: { in: applicationIds }, status: 'active' }, + include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, + }) : []; + const hasCommonDrainageFields = await this.prisma.commonReportField.count({ + where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, + }).then((count) => count > 0); + return signatures.map((signature) => { + const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; + const drainageLinks = signature.drainageItems.map((item) => ({ + id: item.id, + siteName: item.siteName, + url: item.url, + remark: item.remark ?? '', + reportValues: isRecord(item.reportValues) ? item.reportValues : {}, + auditStatus: item.auditStatus, + rejectReason: item.rejectReason, + submittedAt: item.submittedAt.toISOString(), + reviewedAt: item.reviewedAt?.toISOString(), + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + })); + return { + ...signature, + name: normalizeSmsSignature(signature.name), + drainageInfo: { ...legacyPayload, links: drainageLinks }, + reportTargets: (() => { + const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); + const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); + return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id })); + })(), + drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => { + const drainageItemId = drainageItem.id; + const channels = routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); + const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); + return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => { + const task = taskByChannel.get(channel.id); + return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; + })]; + })), + drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => { + const drainageItemId = drainageItem.id; + const channels = routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); + const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; + const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); + return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { + const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); + const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []); + const approved = statuses.filter((status) => status === 'approved').length; + const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; + return [carrier, { status, approved, total: statuses.length }]; + }))]; + })), + carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { + const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all')); + const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; + const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); + const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + const approved = statuses.filter((status) => status === 'approved').length; + const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; + return [carrier, { status, approved, total: targets.length }]; + })), + }; + }); + } + + async listSignaturesPage(query: SignatureListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsSignatureWhereInput = { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, + drainageItems: query.drainageKeyword ? { + some: { + auditStatus: { not: 'deleted' }, + OR: [ + { siteName: { contains: query.drainageKeyword } }, + { url: { contains: query.drainageKeyword } }, + { remark: { contains: query.drainageKeyword } }, + ], + }, + } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { purpose: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.listSignatures({ ...query, page, pageSize }), + this.prisma.smsSignature.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + listSignatureOptions(tenantId?: string) { + return this.prisma.smsSignature.findMany({ + where: { tenantId, auditStatus: { not: 'deleted' } }, + select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + } + + async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { + const signatures = await this.prisma.smsSignature.findMany({ + where: { + id: signatureId, + tenantId, + applicationId: query.applicationId, + auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, + OR: query.keyword?.trim() ? [ + { name: { contains: query.keyword.trim() } }, + { purpose: { contains: query.keyword.trim() } }, + { application: { name: { contains: query.keyword.trim() } } }, + ] : undefined, + }, + select: { + id: true, + tenantId: true, + applicationId: true, + name: true, + purpose: true, + auditStatus: true, + reportStatus: true, + pendingReport: true, + reportChangedAt: 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' }, + skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, + take: query.pageSize, + }); + return signatures.map((signature) => { + const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; + return { + id: signature.id, + tenantId: signature.tenantId, + applicationId: signature.applicationId, + name: normalizeSmsSignature(signature.name), + purpose: signature.purpose, + auditStatus: signature.auditStatus, + reportStatus: signature.reportStatus, + pendingReport: signature.pendingReport, + reportChangedAt: signature.reportChangedAt, + 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, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const filteredWhere: Prisma.SmsSignatureWhereInput = { + tenantId, + applicationId: query.applicationId, + auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, + OR: query.keyword?.trim() ? [ + { name: { contains: query.keyword.trim() } }, + { purpose: { contains: query.keyword.trim() } }, + { application: { name: { contains: query.keyword.trim() } } }, + ] : undefined, + }; + const [items, total, statusCounts] = await Promise.all([ + this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), + this.prisma.smsSignature.count({ where: filteredWhere }), + 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, total, page, pageSize }; + } + + async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { + await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo); + const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); + const name = validateCompleteSmsSignature(data.name); + const signature = await this.prisma.smsSignature.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + name, + purpose: data.purpose, + auditStatus: options.initialAuditStatus, + drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, + }, + }); + await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); + if (options.initialAuditStatus) { + await this.audit.createAuditRecord({ + tenantId: signature.tenantId, + targetType: 'sms_signature', + targetId: signature.id, + action: 'admin_create_approved', + statusAfter: options.initialAuditStatus, + reason: '运营端新建签名自动审核通过', + }); + } + return signature; + } + + async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature || (tenantId && signature.tenantId !== tenantId)) { + throw new NotFoundException('Signature not found'); + } + await this.reportValidation.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo); + const applicationId = data.applicationId ?? signature.applicationId ?? undefined; + const drainageInfo = data.drainageInfo + ? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo) + : undefined; + const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name); + const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) + || (name !== undefined && name !== normalizeSmsSignature(signature.name)) + || (data.purpose !== undefined && data.purpose !== signature.purpose) + || (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null)); + const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; + const updated = await this.prisma.smsSignature.update({ + where: { id: signatureId }, + data: { + applicationId: data.applicationId, + name, + purpose: data.purpose, + auditStatus, + rejectReason: auditStatus === 'pending' ? null : undefined, + drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, + materialVersion: { increment: 1 }, + pendingReport: true, + reportChangedAt: new Date(), + }, + include: { materials: true, tenant: true, application: true }, + }); + await this.reportValidation.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo); + 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.audit.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_signature', + targetId: signatureId, + action: 'client_update_submit', + statusBefore: current.auditStatus, + statusAfter: 'pending', + }); + return updated; + } + + createSignatureMaterial(data: CreateSignatureMaterialDto) { + return this.prisma.signatureMaterial.create({ + data: { + signatureId: data.signatureId, + fileObjectId: data.fileObjectId, + materialType: data.materialType, + title: data.title, + description: data.description, + }, + }); + } + + async submitSignature(signatureId: string, tenantId?: string) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature || (tenantId && signature.tenantId !== tenantId)) { + throw new NotFoundException('Signature not found'); + } + + const updated = await this.prisma.smsSignature.update({ + where: { id: signatureId }, + data: { auditStatus: 'pending', rejectReason: null }, + }); + await this.audit.createAuditRecord({ + tenantId: signature.tenantId, + targetType: 'sms_signature', + targetId: signatureId, + action: 'submit', + statusBefore: signature.auditStatus, + statusAfter: 'pending', + }); + return updated; + } +} diff --git a/api/src/sms-config/sms-config.contracts.ts b/api/src/sms-config/sms-config.contracts.ts new file mode 100644 index 0000000..374b988 --- /dev/null +++ b/api/src/sms-config/sms-config.contracts.ts @@ -0,0 +1,155 @@ +/** Stable request and query contracts shared by controllers and SMS configuration domains. */ + +export interface CreateSmsApplicationDto { + tenantId: string; + name: string; + scene?: string; + callbackUrl?: string; + cmppAccount?: string; + cmppEnterpriseCode?: string; + cmppApplicationExtension?: string; + cmppAccessNumberFillEnabled?: boolean; + cmppAccessNumberFillPrefix?: string; + passwordCipher?: string; + interfaceEnabled?: boolean; + interfaceType?: string; + cmppMaxConnections?: number; + cmppWindowSize?: number; + dailyLimit?: number; + customerUnitPrice?: number; + queuePriority?: string; + templateMismatchMode?: string; + downstreamReceiptRetryEnabled?: boolean; + downstreamUplinkRetryEnabled?: boolean; + ipAllowlist?: string[]; +} + +export type UpdateSmsApplicationDto = Partial> & { + status?: string; +}; + +export interface ReplaceApplicationRouteRulesDto { + routes: Array<{ + carrier: string; + groupId: string; + priority?: number; + status?: string; + }>; +} + +export interface CreateSmsSignatureDto { + tenantId: string; + applicationId?: string; + name: string; + purpose?: string; + drainageInfo?: Record; +} + +export interface CreateSmsSignatureOptions { + initialAuditStatus?: string; +} + +export type UpdateSmsSignatureDto = Partial> & { + auditStatus?: string; +}; + +export interface CreateSmsDrainageInfoDto { + siteName: string; + url: string; + remark?: string; + reportValues?: Record; +} + +export type UpdateSmsDrainageInfoDto = Partial; + +export interface DrainageInfoListQuery { + tenantId?: string; + signatureId?: string; + status?: string; + keyword?: string; +} + +export interface CreateSignatureMaterialDto { + signatureId: string; + fileObjectId?: string; + materialType: string; + title: string; + description?: string; +} + +export interface CreateSmsTemplateDto { + tenantId: string; + applicationId: string; + signatureId?: string; + name: string; + content: string; + category?: string; + variables?: Array<{ name: string; example?: string; required?: boolean }>; +} + +export interface CreateSmsTemplateOptions { + initialAuditStatus?: string; +} + +export type UpdateSmsTemplateDto = Partial> & { + signatureId?: string | null; + auditStatus?: string; +}; + +export interface ReviewDto { + reviewerId?: string; + reason?: string; +} + +export interface StatusChangeDto { + status?: string; + operatorId?: string; + reason?: string; + force?: boolean; +} + +export interface TemplateListQuery { + tenantId?: string; + status?: string; + keyword?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + nameKeyword?: string; + contentKeyword?: string; + page?: number; + pageSize?: number; +} + +export interface ApplicationListQuery { + tenantId?: string; + keyword?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + status?: string; + includeConnections?: boolean; + page?: number; + pageSize?: number; +} + +export interface SignatureListQuery { + tenantId?: string; + keyword?: string; + status?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + signatureKeyword?: string; + drainageKeyword?: string; + page?: number; + pageSize?: number; +} + +export interface GatewayDownstreamConnectionEventDto { + account: string; + connectionId: string; + status: 'connected' | 'heartbeat' | 'submit' | 'deliver' | 'disconnected'; + remoteIp?: string; + protocol?: string; + connectedAt?: string; + observedAt?: string; + errorMessage?: string; +} diff --git a/api/src/sms-config/sms-config.helpers.ts b/api/src/sms-config/sms-config.helpers.ts new file mode 100644 index 0000000..aafdb5e --- /dev/null +++ b/api/src/sms-config/sms-config.helpers.ts @@ -0,0 +1,215 @@ +import { BadRequestException } from '@nestjs/common'; +import { randomInt, randomUUID } from 'node:crypto'; +import type { CreateSmsApplicationDto } from './sms-config.contracts'; + +/** Pure normalization and report-value helpers shared by the R3 domain services. */ +export const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const; +export type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number]; +export const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const; +export type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number]; +export const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000; +export const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000; +export const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000; +export const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const; + +export interface TemplateVariableInput { + name: string; + example?: string; + required?: boolean; +} + +export function normalizeApplicationPassword(value: string | undefined) { + const password = value?.trim() || generateApplicationPassword(); + if (password.length !== 16) { + throw new BadRequestException('passwordCipher must be 16 characters'); + } + return password; +} + +export function generateApplicationPassword() { + return randomUUID().replace(/-/g, '').slice(0, 16); +} + +export function estimateBillingUnits(content: string) { + const length = [...content].length; + if (length <= 70) { + return 1; + } + return Math.ceil(length / 67); +} + +export function inferTemplateVariables(content: string): TemplateVariableInput[] { + const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? []; + return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); +} + +export function validateAndNormalizeTemplateVariables( + content: string, + supplied?: Array<{ name: string; example?: string; required?: boolean }>, +): TemplateVariableInput[] { + const names: string[] = []; + let cursor = 0; + while (true) { + const start = content.indexOf('${', cursor); + if (start < 0) break; + const end = content.indexOf('}', start + 2); + if (end < 0) throw new BadRequestException('模板变量未闭合'); + const name = content.slice(start + 2, end); + if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) { + throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母、数字和下划线,长度1至32位'); + } + if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`); + names.push(name); + cursor = end + 1; + } + if (!supplied) return names.map((name) => ({ name, required: true })); + const suppliedNames = supplied.map((item) => item.name?.trim()); + if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) { + throw new BadRequestException('变量配置中包含非法变量名'); + } + if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量'); + if (suppliedNames.length !== names.length || suppliedNames.some((name) => !names.includes(name))) { + throw new BadRequestException('变量配置必须与模板正文中的占位符完全一致'); + } + return supplied.map((item) => ({ ...item, name: item.name.trim() })); +} + +export function normalizeSmsSignature(name: string) { + const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim(); + return innerName ? `【${innerName}】` : ''; +} + +export function validateCompleteSmsSignature(name: string) { + const value = name; + if (/[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(value)) { + throw new BadRequestException('短信签名不能包含空格、换行或不可见字符'); + } + const match = value.match(/^【([^【】]+)】$/); + if (!match) { + throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】'); + } + return value; +} + +export function startOfToday() { + const date = new Date(); + date.setHours(0, 0, 0, 0); + return date; +} + +export function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePriority { + const queuePriority = value ?? 'normal'; + if (!APPLICATION_QUEUE_PRIORITIES.includes(queuePriority as ApplicationQueuePriority)) { + throw new BadRequestException('queuePriority must be normal or priority'); + } + return queuePriority as ApplicationQueuePriority; +} + +export function normalizeApplicationInterfaceType(value?: string): ApplicationInterfaceType { + const interfaceType = value ?? 'cmpp20'; + if (!APPLICATION_INTERFACE_TYPES.includes(interfaceType as ApplicationInterfaceType)) { + throw new BadRequestException('interfaceType only supports cmpp20; HTTP interface is not available yet'); + } + return interfaceType as ApplicationInterfaceType; +} + +export function normalizeCmppAccessNumberConfig( + data: Pick, + current?: { + cmppApplicationExtension?: string | null; + cmppAccessNumberFillEnabled?: boolean | null; + cmppAccessNumberFillPrefix?: string | null; + }, +) { + const applicationExtension = ( + data.cmppApplicationExtension === undefined + ? current?.cmppApplicationExtension + : data.cmppApplicationExtension + )?.trim() || null; + const fillEnabled = data.cmppAccessNumberFillEnabled + ?? current?.cmppAccessNumberFillEnabled + ?? false; + const configuredPrefix = ( + data.cmppAccessNumberFillPrefix === undefined + ? current?.cmppAccessNumberFillPrefix + : data.cmppAccessNumberFillPrefix + )?.trim() || null; + + if (applicationExtension && !/^\d+$/.test(applicationExtension)) { + throw new BadRequestException('cmppApplicationExtension must contain digits only'); + } + if (applicationExtension && applicationExtension.length > 21) { + throw new BadRequestException('cmppApplicationExtension must not exceed 21 digits'); + } + if (fillEnabled && !applicationExtension) { + throw new BadRequestException('cmppApplicationExtension is required when access number filling is enabled'); + } + if (fillEnabled && !configuredPrefix) { + throw new BadRequestException('cmppAccessNumberFillPrefix is required when access number filling is enabled'); + } + if (configuredPrefix && !/^\d+$/.test(configuredPrefix)) { + throw new BadRequestException('cmppAccessNumberFillPrefix must contain digits only'); + } + + const fillPrefix = fillEnabled ? configuredPrefix : null; + const clientSrcId = applicationExtension + ? `${fillPrefix ?? ''}${applicationExtension}` + : null; + if (clientSrcId && clientSrcId.length > 21) { + throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits'); + } + return { applicationExtension, fillEnabled, fillPrefix, clientSrcId }; +} + +export function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) { + if (value === undefined || value === null) { + return fallback; + } + const normalized = Number(value); + if (!Number.isInteger(normalized) || normalized <= 0) { + throw new BadRequestException(`${fieldName} must be a positive integer`); + } + return normalized; +} + +export function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) { + if (!['active', 'disabling'].includes(applicationStatus)) { + return 'inactive'; + } + if (connections.some((connection) => connection.status === 'connected')) { + return 'connected'; + } + if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) { + return 'degraded'; + } + return 'disconnected'; +} + +export function getPositiveIntegerEnv(name: string, fallback: number) { + const value = Number(process.env[name] ?? fallback); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +export function parseGatewayDate(value?: string) { + if (!value) return undefined; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function reportValueParts(value: unknown) { + if (isRecord(value) && typeof value.fileObjectId === 'string') { + return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId }; + } + return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined }; +} + +export function hasReportValue(value: unknown) { + if (isRecord(value)) { + return Boolean(value.fileObjectId || value.fieldValue || value.value); + } + return value !== undefined && value !== null && String(value).trim().length > 0; +} diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 5b92f25..fa51126 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -1,2268 +1,244 @@ -import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { randomInt, randomUUID } from 'node:crypto'; -import { isIpAllowed } from '../common/ip-allowlist'; -import { assertMoneyUnits } from '../common/money'; +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { automaticDeliveryMode } from '../open-api/delivery-mode'; - -export interface CreateSmsApplicationDto { - tenantId: string; - name: string; - scene?: string; - callbackUrl?: string; - cmppAccount?: string; - cmppEnterpriseCode?: string; - cmppApplicationExtension?: string; - cmppAccessNumberFillEnabled?: boolean; - cmppAccessNumberFillPrefix?: string; - passwordCipher?: string; - interfaceEnabled?: boolean; - interfaceType?: string; - cmppMaxConnections?: number; - cmppWindowSize?: number; - dailyLimit?: number; - customerUnitPrice?: number; - queuePriority?: string; - templateMismatchMode?: string; - downstreamReceiptRetryEnabled?: boolean; - downstreamUplinkRetryEnabled?: boolean; - ipAllowlist?: string[]; -} - -export type UpdateSmsApplicationDto = Partial> & { - status?: string; -}; - -export interface ReplaceApplicationRouteRulesDto { - routes: Array<{ - carrier: string; - groupId: string; - priority?: number; - status?: string; - }>; -} - -export interface CreateSmsSignatureDto { - tenantId: string; - applicationId?: string; - name: string; - purpose?: string; - drainageInfo?: Record; -} - -export interface CreateSmsSignatureOptions { - initialAuditStatus?: string; -} - -export type UpdateSmsSignatureDto = Partial> & { - auditStatus?: string; -}; - -export interface CreateSmsDrainageInfoDto { - siteName: string; - url: string; - remark?: string; - reportValues?: Record; -} - -export type UpdateSmsDrainageInfoDto = Partial; - -export interface DrainageInfoListQuery { - tenantId?: string; - signatureId?: string; - status?: string; - keyword?: string; -} - -export interface CreateSignatureMaterialDto { - signatureId: string; - fileObjectId?: string; - materialType: string; - title: string; - description?: string; -} - -export interface CreateSmsTemplateDto { - tenantId: string; - applicationId: string; - signatureId?: string; - name: string; - content: string; - category?: string; - variables?: Array<{ name: string; example?: string; required?: boolean }>; -} - -export interface CreateSmsTemplateOptions { - initialAuditStatus?: string; -} - -export type UpdateSmsTemplateDto = Partial> & { - signatureId?: string | null; - auditStatus?: string; -}; - -export interface ReviewDto { - reviewerId?: string; - reason?: string; -} - -export interface StatusChangeDto { - status?: string; - operatorId?: string; - reason?: string; - force?: boolean; -} - -export interface TemplateListQuery { - tenantId?: string; - status?: string; - keyword?: string; - enterpriseKeyword?: string; - applicationKeyword?: string; - nameKeyword?: string; - contentKeyword?: string; - page?: number; - pageSize?: number; -} - -export interface ApplicationListQuery { - tenantId?: string; - keyword?: string; - enterpriseKeyword?: string; - applicationKeyword?: string; - status?: string; - includeConnections?: boolean; - page?: number; - pageSize?: number; -} - -export interface SignatureListQuery { - tenantId?: string; - keyword?: string; - status?: string; - enterpriseKeyword?: string; - applicationKeyword?: string; - signatureKeyword?: string; - drainageKeyword?: string; - page?: number; - pageSize?: number; -} - -export interface GatewayDownstreamConnectionEventDto { - account: string; - connectionId: string; - status: 'connected' | 'heartbeat' | 'submit' | 'deliver' | 'disconnected'; - remoteIp?: string; - protocol?: string; - connectedAt?: string; - observedAt?: string; - errorMessage?: string; -} - -const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const; -type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number]; -const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const; -type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number]; -const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000; -const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000; -const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000; -const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { SmsApplicationConfigService } from './application-config.service'; +import { SmsApplicationLifecycleService } from './application-lifecycle.service'; +import { SmsAuditService } from './audit.service'; +import { SmsDrainageService } from './drainage.service'; +import { SmsReportValidationService } from './report-validation.service'; +import { SmsSignatureService } from './signature.service'; +import { SmsTemplateService } from './template.service'; +/** + * Stable compatibility facade for SMS configuration. + * R3 moves business behavior into focused domain services while callers keep this API. + */ @Injectable() export class SmsConfigService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(SmsConfigService.name); - private applicationDisableTimer?: ReturnType; - private applicationDisableScanRunning = false; + private readonly lifecycle: SmsApplicationLifecycleService; + private readonly applications: SmsApplicationConfigService; + private readonly reportValidation: SmsReportValidationService; + private readonly audit: SmsAuditService; + private readonly signatures: SmsSignatureService; + private readonly drainage: SmsDrainageService; + private readonly templates: SmsTemplateService; - constructor(private readonly prisma: PrismaService) {} + constructor(prisma: PrismaService) { + this.lifecycle = new SmsApplicationLifecycleService(prisma); + this.applications = new SmsApplicationConfigService(prisma, this.lifecycle); + this.reportValidation = new SmsReportValidationService(prisma, this.applications); + this.audit = new SmsAuditService(prisma, this.lifecycle, this.reportValidation); + this.signatures = new SmsSignatureService(prisma, this.reportValidation, this.audit); + this.drainage = new SmsDrainageService(prisma, this.reportValidation, this.audit); + this.templates = new SmsTemplateService(prisma, this.audit); + } onModuleInit() { - this.applicationDisableTimer = setInterval( - () => void this.runApplicationDisableScan(), - getPositiveIntegerEnv('APPLICATION_DISABLE_SCAN_INTERVAL_MS', DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS), - ); - this.applicationDisableTimer.unref?.(); - void this.runApplicationDisableScan(); + return this.lifecycle.onModuleInit(); } onModuleDestroy() { - if (this.applicationDisableTimer) clearInterval(this.applicationDisableTimer); + return this.lifecycle.onModuleDestroy(); } async listApplications(queryOrTenantId?: string | ApplicationListQuery) { - const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; - if (query.includeConnections) { - await this.markTimedOutDownstreamConnections(); - } - const applications = await this.prisma.smsApplication.findMany({ - where: { - tenantId: query.tenantId, - status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined, - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - ] : undefined, - }, - include: { - tenant: true, - ipAllowlist: true, - httpConfig: true, - }, - omit: { - secretHash: true, - }, - orderBy: { createdAt: 'desc' }, - ...(query.page && query.pageSize ? { - skip: (query.page - 1) * query.pageSize, - take: query.pageSize, - } : {}), - }); - if (!query.includeConnections) { - return applications; - } - const applicationIds = applications.map((application) => application.id); - const [connections, messageStats] = await Promise.all([ - this.prisma.cmppDownstreamConnection.findMany({ - where: { applicationId: { in: applicationIds }, status: 'connected' }, - orderBy: { updatedAt: 'desc' }, - }), - this.prisma.smsMessageRecord.groupBy({ - by: ['applicationId', 'status'], - where: { applicationId: { in: applicationIds }, queuedAt: { gte: startOfToday() } }, - _count: { _all: true }, - }), - ]); - const disablingDetails = new Map((await Promise.all(applications - .filter((application) => application.status === 'disabling') - .map(async (application) => [application.id, await this.getApplicationDeactivationPreview(application.id)] as const)))); - return applications.map((application) => { - const appConnections = connections.filter((connection) => connection.applicationId === application.id); - const appStats = messageStats.filter((item) => item.applicationId === application.id); - const todayTotal = appStats.reduce((sum, item) => sum + item._count._all, 0); - const delivered = appStats.find((item) => item.status === 'delivered')?._count._all ?? 0; - return { - ...application, - cmppConnections: appConnections, - cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status), - sentToday: todayTotal, - deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0, - deactivation: disablingDetails.get(application.id) ?? null, - }; - }).sort((left, right) => right.sentToday - left.sentToday - || left.name.localeCompare(right.name, 'zh-CN') - || left.id.localeCompare(right.id)); + return this.applications.listApplications(queryOrTenantId); } async listApplicationsPage(query: ApplicationListQuery) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const where: Prisma.SmsApplicationWhereInput = { - tenantId: query.tenantId, - status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined, - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.listApplications({ ...query, page, pageSize }), - this.prisma.smsApplication.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.applications.listApplicationsPage(query); } listApplicationOptions(tenantId?: string) { - return this.prisma.smsApplication.findMany({ - where: { tenantId, status: { not: 'deleted' } }, - select: { id: true, tenantId: true, name: true, status: true }, - orderBy: [{ name: 'asc' }, { id: 'asc' }], - }); + return this.applications.listApplicationOptions(tenantId); } async getApplication(applicationId: string, tenantId?: string) { - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - include: { - tenant: true, - ipAllowlist: true, - httpConfig: true, - }, - }); - if (!application || (tenantId && application.tenantId !== tenantId)) { - throw new NotFoundException('Application not found'); - } - return application; + return this.applications.getApplication(applicationId, tenantId); } async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') { - if (applicationId) await this.getApplication(applicationId); - const [commonFields, routes] = await Promise.all([ - this.prisma.commonReportField.findMany({ - where: { - status: 'active', - reportType, - drainageField: { status: 'active' }, - }, - include: { drainageField: true }, - orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], - }), - applicationId ? this.prisma.channelRouteRule.findMany({ - where: { applicationId, status: 'active' }, - include: { - group: { - include: { - items: { - include: { - channel: { - include: { - reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } }, - }, - }, - }, - }, - }, - }, - }, - orderBy: { priority: 'asc' }, - }) : Promise.resolve([]), - ]); - type MergedReportField = { - id: string; - code: string; - name: string; - fieldType: string; - required: boolean; - description?: string | null; - reportTypes: string[]; - commonReportTypes: string[]; - channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>; - }; - const merged = new Map(); - const routeChannels = new Map(); - for (const route of routes) { - if (!route.group) continue; - for (const item of route.group.items) { - if (!routeChannels.has(item.channel.id)) { - routeChannels.set(item.channel.id, { - id: item.channel.id, - code: item.channel.code, - name: item.channel.name, - groupId: route.group.id, - groupName: route.group.name, - }); - } - } - } - for (const configured of commonFields) { - merged.set(configured.drainageField.id, { - id: configured.drainageField.id, - code: configured.drainageField.code, - name: configured.drainageField.name, - fieldType: configured.drainageField.fieldType, - required: configured.required, - description: configured.drainageField.description, - reportTypes: [configured.reportType], - commonReportTypes: [configured.reportType], - channels: Array.from(routeChannels.values()).map((channel) => ({ - ...channel, - required: configured.required, - reportType: configured.reportType, - source: 'common' as const, - })), - }); - } - for (const route of routes) { - if (!route.group) continue; - for (const item of route.group.items) { - for (const configured of item.channel.reportFields) { - if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue; - if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue; - const key = configured.drainageField.id; - const current: MergedReportField = merged.get(key) ?? { - id: configured.drainageField.id, - code: configured.drainageField.code, - name: configured.drainageField.name, - fieldType: configured.drainageField.fieldType, - required: false, - description: configured.drainageField.description, - reportTypes: [], - commonReportTypes: [], - channels: [], - }; - current.required = current.required || configured.required; - if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType); - const existingChannel = current.channels.find((channel) => channel.id === item.channel.id); - if (existingChannel) { - existingChannel.required = existingChannel.required || configured.required; - existingChannel.reportType = configured.reportType; - existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source; - } else { - current.channels.push({ - id: item.channel.id, - code: item.channel.code, - name: item.channel.name, - groupId: route.group.id, - groupName: route.group.name, - required: configured.required, - reportType: configured.reportType, - source: 'channel', - }); - } - merged.set(key, current); - } - } - } - return Array.from(merged.values()); + return this.applications.getApplicationReportFields(applicationId, reportType); } async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') { - const fields = await this.getApplicationReportFields(applicationId, reportType); - return fields.map(({ channels: _channels, commonReportTypes: _commonReportTypes, ...field }) => field); + return this.applications.getClientApplicationReportFields(applicationId, reportType); } async createApplication(data: CreateSmsApplicationDto) { - assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价'); - const secret = normalizeApplicationPassword(data.passwordCipher); - const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); - const interfaceType = normalizeApplicationInterfaceType(data.interfaceType); - const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount(); - const cmppEnterpriseCode = cmppAccount; - const accessNumber = normalizeCmppAccessNumberConfig(data); - await this.validateClientSrcIdAvailable(accessNumber.clientSrcId); - return this.prisma.smsApplication.create({ - data: { - tenantId: data.tenantId, - name: data.name, - scene: data.scene, - callbackUrl: data.callbackUrl, - cmppAccount, - cmppEnterpriseCode, - cmppApplicationExtension: accessNumber.applicationExtension, - cmppAccessNumberFillEnabled: accessNumber.fillEnabled, - cmppAccessNumberFillPrefix: accessNumber.fillPrefix, - cmppClientSrcId: accessNumber.clientSrcId, - secretHash: secret, - interfaceEnabled: data.interfaceEnabled ?? true, - interfaceType, - cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), - cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), - dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), - customerUnitPrice: data.customerUnitPrice ?? 0, - queuePriority, - templateMismatchMode: data.templateMismatchMode ?? 'reject', - downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true, - downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true, - ipAllowlist: { - create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })), - }, - }, - include: { ipAllowlist: true }, - }); + return this.applications.createApplication(data); } async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) { - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - include: { httpConfig: true }, - }); - if (!application) { - throw new NotFoundException('Application not found'); - } - if (data.customerUnitPrice !== undefined) { - assertMoneyUnits(data.customerUnitPrice, '客户单价'); - } - const queuePriority = data.queuePriority === undefined - ? undefined - : normalizeApplicationQueuePriority(data.queuePriority); - const cmppAccount = data.cmppAccount === undefined - ? undefined - : await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId); - const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount; - const interfaceType = data.interfaceType === undefined - ? undefined - : normalizeApplicationInterfaceType(data.interfaceType); - const secretHash = data.passwordCipher === undefined - ? undefined - : normalizeApplicationPassword(data.passwordCipher); - const accessNumberChanged = data.cmppApplicationExtension !== undefined - || data.cmppAccessNumberFillEnabled !== undefined - || data.cmppAccessNumberFillPrefix !== undefined; - const accessNumber = accessNumberChanged - ? normalizeCmppAccessNumberConfig(data, application) - : undefined; - if (accessNumber?.clientSrcId && accessNumber.clientSrcId !== application.cmppClientSrcId) { - await this.validateClientSrcIdAvailable(accessNumber.clientSrcId, applicationId); - } - - return this.prisma.$transaction(async (tx) => { - if (data.ipAllowlist) { - await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } }); - } - const updated = await tx.smsApplication.update({ - where: { id: applicationId }, - data: { - name: data.name, - scene: data.scene, - callbackUrl: data.callbackUrl, - cmppAccount, - cmppEnterpriseCode, - cmppApplicationExtension: accessNumber?.applicationExtension, - cmppAccessNumberFillEnabled: accessNumber?.fillEnabled, - cmppAccessNumberFillPrefix: accessNumber?.fillPrefix, - cmppClientSrcId: accessNumber?.clientSrcId, - secretHash, - interfaceEnabled: data.interfaceEnabled, - interfaceType, - cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), - cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), - dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), - customerUnitPrice: data.customerUnitPrice, - queuePriority, - templateMismatchMode: data.templateMismatchMode, - downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled, - downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled, - status: data.status, - ipAllowlist: data.ipAllowlist ? { - create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })), - } : undefined, - }, - include: { tenant: true, ipAllowlist: true }, - }); - if (data.interfaceEnabled !== undefined && application.httpConfig) { - const deliveryMode = automaticDeliveryMode(data.interfaceEnabled, application.httpConfig.enabled); - await tx.smsApplicationHttpConfig.update({ - where: { applicationId }, - data: { - receiptDeliveryMode: deliveryMode, - uplinkDeliveryMode: deliveryMode, - }, - }); - } - return updated; - }); + return this.applications.updateApplication(applicationId, data); } async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) { - const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); - if (!application) { - throw new NotFoundException('Application not found'); - } - const routes = data.routes ?? []; - if (routes.length === 0) { - throw new BadRequestException('At least one carrier channel group is required'); - } - - const carriers = new Set(); - routes.forEach((route) => { - if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) { - throw new BadRequestException('carrier must be mobile, unicom or telecom'); - } - if (carriers.has(route.carrier)) { - throw new BadRequestException('Duplicate carrier route is not allowed'); - } - carriers.add(route.carrier); - }); - - const groups = await this.prisma.smsChannelGroup.findMany({ - where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } }, - select: { id: true, carrier: true }, - }); - const groupMap = new Map(groups.map((group) => [group.id, group])); - routes.forEach((route) => { - const group = groupMap.get(route.groupId); - if (!group) { - throw new BadRequestException(`channel group ${route.groupId} does not exist`); - } - if (group.carrier !== route.carrier) { - throw new BadRequestException('channel group carrier must match route carrier'); - } - }); - - return this.prisma.$transaction(async (tx) => { - await tx.channelRouteRule.deleteMany({ - where: { - applicationId, - channelId: null, - province: null, - }, - }); - await tx.channelRouteRule.createMany({ - data: routes.map((route, index) => ({ - tenantId: application.tenantId, - applicationId, - groupId: route.groupId, - carrier: route.carrier, - priority: route.priority ?? (index + 1) * 10, - status: route.status ?? 'active', - })), - }); - return tx.channelRouteRule.findMany({ - where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } }, - orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], - }); - }); + return this.applications.replaceApplicationRouteRules(applicationId, data); } async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) { - const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); - if (!application) { - throw new NotFoundException('Application not found'); - } - const secret = generateApplicationPassword(); - const updated = await this.prisma.smsApplication.update({ - where: { id: applicationId }, - data: { secretHash: secret }, - }); - await this.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, { - reason: data.reason, - }); - return { ...updated, secret }; + return this.applications.resetApplicationSecret(applicationId, data); } async changeApplicationStatus(applicationId: string, data: StatusChangeDto) { - const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); - if (!application) { - throw new NotFoundException('Application not found'); - } - const status = data.status ?? 'disabled'; - if (status === 'active') { - const updated = await this.prisma.smsApplication.update({ - where: { id: applicationId }, - data: { status: 'active', disablingAt: null, autoDisableAt: null, disableReason: null }, - }); - await this.writeApplicationStatusLog(application, data, 'active', {}); - return updated; - } - if (!['disabled', 'disabling', 'deleted'].includes(status)) { - throw new BadRequestException(`不支持的企业应用状态:${status}`); - } - - const preview = await this.getApplicationDeactivationPreview(applicationId); - if ((status === 'disabling' || status === 'disabled') && preview.totalOutstanding > 0 && !data.force) { - const disablingAt = new Date(); - const autoDisableAt = new Date(disablingAt.getTime() + APPLICATION_DISABLE_GRACE_MS); - const updated = await this.prisma.smsApplication.update({ - where: { id: applicationId }, - data: { - status: 'disabling', - disablingAt, - autoDisableAt, - disableReason: data.reason?.trim() || '等待未完成回执清算', - }, - }); - await this.writeApplicationStatusLog(application, data, 'disabling', { preview, disablingAt, autoDisableAt }); - return { ...updated, deactivation: { ...preview, disablingAt, autoDisableAt } }; - } - - const finalStatus = status === 'deleted' ? 'deleted' : 'disabled'; - const abandonReason = status === 'deleted' - ? '企业应用已删除,放弃剩余下游投递' - : data.force - ? '运营强制停用企业应用,放弃剩余下游投递' - : '企业应用无待清算数据,完成停用'; - const abandoned = await this.abandonApplicationDeliveries(applicationId, abandonReason); - const updated = await this.prisma.smsApplication.update({ - where: { id: applicationId }, - data: { - status: finalStatus, - disablingAt: null, - autoDisableAt: null, - disableReason: data.reason?.trim() || abandonReason, - }, - }); - const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, abandonReason); - await this.writeApplicationStatusLog(application, data, finalStatus, { preview, abandoned, disconnect }); - return { ...updated, deactivation: null, abandoned, disconnect }; + return this.lifecycle.changeApplicationStatus(applicationId, data); } async getApplicationDeactivationPreview(applicationId: string) { - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - select: { - id: true, - status: true, - disablingAt: true, - autoDisableAt: true, - disableReason: true, - }, - }); - if (!application) throw new NotFoundException('Application not found'); - const [ - awaitingSupplierReceipt, - waitingToSend, - awaitingClientAck, - retryableFailures, - pendingUplinks, - activeConnections, - ] = await Promise.all([ - this.prisma.smsMessageRecord.count({ - where: { applicationId, status: { in: ['submitted', 'unknown'] }, receiptStatus: null }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { applicationId, deliveryType: 'receipt', status: { in: ['pending', 'manual_requeueing'] } }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { applicationId, deliveryType: 'receipt', status: 'awaiting_ack' }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { applicationId, deliveryType: 'receipt', status: 'failed', retryEnabled: true }, - }), - this.prisma.cmppDownstreamDelivery.count({ - where: { applicationId, deliveryType: 'uplink', status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, - }), - this.prisma.cmppDownstreamConnection.count({ - where: { applicationId, status: 'connected' }, - }), - ]); - return { - status: application.status, - reason: application.disableReason, - disablingAt: application.disablingAt, - autoDisableAt: application.autoDisableAt, - awaitingSupplierReceipt, - waitingToSend, - awaitingClientAck, - retryableFailures, - pendingUplinks, - activeConnections, - totalOutstanding: awaitingSupplierReceipt + waitingToSend + awaitingClientAck + retryableFailures + pendingUplinks, - }; + return this.lifecycle.getApplicationDeactivationPreview(applicationId); } async listApplicationConnections(applicationId: string) { - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - include: { tenant: true }, - }); - if (!application) { - throw new NotFoundException('Application not found'); - } - await this.markTimedOutDownstreamConnections(); - const connections = await this.prisma.cmppDownstreamConnection.findMany({ - where: { applicationId }, - orderBy: { updatedAt: 'desc' }, - }); - return { - application, - connections, - summary: { - desiredConnections: application.cmppMaxConnections, - currentConnections: connections.filter((connection) => connection.status === 'connected').length, - status: normalizeApplicationCmppStatus(connections, application.status), - }, - }; + return this.lifecycle.listApplicationConnections(applicationId); } async getApplicationCmppParams(applicationId: string, tenantId?: string) { - const application = await this.prisma.smsApplication.findUnique({ - where: { id: applicationId }, - include: { tenant: true }, - }); - if (!application || (tenantId && application.tenantId !== tenantId)) { - throw new NotFoundException('Application not found'); - } - if (tenantId && !application.interfaceEnabled) { - throw new ForbiddenException('该企业应用未开通 CMPP 接口'); - } - return { - applicationId: application.id, - applicationName: application.name, - tenantId: application.tenantId, - tenantName: application.tenant.name, - appCode: application.id, - gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1', - gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890), - enterpriseCode: application.cmppEnterpriseCode, - account: application.cmppAccount, - passwordCipher: application.secretHash, - srcId: application.cmppClientSrcId ?? '', - applicationExtension: application.cmppApplicationExtension, - accessNumberFillEnabled: application.cmppAccessNumberFillEnabled, - accessNumberFillPrefix: application.cmppAccessNumberFillPrefix, - interfaceEnabled: application.interfaceEnabled, - interfaceType: application.interfaceType, - maxConnections: application.cmppMaxConnections, - heartbeatSeconds: 30, - windowSize: application.cmppWindowSize, - protocolVersion: application.interfaceType === 'cmpp20' ? 'CMPP2.0' : application.interfaceType, - }; - } - - private async validateAndReserveCmppAccount(cmppAccount: string, currentApplicationId?: string) { - if (!/^\d{6}$/.test(cmppAccount)) { - throw new BadRequestException('cmppAccount must be a 6-digit number'); - } - const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } }); - if (exists && exists.id !== currentApplicationId) { - throw new BadRequestException('cmppAccount already exists'); - } - return cmppAccount; - } - - private async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string) { - if (!clientSrcId) return; - const exists = await this.prisma.smsApplication.findUnique({ where: { cmppClientSrcId: clientSrcId } }); - if (exists && exists.id !== currentApplicationId) { - throw new BadRequestException('client CMPP Src_Id already exists'); - } - } - - private async generateCmppAccount() { - for (let attempt = 0; attempt < 20; attempt += 1) { - const cmppAccount = String(randomInt(100000, 1000000)); - const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } }); - if (!exists) { - return cmppAccount; - } - } - throw new BadRequestException('Unable to generate unique CMPP account'); + return this.applications.getApplicationCmppParams(applicationId, tenantId); } async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) { - const application = await this.prisma.smsApplication.findUnique({ - where: { cmppAccount: data.account }, - include: { ipAllowlist: true }, - }); - if (!application) { - throw new BadRequestException('CMPP account does not reference an application'); - } - const observedAt = parseGatewayDate(data.observedAt) ?? new Date(); - const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt; - const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } }); - if (data.status === 'disconnected') { - if (existing) { - await this.prisma.cmppDownstreamConnection.delete({ where: { id: existing.id } }); - } - await this.writeOperationLog(application.tenantId, undefined, 'cmpp_downstream_connection.disconnected', 'cmpp_downstream_connection', data.connectionId, { - applicationId: application.id, - account: data.account, - remoteIp: data.remoteIp, - protocol: data.protocol, - status: 'disconnected', - errorMessage: data.errorMessage, - }); - return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) }; - } - if (!application.interfaceEnabled || !['active', 'disabling'].includes(application.status)) { - throw new ForbiddenException('CMPP interface is disabled for this application'); - } - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { - throw new ForbiddenException('CMPP source IP is not in application allowlist'); - } - // A Gateway process can disappear before it reports disconnect. Prune its - // expired rows here so a restarted process can reclaim connection slots - // without waiting for an operator to open the connection-list page. - await this.markTimedOutDownstreamConnections(observedAt); - const activeConnections = await this.prisma.cmppDownstreamConnection.findMany({ - where: { applicationId: application.id, status: 'connected' }, - select: { connectionId: true }, - orderBy: [{ connectedAt: 'asc' }, { connectionId: 'asc' }], - }); - const allowedConnectionIds = activeConnections.slice(0, application.cmppMaxConnections).map((item) => item.connectionId); - if ((!existing && activeConnections.length >= application.cmppMaxConnections) - || (existing && activeConnections.length > application.cmppMaxConnections && !allowedConnectionIds.includes(data.connectionId))) { - throw new ForbiddenException(`CMPP connection limit exceeded (${application.cmppMaxConnections})`); - } - const payload = { - tenantId: application.tenantId, - applicationId: application.id, - account: data.account, - enterpriseCode: application.cmppEnterpriseCode, - remoteIp: data.remoteIp, - protocol: data.protocol, - status: 'connected', - connectedAt: existing?.connectedAt ?? connectedAt, - lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt, - lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt, - lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt, - disconnectedAt: null, - lastError: null, - }; - const connection = existing - ? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload }) - : await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } }); - if (data.status === 'connected') { - await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, { - applicationId: application.id, - account: data.account, - remoteIp: data.remoteIp, - protocol: data.protocol, - status: connection.status, - }); - } - return connection; + return this.lifecycle.recordDownstreamConnectionEvent(data); } async markTimedOutDownstreamConnections(now = new Date()) { - const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS); - const cutoff = new Date(now.getTime() - timeoutMs); - return this.prisma.cmppDownstreamConnection.deleteMany({ - where: { - status: 'connected', - OR: [ - { lastHeartbeatAt: { lt: cutoff } }, - { lastHeartbeatAt: null, connectedAt: { lt: cutoff } }, - ], - }, - }); + return this.lifecycle.markTimedOutDownstreamConnections(now); } async listSignatures(queryOrTenantId?: string | SignatureListQuery) { - const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; - const signatures = await this.prisma.smsSignature.findMany({ - where: { - tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, - name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, - drainageItems: query.drainageKeyword ? { - some: { - auditStatus: { not: 'deleted' }, - OR: [ - { siteName: { contains: query.drainageKeyword } }, - { url: { contains: query.drainageKeyword } }, - { remark: { contains: query.drainageKeyword } }, - ], - }, - } : undefined, - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { purpose: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - { application: { name: { contains: query.keyword } } }, - ] : undefined, - }, - include: { - materials: true, - tenant: true, - application: true, - drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, - reportTasks: { include: { channel: true, drainageInfo: true } }, - }, - orderBy: { createdAt: 'desc' }, - ...(query.page && query.pageSize ? { - skip: (query.page - 1) * query.pageSize, - take: query.pageSize, - } : {}), - }); - const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); - const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ - where: { applicationId: { in: applicationIds }, status: 'active' }, - include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, - }) : []; - const hasCommonDrainageFields = await this.prisma.commonReportField.count({ - where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, - }).then((count) => count > 0); - return signatures.map((signature) => { - const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; - const drainageLinks = signature.drainageItems.map((item) => ({ - id: item.id, - siteName: item.siteName, - url: item.url, - remark: item.remark ?? '', - reportValues: isRecord(item.reportValues) ? item.reportValues : {}, - auditStatus: item.auditStatus, - rejectReason: item.rejectReason, - submittedAt: item.submittedAt.toISOString(), - reviewedAt: item.reviewedAt?.toISOString(), - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - })); - return { - ...signature, - name: normalizeSmsSignature(signature.name), - drainageInfo: { ...legacyPayload, links: drainageLinks }, - reportTargets: (() => { - const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); - const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); - return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id })); - })(), - drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => { - const drainageItemId = drainageItem.id; - const channels = routes - .filter((route) => route.applicationId === signature.applicationId && route.group) - .flatMap((route) => route.group!.items.map((item) => item.channel)) - .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); - const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); - return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => { - const task = taskByChannel.get(channel.id); - return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; - })]; - })), - drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => { - const drainageItemId = drainageItem.id; - const channels = routes - .filter((route) => route.applicationId === signature.applicationId && route.group) - .flatMap((route) => route.group!.items.map((item) => item.channel)) - .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); - const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); - return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); - const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []); - const approved = statuses.filter((status) => status === 'approved').length; - const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; - return [carrier, { status, approved, total: statuses.length }]; - }))]; - })), - carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all')); - const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); - const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); - const approved = statuses.filter((status) => status === 'approved').length; - const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; - return [carrier, { status, approved, total: targets.length }]; - })), - }; - }); + return this.signatures.listSignatures(queryOrTenantId); } async listSignaturesPage(query: SignatureListQuery) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const where: Prisma.SmsSignatureWhereInput = { - tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, - name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, - drainageItems: query.drainageKeyword ? { - some: { - auditStatus: { not: 'deleted' }, - OR: [ - { siteName: { contains: query.drainageKeyword } }, - { url: { contains: query.drainageKeyword } }, - { remark: { contains: query.drainageKeyword } }, - ], - }, - } : undefined, - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { purpose: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - { application: { name: { contains: query.keyword } } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.listSignatures({ ...query, page, pageSize }), - this.prisma.smsSignature.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.signatures.listSignaturesPage(query); } listSignatureOptions(tenantId?: string) { - return this.prisma.smsSignature.findMany({ - where: { tenantId, auditStatus: { not: 'deleted' } }, - select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true }, - orderBy: [{ name: 'asc' }, { id: 'asc' }], - }); + return this.signatures.listSignatureOptions(tenantId); } async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { - const signatures = await this.prisma.smsSignature.findMany({ - where: { - id: signatureId, - tenantId, - applicationId: query.applicationId, - auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, - OR: query.keyword?.trim() ? [ - { name: { contains: query.keyword.trim() } }, - { purpose: { contains: query.keyword.trim() } }, - { application: { name: { contains: query.keyword.trim() } } }, - ] : undefined, - }, - select: { - id: true, - tenantId: true, - applicationId: true, - name: true, - purpose: true, - auditStatus: true, - reportStatus: true, - pendingReport: true, - reportChangedAt: 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' }, - skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, - take: query.pageSize, - }); - return signatures.map((signature) => { - const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; - return { - id: signature.id, - tenantId: signature.tenantId, - applicationId: signature.applicationId, - name: normalizeSmsSignature(signature.name), - purpose: signature.purpose, - auditStatus: signature.auditStatus, - reportStatus: signature.reportStatus, - pendingReport: signature.pendingReport, - reportChangedAt: signature.reportChangedAt, - 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 : {}, - })), - }, - }; - }); + return this.signatures.listClientSignatures(tenantId, signatureId, query); } async getClientSignatureView(signatureId: string, tenantId?: string) { - const [signature] = await this.listClientSignatures(tenantId, signatureId); - if (!signature) throw new NotFoundException('Signature not found'); - return signature; + return this.signatures.getClientSignatureView(signatureId, tenantId); } async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const filteredWhere: Prisma.SmsSignatureWhereInput = { - tenantId, - applicationId: query.applicationId, - auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, - OR: query.keyword?.trim() ? [ - { name: { contains: query.keyword.trim() } }, - { purpose: { contains: query.keyword.trim() } }, - { application: { name: { contains: query.keyword.trim() } } }, - ] : undefined, - }; - const [items, total, statusCounts] = await Promise.all([ - this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), - this.prisma.smsSignature.count({ where: filteredWhere }), - 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, total, page, pageSize }; + return this.signatures.getClientSignatureWorkspace(tenantId, query); } 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' }, - }); + return this.drainage.listClientDrainageInfos(tenantId, itemId); } async getClientDrainageInfoView(itemId: string, tenantId?: string) { - const [item] = await this.listClientDrainageInfos(tenantId, itemId); - if (!item) throw new NotFoundException('Drainage info not found'); - return item; + return this.drainage.getClientDrainageInfoView(itemId, tenantId); } async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { - await this.validateSignatureReportValues(data.applicationId, data.drainageInfo); - const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); - const name = validateCompleteSmsSignature(data.name); - const signature = await this.prisma.smsSignature.create({ - data: { - tenantId: data.tenantId, - applicationId: data.applicationId, - name, - purpose: data.purpose, - auditStatus: options.initialAuditStatus, - drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, - }, - }); - await this.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); - if (options.initialAuditStatus) { - await this.createAuditRecord({ - tenantId: signature.tenantId, - targetType: 'sms_signature', - targetId: signature.id, - action: 'admin_create_approved', - statusAfter: options.initialAuditStatus, - reason: '运营端新建签名自动审核通过', - }); - } - return signature; + return this.signatures.createSignature(data, options); } async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature || (tenantId && signature.tenantId !== tenantId)) { - throw new NotFoundException('Signature not found'); - } - await this.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo); - const applicationId = data.applicationId ?? signature.applicationId ?? undefined; - const drainageInfo = data.drainageInfo - ? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo) - : undefined; - const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name); - const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) - || (name !== undefined && name !== normalizeSmsSignature(signature.name)) - || (data.purpose !== undefined && data.purpose !== signature.purpose) - || (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null)); - const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; - const updated = await this.prisma.smsSignature.update({ - where: { id: signatureId }, - data: { - applicationId: data.applicationId, - name, - purpose: data.purpose, - auditStatus, - rejectReason: auditStatus === 'pending' ? null : undefined, - drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, - materialVersion: { increment: 1 }, - pendingReport: true, - reportChangedAt: new Date(), - }, - include: { materials: true, tenant: true, application: true }, - }); - await this.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo); - return updated; + return this.signatures.updateSignature(signatureId, data, tenantId); } 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; + return this.signatures.updateClientSignature(signatureId, data, tenantId); } listDrainageInfos(query: DrainageInfoListQuery = {}) { - return this.prisma.smsDrainageInfo.findMany({ - where: { - tenantId: query.tenantId, - signatureId: query.signatureId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - OR: query.keyword ? [ - { siteName: { contains: query.keyword } }, - { url: { contains: query.keyword } }, - { signature: { name: { contains: query.keyword } } }, - { tenant: { name: { contains: query.keyword } } }, - { application: { name: { contains: query.keyword } } }, - ] : undefined, - }, - include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } }, - orderBy: { updatedAt: 'desc' }, - }); + return this.drainage.listDrainageInfos(query); } async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) throw new NotFoundException('Signature not found'); - if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found'); - if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息'); - if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required'); - await this.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues); - const auditStatus = options.initialAuditStatus ?? 'pending'; - const item = await this.prisma.smsDrainageInfo.create({ - data: { - tenantId: signature.tenantId, - signatureId, - applicationId: signature.applicationId, - siteName: data.siteName.trim(), - url: data.url.trim(), - remark: data.remark, - reportValues: data.reportValues as Prisma.InputJsonValue | undefined, - auditStatus, - reviewedAt: auditStatus === 'approved' ? new Date() : undefined, - }, - include: { tenant: true, signature: true, application: true }, - }); - await this.createAuditRecord({ - tenantId: item.tenantId, - targetType: 'sms_drainage_info', - targetId: item.id, - action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit', - statusAfter: auditStatus, - reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined, - }); - if (auditStatus === 'approved') await this.activateDrainageReporting(item.id); - return item; + return this.drainage.createDrainageInfo(signatureId, data, options, tenantId); } async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) { - const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); - if (!current) throw new NotFoundException('Drainage info not found'); - if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); - if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改'); - if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required'); - if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required'); - const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined; - await this.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {})); - const auditStatus = options.initialAuditStatus ?? 'pending'; - const updated = await this.prisma.smsDrainageInfo.update({ - where: { id: itemId }, - data: { - applicationId, - siteName: data.siteName?.trim(), - url: data.url?.trim(), - remark: data.remark, - reportValues: data.reportValues as Prisma.InputJsonValue | undefined, - auditStatus, - rejectReason: null, - submittedAt: new Date(), - reviewedAt: auditStatus === 'approved' ? new Date() : null, - materialVersion: { increment: 1 }, - pendingReport: true, - reportChangedAt: new Date(), - }, - include: { tenant: true, signature: true, application: true }, - }); - await this.createAuditRecord({ - tenantId: current.tenantId, - targetType: 'sms_drainage_info', - targetId: itemId, - action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit', - statusBefore: current.auditStatus, - statusAfter: auditStatus, - reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined, - }); - if (auditStatus === 'approved') await this.activateDrainageReporting(itemId); - else await this.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核'); - return updated; + return this.drainage.updateDrainageInfo(itemId, data, options, tenantId); } approveDrainageInfo(itemId: string, data: ReviewDto) { - return this.reviewDrainageInfo(itemId, 'approved', 'approve', data); + return this.drainage.approveDrainageInfo(itemId, data); } rejectDrainageInfo(itemId: string, data: ReviewDto) { - return this.reviewDrainageInfo(itemId, 'rejected', 'reject', data); + return this.drainage.rejectDrainageInfo(itemId, data); } async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) { - const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); - if (!current) throw new NotFoundException('Drainage info not found'); - if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); - const status = data.status ?? 'deleted'; - if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态'); - const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } }); - if (status === 'deleted') await this.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned'); - await this.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason }); - return updated; - } - - private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record) { - if (!drainageInfo) return drainageInfo; - const fields = await this.getApplicationReportFields(applicationId); - return { - ...drainageInfo, - reportRequirementSnapshot: { - capturedAt: new Date().toISOString(), - applicationId, - fields: fields.map((field) => ({ - id: field.id, - code: field.code, - name: field.name, - fieldType: field.fieldType, - required: field.required, - reportTypes: field.reportTypes, - commonReportTypes: field.commonReportTypes, - channels: field.channels, - })), - }, - }; - } - - private async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record) { - if (!drainageInfo) return; - const fields = await this.getApplicationReportFields(applicationId); - const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; - for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { - const value = reportValueParts(signatureValues[field.code]); - for (const channel of field.channels) { - await this.prisma.signatureReportMaterial.upsert({ - where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } }, - update: value, - create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value }, - }); - } - } - } - - private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record) { - const fields = await this.getApplicationReportFields(applicationId, 'signature'); - const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {}; - const missingSignature = fields - .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')) - .filter((field) => !hasReportValue(signatureValues[field.code])); - if (missingSignature.length > 0) { - throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`); - } - } - - private async validateDrainageReportValues(applicationId?: string, reportValues: Record = {}) { - const fields = await this.getApplicationReportFields(applicationId, 'drainage'); - const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code])); - if (missing.length > 0) { - throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`); - } - } - - private async activateDrainageReporting(itemId: string) { - const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); - if (!item) throw new NotFoundException('Drainage info not found'); - if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); - const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined; - if (!applicationId) return; - const fields = (await this.getApplicationReportFields(applicationId, 'drainage')) - .filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both')); - const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])); - const values = isRecord(item.reportValues) ? item.reportValues : {}; - await this.prisma.$transaction(async (tx) => { - await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); - for (const field of fields) { - const value = reportValueParts(values[field.code]); - for (const channel of field.channels) { - await tx.drainageReportMaterial.create({ - data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value }, - }); - } - } - const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); - const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task])); - for (const channel of channels.values()) { - const existing = existingByChannel.get(channel.id); - const task = existing - ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } }) - : await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } }); - await tx.channelSignatureReportRecord.create({ - data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' }, - }); - } - for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) { - await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } }); - await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } }); - } - }); - } - - private async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') { - await this.prisma.$transaction(async (tx) => { - const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } }); - if (!item) throw new NotFoundException('Drainage info not found'); - await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); - const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); - for (const task of tasks.filter((current) => current.status !== statusAfter)) { - await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } }); - await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } }); - } - }); + return this.drainage.changeDrainageInfoStatus(itemId, data, tenantId); } createSignatureMaterial(data: CreateSignatureMaterialDto) { - return this.prisma.signatureMaterial.create({ - data: { - signatureId: data.signatureId, - fileObjectId: data.fileObjectId, - materialType: data.materialType, - title: data.title, - description: data.description, - }, - }); + return this.signatures.createSignatureMaterial(data); } async submitSignature(signatureId: string, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature || (tenantId && signature.tenantId !== tenantId)) { - throw new NotFoundException('Signature not found'); - } - - const updated = await this.prisma.smsSignature.update({ - where: { id: signatureId }, - data: { auditStatus: 'pending', rejectReason: null }, - }); - await this.createAuditRecord({ - tenantId: signature.tenantId, - targetType: 'sms_signature', - targetId: signatureId, - action: 'submit', - statusBefore: signature.auditStatus, - statusAfter: 'pending', - }); - return updated; + return this.signatures.submitSignature(signatureId, tenantId); } listTemplates(queryOrTenantId?: string | TemplateListQuery) { - const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; - return this.prisma.smsTemplate.findMany({ - where: { - tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, - name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, - content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { content: { contains: query.keyword } }, - { category: { contains: query.keyword } }, - { application: { name: { contains: query.keyword } } }, - { tenant: { name: { contains: query.keyword } } }, - ] : undefined, - }, - include: { variables: true, application: true, tenant: true, signature: true }, - orderBy: { createdAt: 'desc' }, - ...(query.page && query.pageSize ? { - skip: (query.page - 1) * query.pageSize, - take: query.pageSize, - } : {}), - }); + return this.templates.listTemplates(queryOrTenantId); } async listTemplatesPage(query: TemplateListQuery) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const where: Prisma.SmsTemplateWhereInput = { - tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, - name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, - content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { content: { contains: query.keyword } }, - { category: { contains: query.keyword } }, - { application: { name: { contains: query.keyword } } }, - { tenant: { name: { contains: query.keyword } } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.listTemplates({ ...query, page, pageSize }), - this.prisma.smsTemplate.count({ where }), - ]); - return { items, total, page, pageSize }; + return this.templates.listTemplatesPage(query); } listClientTemplates(tenantId: string | undefined, includeHistory = false) { - return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); + return this.templates.listClientTemplates(tenantId, includeHistory); } async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { - const variables = validateAndNormalizeTemplateVariables(data.content, data.variables); - const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); - if (!application || application.tenantId !== data.tenantId) { - throw new BadRequestException('applicationId does not belong to the template tenant'); - } - await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content); - return this.prisma.smsTemplate.create({ - data: { - tenantId: data.tenantId, - applicationId: data.applicationId, - signatureId: data.signatureId, - name: data.name, - content: data.content, - category: data.category, - auditStatus: options.initialAuditStatus, - billingUnits: estimateBillingUnits(data.content), - variables: { - create: variables.map((variable: TemplateVariableInput) => ({ - name: variable.name, - example: variable.example, - required: variable.required ?? true, - })), - }, - }, - include: { variables: true, application: true, tenant: true, signature: true }, - }); + return this.templates.createTemplate(data, options); } async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template || (tenantId && template.tenantId !== tenantId)) { - throw new NotFoundException('Template not found'); - } - if (data.applicationId) { - const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); - if (!application || application.tenantId !== template.tenantId) { - throw new BadRequestException('applicationId does not belong to the template tenant'); - } - } - if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) { - await this.validateTemplateSignature( - data.signatureId === undefined ? template.signatureId : data.signatureId, - template.tenantId, - data.applicationId ?? template.applicationId, - data.content ?? template.content, - ); - } - const variables = data.content !== undefined || data.variables !== undefined - ? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables) - : undefined; - const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId) - || (data.signatureId !== undefined && data.signatureId !== template.signatureId) - || (data.content !== undefined && data.content !== template.content) - || (data.category !== undefined && data.category !== template.category) - || data.variables !== undefined; - const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus; - return this.prisma.$transaction(async (tx) => { - if (variables) { - await tx.templateVariable.deleteMany({ where: { templateId } }); - } - return tx.smsTemplate.update({ - where: { id: templateId }, - data: { - applicationId: data.applicationId, - signatureId: data.signatureId, - name: data.name, - content: data.content, - category: data.category, - auditStatus, - rejectReason: auditStatus === 'pending' ? null : undefined, - billingUnits: data.content ? estimateBillingUnits(data.content) : undefined, - variables: variables ? { - create: variables.map((variable) => ({ - name: variable.name, - example: variable.example, - required: variable.required ?? true, - })), - } : undefined, - }, - include: { variables: true, application: true, tenant: true, signature: true }, - }); - }); + return this.templates.updateTemplate(templateId, data, tenantId); } async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { - const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found'); - if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { - throw new BadRequestException('当前审核状态不允许修改模板'); - } - const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId); - await this.createAuditRecord({ - tenantId: current.tenantId, - targetType: 'sms_template', - targetId: templateId, - action: 'client_update_submit', - statusBefore: current.auditStatus, - statusAfter: 'pending', - }); - return updated; + return this.templates.updateClientTemplate(templateId, data, tenantId); } async submitTemplate(templateId: string, tenantId?: string) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template || (tenantId && template.tenantId !== tenantId)) { - throw new NotFoundException('Template not found'); - } - await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content); - - const updated = await this.prisma.smsTemplate.update({ - where: { id: templateId }, - data: { auditStatus: 'pending', rejectReason: null }, - }); - await this.createAuditRecord({ - tenantId: template.tenantId, - targetType: 'sms_template', - targetId: templateId, - action: 'submit', - statusBefore: template.auditStatus, - statusAfter: 'pending', - }); - return updated; - } - - private async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) { - if (!signatureId) { - throw new BadRequestException('短信模板必须选择短信签名'); - } - const signature = await this.prisma.smsSignature.findUnique({ - where: { id: signatureId }, - select: { tenantId: true, applicationId: true, name: true }, - }); - if (!signature || signature.tenantId !== tenantId) { - throw new BadRequestException('signatureId does not belong to the template tenant'); - } - if (signature.applicationId && signature.applicationId !== applicationId) { - throw new BadRequestException('signatureId does not belong to the template application'); - } - const signaturePrefix = normalizeSmsSignature(signature.name); - if (!signaturePrefix || !content.startsWith(signaturePrefix)) { - throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`); - } + return this.templates.submitTemplate(templateId, tenantId); } listAuditRecords(targetType?: string, targetId?: string) { - return this.prisma.auditRecord.findMany({ - where: { - targetType, - targetId, - }, - include: { - reviewer: { select: { id: true, username: true, displayName: true } }, - }, - orderBy: { createdAt: 'desc' }, - }); + return this.audit.listAuditRecords(targetType, targetId); } approveSignature(signatureId: string, data: ReviewDto) { - return this.reviewSignature(signatureId, 'approved', 'approve', data); + return this.audit.approveSignature(signatureId, data); } rejectSignature(signatureId: string, data: ReviewDto) { - return this.reviewSignature(signatureId, 'rejected', 'reject', data); + return this.audit.rejectSignature(signatureId, data); } approveTemplate(templateId: string, data: ReviewDto) { - return this.reviewTemplate(templateId, 'approved', 'approve', data); + return this.audit.approveTemplate(templateId, data); } rejectTemplate(templateId: string, data: ReviewDto) { - return this.reviewTemplate(templateId, 'rejected', 'reject', data); + return this.audit.rejectTemplate(templateId, data); } async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature || (tenantId && signature.tenantId !== tenantId)) { - throw new NotFoundException('Signature not found'); - } - const status = data.status ?? 'deleted'; - const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } }); - await this.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, { - statusBefore: signature.auditStatus, - statusAfter: status, - reason: data.reason, - }); - return updated; + return this.audit.changeSignatureStatus(signatureId, data, tenantId); } async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template || (tenantId && template.tenantId !== tenantId)) { - throw new NotFoundException('Template not found'); - } - const status = data.status ?? 'deleted'; - const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } }); - await this.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, { - statusBefore: template.auditStatus, - statusAfter: status, - reason: data.reason, - }); - return updated; + return this.audit.changeTemplateStatus(templateId, data, tenantId); } - private async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) { - throw new NotFoundException('Signature not found'); - } - const reviewerId = await this.resolveReviewerId(data.reviewerId); - - const updated = await this.prisma.smsSignature.update({ - where: { id: signatureId }, - data: { - auditStatus: statusAfter, - rejectReason: statusAfter === 'rejected' ? data.reason : null, - }, - }); - await this.createAuditRecord({ - tenantId: signature.tenantId, - targetType: 'sms_signature', - targetId: signatureId, - action, - statusBefore: signature.auditStatus, - statusAfter, - reason: data.reason, - reviewerId, - }); - return updated; - } - - private async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) { - const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); - if (!item) throw new NotFoundException('Drainage info not found'); - if (!['pending', 'rejected'].includes(item.auditStatus)) { - throw new BadRequestException('只有待审核或已驳回的引流信息可以审核'); - } - if (statusAfter === 'rejected' && !data.reason?.trim()) { - throw new BadRequestException('驳回引流信息时必须填写原因'); - } - const reviewerId = await this.resolveReviewerId(data.reviewerId); - const updated = await this.prisma.smsDrainageInfo.update({ - where: { id: itemId }, - data: { - auditStatus: statusAfter, - rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null, - reviewedAt: new Date(), - }, - include: { tenant: true, signature: true, application: true }, - }); - await this.createAuditRecord({ - tenantId: item.tenantId, - targetType: 'sms_drainage_info', - targetId: itemId, - action, - statusBefore: item.auditStatus, - statusAfter, - reason: data.reason, - reviewerId, - }); - if (statusAfter === 'approved') await this.activateDrainageReporting(itemId); - else await this.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回'); - return updated; - } - - private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template) { - throw new NotFoundException('Template not found'); - } - const reviewerId = await this.resolveReviewerId(data.reviewerId); - - const updated = await this.prisma.smsTemplate.update({ - where: { id: templateId }, - data: { - auditStatus: statusAfter, - rejectReason: statusAfter === 'rejected' ? data.reason : null, - }, - }); - await this.createAuditRecord({ - tenantId: template.tenantId, - targetType: 'sms_template', - targetId: templateId, - action, - statusBefore: template.auditStatus, - statusAfter, - reason: data.reason, - reviewerId, - }); - return updated; - } - - private async resolveReviewerId(reviewerId?: string) { - if (!reviewerId) { - return undefined; - } - const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } }); - if (!reviewer) { - throw new BadRequestException('reviewerId does not reference an existing user'); - } - return reviewerId; - } - - private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) { - return this.prisma.auditRecord.create({ data }); - } - - private async abandonApplicationDeliveries(applicationId: string, reason: string) { - const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ - where: { applicationId, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, - select: { id: true }, - }); - const deliveryIds = deliveries.map((delivery) => delivery.id); - if (deliveryIds.length === 0) return 0; - await this.prisma.cmppDownstreamDeliveryAttempt.updateMany({ - where: { deliveryId: { in: deliveryIds }, status: { in: ['awaiting_ack', 'sent'] } }, - data: { - status: 'abandoned', - ackDeadlineAt: null, - failureType: 'application_disabled', - errorMessage: reason, - }, - }); - const updated = await this.prisma.cmppDownstreamDelivery.updateMany({ - where: { id: { in: deliveryIds }, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, - data: { - status: 'abandoned', - retryEnabled: false, - nextRetryAt: null, - ackDeadlineAt: null, - lastError: reason, - }, - }); - return updated.count; - } - - private async disconnectDownstreamAccount(account: string, reason: string) { - const baseUrl = process.env.GATEWAY_CONTROL_URL?.trim() || 'http://127.0.0.1:8090'; - try { - const response = await fetch(`${baseUrl}/downstream/connections/disconnect`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ account, reason }), - signal: AbortSignal.timeout(10_000), - }); - const responseText = await response.text(); - if (!response.ok) { - throw new Error(`Gateway returned ${response.status}: ${responseText}`); - } - return responseText ? JSON.parse(responseText) as { account: string; disconnected: number } : { account, disconnected: 0 }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to disconnect downstream CMPP account ${account}: ${message}`); - return { account, disconnected: 0, error: message }; - } - } - - private writeApplicationStatusLog( - application: { id: string; tenantId: string; status: string }, - data: StatusChangeDto, - statusAfter: string, - detail: Record, - ) { - return this.writeOperationLog( - application.tenantId, - data.operatorId, - `sms_application.${statusAfter}`, - 'sms_application', - application.id, - { - statusBefore: application.status, - statusAfter, - reason: data.reason, - force: Boolean(data.force), - ...JSON.parse(JSON.stringify(detail)) as Record, - }, - ); - } - - private async runApplicationDisableScan() { - if (this.applicationDisableScanRunning) return; - this.applicationDisableScanRunning = true; - try { - const applications = await this.prisma.smsApplication.findMany({ - where: { status: 'disabling' }, - select: { id: true, tenantId: true, cmppAccount: true, status: true, autoDisableAt: true }, - take: 500, - }); - const now = new Date(); - for (const application of applications) { - const preview = await this.getApplicationDeactivationPreview(application.id); - if (preview.totalOutstanding === 0) { - await this.finalizeDisablingApplication(application, false, '待处理回执已清算完成,系统自动停用', preview); - } else if (application.autoDisableAt && application.autoDisableAt <= now) { - await this.finalizeDisablingApplication(application, true, '进入停用中状态已满72小时,系统自动放弃剩余回执', preview); - } - } - } catch (error) { - this.logger.error(`Application disabling scan failed: ${error instanceof Error ? error.message : String(error)}`); - } finally { - this.applicationDisableScanRunning = false; - } - } - - private async finalizeDisablingApplication( - application: { id: string; tenantId: string; cmppAccount: string; status: string }, - abandonOutstanding: boolean, - reason: string, - preview: Awaited>, - ) { - const claimed = await this.prisma.smsApplication.updateMany({ - where: { id: application.id, status: 'disabling' }, - data: { - status: 'disabled', - disablingAt: null, - autoDisableAt: null, - disableReason: reason, - }, - }); - if (claimed.count !== 1) return false; - const abandoned = abandonOutstanding - ? await this.abandonApplicationDeliveries(application.id, reason) - : 0; - const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, reason); - await this.writeApplicationStatusLog(application, { reason, force: abandonOutstanding }, 'disabled', { - preview, - abandoned, - disconnect, - automatic: true, - }); - return true; - } - - private writeOperationLog( - tenantId: string, - userId: string | undefined, - action: string, - resource: string, - resourceId: string, - detail: Record, - ) { - return this.prisma.operationLog.create({ - data: { - tenantId, - userId, - action, - resource, - resourceId, - detail: detail as Prisma.InputJsonValue, - }, - }); + /** Preserves the existing private test seam while the implementation lives in the lifecycle domain. */ + private runApplicationDisableScan() { + return this.lifecycle.runApplicationDisableScan(); } } - -interface TemplateVariableInput { - name: string; - example?: string; - required?: boolean; -} - -function normalizeApplicationPassword(value: string | undefined) { - const password = value?.trim() || generateApplicationPassword(); - if (password.length !== 16) { - throw new BadRequestException('passwordCipher must be 16 characters'); - } - return password; -} - -function generateApplicationPassword() { - return randomUUID().replace(/-/g, '').slice(0, 16); -} - -function estimateBillingUnits(content: string) { - const length = [...content].length; - if (length <= 70) { - return 1; - } - return Math.ceil(length / 67); -} - -function inferTemplateVariables(content: string): TemplateVariableInput[] { - const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? []; - return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); -} - -function validateAndNormalizeTemplateVariables( - content: string, - supplied?: Array<{ name: string; example?: string; required?: boolean }>, -): TemplateVariableInput[] { - const names: string[] = []; - let cursor = 0; - while (true) { - const start = content.indexOf('${', cursor); - if (start < 0) break; - const end = content.indexOf('}', start + 2); - if (end < 0) throw new BadRequestException('模板变量未闭合'); - const name = content.slice(start + 2, end); - if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) { - throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母、数字和下划线,长度1至32位'); - } - if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`); - names.push(name); - cursor = end + 1; - } - if (!supplied) return names.map((name) => ({ name, required: true })); - const suppliedNames = supplied.map((item) => item.name?.trim()); - if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) { - throw new BadRequestException('变量配置中包含非法变量名'); - } - if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量'); - if (suppliedNames.length !== names.length || suppliedNames.some((name) => !names.includes(name))) { - throw new BadRequestException('变量配置必须与模板正文中的占位符完全一致'); - } - return supplied.map((item) => ({ ...item, name: item.name.trim() })); -} - -function normalizeSmsSignature(name: string) { - const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim(); - return innerName ? `【${innerName}】` : ''; -} - -function validateCompleteSmsSignature(name: string) { - const value = name; - if (/[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(value)) { - throw new BadRequestException('短信签名不能包含空格、换行或不可见字符'); - } - const match = value.match(/^【([^【】]+)】$/); - if (!match) { - throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】'); - } - return value; -} - -function startOfToday() { - const date = new Date(); - date.setHours(0, 0, 0, 0); - return date; -} - -function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePriority { - const queuePriority = value ?? 'normal'; - if (!APPLICATION_QUEUE_PRIORITIES.includes(queuePriority as ApplicationQueuePriority)) { - throw new BadRequestException('queuePriority must be normal or priority'); - } - return queuePriority as ApplicationQueuePriority; -} - -function normalizeApplicationInterfaceType(value?: string): ApplicationInterfaceType { - const interfaceType = value ?? 'cmpp20'; - if (!APPLICATION_INTERFACE_TYPES.includes(interfaceType as ApplicationInterfaceType)) { - throw new BadRequestException('interfaceType only supports cmpp20; HTTP interface is not available yet'); - } - return interfaceType as ApplicationInterfaceType; -} - -function normalizeCmppAccessNumberConfig( - data: Pick, - current?: { - cmppApplicationExtension?: string | null; - cmppAccessNumberFillEnabled?: boolean | null; - cmppAccessNumberFillPrefix?: string | null; - }, -) { - const applicationExtension = ( - data.cmppApplicationExtension === undefined - ? current?.cmppApplicationExtension - : data.cmppApplicationExtension - )?.trim() || null; - const fillEnabled = data.cmppAccessNumberFillEnabled - ?? current?.cmppAccessNumberFillEnabled - ?? false; - const configuredPrefix = ( - data.cmppAccessNumberFillPrefix === undefined - ? current?.cmppAccessNumberFillPrefix - : data.cmppAccessNumberFillPrefix - )?.trim() || null; - - if (applicationExtension && !/^\d+$/.test(applicationExtension)) { - throw new BadRequestException('cmppApplicationExtension must contain digits only'); - } - if (applicationExtension && applicationExtension.length > 21) { - throw new BadRequestException('cmppApplicationExtension must not exceed 21 digits'); - } - if (fillEnabled && !applicationExtension) { - throw new BadRequestException('cmppApplicationExtension is required when access number filling is enabled'); - } - if (fillEnabled && !configuredPrefix) { - throw new BadRequestException('cmppAccessNumberFillPrefix is required when access number filling is enabled'); - } - if (configuredPrefix && !/^\d+$/.test(configuredPrefix)) { - throw new BadRequestException('cmppAccessNumberFillPrefix must contain digits only'); - } - - const fillPrefix = fillEnabled ? configuredPrefix : null; - const clientSrcId = applicationExtension - ? `${fillPrefix ?? ''}${applicationExtension}` - : null; - if (clientSrcId && clientSrcId.length > 21) { - throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits'); - } - return { applicationExtension, fillEnabled, fillPrefix, clientSrcId }; -} - -function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) { - if (value === undefined || value === null) { - return fallback; - } - const normalized = Number(value); - if (!Number.isInteger(normalized) || normalized <= 0) { - throw new BadRequestException(`${fieldName} must be a positive integer`); - } - return normalized; -} - -function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) { - if (!['active', 'disabling'].includes(applicationStatus)) { - return 'inactive'; - } - if (connections.some((connection) => connection.status === 'connected')) { - return 'connected'; - } - if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) { - return 'degraded'; - } - return 'disconnected'; -} - -function getPositiveIntegerEnv(name: string, fallback: number) { - const value = Number(process.env[name] ?? fallback); - return Number.isInteger(value) && value > 0 ? value : fallback; -} - -function parseGatewayDate(value?: string) { - if (!value) return undefined; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? undefined : parsed; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function reportValueParts(value: unknown) { - if (isRecord(value) && typeof value.fileObjectId === 'string') { - return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId }; - } - return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined }; -} - -function hasReportValue(value: unknown) { - if (isRecord(value)) { - return Boolean(value.fileObjectId || value.fieldValue || value.value); - } - return value !== undefined && value !== null && String(value).trim().length > 0; -} diff --git a/api/src/sms-config/template.service.ts b/api/src/sms-config/template.service.ts new file mode 100644 index 0000000..75fbfa7 --- /dev/null +++ b/api/src/sms-config/template.service.ts @@ -0,0 +1,215 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomInt, randomUUID } from 'node:crypto'; +import { isIpAllowed } from '../common/ip-allowlist'; +import { assertMoneyUnits } from '../common/money'; +import { PrismaService } from '../prisma/prisma.service'; +import { automaticDeliveryMode } from '../open-api/delivery-mode'; +import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; +import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import { SmsAuditService } from './audit.service'; + +/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ +export class SmsTemplateService { + constructor(private readonly prisma: PrismaService, private readonly audit: SmsAuditService) {} + listTemplates(queryOrTenantId?: string | TemplateListQuery) { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; + return this.prisma.smsTemplate.findMany({ + where: { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, + content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { content: { contains: query.keyword } }, + { category: { contains: query.keyword } }, + { application: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }, + include: { variables: true, application: true, tenant: true, signature: true }, + orderBy: { createdAt: 'desc' }, + ...(query.page && query.pageSize ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } : {}), + }); + } + + async listTemplatesPage(query: TemplateListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsTemplateWhereInput = { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, + content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { content: { contains: query.keyword } }, + { category: { contains: query.keyword } }, + { application: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.listTemplates({ ...query, page, pageSize }), + this.prisma.smsTemplate.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + listClientTemplates(tenantId: string | undefined, includeHistory = false) { + return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); + } + + async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { + const variables = validateAndNormalizeTemplateVariables(data.content, data.variables); + const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); + if (!application || application.tenantId !== data.tenantId) { + throw new BadRequestException('applicationId does not belong to the template tenant'); + } + await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content); + return this.prisma.smsTemplate.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + signatureId: data.signatureId, + name: data.name, + content: data.content, + category: data.category, + auditStatus: options.initialAuditStatus, + billingUnits: estimateBillingUnits(data.content), + variables: { + create: variables.map((variable: TemplateVariableInput) => ({ + name: variable.name, + example: variable.example, + required: variable.required ?? true, + })), + }, + }, + include: { variables: true, application: true, tenant: true, signature: true }, + }); + } + + async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template || (tenantId && template.tenantId !== tenantId)) { + throw new NotFoundException('Template not found'); + } + if (data.applicationId) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); + if (!application || application.tenantId !== template.tenantId) { + throw new BadRequestException('applicationId does not belong to the template tenant'); + } + } + if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) { + await this.validateTemplateSignature( + data.signatureId === undefined ? template.signatureId : data.signatureId, + template.tenantId, + data.applicationId ?? template.applicationId, + data.content ?? template.content, + ); + } + const variables = data.content !== undefined || data.variables !== undefined + ? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables) + : undefined; + const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId) + || (data.signatureId !== undefined && data.signatureId !== template.signatureId) + || (data.content !== undefined && data.content !== template.content) + || (data.category !== undefined && data.category !== template.category) + || data.variables !== undefined; + const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus; + return this.prisma.$transaction(async (tx) => { + if (variables) { + await tx.templateVariable.deleteMany({ where: { templateId } }); + } + return tx.smsTemplate.update({ + where: { id: templateId }, + data: { + applicationId: data.applicationId, + signatureId: data.signatureId, + name: data.name, + content: data.content, + category: data.category, + auditStatus, + rejectReason: auditStatus === 'pending' ? null : undefined, + billingUnits: data.content ? estimateBillingUnits(data.content) : undefined, + variables: variables ? { + create: variables.map((variable) => ({ + name: variable.name, + example: variable.example, + required: variable.required ?? true, + })), + } : undefined, + }, + include: { variables: true, application: true, tenant: true, signature: true }, + }); + }); + } + + async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { + const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found'); + if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { + throw new BadRequestException('当前审核状态不允许修改模板'); + } + const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId); + await this.audit.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_template', + targetId: templateId, + action: 'client_update_submit', + statusBefore: current.auditStatus, + statusAfter: 'pending', + }); + return updated; + } + + async submitTemplate(templateId: string, tenantId?: string) { + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template || (tenantId && template.tenantId !== tenantId)) { + throw new NotFoundException('Template not found'); + } + await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content); + + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: { auditStatus: 'pending', rejectReason: null }, + }); + await this.audit.createAuditRecord({ + tenantId: template.tenantId, + targetType: 'sms_template', + targetId: templateId, + action: 'submit', + statusBefore: template.auditStatus, + statusAfter: 'pending', + }); + return updated; + } + + async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) { + if (!signatureId) { + throw new BadRequestException('短信模板必须选择短信签名'); + } + const signature = await this.prisma.smsSignature.findUnique({ + where: { id: signatureId }, + select: { tenantId: true, applicationId: true, name: true }, + }); + if (!signature || signature.tenantId !== tenantId) { + throw new BadRequestException('signatureId does not belong to the template tenant'); + } + if (signature.applicationId && signature.applicationId !== applicationId) { + throw new BadRequestException('signatureId does not belong to the template application'); + } + const signaturePrefix = normalizeSmsSignature(signature.name); + if (!signaturePrefix || !content.startsWith(signaturePrefix)) { + throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`); + } + } +} diff --git a/docs/CMPP平台代码文件渐进式拆分路线图_打印版.docx b/docs/CMPP平台代码文件渐进式拆分路线图_打印版.docx new file mode 100644 index 0000000..d0a1fac Binary files /dev/null and b/docs/CMPP平台代码文件渐进式拆分路线图_打印版.docx differ diff --git a/docs/codebase-modularization-roadmap.md b/docs/codebase-modularization-roadmap.md new file mode 100644 index 0000000..09c6491 --- /dev/null +++ b/docs/codebase-modularization-roadmap.md @@ -0,0 +1,1140 @@ +# CMPP 平台代码文件渐进式拆分路线图 + +更新日期:2026-07-30 +适用仓库:CMPP 平台主仓库 +规划性质:跨多个发布版本逐步实施,不要求一次完成 + +> R0 已于 2026-07-30 在本地建立,不包含生产代码移动。执行资料见 +> `docs/refactoring/r0-responsibility-index.md`、 +> `docs/refactoring/r0-release-gate.md` 和 +> `docs/contracts/refactoring-r0-manifest.json`;当前仍未提交、未推送、未部署。 + +## 1. 目标 + +本路线图用于解决部分代码文件持续膨胀、职责混杂、修改影响面难以判断的问题,同时避免“大文件一次性拆分后引入大量回归 Bug”。 + +拆分的首要目标不是追求更少的行数,而是: + +1. 保持现有业务行为、接口、数据库事务、队列协议和页面交互不变。 +2. 让每次需求修改只需要进入一个清晰的业务模块。 +3. 降低多人或多个 AI 会话同时修改同一文件时的冲突概率。 +4. 让测试能够按业务域定位,而不是所有测试集中在一个超大文件。 +5. 让每个拆分版本都可以单独验证、发布、观察和回滚。 + +## 2. 当前基线 + +本次盘点基于本地 `main` 分支,`HEAD` 与 `origin/main` 均为: + +`0af671b4ed4713912e703defd08791f164d4eb25` + +当前工作区存在未提交业务修改、其他会话修改及构建产物。未来执行拆分前必须重新确认基线,不得把本文件记录的状态直接视为届时事实。 + +### 2.1 主要大文件 + +| 文件 | 当前约行数 | 最近80次提交中的变更次数 | 主要问题 | 初步风险 | +|---|---:|---:|---|---| +| `src/styles/global.css` | 10322 | 42 | 全局样式、页面样式、响应式规则持续叠加,级联影响难判断 | 高 | +| `api/src/send-chain/send-chain.service.ts` | 5155 | 50 | 入站、审核、计费、路由、提交、回执、补发、下游投递集中 | 极高 | +| `api/src/send-chain/send-chain.service.spec.ts` | 3583 | - | 多类业务共用巨型 Mock,失败定位困难 | 高 | +| `api/src/channels/channels.service.ts` | 2443 | 30 | 通道配置、连接、测试、路由、报备、删除治理混合 | 高 | +| `api/src/operations/operations.service.ts` | 2282 | 27 | 短信记录、看板、统计、日志、下游恢复、追踪混合 | 中高 | +| `src/api/adminApi.ts` | 2177 | 54 | 请求基础设施、全部 DTO/类型、所有运营端接口集中 | 中高 | +| `api/src/sms-config/sms-config.service.ts` | 2143 | 38 | 应用、签名、模板、引流、审核、连接生命周期混合 | 高 | +| `gateway/internal/inbound/server.go` | 约1600 | 18 | 登录、提交、会话、下游投递、ACK、恢复集中 | 极高 | +| `gateway/internal/upstream/manager.go` | 约1500 | 9 | 连接池、重连、窗口、提交、回执、上行集中 | 极高 | +| `api/src/report-materials/report-materials.service.ts` | 1077 | - | 导入、审核、批次生成、导出和幂等操作混合 | 中高 | +| `src/apps/admin/AdminEnterpriseSignaturesPage.tsx` | 829 | - | 查询、表格、编辑、报备资料和多个弹窗集中 | 中 | +| `src/apps/admin/AdminChannelsPage.tsx` | 734 | - | 列表、状态、测试、连接详情和编辑交互集中 | 中 | + +行数只能用于发现候选文件,不能单独决定优先级。发送链和 Gateway 即使测试较多,仍因并发、幂等、账务和协议状态机而具有最高拆分风险。 + +## 3. 必须遵守的拆分原则 + +### 3.1 先锁定行为,再移动代码 + +每个拆分版本先补“特征测试”,记录当前真实行为,再执行移动。特征测试至少覆盖: + +- 请求和响应结构; +- 数据库写入及事务边界; +- 幂等键和唯一约束; +- Redis Stream 或队列消息结构; +- 状态流转; +- 错误码和用户可见提示; +- 时间、时区及金额精度; +- CMPP 报文、Sequence_Id、Msg_Id 和分片行为。 + +如果当前行为本身存在 Bug,应先单独修复并发布,再开始对应模块的纯重构。禁止在同一个提交中同时“大范围拆分 + 修改业务规则”。 + +### 3.2 保留稳定门面 + +拆分初期保留原有类、导出名和控制器调用方式: + +```text +Controller + ↓ +原 Service / API 门面(签名不变) + ↓ +新拆分的领域服务、查询服务或纯函数 +``` + +例如: + +- `SendChainService` 在较长时间内继续存在,控制器和其他模块不立即改依赖。 +- `adminApi.ts` 继续导出 `adminApi`,页面不在同一版本内批量改 import。 +- Gateway 的 `Server` 和 `Manager` 对外方法签名先保持不变。 + +只有新模块稳定运行至少一个发布周期后,才评估是否缩小或删除旧门面。 + +### 3.3 每个版本只拆一个业务域 + +一个拆分版本不得同时横跨发送链、通道、运营统计和 Gateway。建议边界: + +- 一个后端业务域; +- 或一个前端 API 域; +- 或一个页面; +- 或一个 Gateway 状态机子域。 + +如果一个版本的 diff 已经难以由人工逐段复核,应继续切小。 + +### 3.4 先拆纯逻辑,再拆副作用 + +优先移动: + +- DTO 和类型; +- 常量; +- 数据格式转换; +- 状态判定; +- 号码、运营商、报文和时间计算; +- 查询条件构造; +- 响应映射。 + +后移动: + +- PostgreSQL 事务; +- 账户扣费和退款; +- Redis Stream 发布; +- Gateway 控制命令; +- 补发抢占; +- 最终回执投递; +- CMPP 连接与 ACK 状态机。 + +### 3.5 事务不能被“拆没” + +原本在一个 `$transaction` 中完成的操作,不得因为拆成多个 Service 就变成多个独立事务。 + +推荐做法: + +- 顶层用例服务持有事务; +- 子模块接收 `Prisma.TransactionClient`; +- 子模块不得自行提交与顶层业务重复的事务; +- 明确记录锁顺序、唯一约束和幂等键; +- 对并发路径保留真实 PostgreSQL 验证。 + +### 3.6 依赖只能单向流动 + +建议依赖方向: + +```text +controller / transport + ↓ +application use case + ↓ +domain policy / pure logic + ↓ +repository and infrastructure adapter +``` + +禁止新模块之间互相注入形成循环依赖。出现循环依赖时,优先提取共享契约或重新确定用例归属,不用更多 `forwardRef` 掩盖问题。 + +### 3.7 每个移动都要可追溯 + +纯移动时尽量保留原代码,不同时重命名、格式化和改写逻辑。推荐顺序: + +1. 原样复制到新文件; +2. 原门面改为委托; +3. 测试通过; +4. 再在后续小提交中优化命名或结构。 + +## 4. 目标目录结构 + +目标结构不是一次性创建,只有实际拆到对应版本时才新增目录。 + +### 4.1 前端 API + +```text +src/api/ +├─ core/ +│ ├─ httpClient.ts +│ ├─ requestError.ts +│ ├─ query.ts +│ └─ upload.ts +├─ admin/ +│ ├─ auth.api.ts +│ ├─ tenants.api.ts +│ ├─ applications.api.ts +│ ├─ channels.api.ts +│ ├─ riskReview.api.ts +│ ├─ operations.api.ts +│ ├─ reports.api.ts +│ └─ billing.api.ts +├─ client/ +│ └─ ... +├─ types/ +│ ├─ common.ts +│ ├─ channel.ts +│ ├─ sms.ts +│ ├─ risk.ts +│ └─ operations.ts +└─ adminApi.ts +``` + +`adminApi.ts` 暂时作为兼容门面重新组合各领域 API,现有页面仍可继续从原路径导入。 + +### 4.2 NestJS 业务模块 + +每个现有模块内部优先按用例拆分,不急于创建新的顶级 Nest Module: + +```text +api/src/operations/ +├─ operations.service.ts +├─ queries/ +│ ├─ message-query.service.ts +│ ├─ dashboard-query.service.ts +│ ├─ quality-query.service.ts +│ └─ system-log-query.service.ts +├─ downstream/ +│ ├─ downstream-delivery-query.service.ts +│ └─ downstream-recovery-query.service.ts +├─ mappers/ +└─ types/ +``` + +只有当子域具有独立控制器、生命周期和明确依赖边界时,才升级为独立 Nest Module。 + +### 4.3 发送链 + +```text +api/src/send-chain/ +├─ send-chain.service.ts # 稳定门面 +├─ contracts/ +│ ├─ send-command.types.ts +│ ├─ gateway-event.types.ts +│ └─ send-result.types.ts +├─ ingress/ +│ ├─ http-ingress.service.ts +│ ├─ cmpp-ingress.service.ts +│ └─ batch-ingress.service.ts +├─ policy/ +│ ├─ send-resource.policy.ts +│ ├─ message-classification.policy.ts +│ └─ channel-selection.policy.ts +├─ dispatch/ +│ ├─ dispatch.service.ts +│ └─ gateway-submit.publisher.ts +├─ receipts/ +│ ├─ submit-result.service.ts +│ ├─ segment-receipt.service.ts +│ └─ receipt-aggregation.service.ts +├─ retry/ +│ └─ retry-orchestrator.service.ts +├─ downstream/ +│ └─ final-receipt-delivery.service.ts +└─ persistence/ + └─ send-chain.repository.ts +``` + +该结构只是目标边界。不得在一个版本中一次创建并迁移全部目录。 + +### 4.4 Gateway + +```text +gateway/internal/inbound/ +├─ server.go # 对外门面和生命周期 +├─ auth.go +├─ submit_handler.go +├─ session_registry.go +├─ downstream_delivery.go +├─ acknowledgement.go +├─ recovery.go +└─ protocol_log.go + +gateway/internal/upstream/ +├─ manager.go # 对外门面 +├─ pool.go +├─ connection.go +├─ reconnect.go +├─ submit.go +├─ deliver.go +├─ heartbeat.go +└─ protocol_log.go +``` + +Go 拆分仍保留同一个 package,第一阶段不跨 package,以免扩大可见性和循环依赖问题。 + +## 5. 分版本实施路线 + +### 版本 R0:建立重构安全护栏 + +本版本不拆生产代码。 + +工作内容: + +1. 固化当前全量测试基线和关键业务路径清单。 +2. 为大文件建立职责索引,记录每个公开方法的调用方、数据库表、队列和副作用。 +3. 补齐当前缺失的特征测试,优先覆盖发送链并发、通道重连、Gateway ACK 和账号账务。 +4. 保存典型 API 响应、Redis Stream 消息和 CMPP 报文样本。 +5. 建立每版本固定验收清单和回滚清单。 +6. 约定同一重构版本内,大文件只由一个会话负责结构修改。 + +完成标准: + +- 没有业务代码移动; +- 已能用测试回答“拆前行为是什么”; +- 所有后续版本都有可复用的门禁命令。 + +### 版本 R1:拆分 `adminApi.ts` + +这是推荐的第一个实际拆分版本,风险相对可控,且能快速减少多人修改冲突。 + +> 本地实施状态(2026-07-31):R1.1~R1.5 已完成,尚未提交、推送或部署。 +> `src/api/adminApi.ts` 保留为兼容门面,页面 import 未修改;HTTP/会话基础能力、 +> 运营端五个业务域、客户端 API 和五组类型文件已经拆开。拆分前的 183 个运营端方法、 +> 60 个客户端方法、7 个会话方法及 9 个 HTTP 核心函数由 +> `docs/contracts/admin-api-r1-methods.json` 和 +> `tools/quality/verify-admin-api-r1.mjs` 固定验证。 + +工作内容: + +1. 先把通用请求、错误处理、文件上传和查询字符串逻辑移动到 `src/api/core/`。 +2. 把类型按业务域移动到 `src/api/types/`。 +3. 每次只移动一个领域 API,例如先认证和企业,再通道,再运营统计。 +4. `adminApi.ts` 继续汇总并导出同名方法,页面 import 暂时不变。 +5. 不修改 URL、HTTP 方法、请求体、返回类型和会话处理。 + +建议拆成多个小发布: + +- R1.1:请求核心与公共类型; +- R1.2:企业、用户、应用; +- R1.3:通道、通道组、报备; +- R1.4:运营统计、日志、短信记录; +- R1.5:审核、风控、账务。 + +专项验证: + +- 登录、401、403重新认证和会话锁定; +- 文件上传与Blob导出; +- 所有页面 TypeScript 构建; +- 运营端关键菜单真实 API 冒烟。 + +本地实施目录: + +```text +src/api/ +├─ core/httpClient.ts +├─ admin/ +│ ├─ session.api.ts +│ ├─ identity.api.ts +│ ├─ channels-reports.api.ts +│ ├─ operations.api.ts +│ ├─ governance.api.ts +│ └─ files.api.ts +├─ client/client.api.ts +├─ types/ +│ ├─ common.ts +│ ├─ identity-config.ts +│ ├─ channels-reports.ts +│ ├─ operations.ts +│ ├─ governance.ts +│ └─ index.ts +└─ adminApi.ts +``` + +### 版本 R2:拆分运营查询 `operations.service.ts` + +先拆读多写少的查询,暂不动发送链副作用。 + +> 本地实施状态(2026-07-31):R2 已完成,尚未提交、推送或部署。 +> `OperationsService` 从2368行缩减为150行兼容门面,29个公开方法签名保持不变; +> 原查询实现按短信记录、上行与监控、看板、质量统计、日志、下游恢复、追踪对账 +> 七个领域迁移。`docs/contracts/operations-r2-methods.json` 和 +> `tools/quality/verify-operations-r2.mjs` 固定校验方法签名、原方法体、查询契约及辅助函数。 + +拆分顺序: + +1. 短信记录查询和 CSV 导出; +2. 上行短信查询; +3. 运营看板; +4. 发送质量与签名质量统计; +5. 系统日志与导出; +6. 下游投递、恢复状态和详情; +7. 追踪、对账和审计汇总。 + +保留 `OperationsService` 作为门面。控制器路由、查询参数和响应结构不变。 + +专项验证: + +- PostgreSQL分页总数与当前页一致; +- 北京时间边界和UTC存储解释; +- 运营商兼容值与未识别分类; +- CSV字段、顺序、编码; +- 客户端安全视图不泄漏内部通道信息; +- 大数据量查询计划和索引仍有效。 + +本地实施目录: + +```text +api/src/operations/ +├─ operations.service.ts +├─ operations.contracts.ts +├─ operations.helpers.ts +└─ queries/ + ├─ messages.queries.ts + ├─ uplink.queries.ts + ├─ dashboard.queries.ts + ├─ quality.queries.ts + ├─ logs.queries.ts + ├─ downstream.queries.ts + └─ trace.queries.ts +``` + +### 版本 R3:拆分短信配置 `sms-config.service.ts` + +按业务对象拆分,而不是按“增删改查”拆分: + +> 本地实施状态(2026-07-31):R3 已完成,尚未提交、推送或部署。 +> `SmsConfigService` 从2268行缩减为约244行稳定兼容门面,51个公开方法签名保持不变; +> 21个内部方法和原有业务实现迁移到七个领域服务。这里的“第一阶段”是指暂时保留统一门面, +> 不是只迁移部分短信配置业务;控制器和其他模块仍只依赖门面,后续无需再次拆分该主体。 + +1. 应用配置与接入参数; +2. 应用停用生命周期和下游连接; +3. 签名及报备字段快照; +4. 引流信息; +5. 模板; +6. 审核记录与审核动作; +7. 共享报备资料校验。 + +第一阶段仍由 `SmsConfigService` 委托各子服务。DTO 从 Service 文件移到独立 contract 文件,控制器不再直接从实现类文件导入 DTO。 + +本地实施目录: + +```text +api/src/sms-config/ +├─ sms-config.service.ts +├─ sms-config.contracts.ts +├─ sms-config.helpers.ts +├─ application-config.service.ts +├─ application-lifecycle.service.ts +├─ signature.service.ts +├─ drainage.service.ts +├─ template.service.ts +├─ audit.service.ts +└─ report-validation.service.ts +``` + +`docs/contracts/sms-config-r3-methods.json` 与 +`tools/quality/verify-sms-config-r3.mjs` 固定校验门面签名、领域归属、 +迁移前方法体、DTO/查询契约及共享校验实现。 + +专项验证: + +- 客户端租户边界; +- 应用停用状态机; +- CMPP账号和接入号唯一性; +- 签名、模板、引流审核状态; +- 报备字段快照; +- 操作日志和审核人员; +- 删除治理接口保持不变。 + +### 版本 R4:拆分报备资料与大页面 + +后端 `report-materials.service.ts` 按以下边界拆分: + +> 本地实施状态(2026-07-31):R4 已完成,尚未提交、推送或部署。 +> 后端 `ReportMaterialsService` 从1134行缩减为82行稳定门面,12个公开方法签名保持; +> 11个内部方法迁移到七个领域服务。前端按单版本约束仅拆 +> `AdminEnterpriseSignaturesPage.tsx`,从884行缩减为238行页面容器。 +> “只选择一个页面”是R4的明确安全边界,不代表后端只拆一部分,也不代表所有大页面已拆完。 + +1. 官方模板与导出; +2. 导入解析和映射; +3. 暂存与逐行审核; +4. 待生成资料查询; +5. 批次预检与生成; +6. 通道文件导出; +7. 幂等操作记录。 + +前端只选择一个页面实施,例如先拆 `AdminEnterpriseSignaturesPage.tsx`: + +- 页面容器负责查询参数和协调; +- 表格列独立; +- 编辑弹窗独立; +- 报备资料弹窗独立; +- API请求仍由页面容器或专用 Hook 统一发起。 + +不得在同一版本同时拆多个大页面。 + +本地实施目录: + +```text +api/src/report-materials/ +├─ report-materials.service.ts +├─ report-materials.contracts.ts +├─ report-materials.helpers.ts +├─ official-export.service.ts +├─ import-parser.service.ts +├─ import-review.service.ts +├─ pending-query.service.ts +├─ batch-generation.service.ts +├─ channel-export.service.ts +└─ batch-operation.service.ts + +src/apps/admin/ +├─ AdminEnterpriseSignaturesPage.tsx +└─ enterprise-signatures/ + ├─ signature.types.ts + ├─ signature.helpers.tsx + ├─ SignatureMaterialFields.tsx + ├─ SignatureFormModal.tsx + ├─ DrainageFormModal.tsx + ├─ SignatureReportModals.tsx + └─ EnterpriseSignaturesTable.tsx +``` + +`verify-report-materials-r4.mjs`固定后端领域归属和迁移前实现; +`verify-enterprise-signatures-r4.mjs`固定页面移出函数、表格JSX、真实API调用和页面状态。 + +### 版本 R5:拆分通道服务 `channels.service.ts` + +建议边界: + +1. 通道配置 CRUD; +2. 通道连接状态和重连控制; +3. 通道测试短信; +4. 通道组和路由规则; +5. 报备字段映射; +6. 通道复制; +7. 删除预检和审计。 + +高风险约束: + +- 编辑非连接参数不得触发重连; +- Gateway先连接/断开控制语义不变; +- 不修改真实通道账号、密码和启停状态做测试; +- 测试短信必须单独授权; +- 通道组顺序、权重、主备和补发规则保持一致。 + +> 本地实施状态(2026-07-31):R5 已完成,尚未提交、推送或部署。 +> +> 本轮已完整拆分上述七个边界,不是仅拆一个试点域。原 `ChannelsService` +> 保留为 204 行稳定兼容门面,控制器、模块和既有测试继续依赖同一入口。 +> 连接服务统一持有定时器、Redis、Gateway 控制和队列副作用;配置服务仅在 +> 连接参数实际变化时委托重连;测试短信继续是独立入口,本轮未调用。 + +本地结构如下: + +```text +api/src/channels/ +├─ channels.service.ts # 204行稳定门面 +├─ channel-configuration.service.ts # 213行,配置CRUD与状态 +├─ channel-connection.service.ts # 612行,连接、重连、Redis和Gateway +├─ channel-test.service.ts # 117行,测试短信 +├─ channel-group-routing.service.ts # 221行,通道组和路由规则 +├─ channel-reporting.service.ts # 541行,报备字段、任务、回执和记录 +├─ channel-copy.service.ts # 101行,通道复制 +├─ channel-deletion.service.ts # 19行,删除入口 +├─ channels.contracts.ts # 174行,DTO和查询契约 +└─ channels.helpers.ts # 685行,共享纯函数、常量和类型 +``` + +`docs/contracts/channels-r5-methods.json` 与 +`tools/quality/verify-channels-r5.mjs` 固定 37 个公开方法、14 个内部方法、 +17 个契约及 60 个辅助声明,并专项锁定连接参数重连条件、Gateway +连接/断开路径、定时器、Redis队列、测试短信单次尝试和控制器兼容入口。 + +### 版本 R6:拆分 Gateway 入站服务 + +先在 `gateway/internal/inbound` 同一 package 内移动代码: + +1. 协议辅助函数和报文转换; +2. 登录认证; +3. 下游会话注册表; +4. Submit处理; +5. 下游Deliver发送; +6. ACK追踪与超时; +7. 恢复扫描; +8. 协议日志。 + +`Server.ListenAndServe`、`DisconnectAccount`、`PushReceiptWithResult` 和 `PushUplinkWithResult` 等现有入口保持不变。 + +专项验证: + +- CMPP 2.0/3.0登录; +- 单号码、多号码、长短信; +- SubmitResp只返回一次; +- 原始Msg_Id和重启恢复; +- 下游Deliver ACK; +- ACK超时、断线恢复和重复投递幂等; +- `go test ./...`、`go vet ./...`和本地SMSC集成测试。 + +> 本地实施状态(2026-07-31):R6 已完成,尚未提交、推送或部署。 +> +> 本轮只在 `gateway/internal/inbound` 同一 package 内移动声明,没有修改 +> 公开入口、CMPP报文、HTTP回调、Redis恢复锁或队列契约。原1671行 +> `server.go`缩减为37行稳定启动入口;迁移前93个声明由契约逐项锁定, +> 迁移后实现哈希全部一致。 + +本地结构如下: + +```text +gateway/internal/inbound/ +├─ server.go # 37行,Server与ListenAndServe稳定入口 +├─ authentication.go # 127行,CMPP 2.0/2.1/3.0登录认证 +├─ submit.go # 333行,Submit、长短信和报文转换 +├─ sessions.go # 262行,会话注册、连接状态和断开 +├─ delivery.go # 342行,回执与上行Deliver +├─ acknowledgement.go # 187行,ACK追踪、超时和SubmitResp屏障 +├─ pending_recovery.go # 281行,待投递刷新和恢复扫描 +├─ protocol_log.go # 114行,SubmitResp与Deliver协议日志 +└─ transport.go # 84行,共享HTTP回调和规范化辅助 +``` + +`docs/contracts/inbound-r6-declarations.json` 与 +`go run tools/quality/verify-inbound-r6.go` 固定全部93个迁移声明的文件归属 +和实现哈希,同时检查四个既有公开入口、控制服务调用关系及12项关键协议 +测试仍然存在。复杂并发边界补充了“为什么”注释,未改动实现。 + +### 版本 R7:拆分 Gateway 上游管理 + +按状态机拆分: + +1. Manager与通道连接池注册; +2. connectionPool; +3. connection生命周期; +4. 重连调度; +5. 窗口和心跳; +6. Submit与长短信分片; +7. Deliver、回执和上行; +8. 协议日志与API回调。 + +保持同一 package,避免同时修改公开接口和队列契约。 + +专项验证: + +- 多连接池; +- 窗口满和TPS限制; +- 网络断开与自动重连; +- 鉴权失败分类; +- 长短信每分片提交结果; +- 迟到回执; +- 上行内容编码; +- ConnectionState汇报。 + +> 本地实施状态(2026-07-31):R7 已完成,尚未提交、推送或部署。 +> +> 本轮只在 `gateway/internal/upstream` 同一 package 内移动声明,没有修改 +> Manager公开方法、控制服务调用、CMPP报文、连接参数、重连策略或API回调。 +> 原1495行 `manager.go` 缩减为198行稳定管理入口;迁移前68个声明按接收者 +> 类型分别建立契约,迁移后实现哈希全部一致。既有153行 +> `long_message.go` 已具备单一职责,本轮保持原样。 + +本地结构如下: + +```text +gateway/internal/upstream/ +├─ manager.go # 198行,Manager、连接池注册与公开连接入口 +├─ pool.go # 121行,连接池成员和生命周期 +├─ connection.go # 202行,物理连接、读循环和断线处理 +├─ reconnect.go # 171行,重连状态机、退避和错误分类 +├─ flow_control.go # 138行,窗口分配、心跳和超时 +├─ submit.go # 387行,Submit、分片结果和报文构造 +├─ long_message.go # 153行,既有长短信拆分与上行组装 +├─ deliver.go # 180行,回执与上行Deliver处理 +├─ protocol_log.go # 81行,安全协议日志 +└─ transport.go # 105行,ConnectionState与API回调 +``` + +`docs/contracts/upstream-r7-declarations.json` 与 +`go run tools/quality/verify-upstream-r7.go` 固定68个迁移声明的接收者、 +文件归属和实现哈希,同时检查Manager稳定入口、控制服务调用关系、 +既有长短信模块及15项关键状态机测试仍然存在。 + +### 版本 R8:发送链第一阶段——抽离纯逻辑 + +这是发送链正式拆分的准备版本,不先动核心事务。 + +优先抽离: + +1. DTO、事件和队列契约; +2. 状态常量和错误分类; +3. 号码和发送资源判定; +4. 模板、签名、引流分类; +5. 通道候选排序的纯策略; +6. 回执状态映射; +7. 分片聚合计算; +8. 幂等键和事件键生成。 + +完成后 `SendChainService` 仍负责数据库事务、队列发布和顶层编排。 + +专项验证: + +- 移动前后的输入输出逐例一致; +- 不新增数据库查询; +- 不改变事务范围; +- 不改变日志字段、幂等键和队列消息。 + +> 本地实施状态(2026-07-31):R8 已完成,尚未提交、推送或部署。 + +`api/src/send-chain/send-chain.service.ts` 从5345行缩减为4578行,仍保留 +98个数据库事务、队列发布、Gateway调用和顶层业务编排方法。24个DTO、 +事件和队列契约迁移到 `send-chain.contracts.ts`,64个既有常量、状态映射、 +号码/资源判定、模板/签名/引流分类和事件键等纯声明迁移到 +`send-chain.helpers.ts`。 + +在不增加查询或副作用的前提下,进一步把通道候选选择、通道可发送性、 +分片最终状态聚合、上游端点身份比较和回执事件键生成改为显式纯函数。 +通道候选策略继续保留数据库既有顺序,并按省内优先、全国兜底选择; +分片聚合继续执行“任一明确失败优先,全部预期分片成功才最终成功”。 +控制器继续注入同一个 `SendChainService`,仅把DTO导入切换到独立契约文件。 + +`docs/contracts/send-chain-r8-pure-logic.json` 与 +`node tools/quality/verify-send-chain-r8.mjs` 锁定24个契约和64个迁移声明的 +实现哈希,确认98个编排方法及数据库事务、队列、重试和分片审计副作用仍在 +原服务中,并检查4项新增纯策略的委托关系。R9、R10再分别处理入口/提交和 +回执/补发/下游投递编排;R8不以单纯降低行数为目标。 + +### 版本 R9:发送链第二阶段——拆分入口和提交 + +每次只迁移一个入口: + +1. 客户端批量任务入口; +2. HTTP API入口; +3. CMPP入站入口; +4. 审核通过后的继续发送; +5. Gateway提交发布。 + +`SendChainService` 作为Facade保持控制器和其他模块调用稳定。 + +专项验证: + +- 格式非法和黑名单号码不计频控; +- 任务级风控与号码级风控; +- 每号码独立记录; +- 余额预占、扣费和释放; +- 应用日限额; +- 一次业务短信、长短信分片和补发的计数口径; +- SubmitResp与最终回执的区别。 + +> 本地实施状态(2026-07-31):R9 已完成,尚未提交、推送或部署。 + +`SendChainService` 从R8完成时的4578行缩减为2983行,仍是控制器、 +Open API、审核中心和其他模块使用的唯一稳定NestJS门面。45个入口和提交 +编排方法按五个职责域迁移: + +```text +send-submission.service.ts # 305行,内部兼容门面 +send-batch-entry.service.ts # 552行,客户端批量、HTTP和导入入口 +send-inbound-entry.service.ts # 840行,CMPP认证、长短信聚合和入站提交 +send-review-continuation.service.ts # 109行,审核通过/拒绝后的续发 +send-scheduled-dispatch.service.ts # 160行,定时任务认领和恢复调度 +send-gateway-submit.service.ts # 491行,Worker、路由和Gateway提交发布 +``` + +内部跨方法调用仍返回 `SendChainService` 稳定门面,再由内部兼容门面分派, +以保留既有覆盖点、测试缝和调用可观察性。数据库事务、余额预占、应用日限额、 +号码频控、长短信业务条数、通道报备检查、BullMQ和Redis Stream消息体均沿用 +原方法体和原调用顺序。 + +`docs/contracts/send-chain-r9-submission.json` 与 +`node tools/quality/verify-send-chain-r9.mjs` 锁定45个迁移方法的实现哈希、 +五个文件归属、双层门面委托和原日志上下文。Gateway提交结果、分片审计、 +最终回执、补发抢占、退款及下游投递仍在 `SendChainService`,没有提前进入 +R10范围。 + +### 版本 R10:发送链第三阶段——拆分回执、补发和下游投递 + +最后处理事故影响最大的部分: + +1. Gateway提交结果; +2. 分片提交审计; +3. 分片回执; +4. 最终状态聚合; +5. 失败补发抢占; +6. 退款和预占释放; +7. CMPP/HTTP最终回执投递; +8. 超时扫描。 + +必须保留: + +- 来源提交记录唯一补发关系; +- PostgreSQL事务锁和唯一约束; +- 账务稳定幂等键; +- 每短信唯一最终回执; +- 迟到旧尝试不得覆盖当前尝试; +- 历史事故记录不得删除。 + +> 本地实施状态(2026-07-31):R10 已完成,尚未提交、推送或部署。 + +`SendChainService` 从R9完成时的2983行缩减为878行,控制器、Open API、 +Gateway事件和其他模块仍只依赖这个包含98个稳定方法的NestJS门面。41个 +完成链方法经323行的 `send-completion.service.ts` 内部兼容门面,按事故 +边界迁移到七个领域文件: + +```text +send-gateway-result.service.ts # 390行,Gateway提交结果与分片提交审计 +send-receipt.service.ts # 593行,上游回执收件箱、分片回执和最终聚合 +send-retry.service.ts # 359行,失败补发资格、抢占和新尝试创建 +send-accounting.service.ts # 142行,扣费、退款和余额预占释放 +send-downstream-state.service.ts # 510行,最终回执状态、认领、ACK和恢复状态 +send-downstream-delivery.service.ts # 489行,CMPP/HTTP最终回执投递与人工重排 +send-timeout.service.ts # 104行,回执超时扫描 +``` + +跨领域调用继续返回 `SendChainService` 稳定门面,保留既有测试缝、调用 +可观察性和R9提交域依赖方向。原方法体、事务范围、PostgreSQL锁和唯一约束、 +当前尝试判定、分片聚合、稳定账务幂等键、最终回执去重键、下游投递去重与 +人工重排键均未改写;七个领域文件中不允许删除历史记录。 + +`docs/contracts/send-chain-r10-completion.json` 与 +`node tools/quality/verify-send-chain-r10.mjs` 锁定41个迁移方法的实现 +哈希、七个领域归属、双层门面委托、98个稳定方法及上述事故不变量。该本地 +版本一次完成路线图列出的八项结构迁移,但仍作为单独R10版本验收和发布; +后续不得把新的发送完成链业务重新堆回稳定门面。 + +### 版本 R11:样式和剩余页面整理 + +`global.css` 不按固定行数硬切,而按作用域迁移: + +1. tokens和reset; +2. AppShell与通用布局; +3. 通用表格、表单、弹窗; +4. admin页面域; +5. client页面域; +6. 单页面样式。 + +迁移时保持入口加载顺序,优先使用CSS Layers或明确的文件顺序,不在同一提交中同时修改选择器权重。 + +每次只迁移一个页面域并完成: + +- 桌面宽屏; +- 1024px; +- 768px; +- 375px; +- 弹窗、长表格、空状态、错误状态; +- 截图对比和控制台检查。 + +> 本地实施状态(2026-07-31):R11既定九个步骤已全部完成,尚未提交、推送或部署;第六至九步真实登录后页面点击验收因本地浏览器无可复用登录态而待补。9/9表示本轮计划范围完成,不表示剩余所有单页面CSS已被一次性清空。 + +本版严格执行“每次只迁移一个页面域”,选择剩余页面中最大的 +`AdminChannelsPage.tsx`。稳定页面入口从782行缩减为197行,仅保留真实查询、 +状态协调和无副作用弹窗编排;类型、API映射、通道表格、编辑弹窗、测试短信 +弹窗和连接日志弹窗迁入 `src/apps/admin/channels/` 的聚焦文件。所有调用继续 +直接使用真实 `adminApi`,没有引入barrel、mock、静态数据或localStorage。 + +通道页面专属的列表、质量指标、编辑表单、测试结果和连接日志样式迁入 +`AdminChannelsPage.css`,`global.css` 从12270行缩减为11800行。该步骤结束时 +通道组仍使用的 `.channel-confirm` 保留在全局样式,第八步已将其迁入 +`admin.css`;移动端连接摘要规则随页面样式迁移。 +页面通过入口直接导入该CSS,未改变 `main.tsx` 中tokens、global和components +三层加载顺序,也未在本版修改选择器权重或视觉设计。 + +`docs/contracts/admin-channels-r11.json` 与 +`node tools/quality/verify-admin-channels-r11.mjs` 锁定稳定入口、五个聚焦 +模块、真实API调用、交互入口和页面样式归属。 + +R11 第二页面域选择 `AdminSmsTaskProgressPage.tsx`,稳定入口从657行缩减为 +163行,只协调真实任务查询、筛选选项、选中状态和终止操作。纯任务映射、筛选区、 +主表、详情弹窗、真实号码分页弹窗和终止确认拆入 +`src/apps/admin/sms-task-progress/`;没有改变任务口径、聚合公式、分页参数、 +终止API或弹窗交互。 + +短信任务详情、运营商卡片和页面移动端规则共273行专属样式迁入 +`AdminSmsTaskProgressPage.css`,`global.css` 从11800行缩减为11527行。 +该步骤结束时报备记录、下游记录等页面继续使用的 `.admin-task-filter`、 +`.admin-task-table-card`、`.admin-task-id`、`.admin-task-enterprise` 和 +`.admin-task-card` 仍保留全局;第八步已将这些跨运营页面模式迁入 +`admin.css`,避免页面域拆分改变共享视觉。 + +`docs/contracts/admin-sms-task-progress-r11.json` 与 +`node tools/quality/verify-admin-sms-task-progress-r11.mjs` 锁定稳定入口、 +六个聚焦模块、真实任务/号码/终止API边界、交互入口和专属样式归属。 + +R11 第三页面域选择 `AdminSmsRecordsPage.tsx`,稳定入口从640行缩减为195行, +只协调真实记录分页、筛选项、分片审计、后端CSV导出和选中详情状态。时间/状态/ +运营商/路由映射、筛选区、记录卡片与分页、发送详情弹窗拆入 +`src/apps/admin/sms-records/`;记录默认日期、提交失败覆盖口径、通道尝试顺序、 +接入号拼接和分片审计排序保持原实现。 + +短信记录列表、状态、详情、通道路由、分片审计和两级响应式规则迁入 +`AdminSmsRecordsPage.css`,`global.css` 从11527行缩减为11098行。该步骤结束时 +多个运营页面共用的 `.template-modal-title`、`.muted` 和 `.ui-table__empty` +仍保留全局;第七步已将前两项组件选择器迁入 `components.css`,`.muted` +继续留在全局,未改变公共弹窗标题或空状态视觉。 + +`docs/contracts/admin-sms-records-r11.json` 与 +`node tools/quality/verify-admin-sms-records-r11.mjs` 锁定稳定入口、四个聚焦 +模块、五个真实API调用、十五项交互入口和页面样式归属。其他大页面与其专属样式 +继续留待后续独立小版本,不能在同一实施步骤中批量迁移。 + +R11 第四页面域选择 `AdminEnterpriseApplicationsPage.tsx`,稳定入口从616行 +缩减为273行,只协调真实应用分页、企业选项、筛选状态、应用生命周期和参数详情 +请求。筛选区、主表、生命周期弹窗、CMPP/HTTP参数弹窗、连接详情及纯映射拆入 +`src/apps/admin/enterprise-applications/`;查询草稿与已应用条件分离、分页、 +停用预检、等待/强制停用、启用、删除和参数加载时序保持原实现。 + +企业应用筛选、连接状态、操作区、连接详情、参数详情和新增应用提示共222行 +专属样式迁入 `AdminEnterpriseApplicationsPage.css`,`global.css` 从11098行 +缩减为10881行。该步骤结束时 `.admin-split-filter`、`.admin-confirm-text`、 +`.template-modal-title`、`.section-stack` 和 `.form-grid` 等共享样式仍保留 +原共享层;第六步已将 `.section-stack` 迁入 `shell.css`,第七步已将 +`.template-modal-title` 和 `.form-grid` 迁入 `components.css`,其余共享筛选和 +确认样式在第八步迁入 `admin.css`。780px筛选单列以及900px和520px连接详情 +布局保持原规则。 + +`docs/contracts/admin-enterprise-applications-r11.json` 与 +`node tools/quality/verify-admin-enterprise-applications-r11.mjs` 锁定稳定入口、 +六个聚焦模块、六类真实API边界、十六项交互文案、页面样式归属和筛选状态分层。 + +R11 第五步进入共享CSS域,但只处理“tokens和reset”。现有97行 +`tokens.css`继续作为唯一设计令牌入口,76个颜色、排版、间距、形状、阴影、 +布局和组件基础变量不改值、不改名;新建84行`reset.css`,从`global.css` +迁出13组通配符、文档、链接、表单控件、焦点和标题基础规则。`global.css` +从10881行缩减为10801行。 + +`main.tsx`将基础样式加载顺序显式固定为 +`tokens.css → reset.css → global.css → components.css → AppRoutes`,确保打包器 +先收集四层基础样式,再进入页面依赖图。本步骤没有迁移 +AppShell、通用组件、admin/client共享域、响应式页面规则或任何单页面样式, +避免在同一步骤中改变选择器权重和多类职责。 + +`docs/contracts/foundation-styles-r11.json` 与 +`node tools/quality/verify-foundation-styles-r11.mjs` 锁定76个令牌、13组reset +规则的声明哈希、reset选择器白名单、原`global.css`所有权清理和四层确定性 +加载顺序,并以生产产物位置复核实际拼接顺序。剩余四个共享CSS域步骤继续独立推进。 + +R11 第六步只迁移 `AppShell` 与通用页面布局基础域。新增811行 +`shell.css`,从 `global.css` 迁出侧栏、折叠导航、顶栏、通知/用户菜单、 +移动端抽屉与遮罩、减少动画适配,以及 `.page-content`、`.page-stack`、 +`.page-heading`、`.page-heading__actions`、`.page-actions`、`.surface`、 +`.section-stack`、`.section-heading` 九组通用布局选择器;`global.css` 从 +10801行缩减为10001行。被表单页面复用的 `.icon-button`、通用页签、表格、 +表单和弹窗样式继续保留原层,避免提前进入第七步范围。 + +`main.tsx` 的当前加载顺序固定为 +`tokens.css → reset.css → shell.css → global.css → components.css → AppRoutes`。 +生产产物中tokens、reset、shell、global和components的代表标记依次位于 +76、1971、3030、15675和36609,确认新增壳层实际进入正确级联位置。 +`docs/contracts/app-shell-styles-r11.json` 与 +`node tools/quality/verify-app-shell-styles-r11.mjs` 锁定118组规则、44个壳层 +类、九组布局原语、桌面折叠、780px移动端抽屉和减少动画边界;既有企业应用 +契约同步改为确认 `.section-stack` 由 `shell.css` 托管。 + +本步骤代码门禁与全量构建测试通过。后续诊断确认恢复运营会话时返回500并非 +AppShell拆分缺陷:当时本地PostgreSQL未监听,而API和Redis仍正常;有效Redis +会话进入用户查询后,Prisma连接数据库被拒绝,Nest因而返回500。恢复本地 +PostgreSQL后,真实Prisma用户查询、API health和无会话401边界均恢复;浏览器 +仍无可复用运营端登录态,Chrome亦无已登录运营端标签。遵守验证码和真实会话 +边界,没有绕过登录或伪造状态,因此桌面展开/折叠、移动端抽屉和通知/用户菜单 +的真实点击验收仍需在可用登录态下补做。 + +R11 第七步只迁移“通用表格、表单和弹窗”。从 `global.css` 迁出52组规则、 +57个选择器,包括 `.ui-table*`、`.ui-modal*`、`.form-grid*`、`.radio-row`、 +`.table-actions`、表格文本辅助类、`.icon-button`、`.ui-tabs__tab`、XL弹窗和 +780px移动端卡片/表单/弹窗规则;`global.css` 从10001行缩减为9660行, +`components.css` 从1333行增加为1694行。页面域复合选择器仍留在原页面或全局 +层,没有顺带迁移admin/client业务样式,也没有改React组件、API、文案或数据。 + +为保持既有级联,旧兼容规则放在 `components.css` 的现有规范组件规则之前, +响应式规则放在对应组件规则之后;`main.tsx` 的 +`tokens → reset → shell → global → components → AppRoutes` 加载顺序不变。 +新增 `docs/contracts/shared-components-r11.json` 与 +`node tools/quality/verify-shared-components-r11.mjs`,锁定259组规则、 +291个选择器、14组通用类族、桌面/780px所有权和Button、Input、Select、Table、 +Modal真实组件绑定;短信记录和企业应用旧契约同步改为校验组件层所有权。 + +第七步的结构门禁、生产构建和全量后端/Gateway门禁均通过。浏览器确认数据库 +恢复后未登录访问不再出现500,而是正常跳转登录页且控制台无warning/error; +由于应用内浏览器和Edge均无可复用登录态,未求解验证码,登录后桌面/移动端 +表格、弹窗及第六步AppShell交互仍按用例保留待补。第八步只能继续admin共享域, +不得顺带迁移client域或单页面样式。 + +R11 第八步只迁移运营端跨页面共享样式。以“至少被两个admin源码文件复用且 +client源码不依赖”为主要判定,再按完整样式族补齐同一模式的变体和响应式规则。 +新增678行 `admin.css`,从 `global.css` 迁出117组规则、141个选择器; +`global.css` 从9660行缩减为9056行。迁移范围包括审核筛选、任务/报备记录、 +黑名单与敏感词安全页、系统管理、三类统计筛选、跨页面确认与表单提示、 +下游明细、通道字段配置等运营端共享模式。 + +两个带单页面上下文的覆盖规则 +`.report-task-detail .admin-task-card` 与 +`.gateway-exception-page .report-task-table-card > .ui-pagination` +继续留在 `global.css`,避免把单页面特例误归为共享样式。通用 `ui-*` 只在 +admin所有者选择器的后代上下文中出现;客户端源码不引用admin所有权类。 +本步骤没有迁移client域、单页面业务样式,也没有修改React组件、API或数据。 + +`main.tsx` 的确定性顺序更新为 +`tokens → reset → shell → global → admin → components → AppRoutes`。 +新增 `docs/contracts/admin-shared-styles-r11.json` 与 +`node tools/quality/verify-admin-shared-styles-r11.mjs`,锁定117组规则、 +141个选择器、11项跨页面复用下限、admin/client所有权边界以及780px/360px +响应式规则;通道、企业应用和短信任务进度旧契约同步改为校验 `admin.css` +归属。生产构建、API 29套/389项测试、Prisma、Gateway测试/vet、安全及全部 +结构门禁通过。浏览器运行时确认编译CSS包含admin任务与统计规则且控制台无 +warning/error,但仍无可复用运营端登录态,因此登录后代表页面验收继续待补。 +第九步只处理client共享样式域,不得回收本步骤保留的单页面覆盖规则。 + +R11 第九步只处理client跨页面共享样式。静态引用盘点发现,真正满足“至少被 +两个client源码文件复用且admin源码完全不依赖”的全局类只有 `.eyebrow`, +由客户端首页和账户账单共同使用。因此新增8行 `client.css`,迁移这一组声明, +`global.css` 从9056行缩减为9049行;页面上下文覆盖 +`.overview-hero .eyebrow` 继续留在global。 + +`.sms-send-title`、`.system-page-toolbar` 和 `.system-table-card` 虽被多个 +客户端页面使用,但运营端系统日志页也真实依赖,继续作为跨门户兼容样式留在 +global,不能错误归入client。`client-signature-*`、发送页、企业认证、发送 +详情和模板卡片等样式当前均为单页面所有权,也没有为了“显得拆得多”而批量搬迁。 +这些保留边界由新契约锁定,后续只能在对应单页面小版本中处理。 + +`main.tsx` 最终确定性顺序为 +`tokens → reset → shell → global → admin → client → components → AppRoutes`。 +新增 `docs/contracts/client-shared-styles-r11.json` 与 +`node tools/quality/verify-client-shared-styles-r11.mjs`,锁定client唯一所有权、 +两页面使用下限、三组跨门户兼容边界和五类单页面保留边界。R11既定九步至此完成; +生产构建、API 29套/389项测试、Prisma、Gateway测试/vet、安全及全部18个结构 +门禁通过。浏览器运行时确认`.eyebrow`、首页上下文覆盖和跨门户标题规则均进入 +生产CSS,375px登录页无横向溢出且控制台无warning/error;因无客户端登录态, +登录后首页/账单视觉验收继续待补。后续如继续拆单页面CSS,应建立新的小版本 +编号,而不是扩大R11第九步。 + +## 6. 每个拆分版本的固定执行步骤 + +### 6.1 开始前 + +1. 执行 `git status --short --branch`。 +2. 执行 `git diff`。 +3. 执行 `git fetch`。 +4. 分别确认 `HEAD` 和 `origin/main`。 +5. 完整阅读 `docs/testing-progress.md` 最新记录。 +6. 识别并保护其他会话的未提交修改和未跟踪文件。 +7. 明确本版本唯一拆分边界、负责人和禁止触碰的文件。 +8. 记录拆分前全量测试结果。 + +### 6.2 实施中 + +1. 先补特征测试。 +2. 只移动代码,不改业务。 +3. 保留原门面和公开签名。 +4. 每完成一个委托点立即运行定向测试和类型检查。 +5. 检查数据库事务、锁、幂等键和查询次数。 +6. 检查是否形成循环依赖。 +7. 给复杂并发、事务和协议边界添加解释“为什么”的注释。 +8. 如果修改范围超过原计划,先停止并重新评估,不顺手扩大重构。 + +### 6.3 发布前门禁 + +按模块风险选择,但完整发布至少包括: + +- `git diff --check`; +- Prisma format、validate、generate; +- API TypeScript正式构建; +- API全量测试; +- 前端TypeScript和Vite生产构建; +- Gateway `go test ./...`; +- Gateway `go vet ./...`; +- 依赖安全门禁; +- 真实PostgreSQL和真实API关键路径; +- 对应页面登录后视觉和交互; +- Redis Stream pending/lag; +- 不发送真实短信的无副作用验证。 + +### 6.4 发布后 + +1. 只使用标准部署脚本和精确Git提交。 +2. 发布前备份PostgreSQL、运行源码和环境文件。 +3. Gateway先于API重启。 +4. 检查服务、端口、health、Redis、Stream和错误日志。 +5. 对本次拆分的业务域进行真实只读或安全写入验证。 +6. 至少观察一个稳定窗口,再开始下一拆分版本。 +7. 在 `docs/testing-progress.md` 记录实际结果和未完成项。 + +## 7. 回滚策略 + +拆分版本必须做到: + +- 不包含无关需求; +- 不包含不可逆数据迁移; +- 原门面仍存在; +- 新旧模块之间只有清晰委托关系; +- 可以通过回滚该版本Git提交恢复原实现。 + +如果拆分确实需要migration,应只做向前兼容的新增,先部署兼容代码,再迁移读取,最后在更晚版本清理旧结构。禁止在同一版本删除旧字段或历史数据。 + +触发回滚的条件包括: + +- API响应结构变化; +- 数据库写入数量或事务边界变化; +- 余额、退款、频控、补发或回执出现重复; +- Redis Stream pending或lag异常增长; +- Gateway连接、ACK或重连行为变化; +- error日志明显增加; +- 页面关键流程无法完成; +- 无法在短时间内解释差异来自何处。 + +## 8. 文件大小和依赖健康目标 + +这些是方向性目标,不作为机械验收条件: + +- 普通业务Service建议控制在300至600行; +- Facade建议控制在100至300行; +- 单一React页面建议控制在300至500行; +- 单个测试文件建议控制在800行以内,并按业务域拆分; +- 单个文件不应同时包含DTO、数据库查询、状态机、外部调用和页面展示五类职责; +- 新业务应直接进入对应子模块,不再回填到旧大文件。 + +如果拆分后文件虽小但依赖更多、事务更散、调用链更长,则视为失败拆分。 + +## 9. 不建议采用的做法 + +1. 一次性重写整个大文件。 +2. 一边拆分一边更换框架、状态库或ORM。 +3. 同时重命名大量方法和字段。 +4. 仅依靠TypeScript编译通过判断行为没变。 +5. 先删除旧门面,再批量修改所有调用方。 +6. 为了减少行数把代码拆成大量无业务含义的 `utils.ts`。 +7. 把事务拆到多个Service后分别提交。 +8. 用Mock通过替代真实数据库、Redis、Gateway和页面验证。 +9. 多个会话同时修改同一个核心文件。 +10. 在发送链或Gateway重构版本顺手加入新功能。 + +## 10. 推荐起步顺序 + +实际实施时推荐从以下三个版本开始: + +1. **R0 安全护栏**:没有生产代码移动,先补特征测试和职责索引。 +2. **R1.1 前端请求核心**:只抽 `adminApi.ts` 的HTTP、错误和上传基础能力,保留全部原导出。 +3. **R1.2 企业与用户API**:选择低副作用领域验证兼容门面模式。 + +完成并稳定发布后,再进入 `OperationsService`。发送链和Gateway不要作为第一个拆分试点。 + +## 11. 每个版本的决策记录模板 + +```markdown +### 拆分版本 + +- 目标文件: +- 本次唯一业务域: +- 明确不改的行为: +- 原公开入口: +- 新模块: +- 数据库表: +- 事务和锁: +- Redis/队列/Gateway副作用: +- 特征测试: +- 真实后端验证: +- 页面验证: +- 发布观察指标: +- 回滚提交: +- 遗留项: +``` + +每次开始新拆分版本时,在本路线图基础上另写该版本的短实施计划,不直接把路线图当作可执行变更清单。 diff --git a/docs/contracts/admin-api-r1-methods.json b/docs/contracts/admin-api-r1-methods.json new file mode 100644 index 0000000..e0024e0 --- /dev/null +++ b/docs/contracts/admin-api-r1-methods.json @@ -0,0 +1,1024 @@ +{ + "version": "r1", + "baselineCommit": "0af671b4ed4713912e703defd08791f164d4eb25", + "generatedAt": "2026-07-31", + "coreFunctions": { + "readErrorBody": "a12b96623f266784d928784ab83fe172fea7470f14b8e2be6684f776119b50dd", + "requestPortal": "0b19b95d4ece5b775f2b62951368b1a8b19d7e74f4c2644b40b02866783b49ea", + "handleSessionFailure": "bded6408445ac01472339109835175791347d7d9846f037d4b77a06e9936bb7e", + "readErrorMessage": "e5b2f46ba0662a3f41db24fb40fa2f17dbbc9e12f86e523a830b5f1469db8156", + "request": "cd40a6cd970ec88c5580884457244fbaeaa002efbe2aeb795a78afcac36f2f1a", + "requestBlob": "8f4b3aad9b383911f8b3ba7a95e9c209e621bde1f60b72df60a19f658e92f66d", + "requestForm": "3028258ad66759c56ae803c1cea19ca9cb8f94d07e759341ac06486d0372abd2", + "withQuery": "fdb2dfbebb84baa39f066c99b5f6301c0455c9ecb32914e9864d0c39508827fd", + "fileDownloadUrl": "11767e2071f81321774b94787434b13cffbb9532517fd194a40e483e6badd093" + }, + "objects": { + "portalSessionApi": [ + { + "name": "current", + "implementationSha256": "a310c7bf164439cc4c7ff7050963a81988d01f7231348cb829033f349bdb7110" + }, + { + "name": "touch", + "implementationSha256": "67f22a29d42b8286e3e8d51d84f8cdd612f442fdf30c7f315cc2ef3501d66ecd" + }, + { + "name": "lock", + "implementationSha256": "46ec577270bc1b87bc8755342d1ba075920c63ae6bf7523fa84af35151d6c0c1" + }, + { + "name": "unlock", + "implementationSha256": "ce61726c8c0d20585161a9dd50b96e83b4a73c32e968a7a7cf75dfe625917b42" + }, + { + "name": "reauthenticate", + "implementationSha256": "3413002c725cb7b9553a613bbaaa6865f1440e2ffb4ec9fab7fe48a0d68634b8" + }, + { + "name": "logout", + "implementationSha256": "9a3dddd9099012946110ecca9d55400a70d8f603a4ba32634f2cddbb5b4934b7" + }, + { + "name": "changeOwnPassword", + "implementationSha256": "f37f9cd1fb78e7e836de0baa44dca054f8f3855c07933db6880044b6edf63832" + } + ], + "adminApi": [ + { + "name": "getCaptcha", + "implementationSha256": "eb656c1550a3c48a52f587a5177ae101e59abf774733d89e698c61baa85b891e" + }, + { + "name": "login", + "implementationSha256": "e646a3390e327075c35dc69d2a98507ceb2c57976c8d291b46d51ddae463d8f0" + }, + { + "name": "touchSession", + "implementationSha256": "c7603e63c7f701fae4b61984b57f2eeda551dbdaa8e012851bcdaa7160998702" + }, + { + "name": "lockSession", + "implementationSha256": "859cbab6b44895bb6564c73e6489f84850b1b7e8ebb1964c217a08fe05bfac5e" + }, + { + "name": "unlockSession", + "implementationSha256": "acb31b7be5425adf01f28d5654b986bcbc22afa96b0f79bb18a185f68ad55f51" + }, + { + "name": "reauthenticate", + "implementationSha256": "05c175ca4ee41ced71f07d8ddcd15c3e6a3c839956114bd28a655a90100c65e0" + }, + { + "name": "logout", + "implementationSha256": "bd9d7fb3263d6cf37653e828085f2f12794e2a1cac7d88829bbbeb5de4e1dc3b" + }, + { + "name": "changeOwnPassword", + "implementationSha256": "1e1144344ac01fa64906d594f97bf4204e2386ad6a5796cac938f421299eeb62" + }, + { + "name": "listTenants", + "implementationSha256": "d9edf7fc692cb28a13772e1e212229dffafd907883533478ac56b1af81c684e2" + }, + { + "name": "listTenantManagementRows", + "implementationSha256": "22fbf8644bf5365335bfec364043fb2ad3fa07e0fb23eca4ffc080c0dd86b10d" + }, + { + "name": "getTenant", + "implementationSha256": "8615515a156a35d6c998c5c080c75215dfc91a42c34f790e18372bff3acc6fae" + }, + { + "name": "createTenant", + "implementationSha256": "afe9d6651bcc109623dc27510c4a5c41c7a576a243f079b1fd8b1a7e650fa766" + }, + { + "name": "updateTenant", + "implementationSha256": "b9a9b3b0ca1900507fe80db77ed0faede2cb0d141c83eb403d99c456b88decf9" + }, + { + "name": "changeTenantStatus", + "implementationSha256": "9f0deb6e0d99857302fcf3371e45f0e40e21604e901abaa477c7884bc0266434" + }, + { + "name": "deleteTenant", + "implementationSha256": "7ae9729b74a749d9fb69de93b4883c5f116bc8a74d5b58bd23f91d1657423b08" + }, + { + "name": "listUsers", + "implementationSha256": "30b5d019ae65d3c512447c6555091f14dab8deb1247b0f78246dd97866b53bae" + }, + { + "name": "createUser", + "implementationSha256": "8747465f366424249e15c8285fc99c13e8ce3dcb4d0669f0f3c2c6e7bcad3fe1" + }, + { + "name": "updateUser", + "implementationSha256": "b3bbbb1bc9db5f053f1a0680b69fd2ece6d7ee12bdbb595849c04ee043423b90" + }, + { + "name": "changeUserStatus", + "implementationSha256": "44f49778d722aff688a706b17fd1aa2c895eef45e8213c21331e4b7bee79e5ae" + }, + { + "name": "deleteUser", + "implementationSha256": "07c09c5919e0b4b31cb49034e41bc917dca2135270d4d24e5c5ce80902fa952a" + }, + { + "name": "changeUserPassword", + "implementationSha256": "2b1bfbeea2d7f8087c406b1d7383a28fa372657df4ee490aab22f3068074918b" + }, + { + "name": "getDashboard", + "implementationSha256": "4cfc7639a483d91a98b32966ad14fc73f576f6e6ed040e717b73bc2481252c5d" + }, + { + "name": "getSendQuality", + "implementationSha256": "75b05d980a296badf271e6619c92a2a87471716c3ae8a94ded1e9a835bbfd079" + }, + { + "name": "getSignatureQuality", + "implementationSha256": "9dcff1d897eaef02ec1fa88e025452d5ebc1cead171943569882f409e078bce2" + }, + { + "name": "listSystemLogs", + "implementationSha256": "057d0e5488e04a5556c6b092344b0649bd0274a769e1ca326f6965460e4c00e5" + }, + { + "name": "listProtocolInteractionLogs", + "implementationSha256": "61e20c35fdc9fa6fb729197646c36a8a13c3fd0db2c8ac832e860089ec20f923" + }, + { + "name": "exportSystemLogs", + "implementationSha256": "bf957aba9f495617dd98ff0b5adf4eceb0af4363cec378154186d934b342d9bf" + }, + { + "name": "listAccounts", + "implementationSha256": "c1e4be942632f403a875048b75db423fb20339baa53ec063fff179755e55855d" + }, + { + "name": "updateCreditLimit", + "implementationSha256": "e28e0793cf962a7b38d6a798d9afe6945d9eca391376e57b5da9325fdefb5f19" + }, + { + "name": "listManualRecharges", + "implementationSha256": "d88711aa264abd121fe948f03d8f156eeb2dc6e846a50235f315f2d840f95e26" + }, + { + "name": "listManualRechargesPage", + "implementationSha256": "7fc7a1aa337c11f215df69514f586552e9dc98503de2f5679a0d89684f9c73ce" + }, + { + "name": "preflightManualRecharge", + "implementationSha256": "d25b89ca760dd7f39321cbbc725cdc740e82a70c04d95d574c90b8854b8d8bf8" + }, + { + "name": "createManualRecharge", + "implementationSha256": "9fe5afdad19a5e000500811d9f62fe0c0628406458a67703764afaf6ff8338e7" + }, + { + "name": "listEnterpriseApplications", + "implementationSha256": "7f91a906b57799ad9ed24408f10d6eb745393fb7f3ee1c326f5c5a1c2e607de3" + }, + { + "name": "listEnterpriseApplicationsPage", + "implementationSha256": "81a36b6c2e9dfb12d4a5f065fae7c312e747846c76a6ce6d98d84cd20d8952d6" + }, + { + "name": "listEnterpriseApplicationOptions", + "implementationSha256": "2e844646351ee0c4e78b2b0bc8fd6efb21ee19c0bc85b85f5e73052c1290bff1" + }, + { + "name": "getEnterpriseApplication", + "implementationSha256": "3d959973cd7b97a70bf7f4c7e5d3d6f200abab9446ee7378266aa5e3a6dc84e4" + }, + { + "name": "createEnterpriseApplication", + "implementationSha256": "443effd193b793b332f65d793d23701ae383fd1f1c67d6f3f70f690fd3a67e4a" + }, + { + "name": "updateEnterpriseApplication", + "implementationSha256": "09f6c1cbb03a8f673d9309bd4a1a881fb98a2a693e9cc658d9e5a77723b8c4a9" + }, + { + "name": "getApplicationDeactivationPreview", + "implementationSha256": "8c22ea93f64574572da8045db3e3284169daae1446a677ad925c7e7d43369b47" + }, + { + "name": "changeApplicationStatus", + "implementationSha256": "29c1f6726d6796880381fb5a01ac9650a3488c9bd126d48277f0a53f69769bde" + }, + { + "name": "listApplicationConnections", + "implementationSha256": "fb633a0737d18c31e9c0702090d8cd78c0d3bec2181b5d5d4a5112edc4ee6526" + }, + { + "name": "listApplicationReportFields", + "implementationSha256": "18d540d99be4273fb0f6c678ff0e4f959c8fe85b947241e5d740b02842105354" + }, + { + "name": "listCommonApplicationReportFields", + "implementationSha256": "38809178cc3b3f6effec5fff443398f401719ed2d73c809fc187fc0ccba81b0b" + }, + { + "name": "getApplicationCmppParams", + "implementationSha256": "814dde0d7c6a527a12fa4c2db2f18d66fdf70202dbc378d22d9dfdb85ccd6052" + }, + { + "name": "getApplicationHttpApiConfig", + "implementationSha256": "526c635f38c1bec2f5c6c1139ac6a69869e429783e04e4808b69c36924ee7230" + }, + { + "name": "updateApplicationHttpApiConfig", + "implementationSha256": "52c78dfb289664ed3df737574a8bf69d9439af332178978cbb0144fbc6a986d5" + }, + { + "name": "listApplicationHttpWebhooks", + "implementationSha256": "30c001faf6d474f4fbf7519fe9f701dc9be2396b076282d8880bca1b30651d25" + }, + { + "name": "saveApplicationHttpWebhook", + "implementationSha256": "e2dbfb2227e7b62f8b0f068e17372e0b3451b968d4d380af2874d046730b99be" + }, + { + "name": "listChannels", + "implementationSha256": "8c73934d3a8a3552607798ee1d2c847e64d0e0f5e3c122d003d263d66b17f858" + }, + { + "name": "listChannelsPage", + "implementationSha256": "571b7dde8a442bddcfae62651791ea94aa48fe2dbdfdb048c9d707ad67378eb3" + }, + { + "name": "listReconciliationReports", + "implementationSha256": "7d4dafd20064b8fb5ffec207c7656fc3807c805d8ebb112867751ef79b04c1c0" + }, + { + "name": "exportReconciliationReports", + "implementationSha256": "e0f9a3a8a85b574c7244d6a1348276049930fc42cb7da49dd61abfe5a565683f" + }, + { + "name": "listProfitReports", + "implementationSha256": "d037e421b83586ada4758e1f3b0a5d1603c31295093b36f58f287c09ce98aed0" + }, + { + "name": "exportProfitReports", + "implementationSha256": "c71371916165dcfdcfd4ee1087b3334adeb4d472ccf0845d4249401dd3a7c9b6" + }, + { + "name": "listQualityReports", + "implementationSha256": "293d470b44d6ea12ff90d7497d985c3194386ff3ba1f6cedafb7a6d6925352ad" + }, + { + "name": "exportQualityReports", + "implementationSha256": "deb331da502aaa2e231e038f4b4ff2a13aac67efbf423fecb0566c82ef7a44d1" + }, + { + "name": "createChannel", + "implementationSha256": "b0ede435043515739c124f4bade34d4d42d749e2cd0b48a0fca82922ffeb0b00" + }, + { + "name": "updateChannel", + "implementationSha256": "26d4b1037a55bb010c7dbc7f80a7773f464b0d552117f3e701d69d8f2cd93ad2" + }, + { + "name": "copyChannel", + "implementationSha256": "e5ee87a520791ff3215246ab851a9d78886243e652a5c6dc00007f3e7bd4a9ed" + }, + { + "name": "testChannel", + "implementationSha256": "bbf05cc72254483c53d2d02529d2f05c6190b2605a21a6775d96826e9762f683" + }, + { + "name": "changeChannelStatus", + "implementationSha256": "7b66514ea24b7aeb6e61ba2a6ecb00f84188c245d0a80b8da24b1adbffd44676" + }, + { + "name": "deleteChannel", + "implementationSha256": "3f4902b34bf040dd0a0ea1163036e38fe54b2b8c39c90983725d501aba94f525" + }, + { + "name": "getDeletionPreflight", + "implementationSha256": "85c75cd96117f57d92447cabb035769a558d1678afd3cb3a59bf28866f3f8c5e" + }, + { + "name": "deleteGovernedTarget", + "implementationSha256": "c68c415c4d0ef23ca6fda603374968de363ee6e3aea9b0c73a424a0b37830fd0" + }, + { + "name": "listChannelConnectionLogs", + "implementationSha256": "461ba4ee9c4e37866e8e879b04d9ea94f3c67fb059f89c0f52f8d22778efc6af" + }, + { + "name": "listTemplateAudits", + "implementationSha256": "cd52a1a4ce1d7a64468089f5cb7597904c246963665b1174e01ec6fce673760d" + }, + { + "name": "approveTemplate", + "implementationSha256": "44d8a7b7b4d35e623a5f1b24f0f93be3685ebf940d7dd7fcd14af13bd7927f07" + }, + { + "name": "rejectTemplate", + "implementationSha256": "30352844d5b8b37d92b980a99da8d0c52dc3ceee6a5b8c442c8dd3e4cf63ccde" + }, + { + "name": "approveSignature", + "implementationSha256": "103d23a53139c6231d4b58e93b0f1d57ac67d5c3e7c8393c6c0158a586dfe1b5" + }, + { + "name": "rejectSignature", + "implementationSha256": "32f2ad067c8d26a84300d3ef088503d912d132c5a5d12eb0b32f46af0e2b6014" + }, + { + "name": "getReviewPreflight", + "implementationSha256": "1935bd156ea938f303dd1021a802f678d293520e157fce3fc81c596f2ffabb4a" + }, + { + "name": "submitReviewDecision", + "implementationSha256": "0ff8ecf3903d27d32c4ac23973e4e2d914ff8a1ef75aa2d54e3a51fcc23afe0c" + }, + { + "name": "listEnterpriseSignatures", + "implementationSha256": "e60e04452f022052a5aa21159b1b69828c7a0c7de731ce723c7d5742751ecb74" + }, + { + "name": "listEnterpriseSignaturesPage", + "implementationSha256": "d1bc99a6fd3c9aa0e0f14418ef7bdf96c584955b191df40afb06a61679c18647" + }, + { + "name": "listEnterpriseSignatureOptions", + "implementationSha256": "7563686f5d604f2175e531f176a23098830d5c4943eefa922856d7d40f2f0bb4" + }, + { + "name": "createEnterpriseSignature", + "implementationSha256": "ee8ad247029a55abec6da242e46a19c3707087d62215c3f8d085878794b02dc7" + }, + { + "name": "updateEnterpriseSignature", + "implementationSha256": "9a1bbf347a4f0a590e1c442c109dc07aa6cc1f35be75fab4f0174ff0275f305e" + }, + { + "name": "changeEnterpriseSignatureStatus", + "implementationSha256": "b1054369d908887c2f85edf06820d4542f0964e4fcfc10ac4851b10c58c8575b" + }, + { + "name": "listDrainageInfos", + "implementationSha256": "e75bafd3e0bb991f853f9cb195b8043c2ab180383e14ae0bd195cba441c70dbe" + }, + { + "name": "listAuditRecords", + "implementationSha256": "258f244bd4d21c909303a42a14f0dc6ba43babe67a25372cb53883e1a9f119af" + }, + { + "name": "createDrainageInfo", + "implementationSha256": "69e61d4b7039d5c3d06934d19b6c74caeeb8dc4e7d5360274b6b853e68c2fbab" + }, + { + "name": "updateDrainageInfo", + "implementationSha256": "17de50ebba4af0f7aa34f559665a328a4bd9a7fedd7319acd07341d0827a715e" + }, + { + "name": "approveDrainageInfo", + "implementationSha256": "0a0f54f2a2d0f85fe9d97835c700b927740d5acf1698e0cec3646c7723a20511" + }, + { + "name": "rejectDrainageInfo", + "implementationSha256": "b7d0d112325b0062c4264855e9b69b5c1b88c78bdc30e1104a8c6ebd45d63280" + }, + { + "name": "changeDrainageInfoStatus", + "implementationSha256": "1d6c7abf222d5021f61c9f3e9b793e77296894884c89a0eb4533ccb402c3bfec" + }, + { + "name": "listEnterpriseTemplates", + "implementationSha256": "11ca2f07e57c96de386896a90a131e67e85314b9c050f824b5568927d5d38993" + }, + { + "name": "listEnterpriseTemplatesPage", + "implementationSha256": "4e856eb59ad9d437d5e4a6389ea26342c90a55192daca38aa9f52de4c13aa564" + }, + { + "name": "createEnterpriseTemplate", + "implementationSha256": "cdd6cf332068adbcb73efbf4198b045869978473953954f725061fb7e8256805" + }, + { + "name": "updateEnterpriseTemplate", + "implementationSha256": "52564f9c7d9f0d4ae18a35bba7986935ac0da4e22c534cd5549ed56067ebc131" + }, + { + "name": "changeEnterpriseTemplateStatus", + "implementationSha256": "4315755e114f04b3c1abadf78b9c716a7f2147f85ed67f4e87efc1a08636e758" + }, + { + "name": "listEnterpriseCertifications", + "implementationSha256": "dde64635239c428de705e832356bcf39195ae5ce3af93bccd08d0be0ca436d23" + }, + { + "name": "getEnterpriseCertification", + "implementationSha256": "abfc96b08df70f8408d9a29053218369551b8e22fe9a919b38adaadfc8e3b336" + }, + { + "name": "approveEnterpriseCertification", + "implementationSha256": "fa71402ad8259ccb606470b71c23d612dbe54520ef12d5e889059138016f7cb2" + }, + { + "name": "rejectEnterpriseCertification", + "implementationSha256": "bcde969bcc255d1aeeb14299c88c795b7188506805e15a170a47e57ab04eb3a2" + }, + { + "name": "listChannelGroups", + "implementationSha256": "64bdf21e37d174f7fa5c17142e319cd5ad8f00887ec63d59cde1188f10e2316c" + }, + { + "name": "createChannelGroup", + "implementationSha256": "8162858a23ef1fa06da1ab5dd044f25cedae99b2593f04d2317850d3baf5a33b" + }, + { + "name": "updateChannelGroup", + "implementationSha256": "d01b2318706087800997b8f5d2e4dff6677b67bc92c1880211161a7dcea5d04f" + }, + { + "name": "deleteChannelGroup", + "implementationSha256": "923c0d33527fb859f7a7094862cace2f63b99545072eb63a894a05a9ecc921d4" + }, + { + "name": "addChannelGroupItem", + "implementationSha256": "c88fb1a9cb9ca0e51ff44beb7f631ec8fd8ea55ba7d12907d2af767d5e96a2d9" + }, + { + "name": "listChannelRouteRules", + "implementationSha256": "728bb882cd790f719ccd708b7d576145e000857cefc98ab1a1ac7f8b1f72db45" + }, + { + "name": "createChannelRouteRule", + "implementationSha256": "f10cdc67bf4086311fc607569ec05db23ef8190e62d996766a5a43075137b597" + }, + { + "name": "listChannelConnections", + "implementationSha256": "f503dbcbb8206989409f98b5e35a623a96ae4e89f8a7d76d65ff9bd0fd8508bb" + }, + { + "name": "replaceApplicationRouteRules", + "implementationSha256": "86edf53b5966fd2aa381541b904c60af4e2b9160a9553dc6297267bb85eb2fa4" + }, + { + "name": "listChannelReportFields", + "implementationSha256": "8b41262a67ab7227c44ac19bad7361be0e4bf7264c726f9f002e9731f6b70781" + }, + { + "name": "createChannelReportField", + "implementationSha256": "b1927046ed4fd585d6045b0c3bd7e9edd0d56684030ea6526ff19c28de50ee47" + }, + { + "name": "replaceChannelReportFields", + "implementationSha256": "ab6ced7210dcc5e25416f6e740b56da202386193c319ae10d85fcbf04c5d7711" + }, + { + "name": "listPendingReportMaterials", + "implementationSha256": "ff3e2bd2a6947e2c0ff92d940a0a9f741ae35d15ea284ffef32a36d83474011d" + }, + { + "name": "listReportImportProfiles", + "implementationSha256": "71796c59f298f64e20b2629027b8b5faf9ccfbf490e64c93361ddbf9c4854470" + }, + { + "name": "saveReportImportProfile", + "implementationSha256": "57a869a6fd0fc2ddd853e07656fc4771beb7ec845ebbfd7df9e2972b867717d2" + }, + { + "name": "analyzeReportMaterialImport", + "implementationSha256": "17221cf4b3c36f3604816f05ee29d1b553d0fbe08b4dcf579a5c14efd30b467a" + }, + { + "name": "commitReportMaterialImport", + "implementationSha256": "e571ddb7d1647e3424caa05fd3a45e0742ee854fc1949cdaf8c8721a983141a4" + }, + { + "name": "listReportImportReviewBatches", + "implementationSha256": "aee49d5f26ebf2f150a4d78ddb9fcc2a2700443744152a0826b07c5c59f09985" + }, + { + "name": "reviewReportImportItems", + "implementationSha256": "2adcf8f1264ef96903b14f853d6d078e0eee429d88edb3b6a59d8a629512a8b1" + }, + { + "name": "listReportMaterialBatches", + "implementationSha256": "15410da2670496fc9c52acf9f2719f8777e5e970a7546b2245fa6c9b50b3cd02" + }, + { + "name": "preflightReportMaterialBatch", + "implementationSha256": "7f11aa1a7050b12b7834059d0e1f367821626a524d08c48b71749b2b2956c880" + }, + { + "name": "createReportMaterialBatch", + "implementationSha256": "be72f8463c58cf3febd3afbe28203ef25f2af2c3e7783d522eb348344c62165c" + }, + { + "name": "listReportTasks", + "implementationSha256": "ed2cb72eb80ba492ab77028231d7ab77d099fdd132366901ed74dbac82365cc8" + }, + { + "name": "listReportTasksPage", + "implementationSha256": "3b2495268148c58f35a6e04e16c5948ca99f6410d1566f67133a5882824419f3" + }, + { + "name": "createReportTask", + "implementationSha256": "446aea44bf7b679bd1f40a215c4b2e973bdc681f242d583225e0362b4634ea40" + }, + { + "name": "changeReportTaskStatuses", + "implementationSha256": "06510aab19c0f1c8cd0f96247d2bae69872ac40bac1589db16c692f01636e4f6" + }, + { + "name": "createReportExport", + "implementationSha256": "dca990fb2bd3595048a65b90d143d33ffe5743de2a081f9e9c8ef71d595fc016" + }, + { + "name": "importReportReceipt", + "implementationSha256": "01833acf42b6cc874793a54d796fdb01de4a11efd2a6f5e3e3df458d8a7fabeb" + }, + { + "name": "listReportRecords", + "implementationSha256": "f4e451d81f3b4e5d5585660b8e4f494f65d6659deb0f406e4ce15ba1172f6ca2" + }, + { + "name": "listReportRecordsPage", + "implementationSha256": "52d08af2a71e38a25d60f0891b84cfaba2a3051e17836fe1a24f036054b7c197" + }, + { + "name": "listAdminBatchTasks", + "implementationSha256": "34440c84f147d59438d4b3981f13151a46dbf30ce5887b448e4d50ba941e1dd7" + }, + { + "name": "listAdminBatchTasksPage", + "implementationSha256": "004c80e4bb28b902f20984df4b70022315733096c8d324ba4fa5601f34205858" + }, + { + "name": "listAdminBatchTaskMessages", + "implementationSha256": "2f4ebbf27c8dfeec56ac93e1f0d4d4a6708686a17b8304869bf8be6957815a2e" + }, + { + "name": "terminateAdminBatchTask", + "implementationSha256": "558bbd396767aa607e6cd41114377e27da8e09c28f417bf8a2b5bf6c8d42cf8e" + }, + { + "name": "listAdminMessages", + "implementationSha256": "0b854500df4409c255bb07784080af80a38c260777d3e9f6135d9c495736d542" + }, + { + "name": "listMessageSegmentAudits", + "implementationSha256": "9a7d3fef54503911ffa308103fddfb7feeeb6295a17b9bf30db1c66ae4ba34f6" + }, + { + "name": "listOperationMessages", + "implementationSha256": "d89407edcb6942fe8bc5fd618c2fcc17abb151a1d395d76c512a1b8412a0cb71" + }, + { + "name": "exportOperationMessages", + "implementationSha256": "01c49d9ca8e071c085244a9a2dd66e5f27d271ef9a746f18769956647a62cbd2" + }, + { + "name": "listAdminUplinkMessages", + "implementationSha256": "529ac65bcb5e539d939945ddbc8c702336c81a8e71b73bfe2fb6c0f6fe71122e" + }, + { + "name": "listAdminUplinkMessagesPage", + "implementationSha256": "d8e5bbefd2bb7bf88fdf3eadd39a1eec25b874ed28d374c8861a64d1e923f183" + }, + { + "name": "claimUplinkMatchCandidate", + "implementationSha256": "66e345b8aa3835c2e8c8de7dfb15c7d2adbb5a53f3a34c470f93d49a079a511f" + }, + { + "name": "listMonitor", + "implementationSha256": "f9958023971fc47c6e78f61a4396913a32c3d5faf1789deaf591d55174d75e73" + }, + { + "name": "listGatewaySubmitExceptions", + "implementationSha256": "38d1926764a931fe655169241c1463eb6ec36c1019a27148dbe52a0bbd580ea7" + }, + { + "name": "requeueGatewaySubmitException", + "implementationSha256": "b85c6125a9ae47075fd755e70cc75d653c33a1ee0ff8e5bc280738ca7a848e3c" + }, + { + "name": "listStatistics", + "implementationSha256": "eb870b5f8a03b1cd1449ba0c715303bb049c4048b395bff46dd06b4d98890bfb" + }, + { + "name": "getDownstreamDeliveryDashboard", + "implementationSha256": "48740b099a82cccc63efad0ee08fd5ff6fe776f5222484b52661128b1e6b7d13" + }, + { + "name": "listDownstreamRecoveryStatuses", + "implementationSha256": "e66f2b064dfec77f6aa84341674a87c9c3d6edc99da138d60c27f12bb4f4f42e" + }, + { + "name": "getDownstreamRecoveryStatus", + "implementationSha256": "bd208c0061a7be8edef73e91fa331619a53aeadb66f2109e11d81d2761ee58e2" + }, + { + "name": "exportDownstreamRecoveryStatuses", + "implementationSha256": "fc3ced4385adb8c76e0a4bb1d3282a757be45eaf9edaabb43e6b6c912aa48185" + }, + { + "name": "listDownstreamDeliveries", + "implementationSha256": "17e066ee6a320822991639217726080cab0fb7eea25aca92cc8d172d1fe6469d" + }, + { + "name": "requeueDownstreamDelivery", + "implementationSha256": "ce1db2b258c0602248547e2d15a5f7a28226918b916d7702df9fd545f98d8708" + }, + { + "name": "batchRequeueDownstreamDeliveries", + "implementationSha256": "de43438462102fd553148cb6d3226808746aaa137a0798a6ec8736a42070ebfa" + }, + { + "name": "listRiskReviewTasks", + "implementationSha256": "1040e5775f210a00a26df88b3aac01523ca61c29409edd5f30e6e1c98fb3e5cc" + }, + { + "name": "listRiskRules", + "implementationSha256": "9654501a98d6d77edd5a0139ad9fa33bd330e3c1dc826e3718106c9a235aabac" + }, + { + "name": "createRiskRule", + "implementationSha256": "cb0dbf0742bba092f571ffb1f723028b0110ddcfb036dc3135299596606e6753" + }, + { + "name": "updateRiskRule", + "implementationSha256": "d74b3d40f989d3212df84f24b2726f0122e6ee9d46f9dbfbde164b6d42950824" + }, + { + "name": "listPhoneFrequencyHits", + "implementationSha256": "27771ec52a27b12472fa85092d8f34eee0b36d69762b5edb9ef0e29bd2e919a4" + }, + { + "name": "releasePhoneFrequencyHit", + "implementationSha256": "8f97b9377188f3d355689dcf1882957d0cb40e61c5e643392cf0e3eedbe53749" + }, + { + "name": "listPhoneFrequencyWhitelist", + "implementationSha256": "ae7b93a02cb93c3df6674627568be61126c8aa3d29746f6fdcdd2b4bb0888136" + }, + { + "name": "createPhoneFrequencyWhitelist", + "implementationSha256": "59aff0236716ec346b7762f3505ec596abce9d63344ed87fc0c2f18b438b1d11" + }, + { + "name": "updatePhoneFrequencyWhitelist", + "implementationSha256": "a4955f1ebfd2fdad152df794b9eb426182c97035884dc50f6bb27030022d6a06" + }, + { + "name": "deletePhoneFrequencyWhitelist", + "implementationSha256": "a23658c267cc28f94e85df66ae87b939af170f5b546d3dd6d9b8a3cd4ef6543c" + }, + { + "name": "listRiskReviewTaskMessages", + "implementationSha256": "e6bd82a72b2675e5b019a15d9e792c5230bbf851e1aa0615e9994df77b0f6b05" + }, + { + "name": "approveRiskReviewTask", + "implementationSha256": "9879cc19a473bacaa8cf4e36ac982c010edf6de9951d203d4b7795befb785bb3" + }, + { + "name": "rejectRiskReviewTask", + "implementationSha256": "a276ba0eff242a95e7efac0f4a72695d23c291aca892cfef2d9e3e5a2eaa312e" + }, + { + "name": "rejectRiskReviewTasks", + "implementationSha256": "b41c1a4838c0c9359b2f5116fd5f7670170088d9df14dcee9ff432c75a6eb63e" + }, + { + "name": "listSensitiveWords", + "implementationSha256": "7fc6bc2bc42aef2265c2aa50c0ef7c3f2d7c163ceaa0cae617a3a365159f2213" + }, + { + "name": "createSensitiveWord", + "implementationSha256": "c81682376cb9ab635395a9e78231f76c68f5a8d864871bbdbd3bddca78ef291c" + }, + { + "name": "deleteSensitiveWord", + "implementationSha256": "e3fc55610d92a0d61baabe391a303d376b03ab09be5c3273ea01e2e0e9589575" + }, + { + "name": "listGlobalBlacklist", + "implementationSha256": "0a7031ffbd0acddf2f5b7fe6f4672a123046940a71fd6020212a642d71beded6" + }, + { + "name": "createGlobalBlacklist", + "implementationSha256": "33c663629273ae3e92b0eb6dac8383467c641e114b1551340d61e53918d4277c" + }, + { + "name": "deleteGlobalBlacklist", + "implementationSha256": "d809d1fff9d4f8aa5eed05299524ec62ef25362a524232554419e93643c1db2f" + }, + { + "name": "listEnterpriseBlacklist", + "implementationSha256": "96771de67e4a3902fb1fc7d1d556446767feb436b356166633403a0365db0153" + }, + { + "name": "createEnterpriseBlacklist", + "implementationSha256": "ef4235a05ccef5bd7b745aa8e07a6b05da98244ee8aff33213f26d5423433a98" + }, + { + "name": "deleteEnterpriseBlacklist", + "implementationSha256": "7a1f9c69cd4ba7569495018e2a18f429b454ff469432d147f6bd4701c2062f63" + }, + { + "name": "listPhoneSegments", + "implementationSha256": "3faf0469c44dfdfe69029fa23f77983d7c0c888e181f24e7b8387b1ac87d4c97" + }, + { + "name": "createPhoneSegment", + "implementationSha256": "e93ff81cbf2a93fd2a50356e671755b28f5335cc4293b8aefd22f4dfbe00bf9f" + }, + { + "name": "deletePhoneSegment", + "implementationSha256": "90e3bc214a61d463310933cb48f1104139aa334150d4b92cbb9382d6b60baff4" + }, + { + "name": "listPhoneCarrierRules", + "implementationSha256": "ef8488b018d67b0c3a5343e3bc28fca1bce9830c136f076699c255042c255963" + }, + { + "name": "createPhoneCarrierRule", + "implementationSha256": "9e09e7293c744044b5cadb50d1ed4915053f9a33fe8644545fd7df49c38c6c07" + }, + { + "name": "deletePhoneCarrierRule", + "implementationSha256": "f770a2ef558c8edaf87fa72cba84397da005a1f3dc14808b31384e14c174ad2d" + }, + { + "name": "listDrainageFields", + "implementationSha256": "26b9443a1005312320d02b9fb485993c9eb128984cf695e79ab21ff510271d4d" + }, + { + "name": "createDrainageField", + "implementationSha256": "643147e45c5f4472bf055cc7f5078b076e2ccade96b22cfae2db56bcd5b83af6" + }, + { + "name": "deleteDrainageField", + "implementationSha256": "b2575396385869d0f2131d0397b2ab87bb933665d28370f00f2c12b3d3eb259b" + }, + { + "name": "listCommonReportFields", + "implementationSha256": "8d3f22d612a63b389401300fc595ff5cc8cd20130e1445bd607cd44a54c3b146" + }, + { + "name": "createCommonReportField", + "implementationSha256": "72a2254a1691fca7349575c7296d06625cdb130dc81c10f6b418500d826cfb29" + }, + { + "name": "deleteCommonReportField", + "implementationSha256": "e305aff8b3eabaa020c2044d1993bbd8e5556d2b5b74451c2d97df44a91a96a9" + }, + { + "name": "uploadFileObject", + "implementationSha256": "e4e12f112fdb40274b08c8109dc0bb585cd23ab62dfed0ce9e8e11ca7e508482" + } + ], + "clientApi": [ + { + "name": "getCaptcha", + "implementationSha256": "22ca1dee18287937dcac5304e6baeb9e8653034e5856c7adc99ad2e2d20d0dcd" + }, + { + "name": "login", + "implementationSha256": "62a355ab00672a041fa20c79ed893f63cb3daed126e7e597afa90107d4905252" + }, + { + "name": "listUsers", + "implementationSha256": "08232b543c91966700e5d0d6703c7a9f2c80ff90ab6ea3a6f5bd45bdd321a2c6" + }, + { + "name": "createUser", + "implementationSha256": "34e020dc0d3e6aec3ef0eae73e23567f12b9affbbb4d628e4e7c97abf343164d" + }, + { + "name": "updateUser", + "implementationSha256": "ab218d006b5f8ba5f89e2d523cec4c947ab5f1cae185256a4e477fda7dbb229f" + }, + { + "name": "changeUserStatus", + "implementationSha256": "38f70015e9ebd2ae0060351807180b6be2a4fc6c417bc4042c846d03c4dc4548" + }, + { + "name": "deleteUser", + "implementationSha256": "4acebdc90f6058d8e85ccb977281a0f26dd602c452a7bf6d662d142481322dd1" + }, + { + "name": "changeUserPassword", + "implementationSha256": "96a3a2f91d505d9501449f03734309473209c9047a0938282ad5a8303cf391ef" + }, + { + "name": "getDashboard", + "implementationSha256": "456c3a08ed1a514bc971225b6cf4a8ecad149914d276454e3fc332ba9458d8c4" + }, + { + "name": "listEnterpriseCertifications", + "implementationSha256": "57ce8bc547935dd7afe4ed92afa2e28dab50da3d3e482ba32dca0d0bff067a83" + }, + { + "name": "submitEnterpriseCertification", + "implementationSha256": "ca529572eb86588b575652e693e2fb97fe9b4a2825b367459e945d9888c27b3c" + }, + { + "name": "listSystemLogs", + "implementationSha256": "c7c16c008d23431eb5c5b574edd56614ae3f3e3144739ddac4e5fd08ebbc6871" + }, + { + "name": "exportSystemLogs", + "implementationSha256": "2dc7d2790fc6fe42d01cd6a19fa44c4a894751c96088f40d7685377c2b55a115" + }, + { + "name": "listOrders", + "implementationSha256": "6142df5ad1b49884f07d93969451c2f5dc2c844b83e39490fca241aab6a2dfc3" + }, + { + "name": "listOrdersPage", + "implementationSha256": "156ad99d3fb53ed383fb5ea7fb01a6d88284e50ba6348c4e010e1c57b03cebac" + }, + { + "name": "listApplications", + "implementationSha256": "8aa1d99b43d360819d6cf9693deebd58244d7dd122ccf202d10779a119ebdcd6" + }, + { + "name": "listApplicationsPage", + "implementationSha256": "4412b2bc7ccfe74ac876218caf3029a6e2294d5db49ff67ff28a0631424fccb0" + }, + { + "name": "listApplicationOptions", + "implementationSha256": "d614839410f8e3f8d0a9a7f36aa15610b159c1b3a5c1a756ecbe3304cc865ada" + }, + { + "name": "getApplicationCmppParams", + "implementationSha256": "a9518da73168113bb194dabdf03f3319e93ee7c345d5ca1e339638920538aa59" + }, + { + "name": "getApplicationHttpApiConfig", + "implementationSha256": "b3dd2e3f21d7db9dab86b5103a8b9d0d93369fd05c7269dc42c621f72d8fec1a" + }, + { + "name": "listHttpApiCredentials", + "implementationSha256": "3a22c84be1149e0f992f96231c7a6b9f7c52ff2b454fd76d56a94e1a3f07e2b7" + }, + { + "name": "createHttpApiCredential", + "implementationSha256": "6daab1ff33a2f7d51e598b037d1434ad24e9b467b86226be4520915fa11049a3" + }, + { + "name": "revokeHttpApiCredential", + "implementationSha256": "8be61e40382e68e614d311ddf39ea47f7a14c61dde35023f001374af77c64241" + }, + { + "name": "listHttpWebhooks", + "implementationSha256": "8d6e4edb333ece76d9f0c8fa6cbe59c569eae871bd1917908b074fb056fca737" + }, + { + "name": "saveHttpWebhook", + "implementationSha256": "c04bf45fbccbeddd6705abb19e4b1b021b04bf2d10f57d3e710b7468f33e410e" + }, + { + "name": "listHttpApiRequests", + "implementationSha256": "3748eed65296710f028286aa86f81c500e8261304b29e1d1d30d6a29fcd9a289" + }, + { + "name": "listHttpWebhookDeliveries", + "implementationSha256": "fe7076f6256d092a723cb247362e49c670a9069ddd8744afe25678bcdba85ffd" + }, + { + "name": "retryHttpWebhookDelivery", + "implementationSha256": "595075edf745bc040147dfdb711f86e9ce98a6b54acceafcc73dac5195caa38b" + }, + { + "name": "listApplicationReportFields", + "implementationSha256": "29fa64150dbfddefc3dca5bec4a897a91c093ae2aa6e40fadd87ee064e582208" + }, + { + "name": "listCommonApplicationReportFields", + "implementationSha256": "8245ffc252b0d14bfe906d1fd5a62c814d08c58a1faaa054b6f357e384a77bd4" + }, + { + "name": "listSignatures", + "implementationSha256": "0e2eaab90662becc3e8c46a1f52364b628beb29ecac5192c842777f911d993d2" + }, + { + "name": "listSignatureOptions", + "implementationSha256": "88d083cef0963ffbaef4edae9fbd0da86091c38f5112ab1144e21cad35d1f8c7" + }, + { + "name": "getSignatureWorkspace", + "implementationSha256": "bf391fb998d3b3c63c3213536a07b4c822e9134c504f5dac5127ae0be553d473" + }, + { + "name": "createSignature", + "implementationSha256": "fe9ee52d5c9add4678c00c62093895263bc26f2b4e616429bb2139ccf2b4be0f" + }, + { + "name": "updateSignature", + "implementationSha256": "40cb64149dbd6a5f4f988b83e31a23e29cff4a31b2feecf99e4e0fb9f118e963" + }, + { + "name": "submitSignature", + "implementationSha256": "5370b09dfe02f1968a42c326fa652933f4aee3ce5278c1baf010d716b52db0d3" + }, + { + "name": "changeSignatureStatus", + "implementationSha256": "8ee09e599b9262a9913bfad5bddbc6ebd620efebbc48b02ed80fed69f84e183e" + }, + { + "name": "createSignatureMaterial", + "implementationSha256": "6e295730991d90ddfb3ab549087aed50e29afa6a7eda8ff8570321957a482e9c" + }, + { + "name": "createDrainageInfo", + "implementationSha256": "5796541bf1cebaf327ffd45d91acc2646c282b5b51e9803241038c82aea19372" + }, + { + "name": "updateDrainageInfo", + "implementationSha256": "92875bd86f597c3d2f86600ea78e2d35309681ebfa804ee8be737d7e85a055a0" + }, + { + "name": "changeDrainageInfoStatus", + "implementationSha256": "2172f365183d0a1ae175aa8d38bab163dda09f0700e8e2f56d7f4dd4a166803d" + }, + { + "name": "listTemplates", + "implementationSha256": "0037028063c5b4e8b45c65146a47980b4e57a878107e77d6461b3453681e4dac" + }, + { + "name": "listTemplatesPage", + "implementationSha256": "847dc2b75f461ea6348794e62b6e86b4193150eab5632f88f496fb247dd2da4b" + }, + { + "name": "createTemplate", + "implementationSha256": "68f5db2b2435c2dd03a41700ce2cbd31ac0333506339ba55b8a37f7738beb6c6" + }, + { + "name": "updateTemplate", + "implementationSha256": "51ff22218f3940636ce72fc9717f6d02c52306490d6e03398ac0735ecc44537e" + }, + { + "name": "submitTemplate", + "implementationSha256": "0e456e67f0d1eef0d462ca763dbf19add657ae6b915683be511405e3605c7bce" + }, + { + "name": "changeTemplateStatus", + "implementationSha256": "8be4e54b3863ac9128bd00ed45bdc218b0bd81abca1aa93f91ad7667d370c1dd" + }, + { + "name": "getDeletionPreflight", + "implementationSha256": "fd80a217561ca09c3e2d528704f26615cf3cff870cd0c55399e9f7d98d55d212" + }, + { + "name": "deleteGovernedTarget", + "implementationSha256": "01b63a9c8b12a56d0fc220a0b32a3b2f6d23d2a8f3bc7cab1d02ab7942dd83f7" + }, + { + "name": "listBatchTasks", + "implementationSha256": "97e0d4b6a3c8a0b445dda4acde41f1d97cbda35a5bdd15fdd96dac46fbc816bb" + }, + { + "name": "listBatchTasksPage", + "implementationSha256": "a0bd93a6ae2f550dfc064f9c721b837d1eeca14f8fbe2ae1f0e0372550e89cff" + }, + { + "name": "cancelBatchTask", + "implementationSha256": "37c69c1152a59d861467cbdb0875b0c329109822746296263230adc58dc3ed8e" + }, + { + "name": "createBatchTask", + "implementationSha256": "90accf93ce47777f197de2b21238ff95be9c2187b040e1f5e4c1ef04b117f20d" + }, + { + "name": "previewImport", + "implementationSha256": "5c9ed0ba9e7b0e5967d55d88d864dda061782f2a9964ce6cb00ad10b7ae79226" + }, + { + "name": "confirmImport", + "implementationSha256": "12c8932c748bf74cf918fbb863fc01cc012fa483f915d1ef7b4df93dc39e0920" + }, + { + "name": "listBatchTaskMessages", + "implementationSha256": "c037aeccb02eacac3c864439eba7c8c116e7a3123bb2281ea9c5f983062cc540" + }, + { + "name": "listMessages", + "implementationSha256": "16949032fd16cc04f499b4156c2a09729fabaf2ca382f87e4bb10c887103ea00" + }, + { + "name": "listUplinkMessages", + "implementationSha256": "55ceb696b1a53ef5ce084bbfb921019abacc574d61fd9a5f0860169b0121c3d2" + }, + { + "name": "listUplinkMessagesPage", + "implementationSha256": "ad6a936ded856feed34e491fb94a1772560a522ddf421619769117c3793e3234" + }, + { + "name": "uploadFileObject", + "implementationSha256": "3c70c83caa899a4c575c20bcc434ea7d13751e1df71a845a0b18b9d49f2fe960" + } + ] + } +} diff --git a/docs/contracts/admin-channels-r11.json b/docs/contracts/admin-channels-r11.json new file mode 100644 index 0000000..f7d6040 --- /dev/null +++ b/docs/contracts/admin-channels-r11.json @@ -0,0 +1,65 @@ +{ + "version": "R11", + "stableEntry": "src/apps/admin/AdminChannelsPage.tsx", + "stableExport": "AdminChannelsPage", + "maxStableEntryLines": 300, + "modules": { + "src/apps/admin/channels/ChannelFormModal.tsx": ["ChannelFormModal"], + "src/apps/admin/channels/SmsTestModal.tsx": ["SmsTestModal"], + "src/apps/admin/channels/ChannelTable.tsx": ["ChannelTable"], + "src/apps/admin/channels/ChannelLogModal.tsx": ["ChannelLogModal"], + "src/apps/admin/channels/channelModel.ts": [ + "formatLogDetail", + "resolveChannelStatus", + "mapApiChannel", + "mapUiStatusToApi", + "buildChannelPayload" + ] + }, + "apiCalls": { + "src/apps/admin/AdminChannelsPage.tsx": [ + "listChannelsPage", + "getSendQuality", + "listChannelConnections", + "updateChannel", + "createChannel", + "changeChannelStatus", + "copyChannel", + "listChannelConnectionLogs" + ], + "src/apps/admin/channels/SmsTestModal.tsx": ["testChannel"] + }, + "styleFile": "src/apps/admin/channels/AdminChannelsPage.css", + "styleImport": "./channels/AdminChannelsPage.css", + "pageStyleSelectors": [ + ".sms-channel-page", + ".sms-channel-filter", + ".sms-channel-table", + ".sms-channel-total", + ".sms-channel-identity", + ".sms-channel-carrier-price", + ".sms-channel-status-cell", + ".sms-channel-quality", + ".sms-channel-rate", + ".sms-channel-actions", + ".sms-channel-pagination", + ".sms-channel-form", + ".sms-channel-radio-row", + ".sms-channel-inline-field", + ".sms-test-", + ".channel-log-", + ".channel-connection-summary" + ], + "sharedSelectorsKeptGlobal": [], + "sharedSelectorsKeptInAdmin": [".channel-confirm"], + "interactionLabels": [ + "添加通道", + "查询", + "重置", + "报备详情", + "编辑", + "复制通道", + "发送测试", + "连接日志" + ] +} diff --git a/docs/contracts/admin-enterprise-applications-r11.json b/docs/contracts/admin-enterprise-applications-r11.json new file mode 100644 index 0000000..1792441 --- /dev/null +++ b/docs/contracts/admin-enterprise-applications-r11.json @@ -0,0 +1,88 @@ +{ + "version": "R11", + "stableEntry": "src/apps/admin/AdminEnterpriseApplicationsPage.tsx", + "stableExport": "AdminEnterpriseApplicationsPage", + "maxStableEntryLines": 300, + "modules": { + "src/apps/admin/enterprise-applications/EnterpriseApplicationFilter.tsx": [ + "EnterpriseApplicationFilter" + ], + "src/apps/admin/enterprise-applications/EnterpriseApplicationTable.tsx": [ + "EnterpriseApplicationTable" + ], + "src/apps/admin/enterprise-applications/ApplicationLifecycleModals.tsx": [ + "ConfirmModal", + "DeactivateApplicationModal", + "AddApplicationModal" + ], + "src/apps/admin/enterprise-applications/ApplicationParamsModals.tsx": [ + "CmppParamsModal", + "HttpParamsModal" + ], + "src/apps/admin/enterprise-applications/CmppConnectionModal.tsx": [ + "CmppConnectionModal" + ], + "src/apps/admin/enterprise-applications/applicationModel.ts": [ + "connectionStateMeta", + "formatCmppParams", + "mapConnection", + "mapApplication" + ] + }, + "apiCalls": { + "src/apps/admin/AdminEnterpriseApplicationsPage.tsx": [ + "listEnterpriseApplicationsPage", + "listTenants", + "changeApplicationStatus", + "getApplicationDeactivationPreview", + "getApplicationCmppParams", + "getApplicationHttpApiConfig" + ] + }, + "styleFile": "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css", + "styleImport": "./enterprise-applications/AdminEnterpriseApplicationsPage.css", + "pageStyleSelectors": [ + ".admin-application-filter", + ".cmpp-status-cell", + ".enterprise-app-actions", + ".cmpp-connection-detail", + ".cmpp-connection-summary", + ".cmpp-connection-list", + ".cmpp-connection-card", + ".cmpp-param-detail", + ".cmpp-param-grid", + ".cmpp-param-copy", + ".app-create-modal" + ], + "sharedSelectorsKeptGlobal": [], + "sharedSelectorsKeptInAdmin": [ + ".admin-split-filter", + ".admin-split-filter__actions", + ".admin-confirm-text" + ], + "sharedSelectorsKeptInComponents": [ + ".template-modal-title", + ".form-grid" + ], + "sharedSelectorsKeptInShell": [ + ".section-stack" + ], + "interactionLabels": [ + "企业名称", + "企业应用名称", + "状态", + "查询", + "重置", + "添加应用", + "短信应用", + "彩信应用", + "编辑", + "停用", + "启用", + "删除", + "CMPP参数", + "HTTP参数", + "新建企业应用", + "CMPP连接详情" + ] +} diff --git a/docs/contracts/admin-enterprise-signatures-r4.json b/docs/contracts/admin-enterprise-signatures-r4.json new file mode 100644 index 0000000..7147600 --- /dev/null +++ b/docs/contracts/admin-enterprise-signatures-r4.json @@ -0,0 +1,134 @@ +{ + "version": "R4", + "source": "src/apps/admin/AdminEnterpriseSignaturesPage.tsx", + "originalLines": 885, + "movedFunctions": [ + { + "name": "carrierLabel", + "canonicalSha256": "cc074c9091c0c2198d8b38362242916c08457d2c7ecf9f0d7bb4c3a8e4912afd" + }, + { + "name": "CarrierReportTag", + "canonicalSha256": "2ee4038f46fdb4c8e9af1fdbc5f7959f38d2360e081c7dd3b44d465be3b6cfef" + }, + { + "name": "signatureCardVisual", + "canonicalSha256": "1cea544821c9c03b7c544644d5865e57810ae6f9c8d05d577ed104811ada63ec" + }, + { + "name": "AuditStatusTag", + "canonicalSha256": "1a04a56dd4de4cb1988a15648738aa3cb8d8918dfac2ff15c159b663fa8756ee" + }, + { + "name": "readDrainagePayload", + "canonicalSha256": "fa3d75286a89171511c9c4e52f4126b036128bbc91b15aedaa00360a4c7bbbcd" + }, + { + "name": "buildDrainagePayload", + "canonicalSha256": "13ee432e93b20ba3dc104bd8be3cee31ae2092dbb244d6e12ad8eba018ad2d22" + }, + { + "name": "normalizeReportValues", + "canonicalSha256": "ff050e5d4a1b2be8a606cb595a25d137bd0f08105e0f3cba8977dbb115612864" + }, + { + "name": "hasMissingRequiredReportValue", + "canonicalSha256": "609c62b77ceee37afe961926fa897af0c3e156ce589709ae657a0038a38b481d" + }, + { + "name": "normalizeUploadedFile", + "canonicalSha256": "f276b6ff6b3b9b6432ac638bd228c73923b00816e0f14dae5b43e0cb32393f08" + }, + { + "name": "normalizeCarrierStatus", + "canonicalSha256": "5e4edd6bf9cacd5bcf6aa6197855780e03ab29cd3f5279b41a0eba2125ab1317" + }, + { + "name": "formatDate", + "canonicalSha256": "fa6a491d6fa4f4e517cc9f3c07563424c9f7a8df30535274ddcbf6119c304b42" + }, + { + "name": "SignatureUploadBox", + "canonicalSha256": "1e5c4d1e82c72ffd3bc1ddbd17628c1901d5ba81b0bb3ae161bcfb291a46dd98" + }, + { + "name": "DynamicReportFields", + "canonicalSha256": "6370195b7a7afe5505003f233e31c07cbd3dac0ca55ce68b9859e83a827421f9" + }, + { + "name": "SignatureFormModal", + "canonicalSha256": "664e1cd87309c64c03f9198543171bb519d448eec4a83cf890444fee16d6dd66" + }, + { + "name": "DrainageFormModal", + "canonicalSha256": "0e63725f10e8021e0a7beb5a71f52773ce271faea137735d3fc184f07a39c6d6" + }, + { + "name": "SignatureReportModal", + "canonicalSha256": "7439104e171e7d9eeb8449f497696b6d3736b881195c0b96a1a096079ba0789b" + }, + { + "name": "ChannelReportStatusModal", + "canonicalSha256": "d32f8d7d3025ee286ed7898ed41a22404db3d684b80d23819965b3332e0e8310" + }, + { + "name": "DrainageReportModal", + "canonicalSha256": "716a2d480a4b652ee951de0306854f1d8f6d72759d7b2fbffbfbb600abc8a789" + }, + { + "name": "DrainageReportStatusModal", + "canonicalSha256": "ef692f08d2140e82e40fc12f716b15a902e3c66948140e8512f5a317c629215c" + }, + { + "name": "ConfirmModal", + "canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da" + } + ], + "tableJsxSha256": "203ec16e0ca67f0dafea08d659e84db02dd8346d06a52f41378625e39a43def7", + "apiCalls": [ + "listEnterpriseSignaturesPage", + "listTenants", + "listEnterpriseApplicationOptions", + "updateEnterpriseSignature", + "createEnterpriseSignature", + "updateDrainageInfo", + "createDrainageInfo", + "changeDrainageInfoStatus" + ], + "stateNames": [ + "activeTab", + "applications", + "deleteTarget", + "drainageModal", + "drainageReport", + "drainageStatusTarget", + "enterpriseKeyword", + "appliedEnterpriseKeyword", + "applicationKeyword", + "appliedApplicationKeyword", + "drainageKeyword", + "appliedDrainageKeyword", + "error", + "expandedSignatureId", + "signatureKeyword", + "appliedSignatureKeyword", + "signatureModal", + "signatureReport", + "reportStatusTarget", + "signatures", + "total", + "tenants", + "page", + "importOpen", + "message" + ], + "files": [ + "signature.types.ts", + "signature.helpers.tsx", + "SignatureMaterialFields.tsx", + "SignatureFormModal.tsx", + "DrainageFormModal.tsx", + "SignatureReportModals.tsx", + "EnterpriseSignaturesTable.tsx" + ] +} diff --git a/docs/contracts/admin-shared-styles-r11.json b/docs/contracts/admin-shared-styles-r11.json new file mode 100644 index 0000000..6dc418a --- /dev/null +++ b/docs/contracts/admin-shared-styles-r11.json @@ -0,0 +1,90 @@ +{ + "styleFile": "src/styles/admin.css", + "globalStyleFile": "src/styles/global.css", + "entryFile": "src/main.tsx", + "marker": "Admin-only shared page patterns migrated from global.css in R11 step 8.", + "minimumRuleCount": 117, + "minimumSelectorCount": 141, + "ownedClassPrefixes": [ + "admin-audit-", + "admin-detail-metric-", + "admin-report-filter-", + "admin-security-", + "admin-split-", + "admin-system-", + "admin-task-", + "audit-filter-", + "downstream-breakdown-", + "downstream-bucket-" + ], + "ownedExactClasses": [ + "admin-app-form-row", + "admin-app-form-row--wide", + "admin-confirm-text", + "admin-uplink-empty-match", + "audit-actions", + "channel-confirm", + "channel-field-config", + "channel-field-config-title", + "channel-field-section-head", + "channel-report-empty", + "enterprise-form-footer", + "page-inline-hint", + "report-history", + "report-record-detail", + "report-record-page", + "report-task-table-card", + "signature-alert", + "sms-audit-filter" + ], + "allowedContextClasses": [ + "mini-status-card", + "page-heading" + ], + "requiredSelectors": [ + ".audit-filter-grid", + ".audit-filter-actions", + ".admin-task-filter", + ".admin-task-table-card .ui-table", + ".admin-security-filter", + ".admin-system-toolbar", + ".admin-system-modal-form", + ".admin-report-filter-grid--profit", + ".admin-split-filter", + ".admin-confirm-text", + ".downstream-breakdown-table", + ".channel-field-config", + ".signature-alert" + ], + "usageRequirements": { + "audit-filter-actions": 7, + "admin-task-filter": 7, + "admin-confirm-text": 5, + "admin-task-table-card": 5, + "admin-system-modal-form": 4, + "admin-report-filter-grid": 3, + "admin-security-filter": 3, + "admin-split-filter": 3, + "admin-system-page": 3, + "downstream-breakdown-table": 2, + "enterprise-form-footer": 2 + }, + "responsiveRequirements": [ + { + "media": "(max-width: 780px)", + "selector": ".admin-task-filter" + }, + { + "media": "(max-width: 780px)", + "selector": ".admin-report-filter-grid" + }, + { + "media": "(max-width: 780px)", + "selector": ".audit-filter-actions" + }, + { + "media": "(max-width: 360px)", + "selector": ".audit-filter-actions" + } + ] +} diff --git a/docs/contracts/admin-sms-records-r11.json b/docs/contracts/admin-sms-records-r11.json new file mode 100644 index 0000000..f3b01c5 --- /dev/null +++ b/docs/contracts/admin-sms-records-r11.json @@ -0,0 +1,77 @@ +{ + "version": "R11", + "stableEntry": "src/apps/admin/AdminSmsRecordsPage.tsx", + "stableExport": "AdminSmsRecordsPage", + "maxStableEntryLines": 220, + "modules": { + "src/apps/admin/sms-records/SmsRecordFilter.tsx": ["SmsRecordFilter"], + "src/apps/admin/sms-records/SmsRecordList.tsx": ["SmsRecordList"], + "src/apps/admin/sms-records/SendDetailModal.tsx": ["SendDetailModal"], + "src/apps/admin/sms-records/smsRecordModel.ts": [ + "getDate", + "getTime", + "getClock", + "getRecordStatus", + "getRecordStatusLabel", + "getReceiptNotice", + "getCarrierLabel", + "buildRouteRows", + "downloadCsv", + "defaultSmsRecordDateRange" + ] + }, + "apiCalls": { + "src/apps/admin/AdminSmsRecordsPage.tsx": [ + "listOperationMessages", + "listTenants", + "listEnterpriseApplicationOptions", + "listMessageSegmentAudits", + "exportOperationMessages" + ] + }, + "styleFile": "src/apps/admin/sms-records/AdminSmsRecordsPage.css", + "styleImport": "./sms-records/AdminSmsRecordsPage.css", + "pageStyleSelectors": [ + ".admin-sms-records-page", + ".admin-sms-record-filter", + ".admin-sms-record-table-card", + ".admin-sms-record-toolbar", + ".admin-sms-record-list", + ".admin-sms-record-card", + ".admin-sms-record-status", + ".admin-sms-record-detail-link", + ".admin-sms-send-detail", + ".admin-sms-detail-overview", + ".admin-sms-detail-notice", + ".admin-sms-detail-content", + ".admin-sms-detail-status-grid", + ".admin-sms-detail-failure", + ".admin-sms-segment-list", + ".admin-sms-segment-card", + ".admin-sms-route-list" + ], + "sharedSelectorsKeptGlobal": [ + ".muted" + ], + "sharedSelectorsKeptInComponents": [ + ".template-modal-title", + ".ui-table__empty" + ], + "interactionLabels": [ + "企业", + "应用", + "提交日期", + "手机号码", + "运营商", + "短信内容", + "通道名称", + "发送状态", + "查询", + "重置", + "导出CSV", + "查看发送详情", + "发送详情", + "通道发送与回执", + "分片补偿审计" + ] +} diff --git a/docs/contracts/admin-sms-task-progress-r11.json b/docs/contracts/admin-sms-task-progress-r11.json new file mode 100644 index 0000000..d23e9d6 --- /dev/null +++ b/docs/contracts/admin-sms-task-progress-r11.json @@ -0,0 +1,74 @@ +{ + "version": "R11", + "stableEntry": "src/apps/admin/AdminSmsTaskProgressPage.tsx", + "stableExport": "AdminSmsTaskProgressPage", + "maxStableEntryLines": 220, + "modules": { + "src/apps/admin/sms-task-progress/SmsTaskFilter.tsx": ["SmsTaskFilter"], + "src/apps/admin/sms-task-progress/SmsTaskTable.tsx": ["SmsTaskTable"], + "src/apps/admin/sms-task-progress/TaskDetailModal.tsx": ["TaskDetailModal"], + "src/apps/admin/sms-task-progress/TaskPhoneListModal.tsx": ["TaskPhoneListModal"], + "src/apps/admin/sms-task-progress/TerminateTaskModal.tsx": ["TerminateTaskModal"], + "src/apps/admin/sms-task-progress/taskModel.ts": [ + "mapTask", + "getProgress", + "getSuccessRate", + "getRegionRate", + "splitSignature" + ] + }, + "apiCalls": { + "src/apps/admin/AdminSmsTaskProgressPage.tsx": [ + "listAdminBatchTasksPage", + "listTenants", + "listEnterpriseApplicationOptions", + "terminateAdminBatchTask" + ], + "src/apps/admin/sms-task-progress/TaskPhoneListModal.tsx": [ + "listAdminBatchTaskMessages" + ] + }, + "styleFile": "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css", + "styleImport": "./sms-task-progress/AdminSmsTaskProgressPage.css", + "pageStyleSelectors": [ + ".admin-sms-task-page", + ".admin-task-template-row", + ".admin-task-detail-title", + ".admin-task-detail", + ".admin-task-metrics", + ".admin-task-metric", + ".admin-task-info-list", + ".admin-task-progress-card", + ".admin-task-progress-split", + ".admin-task-template-block", + ".admin-task-template", + ".admin-task-billing-note", + ".admin-task-card--full", + ".admin-carrier-grid", + ".admin-carrier-card", + ".admin-success-text" + ], + "sharedSelectorsKeptGlobal": [ + ".batch-progress" + ], + "sharedSelectorsKeptInAdmin": [ + ".admin-task-filter", + ".admin-task-table-card", + ".admin-task-id", + ".admin-task-enterprise", + ".admin-task-card" + ], + "interactionLabels": [ + "发送批次号", + "选择企业", + "选择应用", + "提交时间", + "查询", + "重置", + "查看列表", + "详情", + "终止", + "发送批次详情", + "号码列表" + ] +} diff --git a/docs/contracts/app-shell-styles-r11.json b/docs/contracts/app-shell-styles-r11.json new file mode 100644 index 0000000..eed31c4 --- /dev/null +++ b/docs/contracts/app-shell-styles-r11.json @@ -0,0 +1,130 @@ +{ + "version": "R11-step-6", + "entry": "src/main.tsx", + "component": "src/layouts/AppShell.tsx", + "shellFile": "src/styles/shell.css", + "legacyFiles": [ + "src/styles/global.css", + "src/styles/components.css" + ], + "importAfter": "@/styles/reset.css", + "importBefore": "@/styles/global.css", + "minimumRuleCount": 118, + "shellClasses": [ + "app-shell", + "app-shell--collapsed", + "app-shell--mobile-nav-open", + "sidebar", + "sidebar--mobile-open", + "sidebar-brand-row", + "mobile-nav-close", + "mobile-nav-toggle", + "mobile-nav-backdrop", + "mobile-topbar-brand", + "brand-block", + "brand-logo", + "brand-logo--full", + "brand-logo--compact", + "side-nav", + "side-nav-section", + "side-nav-group-toggle", + "side-nav-group-label", + "side-nav-group-chevron", + "side-nav-list", + "side-nav-list--closed", + "side-nav-label", + "side-nav-label-text", + "dev-status-badge", + "side-nav-pending-badge", + "sidebar-footer", + "main-area", + "topbar", + "topbar-left", + "topbar-actions", + "desktop-nav-toggle", + "topbar-help", + "notice-menu-wrap", + "notice-count", + "notice-popover", + "notice-popover__header", + "user-menu", + "user-menu-wrap", + "user-menu-popover", + "user-avatar", + "page-content" + ], + "legacyShellClasses": [ + "brand-copy", + "brand-mark", + "brand-subtitle" + ], + "sharedLayoutSelectors": [ + ".page-content", + ".page-stack", + ".page-heading", + ".page-heading > p", + ".page-heading__actions", + ".page-actions", + ".surface", + ".section-stack", + ".section-heading" + ], + "requiredMediaQueries": [ + "(min-width: 781px)", + "(max-width: 780px)", + "(prefers-reduced-motion: reduce)" + ], + "requiredRuleFragments": [ + { + "selector": ".app-shell", + "includes": [ + "display:grid", + "grid-template-columns:var(--sidebar-width) minmax(0, 1fr)", + "overflow:hidden" + ] + }, + { + "selector": ".app-shell--collapsed", + "includes": [ + "grid-template-columns:76px minmax(0, 1fr)" + ] + }, + { + "selector": ".sidebar", + "media": "(max-width: 780px)", + "includes": [ + "position:fixed", + "transform:translateX(-105%)" + ] + }, + { + "selector": ".sidebar--mobile-open", + "media": "(max-width: 780px)", + "includes": [ + "transform:translateX(0)" + ] + }, + { + "selector": ".mobile-nav-backdrop", + "media": "(max-width: 780px)", + "includes": [ + "display:block", + "position:fixed" + ] + }, + { + "selector": ".page-content", + "includes": [ + "overflow-y:auto", + "width:100%" + ] + }, + { + "selector": ".sidebar", + "media": "(prefers-reduced-motion: reduce)", + "includes": [ + "transition:none" + ] + } + ] +} diff --git a/docs/contracts/channels-r5-methods.json b/docs/contracts/channels-r5-methods.json new file mode 100644 index 0000000..069a5ef --- /dev/null +++ b/docs/contracts/channels-r5-methods.json @@ -0,0 +1,928 @@ +{ + "version": "R5", + "source": "api/src/channels/channels.service.ts", + "generatedAt": "2026-07-31T02:40:10.189Z", + "publicMethods": [ + { + "name": "onModuleInit", + "signature": "onModuleInit()", + "canonicalBodySha256": "2a9debc88139a1a61f69060867abe7b4fcaf340ea3b40a19db9d4ae5aee7cfc2", + "originalLines": [ + 210, + 231 + ], + "domain": "connection" + }, + { + "name": "onModuleDestroy", + "signature": "async onModuleDestroy()", + "canonicalBodySha256": "9f72cbd51df2e3df22e08c15928e3382767eadfe9685e1db7dee2ed8a6d97155", + "originalLines": [ + 233, + 246 + ], + "domain": "connection" + }, + { + "name": "listChannels", + "signature": "listChannels()", + "canonicalBodySha256": "7ec25cd31ee4f261804a41a8781987abbfe246ac2118804b45efdef526c947b4", + "originalLines": [ + 248, + 253 + ], + "domain": "configuration" + }, + { + "name": "listChannelsPage", + "signature": "async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number })", + "canonicalBodySha256": "6b82d07ae76c4bfd979dbb319c64b8d8e0388df79e817b844d1e2700af7cc6be", + "originalLines": [ + 255, + 274 + ], + "domain": "configuration" + }, + { + "name": "createChannel", + "signature": "async createChannel(data: CreateChannelDto)", + "canonicalBodySha256": "eaee4eb581cb0891525f84c6912059785fd26cfff923e1973186f876b1e10745", + "originalLines": [ + 276, + 323 + ], + "domain": "configuration" + }, + { + "name": "updateChannel", + "signature": "async updateChannel(channelId: string, data: UpdateChannelDto)", + "canonicalBodySha256": "72eae5cdfca3c2563a6fa3f727056f582642ce3d6a8e35f24a47b1c1e53836d6", + "originalLines": [ + 325, + 413 + ], + "domain": "configuration" + }, + { + "name": "changeChannelStatus", + "signature": "async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto)", + "canonicalBodySha256": "14ef7429e822ff520674699b449571424d9f7b0a13ce37a6c6fa9116449ac6d2", + "originalLines": [ + 415, + 444 + ], + "domain": "configuration" + }, + { + "name": "copyChannel", + "signature": "async copyChannel(channelId: string, data: CopyChannelDto = {})", + "canonicalBodySha256": "bdabf01a200cc5da9f9f17aee7df2fe0781bf79c36653cca9ddb8f84c7c63ed7", + "originalLines": [ + 446, + 530 + ], + "domain": "copy" + }, + { + "name": "deleteChannel", + "signature": "async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' })", + "canonicalBodySha256": "1534ae0c91a6d12a97724d2d3f55b3e48469ad244e855be42aa93207be970d17", + "originalLines": [ + 532, + 534 + ], + "domain": "deletion" + }, + { + "name": "testChannel", + "signature": "async testChannel(channelId: string, data: TestChannelDto = {})", + "canonicalBodySha256": "e01b10f3981c66c5fab6cfd6d1b886abbb45e1fe53852b57c0903fcd826815d3", + "originalLines": [ + 536, + 636 + ], + "domain": "testing" + }, + { + "name": "listChannelMetrics", + "signature": "listChannelMetrics(channelId: string)", + "canonicalBodySha256": "bbe1b9191967c8eb13c413642786287382acbec93307d06ff843be1b9a163bcc", + "originalLines": [ + 638, + 644 + ], + "domain": "connection" + }, + { + "name": "listChannelConnections", + "signature": "listChannelConnections(channelId: string)", + "canonicalBodySha256": "07c644f24792d3c529daf39df4ac6d747e039c282d99d0f3cfffa8858a7ccf91", + "originalLines": [ + 646, + 651 + ], + "domain": "connection" + }, + { + "name": "listChannelConnectionLogs", + "signature": "async listChannelConnectionLogs(channelId: string)", + "canonicalBodySha256": "c01c6680974dc56813829bd6d47665e5be553ac6298f9207d19c951e4c540766", + "originalLines": [ + 653, + 687 + ], + "domain": "connection" + }, + { + "name": "listTenantConnections", + "signature": "listTenantConnections(tenantId: string)", + "canonicalBodySha256": "6aef43831f27c2ae7818dc449aadbaa7f521a6db54b78623b02d6bd0fc0a8810", + "originalLines": [ + 689, + 695 + ], + "domain": "connection" + }, + { + "name": "upsertConnectionState", + "signature": "async upsertConnectionState(data: UpsertConnectionStateDto)", + "canonicalBodySha256": "ce612114ed0abbf6653d11387d66c75c56fda22b8ceac7cdc3aece53cc999a8c", + "originalLines": [ + 697, + 786 + ], + "domain": "connection" + }, + { + "name": "markTimedOutConnectingChannels", + "signature": "async markTimedOutConnectingChannels(now = new Date())", + "canonicalBodySha256": "dbb713b158fa24088b5248a76eca5615c8a7fd3f89074d9627a6545622f55b70", + "originalLines": [ + 788, + 851 + ], + "domain": "connection" + }, + { + "name": "listGroups", + "signature": "listGroups()", + "canonicalBodySha256": "fc4ae9bd701db6f55108aa057f42a56b283901b3401903edb5464058d5a96374", + "originalLines": [ + 853, + 858 + ], + "domain": "groups" + }, + { + "name": "createGroup", + "signature": "createGroup(data: CreateChannelGroupDto)", + "canonicalBodySha256": "b5f50729da5cd843ba1a3ab5cd8ccc684d1baa06556610a030d91906dcf5d931", + "originalLines": [ + 860, + 875 + ], + "domain": "groups" + }, + { + "name": "addGroupItem", + "signature": "async addGroupItem(data: CreateChannelGroupItemDto)", + "canonicalBodySha256": "f34ae9a769d40a2ef7ccb6fab53d4a276190c71c2bee5084b1020a9bef85375a", + "originalLines": [ + 877, + 929 + ], + "domain": "groups" + }, + { + "name": "updateGroup", + "signature": "async updateGroup(groupId: string, data: UpdateChannelGroupDto)", + "canonicalBodySha256": "0a74b719119142424363c7b167f3b25daae018dbece386d863a4a0fcb7614645", + "originalLines": [ + 931, + 996 + ], + "domain": "groups" + }, + { + "name": "deleteGroup", + "signature": "async deleteGroup(groupId: string)", + "canonicalBodySha256": "ca89f1d6861fa4a5808e1d3e6a21062223faeab172246d5615d8447672105800", + "originalLines": [ + 998, + 1015 + ], + "domain": "groups" + }, + { + "name": "listRouteRules", + "signature": "listRouteRules()", + "canonicalBodySha256": "f4a905f76ab67b5ca8d66692b45ef17a2eaa8575329c871d40ec79a6d4b8a4ce", + "originalLines": [ + 1017, + 1022 + ], + "domain": "groups" + }, + { + "name": "createRouteRule", + "signature": "async createRouteRule(data: CreateRouteRuleDto)", + "canonicalBodySha256": "89d6bfc18ff419ac2a4e5eec92a2626e5c0d6e37728babe67a73924e5fcb0051", + "originalLines": [ + 1024, + 1057 + ], + "domain": "groups" + }, + { + "name": "listReportFields", + "signature": "listReportFields(channelId?: string)", + "canonicalBodySha256": "da3b95085c6d58739d4deda5ee7c0835fe181b84aba3473794a88653a6eb2def", + "originalLines": [ + 1059, + 1065 + ], + "domain": "reporting" + }, + { + "name": "createReportField", + "signature": "async createReportField(data: CreateReportFieldDto)", + "canonicalBodySha256": "c5c854beda64f87d1237d46a9d9f06ba98366edddb0a28ad1bbec7a92fc20f06", + "originalLines": [ + 1067, + 1094 + ], + "domain": "reporting" + }, + { + "name": "replaceReportFields", + "signature": "async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto)", + "canonicalBodySha256": "fb1bc89d864d2423216d835e787821d4de5c2f6550573b8b20fdd966e5d41a5a", + "originalLines": [ + 1096, + 1142 + ], + "domain": "reporting" + }, + { + "name": "listReportMaterials", + "signature": "listReportMaterials(signatureId?: string, channelId?: string)", + "canonicalBodySha256": "ff2661b65e206d3a79966f1c921f56b72c3fd8bd08d49a0a8bedff088ee5539e", + "originalLines": [ + 1144, + 1152 + ], + "domain": "reporting" + }, + { + "name": "upsertReportMaterial", + "signature": "upsertReportMaterial(data: CreateReportMaterialDto)", + "canonicalBodySha256": "60704c21da36c28866e8ba12e4d3e8684785d87f34618710995525dbd77eaeb6", + "originalLines": [ + 1154, + 1175 + ], + "domain": "reporting" + }, + { + "name": "listReportTasks", + "signature": "async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string)", + "canonicalBodySha256": "a1374204cf233ff5389e27491674d89f946b184b9bb3722dd5fd1ed74073d21d", + "originalLines": [ + 1177, + 1303 + ], + "domain": "reporting" + }, + { + "name": "listReportTasksPage", + "signature": "async listReportTasksPage(query: {\n tenantId?: string;\n status?: string;\n channelId?: string;\n reportType?: string;\n keyword?: string;\n createdAtFrom?: string;\n createdAtTo?: string;\n page?: number;\n pageSize?: number;\n })", + "canonicalBodySha256": "70ab547809d13c2c0a64898a5937444d819c1945ca1346e973059a8036e5a915", + "originalLines": [ + 1305, + 1360 + ], + "domain": "reporting" + }, + { + "name": "createReportTask", + "signature": "async createReportTask(data: CreateReportTaskDto)", + "canonicalBodySha256": "0aca4b72828ddae3707cc1f12cf9a485868047195140783f269a70a0beb2286d", + "originalLines": [ + 1362, + 1384 + ], + "domain": "reporting" + }, + { + "name": "changeReportTaskStatuses", + "signature": "async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto)", + "canonicalBodySha256": "8427ebc2fa8246481b4232a45b647d28409a26d1a208176856b61690ae21da79", + "originalLines": [ + 1386, + 1422 + ], + "domain": "reporting" + }, + { + "name": "createReportExport", + "signature": "async createReportExport(taskId: string, data: CreateReportExportDto)", + "canonicalBodySha256": "148dd68546b615108fec4ae54d00a435175adb7caa7ef82a3f98ebb6cc3016d8", + "originalLines": [ + 1447, + 1459 + ], + "domain": "reporting" + }, + { + "name": "importReportReceipt", + "signature": "async importReportReceipt(taskId: string, data: CreateReceiptImportDto)", + "canonicalBodySha256": "47c55771a6f8cdc9ff23661d254521dd29905bb717562cbae9d643630d224a6d", + "originalLines": [ + 1461, + 1485 + ], + "domain": "reporting" + }, + { + "name": "listReportRecords", + "signature": "listReportRecords(taskId?: string, channelId?: string)", + "canonicalBodySha256": "0dcea8c84fff26589de04fbd04a60913c46ab76a34f553d083db55525c327ffd", + "originalLines": [ + 1487, + 1493 + ], + "domain": "reporting" + }, + { + "name": "listReportRecordsPage", + "signature": "async listReportRecordsPage(query: {\n taskId?: string;\n channelId?: string;\n keyword?: string;\n reportType?: string;\n createdAtFrom?: string;\n createdAtTo?: string;\n page?: number;\n pageSize?: number;\n })", + "canonicalBodySha256": "10516c3980113b0904b316e160cd1036dfd268cf8b16baf6bbfd791f4cebed32", + "originalLines": [ + 1495, + 1537 + ], + "domain": "reporting" + }, + { + "name": "reconcileGatewayConnections", + "signature": "async reconcileGatewayConnections(now = new Date())", + "canonicalBodySha256": "95751f1fd97cd5991063a7a41e835e088df9754d6b735a3f1c796c2055d2879f", + "originalLines": [ + 1726, + 1773 + ], + "domain": "connection" + } + ], + "internalMethods": [ + { + "name": "recomputeSignatureReportSummary", + "signature": "private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string)", + "canonicalBodySha256": "ced499f221e3132b85ed3152dda8d890a062bd6fbf4287db7d78410e03f746cf", + "originalLines": [ + 1424, + 1445 + ], + "domain": "reporting" + }, + { + "name": "getReportTaskOrThrow", + "signature": "private async getReportTaskOrThrow(taskId: string)", + "canonicalBodySha256": "360c3d76c6b882765f4870ed5f56fdc3f09477d887f866d67876c2f64f504359", + "originalLines": [ + 1539, + 1548 + ], + "domain": "reporting" + }, + { + "name": "updateReportTaskStatus", + "signature": "private async updateReportTaskStatus(\n taskId: string,\n channelId: string,\n statusBefore: string,\n statusAfter: string,\n action: string,\n reason?: string,\n )", + "canonicalBodySha256": "abcfd90557ef6c3ec91ac3ed43ff0d267520f7fc12c5e6a43c4d57545acdb692", + "originalLines": [ + 1550, + 1563 + ], + "domain": "reporting" + }, + { + "name": "recordReportTask", + "signature": "private recordReportTask(\n taskId: string,\n channelId: string,\n action: string,\n statusBefore: string | undefined,\n statusAfter: string,\n reason?: string,\n )", + "canonicalBodySha256": "2128e08b3e3d52e396e0c58350b9d76e40d02e34e2f60bc75e413eca87f62a57", + "originalLines": [ + 1565, + 1583 + ], + "domain": "reporting" + }, + { + "name": "requestChannelConnection", + "signature": "private async requestChannelConnection(\n channel: {\n id: string;\n code: string;\n name: string;\n gatewayHost: string;\n gatewayPort: number;\n account: string;\n passwordCipher: string;\n srcId: string;\n cmppVersion: string;\n rateLimitPerSecond: number;\n config?: Prisma.JsonValue | null;\n },\n reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect',\n operatorId?: string,\n )", + "canonicalBodySha256": "df68d72a118a88e4ace78823e95d8af96079ccd03b756889c49d16adedb7a579", + "originalLines": [ + 1585, + 1709 + ], + "domain": "connection" + }, + { + "name": "reconnectActiveChannelsAfterGatewayRestart", + "signature": "private async reconnectActiveChannelsAfterGatewayRestart()", + "canonicalBodySha256": "e288503945449483bbd2120c0c36eff8b157c7045efe6fce9331ffba354dd958", + "originalLines": [ + 1711, + 1724 + ], + "domain": "connection" + }, + { + "name": "withGatewayReconcileLock", + "signature": "private async withGatewayReconcileLock(channelId: string, action: () => Promise)", + "canonicalBodySha256": "8bbade14b639897f686c7cc3d4de32917b0a3525fbc27f08a44872c4824a3384", + "originalLines": [ + 1775, + 1793 + ], + "domain": "connection" + }, + { + "name": "requestChannelDisconnection", + "signature": "private async requestChannelDisconnection(\n channel: { id: string },\n reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile',\n operatorId?: string,\n )", + "canonicalBodySha256": "02cfac1dc9953dfdbd95d63abd006f0e9b14be75a088a81c3773067a8719c92e", + "originalLines": [ + 1795, + 1852 + ], + "domain": "connection" + }, + { + "name": "getGatewayConnectionQueue", + "signature": "private getGatewayConnectionQueue()", + "canonicalBodySha256": "eb40aa7810cd4a26571eddfd2f21d7f2440b7e39fdb0657214ded056d8e286a3", + "originalLines": [ + 1854, + 1857 + ], + "domain": "connection" + }, + { + "name": "getGatewaySubmitQueue", + "signature": "private getGatewaySubmitQueue()", + "canonicalBodySha256": "d00641e15e3db84893cd302c767d6ac80a13e2ca3da5bc3d88f665fe3ffcacea", + "originalLines": [ + 1859, + 1862 + ], + "domain": "connection" + }, + { + "name": "getRedis", + "signature": "private getRedis()", + "canonicalBodySha256": "80f3524ea6442ba63f979b8daf0c19adbcec58a179e1deda3ce7fd3c7923cf62", + "originalLines": [ + 1864, + 1871 + ], + "domain": "connection" + }, + { + "name": "publishGatewaySubmitCommand", + "signature": "private async publishGatewaySubmitCommand(command: unknown)", + "canonicalBodySha256": "b6d027c12ca7d01a42a3ec81384b77fb07b16297ece386f9021e65d2148c4efc", + "originalLines": [ + 1873, + 1882 + ], + "domain": "connection" + }, + { + "name": "notifyGatewayConnect", + "signature": "private async notifyGatewayConnect(command: Record)", + "canonicalBodySha256": "edc028c9fcb834e24cc9bc4fbc6aada5daff70674981cf5078d9db8631f007db", + "originalLines": [ + 1884, + 1901 + ], + "domain": "connection" + }, + { + "name": "notifyGatewayDisconnect", + "signature": "private async notifyGatewayDisconnect(command: Record)", + "canonicalBodySha256": "8422aaf771157bcdd001c6b742eee437cb33c660872424845885c5850de1aaab", + "originalLines": [ + 1903, + 1920 + ], + "domain": "connection" + } + ], + "contracts": [ + { + "name": "CreateChannelDto", + "sha256": "5de2dff2a196fd99894e03223e169b87b8a9a8d5251c9fd5f700334c3cebbe00" + }, + { + "name": "UpdateChannelDto", + "sha256": "63a98a44dac414fbb8063794d243f823b77dd63c213e677e7c3eb1c22fa92530" + }, + { + "name": "CreateChannelGroupDto", + "sha256": "2d6c30f269ba7bf0c3d15d1a77175900edfad4184d4d469d0db0733878d4fd83" + }, + { + "name": "CreateChannelGroupItemDto", + "sha256": "1a03b65c71ac8f52b84c87c599413bc747e8bb5402ecd23a91ad0ad1f513c2cb" + }, + { + "name": "UpdateChannelGroupDto", + "sha256": "16c4ffdbd94c367e2932bee995d0b2c5357b57617ca2d48556efe896cd68e006" + }, + { + "name": "CreateRouteRuleDto", + "sha256": "c0d24d5c1a266749098a7cecde488262dbebf9131429f018ec4bc05815b8c79d" + }, + { + "name": "CreateReportFieldDto", + "sha256": "dff3ea95e4a5cfd9f880fa1ee8b5399452b3d4ac511e18ec5805f49856a061ef" + }, + { + "name": "ReplaceReportFieldsDto", + "sha256": "3a8f0ca7d9f9113ad4612b50010d4138912b06851e84311a2d3f8b3f6c1b0adb" + }, + { + "name": "CreateReportMaterialDto", + "sha256": "15a83e490bfefbc4c84cc0d70c9b18ab674ce2768577b0b3e7b19fa9fb40f3ac" + }, + { + "name": "CreateReportTaskDto", + "sha256": "5ea18848e89c44f8ad9450af79f97a2cff7e3168fa62d23c6a881fbc61cea62b" + }, + { + "name": "ChangeReportTaskStatusesDto", + "sha256": "028e467f76f796987e232b73d63f3d6babc6c15d7534f1cd4ceb5a67194107e4" + }, + { + "name": "CreateReportExportDto", + "sha256": "e4ba2142b291c1939990c81b66dcef0cdc2747765c3c06a7a75becd1e4e08526" + }, + { + "name": "CreateReceiptImportDto", + "sha256": "1c2a09ae37fba9adb03e5d6860757713d174eaed83b04aa2252d224d643e22cc" + }, + { + "name": "UpsertConnectionStateDto", + "sha256": "432a9bdd2e0e9bcba7f9ae82c1bb45399881ada6faca3f8541dc4f43c3f58c1d" + }, + { + "name": "ChangeChannelStatusDto", + "sha256": "61a8eab26aaf370ff6ceecc4d89810079857945386a1fdb3a10c99fd15d2c2fa" + }, + { + "name": "CopyChannelDto", + "sha256": "9f0bbacd18c38a623dc3a2fc761506e91603f4a1b720a24000e76eff6e493039" + }, + { + "name": "TestChannelDto", + "sha256": "fd1ed651d6a233fa044c4f5f2cd3e50aa046072ab6f39d810d89f15fead09820" + } + ], + "helpers": [ + { + "name": "normalizeTestPhones", + "sha256": "2f989fb549ddb0bf44ecc7912e0fd5a9b3c7fd8c99e5b7db4e8b97a11585d2b4" + }, + { + "name": "normalizeTestContent", + "sha256": "c6aad6035f9afbe795cc1ba17b438d2ad5659fbe4b61900e8aa03ab29520e91e" + }, + { + "name": "calculateBillingUnits", + "sha256": "a8e1449d3781c1bee1adfdeb2df5c2fff066990ca8260d827c8dfd2eb833452a" + }, + { + "name": "buildChannelTestSubmitCommand", + "sha256": "d16842aed45059d881e4b844a5ec66b725fb9419cec4bcdc4fe3831cc5f49a2a" + }, + { + "name": "getConfigValue", + "sha256": "6b5419962b5f1bc918a3d39f3d95a199bb663fd54372981090c51119cf49d1b0" + }, + { + "name": "getStringConfigValue", + "sha256": "35b39e21df3d416c25682e29eda6d0e4626c35beae73e1fb47f77cf1bf9ef2ed" + }, + { + "name": "normalizeConnectionAction", + "sha256": "8d0b596a85db2cb1816f909d180c390f10162f3d10d2cd16ce8eb7424ead399c" + }, + { + "name": "normalizeCmppVersion", + "sha256": "220a9fbda9aeccab9c7331ee118d0f31fc1fca5bf0015e25c849e79d8bb12985" + }, + { + "name": "normalizeGatewayConnectionStatus", + "sha256": "bf81060a9ddd788079bb1e22d3ffa64321a2df18c5fa902b20cdf1298cb40781" + }, + { + "name": "defaultChannelConnectionId", + "sha256": "2ef3e882c598f1e5c003302f4f6143d67c151907b65af6f3eff06b1e89c91a3f" + }, + { + "name": "getDesiredConnections", + "sha256": "d6e3f7b0134f0b94cc59c4b7ef117932980e5d4f86f9f9df6e7e63729232aee6" + }, + { + "name": "ChannelConnectionSettings", + "sha256": "906b57eec65a23d27994c9e8130108cc95c4d4b1f169f05e61a39f76cb885abd" + }, + { + "name": "getRuntimeConfigInteger", + "sha256": "0e595766faa70ba9a33716c10d7c7478f87d0543fc4c7e2b86c95bc5df30221d" + }, + { + "name": "channelConnectionSettingsChanged", + "sha256": "1d9371059cc95c3080577fd9e7c71ba50b16f7ee3e01b70c486c61bb056c89e3" + }, + { + "name": "channelGroupAuditSnapshot", + "sha256": "8ac6c3de6cf9f843b4635e8fb3ff89691b9b0762febe26a3887a5f17fa9de5b6" + }, + { + "name": "normalizeChannelRuntimeConfig", + "sha256": "a979b4bcb5e7b8d1acfb0b99196a9c8dec7f834116140af170113b25da7d14d5" + }, + { + "name": "normalizeCmppServiceId", + "sha256": "e1f620b4df0283aa08421ebe05c25c0cc1407edc105d54df27f655122423debf" + }, + { + "name": "normalizeChannelRateLimit", + "sha256": "897aca7bc66a023e1d4ca2cf1ab5d3d9660128432b4726f88c878da2f55c5098" + }, + { + "name": "normalizeExtensionDigits", + "sha256": "91e1526daad821cddc886cf0f8fe580aa8f7b8beeaef2be836d7c2ebd57eb96d" + }, + { + "name": "getPositiveRuntimeInteger", + "sha256": "b2aff43304863e222db1788ee6177a3a8b14b0c5c3097b1ff3fda98e71fd50a2" + }, + { + "name": "bullmqConnection", + "sha256": "1fe9095c9ed383419f640913f033ff0715d4e241c8114543097a51dbf1acaf67" + }, + { + "name": "getPositiveIntegerEnv", + "sha256": "db5d7ea71b1dc0e9bb72724c533893117f27c689ce485b180a2409ae6a3eed2c" + }, + { + "name": "parseReceiptContent", + "sha256": "068290aef6af4376b6f0ed012914716296dfc1a43d10f447b0939b8aea9a7ec8" + }, + { + "name": "splitReceiptLine", + "sha256": "29768fe9359e8dfc2e280743d3a5eaa0374aaf311eccd3c4ede6ef4e9bf35299" + }, + { + "name": "stripReceiptCell", + "sha256": "bc29e649d5381c1c0c060102cd3e421114f32475e7156260b915a9984acb5369" + }, + { + "name": "findReceiptStatusIndex", + "sha256": "cdd4244f0265164ac134f1645e603d112dde73100975e6438c71b11b3dfa40e0" + }, + { + "name": "normalizeReceiptStatus", + "sha256": "b92ba14156d4e61722d2539e9c6b16ac29f920799fcdd4fcb917feb135394375" + }, + { + "name": "deriveReceiptStatus", + "sha256": "465c39b82cd2cb9ea99b399f7290dfec8859172c8ffb6ad09683656f058b0847" + }, + { + "name": "ChannelReportDeliveryRow", + "sha256": "216600c3398717f2ad200d9352cd64920593d8a07f6127b528eda9daf9404f3c" + }, + { + "name": "summarizeChannelReportDelivery", + "sha256": "411701471d807f3ee01f6894016cb61b03c88c8018bc15a8465ca8296005b9bf" + }, + { + "name": "sumReportDelivery", + "sha256": "73583b6176cbcd2f4625332a852c458bacaf0e3ebf87c5a391739176a612db9f" + }, + { + "name": "percentage", + "sha256": "0745594434cceab4d6e2c4098d996dd338b7406c1506b7fc01edc4fdf488b404" + }, + { + "name": "latestDate", + "sha256": "6686352cea283be461c9b15e76d83725c501397ccb6fbb739f86b05ddd9eec43" + }, + { + "name": "currentShanghaiDayRange", + "sha256": "2684fcc9f35792427c24ad6044b491f8a24ca717ad963e662698983910da1ce3" + }, + { + "name": "normalizeRetryTimeLimitMinutes", + "sha256": "68869e767b9820f076a41bb347bcd93e8245f7e6d8be351bfe17378b0d9b83a3" + }, + { + "name": "normalizeSpreadsheetSize", + "sha256": "bcf7f1849728cf246e41561df3239b5327ce5926c49da43c014337c6bfa4090a" + }, + { + "name": "normalizeBusinessCarrier", + "sha256": "44f10156d5d0da501a56b477ea44c143f41f330fa49d48272bc993b1e698cfa6" + }, + { + "name": "normalizeChannelCarrier", + "sha256": "eb8e4e1b7c192aa3b2cb57586c7415207c3531d31614d8608a5f80e5c64e77d4" + }, + { + "name": "isChannelCarrierCompatible", + "sha256": "35350532169cf227d4222dc48bf0c0b232c1bd74b13a139d8cffd0564a7738f5" + }, + { + "name": "normalizeRegion", + "sha256": "3775eb50779ce87f8982ce691b18f91d8ed145a5dc9c27c8a315b0f55cb09152" + }, + { + "name": "isRegionCompatible", + "sha256": "bbe33cc0c45cb58dbd5ee2daf241df6485abe52713dba185ee4493462b00e1de" + }, + { + "name": "validateGroupItems", + "sha256": "eaf0f6e54c38cbcc52fa8c970fe1d6f7729e3bb12b2366dca89c93da8088bd2c" + }, + { + "name": "normalizeReportType", + "sha256": "22fbaaa9756d56f0665b2bd8571c3bea1c84144a8dc81855e982c757217a920a" + }, + { + "name": "summarizeReportStatuses", + "sha256": "ca9c3f0eece4b9e04f30cbc317041c52d63dcbb74ef1f87021dae5b20ca154b0" + }, + { + "name": "normalizeLinkEvent", + "sha256": "4843d9bd396f02c8e5dcca4eb4b7abaefe422b4caead5aa23ca331bc41f47376" + } + ], + "sharedDeclarations": [ + { + "name": "GATEWAY_CONNECTION_QUEUE", + "sha256": "e76ea45c5057f6597dc25a8b94a34c43a38f2d189577ba8fa04285d5a1f4902f" + }, + { + "name": "GATEWAY_SUBMIT_QUEUE", + "sha256": "c69e4af1b3939a576aa63833947b10b86b87a646b373684d9726dfbaf4c95d8c" + }, + { + "name": "GATEWAY_SUBMIT_STREAM", + "sha256": "70d662152ad15ff017e1f61aca86525554c5b6a83f97686529dca1a0eaf28849" + }, + { + "name": "DEFAULT_GATEWAY_CONTROL_URL", + "sha256": "e4b9884f5435352e666ee82acd2073917c0aea9215cb25c0ef18f1ca2cc1c88e" + }, + { + "name": "DEFAULT_CHANNEL_CONNECTION_ID", + "sha256": "26a18e1d0aaea4a67b919a10ea8e847ea14851a0106d153f545c22c124e41098" + }, + { + "name": "DEFAULT_CONNECTING_TIMEOUT_MS", + "sha256": "50748837da843f79079acee716b39498605a3129285f9e4d48576a05cb7180ad" + }, + { + "name": "DEFAULT_CONNECTING_TIMEOUT_SCAN_MS", + "sha256": "80bc3b12df7aef1b067931d0ed1cd07b9bb56f675543ed03524c51bf6ca64fc8" + }, + { + "name": "DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS", + "sha256": "1b0a0499b6970c99be17ce4e4384e83eaf81b8c3323839e769c5f1a7f6ef6f93" + }, + { + "name": "DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS", + "sha256": "a02067c10cce5264097823109e2cdd5a24f4d8a592d4b845f4180506c8879358" + }, + { + "name": "DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS", + "sha256": "c7cb65a45b9e3e12bedd579190ca0a9b5a07dc74e314ede6874417dba1f51125" + }, + { + "name": "DEFAULT_HEARTBEAT_INTERVAL_SECONDS", + "sha256": "7b96e3a74159fe87eac349f0602d33c9982e1785c89aaec7ec72d5966c3cc449" + }, + { + "name": "DEFAULT_HEARTBEAT_MISS_THRESHOLD", + "sha256": "33d0d1bfbcafb1811d3a521c68d7dfff34c969314fa132badad9576030b2875d" + }, + { + "name": "HEARTBEAT_AUDIT_INTERVAL_MS", + "sha256": "ed7d05fb517bf7ffe6dc5d16af7aa61dc70e6e69ef216706f013b783dfcc03aa" + }, + { + "name": "CONNECTING_TIMEOUT_ERROR", + "sha256": "6b6c5057edff678a5f3d7fef7f5764354ee4b9e80cc9130de25e1baf5e50f33d" + }, + { + "name": "DEFAULT_CMPP_VERSION", + "sha256": "e2c460ac8b6427fa3874e3763c9ba98e5a13d9e14b039be1fccd3d4ae828d358" + } + ], + "domains": { + "configuration": { + "className": "ChannelConfigurationService", + "file": "api/src/channels/channel-configuration.service.ts", + "methods": [ + "listChannels", + "listChannelsPage", + "createChannel", + "updateChannel", + "changeChannelStatus" + ] + }, + "connection": { + "className": "ChannelConnectionService", + "file": "api/src/channels/channel-connection.service.ts", + "methods": [ + "onModuleInit", + "onModuleDestroy", + "listChannelMetrics", + "listChannelConnections", + "listChannelConnectionLogs", + "listTenantConnections", + "upsertConnectionState", + "markTimedOutConnectingChannels", + "requestChannelConnection", + "reconnectActiveChannelsAfterGatewayRestart", + "reconcileGatewayConnections", + "withGatewayReconcileLock", + "requestChannelDisconnection", + "getGatewayConnectionQueue", + "getGatewaySubmitQueue", + "getRedis", + "publishGatewaySubmitCommand", + "notifyGatewayConnect", + "notifyGatewayDisconnect" + ] + }, + "testing": { + "className": "ChannelTestService", + "file": "api/src/channels/channel-test.service.ts", + "methods": [ + "testChannel" + ] + }, + "groups": { + "className": "ChannelGroupRoutingService", + "file": "api/src/channels/channel-group-routing.service.ts", + "methods": [ + "listGroups", + "createGroup", + "addGroupItem", + "updateGroup", + "deleteGroup", + "listRouteRules", + "createRouteRule" + ] + }, + "reporting": { + "className": "ChannelReportingService", + "file": "api/src/channels/channel-reporting.service.ts", + "methods": [ + "listReportFields", + "createReportField", + "replaceReportFields", + "listReportMaterials", + "upsertReportMaterial", + "listReportTasks", + "listReportTasksPage", + "createReportTask", + "changeReportTaskStatuses", + "recomputeSignatureReportSummary", + "createReportExport", + "importReportReceipt", + "listReportRecords", + "listReportRecordsPage", + "getReportTaskOrThrow", + "updateReportTaskStatus", + "recordReportTask" + ] + }, + "copy": { + "className": "ChannelCopyService", + "file": "api/src/channels/channel-copy.service.ts", + "methods": [ + "copyChannel" + ] + }, + "deletion": { + "className": "ChannelDeletionService", + "file": "api/src/channels/channel-deletion.service.ts", + "methods": [ + "deleteChannel" + ] + } + } +} diff --git a/docs/contracts/client-shared-styles-r11.json b/docs/contracts/client-shared-styles-r11.json new file mode 100644 index 0000000..849ca04 --- /dev/null +++ b/docs/contracts/client-shared-styles-r11.json @@ -0,0 +1,38 @@ +{ + "styleFile": "src/styles/client.css", + "globalStyleFile": "src/styles/global.css", + "entryFile": "src/main.tsx", + "marker": "Client-only cross-page patterns migrated from global.css in R11 step 9.", + "ownedClasses": [ + "eyebrow" + ], + "minimumRuleCount": 1, + "minimumSelectorCount": 1, + "usageRequirements": { + "eyebrow": 2 + }, + "requiredGlobalContextSelectors": [ + ".overview-hero .eyebrow" + ], + "crossPortalClassesKeptGlobal": { + "sms-send-title": { + "minimumClientFiles": 9, + "minimumAdminFiles": 1 + }, + "system-page-toolbar": { + "minimumClientFiles": 3, + "minimumAdminFiles": 1 + }, + "system-table-card": { + "minimumClientFiles": 2, + "minimumAdminFiles": 1 + } + }, + "singlePageClassesKeptGlobal": [ + "client-signature-page", + "sms-send-page", + "enterprise-page", + "send-detail-table", + "template-card-grid" + ] +} diff --git a/docs/contracts/foundation-styles-r11.json b/docs/contracts/foundation-styles-r11.json new file mode 100644 index 0000000..d0ed574 --- /dev/null +++ b/docs/contracts/foundation-styles-r11.json @@ -0,0 +1,54 @@ +{ + "version": "R11-step-5", + "entry": "src/main.tsx", + "importOrder": [ + "@/styles/tokens.css", + "@/styles/reset.css", + "@/styles/shell.css", + "@/styles/global.css", + "@/styles/admin.css", + "@/styles/client.css", + "@/styles/components.css" + ], + "applicationImport": "import { AppRoutes } from '@/routes/AppRoutes';", + "tokensFile": "src/styles/tokens.css", + "minimumTokenCount": 76, + "requiredTokens": [ + "--color-brand", + "--color-bg", + "--color-surface", + "--color-text", + "--color-success", + "--color-warning", + "--color-danger", + "--font-family", + "--font-size-md", + "--line-height-base", + "--space-4", + "--radius-md", + "--shadow-md", + "--focus-ring", + "--transition-base", + "--sidebar-width", + "--topbar-height", + "--control-height-md", + "--z-modal" + ], + "resetFile": "src/styles/reset.css", + "sourceFile": "src/styles/global.css", + "resetRuleHashes": { + "*": "06a686d623d71b39cbece4a1d14e066e9fca18db0a088f919462faeb6d866b79", + "html": "c5e097ee4c514843a085f38e8a4092cf8c791561496021e835fe8b075741b973", + "body": "959e2ae61d9722a688f7bb11c48ef52ff9cad53367bfd719b4fb19c8113386fc", + "a": "00e72791df6422df87fbc1aba2097c700dc3fbab83a5bdb1c04a89a03f4c4058", + "button,input,textarea,select": "bba903ee69611779f6c456ba3d96685aeed0e4ba3422d17b1f21cda6c6cebc7c", + "button": "ecfbb78629d7763f151589ad78e4e635758c53eeeb07a7bffef7c00d1785fe2a", + "button:disabled,input:disabled,textarea:disabled,select:disabled": "09672a6b885e18268ff3715646b191eb2d842befcda5b0f5f6630d8201f825cc", + ":focus": "1f6d5babe8d9f56a802929a5c4da11bde3de6d636925eb3898e66c17714d13ea", + ":focus-visible": "f6de580d559ff9e468267d7f3173fc241cff1e65096402696e5ac407fbe8fbad", + "h1,h2,h3,p": "de52e3be99f485f9aeba54183525162a236998888c83bd06c1e543b545afef30", + "h1": "140b54a87d2ccdb060105bf2213e51ac5cc3592ee0992bfc80c830b59d1c78ac", + "h2": "171665ed54789deace1ccfa93b2e7b0c4d9c3bf0ea02a935eddeb18abc99fef3", + "h3": "2b6273b83065e5938f3bf8f1b14d6daba9bd9abecf09aea6d5c9ded4303f6aee" + } +} diff --git a/docs/contracts/inbound-r6-declarations.json b/docs/contracts/inbound-r6-declarations.json new file mode 100644 index 0000000..bab12b6 --- /dev/null +++ b/docs/contracts/inbound-r6-declarations.json @@ -0,0 +1,564 @@ +{ + "version": "R6", + "source": "gateway/internal/inbound/server.go@HEAD-before-R6", + "declarations": [ + { + "name": "beginDownstreamSubmitBarrier", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "0ccbbba14c2ecff9da00681aa9e44181925e9f1793de1e793587ac931e3644e3" + }, + { + "name": "defaultDownstreamAckTimeout", + "kind": "const", + "file": "acknowledgement.go", + "sha256": "92565b410c5f61c6b1a93eb40bc28be9131962f811e0ee912d69332aaefa12df" + }, + { + "name": "downstreamAckKey", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "a3ff3a57dd6952a9a859867136d8a532c9b9c99b60dfc1ba0f4aad8ea51f7bc0" + }, + { + "name": "downstreamAckRegistry", + "kind": "var", + "file": "acknowledgement.go", + "sha256": "0f2c687864a9c57295739510f26ad343d7a5eb22e1f15fb7cdbb13d8e72d440c" + }, + { + "name": "downstreamAckTimeout", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "97d0878d73a6e1d6890207a776e536ad4bcd5efb8fc2bda49f930b23d3c740cc" + }, + { + "name": "downstreamAckTracker", + "kind": "type", + "file": "acknowledgement.go", + "sha256": "53bfa44f334cc06acf6ce564d84d641dc6aafed6b57416023cef4c4ae86d2771" + }, + { + "name": "downstreamSubmitBarrier", + "kind": "var", + "file": "acknowledgement.go", + "sha256": "211cb910963239f8660aa639607b955bbb54cc7d38484c6f89815c4127315cb3" + }, + { + "name": "downstreamSubmitResponsePending", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "9c43ecb4303ea8a70918a211f7e1ab1162947e98fe44b77d5e86f4995d5a16e2" + }, + { + "name": "handleDownstreamAcknowledgement", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "2bf1b5292111360bdda24e6c9348de739e95f2a9bbd56b8c3d2744e5b6ffe071" + }, + { + "name": "registerDownstreamAck", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "33a12b5d4ed3f949d2670a717af6e8febc54de4a1f008e3cbad6b43837b4779a" + }, + { + "name": "removeDownstreamAck", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "76d357d99c8b14dd04d0f6bf0c09699c6f75966f639f7499b26d854899d723a6" + }, + { + "name": "takeDownstreamAck", + "kind": "func", + "file": "acknowledgement.go", + "sha256": "947085393b5a694df4b234040bafaa59ecfe86c3ade0404808f243ae55f0d6b8" + }, + { + "name": "authRequest", + "kind": "type", + "file": "authentication.go", + "sha256": "0d3e6ab7ec4f418fefb88d2930b01c23eaff926097474c50ba678d553a1aa5a1" + }, + { + "name": "authResponse", + "kind": "type", + "file": "authentication.go", + "sha256": "387ff32b3067a0eb342d09e1e5d3308f8359415171a2045f10dc56ca01205727" + }, + { + "name": "authenticate", + "kind": "func", + "file": "authentication.go", + "sha256": "3dba30340070be6b78dcb1de963fde7a567701925684279c5b675ee9b197ec2c" + }, + { + "name": "cmppVersionName", + "kind": "func", + "file": "authentication.go", + "sha256": "b9ab5ed0382868bb23cde01142e4245038ed22501fbef0605ff0c936df07149d" + }, + { + "name": "handleLogin", + "kind": "func", + "file": "authentication.go", + "sha256": "bf3bdbcda7ddfd9f0e2ff53b436e151c94247b4ed40bcf60ec2f78fdb2559e64" + }, + { + "name": "setInboundConnectResponse", + "kind": "func", + "file": "authentication.go", + "sha256": "4dbf12a8fda63789457f9f610871eec97da0122e23ba0e55d1db95a15f5c47ae" + }, + { + "name": "DownstreamReceipt", + "kind": "type", + "file": "delivery.go", + "sha256": "772d9cc917b7f2968532480366634a595ba0b0f501d25b85eb0d799eec991eb0" + }, + { + "name": "DownstreamSendResult", + "kind": "type", + "file": "delivery.go", + "sha256": "28af9ad91e045e2a11e5b91b9d989f7b7b09ef6cc3b2fa5cf420722fcbe5538b" + }, + { + "name": "DownstreamUplink", + "kind": "type", + "file": "delivery.go", + "sha256": "22be370c5a551b5739b0d4f849ad2990febf0db57ec3ae89ec699f9a95895c80" + }, + { + "name": "PushReceipt", + "kind": "func", + "file": "delivery.go", + "sha256": "321b26a357ae633278d92cc87e7f2f796af12c22b165eb19ba5c3e2b80dc67ce" + }, + { + "name": "PushReceiptWithResult", + "kind": "func", + "file": "delivery.go", + "sha256": "ecc96ae40af974c3faedbe991fa01e71d9ed79c3fccaf964526a726cd6853487" + }, + { + "name": "PushUplink", + "kind": "func", + "file": "delivery.go", + "sha256": "47f42edc03b663ba189defc91207da76263837535f1292fe52c9dbf542a4c8a0" + }, + { + "name": "PushUplinkWithResult", + "kind": "func", + "file": "delivery.go", + "sha256": "2ec907ba43d4906b92c0aae693eb19f1e3a26d7a61d1efaa93e9258a11482ad0" + }, + { + "name": "cmppReceiptStatus", + "kind": "func", + "file": "delivery.go", + "sha256": "ba6b72b46f9869fe22005f7d48b2668b0aa2242bf5a01203d8c62b2b778ed8cb" + }, + { + "name": "downstreamDeliverMessageID", + "kind": "func", + "file": "delivery.go", + "sha256": "a58e6d0da35b374361518f7573f5f92b987eddd2f6753bc1e6e4312986cff7ed" + }, + { + "name": "downstreamDeliverMetadata", + "kind": "func", + "file": "delivery.go", + "sha256": "a510caae34ffc46577985cd8b538e5924a3f4a43dcfedf8d60678c497d185912" + }, + { + "name": "downstreamDeliverPacket", + "kind": "func", + "file": "delivery.go", + "sha256": "721de5632b72041a6102687cf4dece41b854c5bb34150c586a01c662b2cb1bbf" + }, + { + "name": "downstreamDeliveryLifecycleEvent", + "kind": "type", + "file": "delivery.go", + "sha256": "4acf5485100e1bc6bb19a8004a3b1b7796fd8a6192fe2899e163abed7d134b69" + }, + { + "name": "errorMessageWithCode", + "kind": "func", + "file": "delivery.go", + "sha256": "56e4380e43ee3fb9f99e6daf6e468583077372ffe0049133589907b87ab5aa3d" + }, + { + "name": "findReceiptSession", + "kind": "func", + "file": "delivery.go", + "sha256": "af27e3accceff7023b09bb7f4ba716978edf89e3a9a4981c41439e279cf3bb5a" + }, + { + "name": "findSession", + "kind": "func", + "file": "delivery.go", + "sha256": "cd9ddd0acd71bdd0ac32d2f2892f37c1461181038f193f9f9411a48b64dd0f22" + }, + { + "name": "pushReceiptWithResult", + "kind": "func", + "file": "delivery.go", + "sha256": "ed0be61791a471b59a92601aa34f47b9d72c606096863493bb1dbb89a2d5e0a9" + }, + { + "name": "recoverReceiptSession", + "kind": "func", + "file": "delivery.go", + "sha256": "b64f7eb8d7817580ddbdb88008169fa2f1f239bbf2d1cf3d976129da40799bee" + }, + { + "name": "reportDownstreamDelivery", + "kind": "func", + "file": "delivery.go", + "sha256": "ea3bb61fb23718fc8d3b5ce2d75984eb00bc7f965612655f08951fc83d8147d9" + }, + { + "name": "sendDownstream", + "kind": "func", + "file": "delivery.go", + "sha256": "1f85300328d803f5efab654f3038e7da59444a53feb5c41db1833b8d1824c2cc" + }, + { + "name": "flushOnlineAccounts", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "692d49fcb9ff3e116ce9476f826445996de00e5f714f21a5fa56a28afba18a10" + }, + { + "name": "flushPending", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "e4e2cfaa9471e132360fd9fdd8e83e03142f6fb450b76ada4b368b5cc6ae1fd2" + }, + { + "name": "logRecoveryCandidates", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "cd44224d2b6f4b4dbadc9a37d94a285d900c3e8323e36d57cea2f578ba818f24" + }, + { + "name": "pendingDelivery", + "kind": "type", + "file": "pending_recovery.go", + "sha256": "fd6d25266aecc90ec7f68c88699c444e74b21215a518775678bbd84d28181c81" + }, + { + "name": "pendingDeliveryRequest", + "kind": "type", + "file": "pending_recovery.go", + "sha256": "f7e72647951c29203eae90189368434d2bdcc3473f2a3ba7eb05f6ba489bc221" + }, + { + "name": "pendingFlushInterval", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "2b11f99b604ff894fba202947ab656b0578f55d0c291fee418574c099852a796" + }, + { + "name": "pendingFlushResult", + "kind": "type", + "file": "pending_recovery.go", + "sha256": "6cb42b62fed78024a6401657e0bb48969fced040da914d043aac49bf7332caa1" + }, + { + "name": "pushPendingDelivery", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "a228f8e0ad2f43a68a9308b31d457294a3b967cfbcd8acb6b9117ccd92e027c7" + }, + { + "name": "recoverPendingCandidates", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "3c4949a98f31e60b1f38a83d3eec4b0fc28719d7300848bb0ed561c77f119e82" + }, + { + "name": "recoveryFailureCategory", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "b1686eeb07ede06fd5f77bee6fe203e5e5ea99a394bd318127c419f0924ce4b4" + }, + { + "name": "runPendingFlusher", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "5b2031765192bd9818931d8418e2790a491e6d76275cd71994d2df023617973a" + }, + { + "name": "syncRecoveryStatus", + "kind": "func", + "file": "pending_recovery.go", + "sha256": "b63dfdf300570e510d45b2ac71cd0ebf06af9cf6b157db2fc436a7fa0e4345d0" + }, + { + "name": "emitProtocolLog", + "kind": "func", + "file": "protocol_log.go", + "sha256": "2f162fe182184f1f2c940e48e5cb35680b41ef828cf375def5839e8713b1a079" + }, + { + "name": "protocolLogEvent", + "kind": "type", + "file": "protocol_log.go", + "sha256": "67492ad6e52c6b57c52d8cf37c5564d4fafbd3655aaad706548da412e93fccaf" + }, + { + "name": "protocolSendResultCode", + "kind": "func", + "file": "protocol_log.go", + "sha256": "f338ef80a1f9baeac7c3ce42f4d89ae629724de3121c13f6568f3b64a1f53472" + }, + { + "name": "protocolSendStatus", + "kind": "func", + "file": "protocol_log.go", + "sha256": "c18f5b646d2b08fe6bf0d007293b1abe432a552ff19f0a27aee29ad765ba2451" + }, + { + "name": "protocolSubmitResponseDetail", + "kind": "func", + "file": "protocol_log.go", + "sha256": "db6055b3e156e733b195f197f4f6ea324dc188f59cf6511a5617c6ba6f8c75aa" + }, + { + "name": "recordDownstreamProtocol", + "kind": "func", + "file": "protocol_log.go", + "sha256": "4a4e70075e619a094abaa5a13885938544a7055f3aa582a861cd5b0e596495c3" + }, + { + "name": "submitResponseProtocolLogger", + "kind": "func", + "file": "protocol_log.go", + "sha256": "94441a243f14e376d6e4f0ac00358752f43a0928f7215943b8e81d0bb9fd3969" + }, + { + "name": "ListenAndServe", + "kind": "func", + "file": "server.go", + "sha256": "c57e5d860f6bdbb7d47a5414e0d186fe34cf61c42f94fc2b1ab5739b6272c272" + }, + { + "name": "Server", + "kind": "type", + "file": "server.go", + "sha256": "9dacd52458fb19e19752e201de3b9c9b6e8fd9ba93afda454e7d7444b17148f5" + }, + { + "name": "defaultHTTPTimeout", + "kind": "const", + "file": "server.go", + "sha256": "247aaf1070a6886f01059677dad739e69cb9dd10bd55d6397ba5fb9d385fe39a" + }, + { + "name": "DisconnectAccount", + "kind": "func", + "file": "sessions.go", + "sha256": "306c822ca618b5d47dd002595e0896b87d86c26a0b35ef7ea6b032df8cefe66b" + }, + { + "name": "downstreamConnectionEvent", + "kind": "type", + "file": "sessions.go", + "sha256": "d56a2bde011b9499ca6436f1da858f66f8aab02b790fe41aef7b49a70190be7e" + }, + { + "name": "downstreamRegistry", + "kind": "var", + "file": "sessions.go", + "sha256": "c1cf77d72cbe4ceff9def118bfdbfbe54417e6156e9af3345a87cd6c4a02fe05" + }, + { + "name": "downstreamSession", + "kind": "type", + "file": "sessions.go", + "sha256": "36d85eab72c673e4e4d47a2b60e68d96561ecb93b0a851de850280706e6779bc" + }, + { + "name": "findSessionByConn", + "kind": "func", + "file": "sessions.go", + "sha256": "5563363dae78b586715ee48b3e6eb93a3ea397116a40e1bb99d0b1ce1072f255" + }, + { + "name": "forgetDownstream", + "kind": "func", + "file": "sessions.go", + "sha256": "7e5b930ff7d1b7400936504d91cc850bce8334ac0953370da3b2d6786d4e6372" + }, + { + "name": "gatewayInstanceID", + "kind": "func", + "file": "sessions.go", + "sha256": "0175fef1017de9e0c65adfe894375a463e8769185c8f7b17d7acbe96993edcb0" + }, + { + "name": "handleActivity", + "kind": "func", + "file": "sessions.go", + "sha256": "7ec78fd7054aca6c55fd4b3a16bf8fa3b3a9c53e398a4164f06a39018fbaa5aa" + }, + { + "name": "handleConnectionClosed", + "kind": "func", + "file": "sessions.go", + "sha256": "a3ed1e51c1057fa9f4a899de266f1750257ad9b7742d319cf5dac22a3341afd0" + }, + { + "name": "onlineAccounts", + "kind": "func", + "file": "sessions.go", + "sha256": "fbbdd0bbe4e222a7d7e83302986bedefb4dcc762bbd9b31e2a886c0ef5f1564f" + }, + { + "name": "rememberAccount", + "kind": "func", + "file": "sessions.go", + "sha256": "50ee995075641dcb3bb7dd2d1aefd72e1ec7fb352ad69862e8504db91c78eafd" + }, + { + "name": "rememberDownstream", + "kind": "func", + "file": "sessions.go", + "sha256": "0803f1f2b2c69d47ac4adf0ec3d48fc122b5c994318dda93738b9c4d5ea56f64" + }, + { + "name": "removePresence", + "kind": "func", + "file": "sessions.go", + "sha256": "4a5332055b810da8432712f2e4d0566d2245ca638401288f9ea616686de13af2" + }, + { + "name": "reportConnection", + "kind": "func", + "file": "sessions.go", + "sha256": "6cdbadc05d0f7afe3dd2a5e77965380474949734ab05eddb463b31753a9064fc" + }, + { + "name": "reportConnectionChecked", + "kind": "func", + "file": "sessions.go", + "sha256": "af5321ddf095bd00f3c790796ae3731bd0aa7b1c52d07bc9e909354bf731df1d" + }, + { + "name": "reportConnectionOrDisconnect", + "kind": "func", + "file": "sessions.go", + "sha256": "61cf0ec3ac4278be5ccbd112d196aec662ae20f6aebd6079ac7102e83b1e7008" + }, + { + "name": "touchPresence", + "kind": "func", + "file": "sessions.go", + "sha256": "d3f55aaaf52c33e3b6916f9e0d4e91754c88b28b997ae80b8d647fb07a3f7fc4" + }, + { + "name": "decodeContent", + "kind": "func", + "file": "submit.go", + "sha256": "a66a6cc45a405c028cf41c7946dc89c6b1a313019863d6aa4b0a468d3ce7ef3f" + }, + { + "name": "decodeInboundSubmitContent", + "kind": "func", + "file": "submit.go", + "sha256": "84f72bc74d7953096788001675d426bde14285a4a617e0524c0d194ce8744fe6" + }, + { + "name": "handleSubmit", + "kind": "func", + "file": "submit.go", + "sha256": "fcb1d5ada61ff28a39976506e6a14f1fc09f840eaa4033ec9ea3fb9b8f0882f0" + }, + { + "name": "inboundLongMessageFragment", + "kind": "type", + "file": "submit.go", + "sha256": "65fccbf8525f98948c307291bced71dff6c31e0f6a4015dcde4a9a45fc85c236" + }, + { + "name": "inboundSubmitPacket", + "kind": "type", + "file": "submit.go", + "sha256": "50984034ef07833dcc2f4c4156cfe7f0add8767ae2b4d376001931e4f211e9d5" + }, + { + "name": "messageIDFrom", + "kind": "func", + "file": "submit.go", + "sha256": "a5b5d130552b844568055e06e96d6d0aa9a421ef61fb8e4ff4fb6e5b6cecc984" + }, + { + "name": "normalizeInboundSubmit", + "kind": "func", + "file": "submit.go", + "sha256": "e9f6b5bee5fe114d0fefee910d818bafa6834a00709fade7f4e0468f821e69fd" + }, + { + "name": "setInboundSubmitResponse", + "kind": "func", + "file": "submit.go", + "sha256": "a21d1418c08aeb65a545f2e9d713f9a6c3fd720e80f996757f9af8d5bc982cc4" + }, + { + "name": "submit", + "kind": "func", + "file": "submit.go", + "sha256": "b541d2c1d7e5592a6b8ad213d0cdbb81fcc98702023f1d5a4d35207476767ae7" + }, + { + "name": "submitRequest", + "kind": "type", + "file": "submit.go", + "sha256": "8b622eefb6255f762b332a1d161269b116c234018a32cc881b9bde138a8fb1cd" + }, + { + "name": "submitResponse", + "kind": "type", + "file": "submit.go", + "sha256": "aa2f05c154f7d6682d4214e8f16063784edabc7d60380c167f7a3b0c01cdfbea" + }, + { + "name": "submitResponseMessage", + "kind": "type", + "file": "submit.go", + "sha256": "4a3b3c59d27627712d40e5caecb760d1dfefab51377c9ff45479850f46e4e9cf" + }, + { + "name": "apiBaseURL", + "kind": "func", + "file": "transport.go", + "sha256": "96f0f2b1f3807bc966ff1b5e81917e2dfe09c64b1e13037563a2a83bb448c06a" + }, + { + "name": "defaultString", + "kind": "func", + "file": "transport.go", + "sha256": "e3f1c7ba3d2aa985b2df53748e307f4e2769815d3cb8500dc616b929cee1e38a" + }, + { + "name": "formatRFC3339Nano", + "kind": "func", + "file": "transport.go", + "sha256": "fd069087ad8497197f55a98ecfb9e5b1234ce641a0fb0a9161f8c320f436dd37" + }, + { + "name": "post", + "kind": "func", + "file": "transport.go", + "sha256": "10fac868d1fe77fe2c261f14692245b6a08b573ee460cde0c7a77f6ee3ca77b1" + }, + { + "name": "remoteIP", + "kind": "func", + "file": "transport.go", + "sha256": "84831ab34bb3ac39f998829ea9e4b61f92711c9c4eafa5ab214ddadefd969bd4" + } + ] +} diff --git a/docs/contracts/operations-r2-methods.json b/docs/contracts/operations-r2-methods.json new file mode 100644 index 0000000..ba52892 --- /dev/null +++ b/docs/contracts/operations-r2-methods.json @@ -0,0 +1,280 @@ +{ + "version": "r2", + "baselineCommit": "0af671b4ed4713912e703defd08791f164d4eb25", + "generatedAt": "2026-07-31", + "contracts": { + "MessageQuery": "07530ba61641758c96b5cd91dcdaba8692d5da4d9657d66e71009e7abf338f13", + "TraceQuery": "636138d9593d61b2936eb32231d071b4682ca1d36cbf6a2b07d3b71b4f5d8453", + "OperationLogQuery": "084d60f6487a2b38264211bf030950baacd2bc9b218e992867fb0e429dae7ece", + "GatewaySubmitDeadLetterQuery": "d40dea1cc7d5ee369ab16a49cada5c6bcfae2b21fc66459c49d9377374aa1445", + "DownstreamDeliveryQuery": "fd5b570bce0723aa6e848dd74ee326f3fb920dca86d5a60ffb8b1ed743343027", + "DownstreamDeliveryDashboardQuery": "62f06aba6ac8bc90b0885a70bcc6038901c1727438e7e9b70c16bfa88b2c7f08", + "DownstreamRecoveryStatusQuery": "74f3018a6d582a4ffde9711217fcc32d42131a9e262ae9b39b3025bd03d2b7f3", + "MessageSegmentAuditQuery": "90cf141034feb9fec50d54a2f6c0b5491d6e38cc05e87a687e9caa487f018f84", + "SignatureQualityQuery": "880bf2a7d74d94f552e50d9d0e19dbe95e893f7deaac16bb831ec4c9171ce663" + }, + "methods": [ + { + "name": "listBatchTasks", + "group": "messages", + "isPrivate": false, + "signatureSha256": "1a807b62cd5a6553fb7736503ad2fdba03dbbed36578d69c6a4d3ea42cc33131", + "bodySha256": "ab7f0cf4b606772d574549e552e081b36e339e3a800d82e7a948ab6dd7e090e8" + }, + { + "name": "listClientBatchTasks", + "group": "messages", + "isPrivate": false, + "signatureSha256": "0561e37c80d66c53cca310d05db707b14a640f7c184e309c3a7ef4d7d0aec3af", + "bodySha256": "530af84aa681bcfdb3b2a7d919955ee3e047d3d5d5c77a96700796aef7db38eb" + }, + { + "name": "listMessages", + "group": "messages", + "isPrivate": false, + "signatureSha256": "566e0533151b80b2d94ca77b1c65ab6faf5d94defc4467ebc521636a92a1a8c2", + "bodySha256": "e422326e97868efacff02ea767a7b4f71ed18231c85315674c9f267c2a13cffb" + }, + { + "name": "listMessagesPage", + "group": "messages", + "isPrivate": false, + "signatureSha256": "fbae7a944bd2cd8c398c4d1f1a72ca272cbb9b4d17eb1092acaadd681a5fe7a8", + "bodySha256": "1bef139de372ce4335e69898f7a078a9ced9d531d545fbae02ec68e8085c56e7" + }, + { + "name": "exportMessages", + "group": "messages", + "isPrivate": false, + "signatureSha256": "37075fd6448d81f6acc25efe9a3670c8d6ec962add6a0b1e3115a31c05f8d7a6", + "bodySha256": "649439bf58c411100dcc24d634c7f2c87c6323c74cfc76539f853485c3c59461" + }, + { + "name": "listClientMessages", + "group": "messages", + "isPrivate": false, + "signatureSha256": "585b2dcab9df6f95965e9df59965bdb525a516fde88b2fcfa2719a8374df2ec2", + "bodySha256": "af305e69074e100706864afdc83fcc29c63222b0628c2dc514a18ff74273c763" + }, + { + "name": "listClientMessagesPage", + "group": "messages", + "isPrivate": false, + "signatureSha256": "2655a52bcb2755a944e7fb95d2d12169b487bc965719739376fe7447dcfe2770", + "bodySha256": "3ea79e41bfe3f9ca09774754ba9213213184153a4ce93bff6f68d085e99baa9b" + }, + { + "name": "listUplinkMessages", + "group": "uplink", + "isPrivate": false, + "signatureSha256": "3384d96065b6fa29c5785d27b5193488b7deb27f2d6fe1482abfdb94cf5e1096", + "bodySha256": "f5a949d7587ff83ca40f0fe1f68677e3dafd3d634ea11086ae633de2b93320cf" + }, + { + "name": "listClientUplinkMessages", + "group": "uplink", + "isPrivate": false, + "signatureSha256": "a06d90bad32600a836a5d823f91914c70e8937bdecb5d140108de348f08ed7bd", + "bodySha256": "bec584ee827d27e65225377e1baa41982f241181804e74c0aa4eae560ed8c861" + }, + { + "name": "listUplinkMessagesPage", + "group": "uplink", + "isPrivate": false, + "signatureSha256": "0ee52e770b7e79b26667d3846e36bc7b1e46352b92c8a48f7f1c1d03dc78b9ac", + "bodySha256": "c067c19488d4f74acc04e3af3e8956cecbf511571e4f3b8315a06ade21a58ff9" + }, + { + "name": "monitor", + "group": "uplink", + "isPrivate": false, + "signatureSha256": "b3e5732d2f136021b62b5bd35c2cb332bfcdfc7a9cb5d4cf6288835152314287", + "bodySha256": "58758622feed4546da827b2b2e5d7a4e73c926349c258819604302626940250b" + }, + { + "name": "dashboard", + "group": "dashboard", + "isPrivate": false, + "signatureSha256": "7bea6ce2744f19d6a6c7ec2078e986b9f36aa8ac139c83a9bf21f2554550a3b0", + "bodySha256": "50f10c48dcb9a0e7a7fdeae445fcd6a930a51894a76ce9834e306a70a598c574" + }, + { + "name": "clientDashboard", + "group": "dashboard", + "isPrivate": false, + "signatureSha256": "56ce59f4e54b1811ade9a04040bafed17a454371dfd6e8780a8c268e6d59f30e", + "bodySha256": "6f257604b3a6bf2e6b9da84b9c76b8b3f26f0987038a2ff1cfec06c7024aa2ec" + }, + { + "name": "statistics", + "group": "quality", + "isPrivate": false, + "signatureSha256": "3e4afcc84ee2dfc40e52ef4bea80fc737c23204b40c0522363e276469f96947b", + "bodySha256": "723a194c3ef3bfc732d36d0ab4b6ebce178782fab7f5d4b6b12a8e0906cf51ed" + }, + { + "name": "sendQuality", + "group": "quality", + "isPrivate": false, + "signatureSha256": "7fa4cd6bcee4092390a38d693aa30c7980781ca1baa73dd07a3a61f097774e9c", + "bodySha256": "730b291f2fa96a9bcb054589a1341bc21f8ade161d232141d74f77eb426c4522" + }, + { + "name": "signatureQuality", + "group": "quality", + "isPrivate": false, + "signatureSha256": "6b9c3761674cb0d01000caca5e12cf91a6e526757592ffb783fc673c178b204c", + "bodySha256": "c85644b03f607339c2eae5390b01fb520264e87f16d0439c81a0c101b4277fa2" + }, + { + "name": "auditLogs", + "group": "logs", + "isPrivate": false, + "signatureSha256": "d4ad56308f1d27e5e131b75c74f03484ae21c1a85d26efc7c5fa2237e94da41e", + "bodySha256": "32646b12989c4a313558779d74fd7a75fbc3d8a965b5e9f4da9830f2a08ee785" + }, + { + "name": "systemLogs", + "group": "logs", + "isPrivate": false, + "signatureSha256": "a2e578db34e6100b6257fd1ed6beabf28d732f014e9fc8f8cf914587f0feb5b2", + "bodySha256": "d0a1fc83b2c306f52679129b235db79ed88ae1ac30af6e27793b6891af3933e8" + }, + { + "name": "exportSystemLogs", + "group": "logs", + "isPrivate": false, + "signatureSha256": "101ded394553c05cd4ed3f80f95a1716bdfb505029ba38f7d04d3f20564b88c3", + "bodySha256": "5f95e55b1095be6370b9327e622e3881c415d34af6f926a94ec8c0bd91ba75dc" + }, + { + "name": "resolveClientTenantId", + "group": "logs", + "isPrivate": true, + "signatureSha256": "2935888aed8bbcfbd04f73f68c1feed70ca3dc0b3b31ef38bd34b479e656b51d", + "bodySha256": "292a41d7e213a6d85c44061f2e04aeed8b18e9b61f2cf6656802cb89f9e32611" + }, + { + "name": "listGatewaySubmitDeadLetters", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "fe33722f549a09191d3132c67c2664b5ce9df518aac70aaf3234b5abaef5e0d8", + "bodySha256": "c9552eb39c44c863d7d916191245620274275229e3f7246ad2596a46ce28560b" + }, + { + "name": "listDownstreamDeliveries", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "5fe6e1847a711fbcf547934513367ba591e6364d630b89a7133f8400cd0a1c84", + "bodySha256": "9f5f8efa141a4efcb057586008c6d942a346ac1b64889e381d4ce23155fe06e9" + }, + { + "name": "downstreamDeliveryDashboard", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "a47a03bfeada0a7ccc284a8a13f9a6481213f9c4d3bd8a9ffc6567396290a376", + "bodySha256": "88412a45bdf75fb2b99873191ca0637deffa6b4fb23e345915ef58ee152f83c0" + }, + { + "name": "listDownstreamRecoveryStatuses", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "cdf304441b834467417e60cf9476589910f84697fae2ec8a911519b202142baa", + "bodySha256": "1db9498db2129d8977604c199de2bf07bcf28fec9f370a7c272aba3cf876c575" + }, + { + "name": "listMessageSegmentAudits", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "fe617d8ba6907506f7d0327d5e0dfed23cd50681c55eeae66291e12058c2d26e", + "bodySha256": "9644db4083b0d960060664254d86449f06ad5bed577941ca52a08983fb470045" + }, + { + "name": "getDownstreamRecoveryStatus", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "00f7598b8bf8b997df01d5ae2471c9941e224342c47d8a77c5701754ee4d4c26", + "bodySha256": "55d9da10bc9357e05f2de7fff47d269e5f64e28791d93d1ee1bae2c8d3d731dd" + }, + { + "name": "exportDownstreamRecoveryStatuses", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "cbf335de93fb85ef3b530f0fcdccb2b1457c2f114a4ae5213db62cfdea98b9e3", + "bodySha256": "3ae45777799c1982ff787886a2a26c896fb1511cd6e51563a9f2fd95feccdb1b" + }, + { + "name": "auditSummary", + "group": "trace", + "isPrivate": false, + "signatureSha256": "ae3db8f6fd9e940bf1bd246e80732dce2cd2a855df5c3acfafa072886e5e9ded", + "bodySha256": "f722254cbd78802ae4b5842e66c4f72b73db18c0701b95575635067dd8aaf11a" + }, + { + "name": "trace", + "group": "trace", + "isPrivate": false, + "signatureSha256": "8d9526bf0683b53c79615d70f2eaa4a33e5e83c4f455c30467361e97b3f20393", + "bodySha256": "eda2d1e0f98a5bf0c25678aaf30efedbe1ce1d90128877b86d3d655f3d2397cb" + }, + { + "name": "reconciliation", + "group": "trace", + "isPrivate": false, + "signatureSha256": "c6d1db8ff18f46f60f3fa5d3c4a8390469bf1f190d245a8a8b07e7fcf6ae0cbb", + "bodySha256": "f1bded76987def7292858bc303485a64403f2c28fe4c2f1a7eed359c30a4f8f6" + }, + { + "name": "countPendingAudits", + "group": "dashboard", + "isPrivate": true, + "signatureSha256": "033be4c74aaaf745ad29c524f528e8db97706034dd907860c56b91b6a599aba3", + "bodySha256": "ee021da1d250f65d7956bcab163a310b74cf1530cb1baf5a78052d1aeef8d654" + }, + { + "name": "gatewayDownstreamRecoveryStatusDelegate", + "group": "downstream", + "isPrivate": true, + "signatureSha256": "f0aae13f430c40e5c0d948b153c60b8a8bac832f8183d5fde9ebd6ded520067e", + "bodySha256": "9b451cd926f3bcfa471aebf4744967d6cdb35726fe5bce64424ae105022b765f" + } + ], + "helpers": { + "messageWhere": "8d73104a7035970fceee92d95f2f76960c64aa5e1862be3441879d666d57a947", + "recognizedCarrierValues": "d60957621e921bf9f5df8fdef616f4e2a0b16be78623c85316a65bae83025bdb", + "carrierWhere": "1c7947c02a1a5cd3aaeda4a586421f6d7d63ae2f912cb11292831af2ffa4c379", + "startOfShanghaiDay": "12eb964ff5ab3680837cf50bccabe5e8e7af733c248d06cb57f20c0c65ce0b4e", + "endOfShanghaiDay": "36fcf2c3f6e7e0144305f1c175d03ab82b3ee43c3b3880e43ad14aa203d8cb4a", + "qualityBusinessDay": "2031fe0fc330b7374596cf340afe6c25ab85499de471d41a2ffb20e5be284def", + "shanghaiDateKey": "aa52e688283b13746a62628c7967b3ac0dbbd4418833701531e0a1004880aaf2", + "normalizeGroupBy": "d1f3b5e846fd9e0904a0e25d5bf9c6a1bb34f3e2d4b4c30fb683c1aa666f6c44", + "returnedTransactionWhere": "9048c20c268204b4bd5ffd14e075b915d846da11f6f13d836f87b47f8eb2e46e", + "createdAtRange": "c1d64506752fba90d17a47531e7939ff57fcdde101b05d1ac6395d9c5bf7c251", + "downstreamAlertPendingMinutes": "49a6e5a6ad05ccf4db538a639aa027c300c60b5748721cecc8cbc847d163fa7a", + "downstreamAlertRecentFailedHours": "66bbfb5ae98bf65119dd05d6c3fde97856c4b279f39d1dcd926d447b9e480b72", + "downstreamAlertWindows": "8b3ff0f8cf2a629ea488361404d30c027f0d9ed3c692b942b1d3bbda299c5cd2", + "downstreamAlertWhere": "d0699b58ae58f4357560aeca47acaf2de6c66b5a57a70d66dbddd63fbbe253e6", + "stalledPendingWhere": "2a2266a9f8888d0a21b0f6123aaf99ab9597419e79ff08115584b39b778473ef", + "downstreamDeliveryScopedWhere": "edf3bd93334fb31744ef28b59b1d15171c88e5c30ae27b2e998a2796cda38e05", + "parseDateBoundary": "62b768f59c084c4b8c4384700c98a88430cb10d8b27554c2cd730eb23e87bc23", + "downstreamRecoveryStatusWhere": "a9d59fac3bad87291ac9c0fb7206df82b7722b5d199fe31521efb97ade44931e", + "escapeCsvCell": "abcd0660648ee2978f2634aeda4de084298828b44d68d4cfbfa72e0a414724b7", + "formatCsvDate": "c52b4517a846bbd1a6010e20e0a8d2413b4e6c478ba830e05af109139cd00ff0", + "formatExportTimestamp": "1ed41fc5ffbc208bbe6752b186ac2b2111f700d7b15bf1197aaf0e8057811bac", + "clientApplicationView": "8af7f3d67d4c38930a4a5cd59e0048881a996de07220c561b65f8b4f97b3b931", + "clientReceiptView": "365eca8e3936433d0d71da2bc83d0e4ae27a7b15e7233dacf49eddd397b71baf", + "clientMessageView": "3bf33efe7c5c94b64148ff44172dd18997147b458703825277b98b24b0bbc37d", + "clientBatchTaskView": "b7d1fd9955f6b1362f8f2ff69e72315c6bda7e58c962ba1fa6667b54c9b71204", + "clientUplinkView": "b9c0093e74b93764de8759710614acf44282b48be1ae154ae459cbaa771b1d03", + "clientAccountView": "d97dd44290b999f97fcad7448e2129746de5080342899d448ace3335ea70ac0f", + "clientRechargeView": "0554a521fd9f7d89aa146185fa8655aeb577f743ebed3ce8bdd9b617f7d4192c", + "summarizeMessageGroups": "4eee30accc8c33587ed16cc9a404fde33ae321325a33e7c3dcd207c82f1e69eb", + "groupDownstreamByType": "1d38bd9ee894dce37eaeb51986220713d3d72c2cfb04be13021d985d354298ed", + "groupDownstreamByApplication": "540d5247bd99115b6d338ee3a4a91a005047a44eef7d492dbb23aab7fc753ab1", + "positiveInteger": "6febd4b21ae047e3c37128677bb6d908f703bc1cbfe7029c2c54c5e9e239a303", + "operationLogLevelWhere": "88ffdb591f2ed3dbb931e5dada32d05f9899e02d2f8e18d7711a5e6ba5b35b03", + "normalizeOperationLog": "f1949cfa395f9d2efe7ecc7f31a058b3fc4e837f08cdaa22cbbc521765cfdda7", + "sanitizeGatewaySubmitException": "3392287e5af7d5557ecbc957820011f81135c1f24ac8eed750c6f9c2fec82203", + "redactGatewayCommandValue": "475f1a557191626e29311fe578277350817ac3d246c821922b2bc7ee305827e7" + } +} diff --git a/docs/contracts/refactoring-r0-manifest.json b/docs/contracts/refactoring-r0-manifest.json new file mode 100644 index 0000000..3614898 --- /dev/null +++ b/docs/contracts/refactoring-r0-manifest.json @@ -0,0 +1,55 @@ +{ + "version": "r0", + "baselineCommit": "0af671b4ed4713912e703defd08791f164d4eb25", + "purpose": "Freeze the cross-process contracts and characterization gates before production code is split.", + "contracts": [ + { + "kind": "redis-stream", + "schema": "docs/contracts/gateway-queue-messages.schema.json", + "examples": [ + "docs/contracts/examples/submit-command.json", + "docs/contracts/examples/submit-result.json", + "docs/contracts/examples/receipt-event.json", + "docs/contracts/examples/uplink-event.json" + ], + "validator": "tools/spike/validate-gateway-queue-contract.mjs" + }, + { + "kind": "cmpp-packet", + "tests": [ + "gateway/internal/cmpp/gocmpp_integration_test.go", + "gateway/internal/upstream/submit_packet_test.go", + "gateway/internal/inbound/server_test.go" + ], + "invariants": [ + "CMPP 2.0 and 3.0 packets remain separately encoded and decoded.", + "SubmitResp preserves the request Sequence_Id.", + "Deliver receipts preserve Msg_Id and require a non-zero business acknowledgement." + ] + }, + { + "kind": "gateway-reconnect", + "tests": [ + "gateway/internal/upstream/reconnect_integration_test.go", + "gateway/internal/upstream/connection_loss_test.go" + ], + "invariants": [ + "Network reconnect uses capped backoff.", + "Authentication failures use slow retry.", + "An explicit disconnect does not start an automatic reconnect." + ] + }, + { + "kind": "send-and-billing", + "tests": [ + "api/src/send-chain/send-chain.service.spec.ts", + "api/src/billing/billing.service.spec.ts" + ], + "invariants": [ + "Concurrent claims cause one enqueue, retry, downstream delivery, or refund.", + "Stale events cannot downgrade a later terminal state.", + "Freeze, charge, release, and refund operations retain their idempotency keys." + ] + } + ] +} diff --git a/docs/contracts/report-materials-r4-methods.json b/docs/contracts/report-materials-r4-methods.json new file mode 100644 index 0000000..7c18f14 --- /dev/null +++ b/docs/contracts/report-materials-r4-methods.json @@ -0,0 +1,478 @@ +{ + "version": "R4", + "source": "api/src/report-materials/report-materials.service.ts", + "generatedAt": "2026-07-31T02:11:40.149Z", + "publicMethods": [ + { + "name": "buildOfficialTemplate", + "signature": "async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string)", + "canonicalBodySha256": "507a97f3e067489fb16a33a040612f5a5e4524227ca8f9d8361e24d1b2baf785", + "originalLines": [ + 99, + 120 + ], + "domain": "officialExport" + }, + { + "name": "exportPending", + "signature": "async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string)", + "canonicalBodySha256": "f9f1cca955a9ce267f6b9c9a6d6c0b81be994908668705da1591bc550ee07cfe", + "originalLines": [ + 122, + 139 + ], + "domain": "officialExport" + }, + { + "name": "listPending", + "signature": "async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery)", + "canonicalBodySha256": "ecd97e4e397b63e7578645100bae82af66a9494ddefb4c1a627d8c30208ffdd9", + "originalLines": [ + 141, + 151 + ], + "domain": "pendingQuery" + }, + { + "name": "listImportProfiles", + "signature": "listImportProfiles(reportType?: 'signature' | 'drainage')", + "canonicalBodySha256": "37b13cca9ffb528b106871cdd3c89023f6d0f9e053080e6891d4271e35e2525d", + "originalLines": [ + 198, + 204 + ], + "domain": "importParser" + }, + { + "name": "saveImportProfile", + "signature": "async saveImportProfile(data: CreateImportProfileDto)", + "canonicalBodySha256": "6712ab96ded2c18120377b7b0c49193d374a858a2916b898df8b161bbbad4795", + "originalLines": [ + 206, + 229 + ], + "domain": "importParser" + }, + { + "name": "analyzeImport", + "signature": "async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions)", + "canonicalBodySha256": "ba5429c8b3fedce0de7420f9c9773215e5a6888cbb72a2b25eec152decb14aa4", + "originalLines": [ + 231, + 297 + ], + "domain": "importParser" + }, + { + "name": "commitImport", + "signature": "async commitImport(batchId: string, data: ImportCommitDto)", + "canonicalBodySha256": "8f68974ad5f7ae0231167e1801b5a3e2dccdcc78b691075967ab5838f62ec1dc", + "originalLines": [ + 299, + 383 + ], + "domain": "importReview" + }, + { + "name": "listImportReviewBatches", + "signature": "async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {})", + "canonicalBodySha256": "6373de6acab0f6f12d99b000965ad798bd8c9aaeddcceece74080b033da5364e", + "originalLines": [ + 385, + 436 + ], + "domain": "importReview" + }, + { + "name": "reviewImportItems", + "signature": "async reviewImportItems(batchId: string, data: ReviewImportItemsDto)", + "canonicalBodySha256": "5303e3a9f6d0313edc69cd5fa6d93cb1408ebe782cbbe3f9e210f3ade939f27f", + "originalLines": [ + 438, + 504 + ], + "domain": "importReview" + }, + { + "name": "listBatches", + "signature": "async listBatches(query: PagedQuery = {})", + "canonicalBodySha256": "a9b65be1a5e9fa64a9aa863c70f3d19f7fe1d175fc8c3494b22c48d9ba425279", + "originalLines": [ + 506, + 546 + ], + "domain": "batchGeneration" + }, + { + "name": "createBatch", + "signature": "async createBatch(data: CreateReportBatchDto)", + "canonicalBodySha256": "59da60790350e19299b7fc80b9dd0d1763f118fb6f82fe3a09b458a3cb02db94", + "originalLines": [ + 548, + 618 + ], + "domain": "batchGeneration" + }, + { + "name": "preflightBatch", + "signature": "async preflightBatch(data: Pick)", + "canonicalBodySha256": "6eb007143fbba353d39a3cca80f93cc4ba9fb9f6bab5182449dc2ca462234c9e", + "originalLines": [ + 620, + 637 + ], + "domain": "batchGeneration" + } + ], + "internalMethods": [ + { + "name": "findPendingItems", + "signature": "private async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery)", + "canonicalBodySha256": "6a4b0b9953f5499afc60605d6f68a17893dad51b89348f25799846ba7ae57f4e", + "originalLines": [ + 153, + 196 + ], + "domain": "pendingQuery" + }, + { + "name": "stageSignatureRow", + "signature": "private async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record)", + "canonicalBodySha256": "c9431e56ee3c93b416c17830feff3eee66dc5a0940d6a28b29f21f7d897a23b3", + "originalLines": [ + 639, + 665 + ], + "domain": "importReview" + }, + { + "name": "stageDrainageRow", + "signature": "private async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record)", + "canonicalBodySha256": "2a174fcb79e93e9abc293ad9205f9aa7d2c740731a250e75e598394b7c083d7c", + "originalLines": [ + 667, + 691 + ], + "domain": "importReview" + }, + { + "name": "applyImportItem", + "signature": "private async applyImportItem(\n batch: { tenantId: string; applicationId: string | null; reportType: string },\n item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue },\n reviewerId: string,\n )", + "canonicalBodySha256": "2c016f04fb0a0aa2f6658a721a0e2c298a6c93d0d9111caf8f9adbdda5233d41", + "originalLines": [ + 693, + 756 + ], + "domain": "importReview" + }, + { + "name": "prepareBatchItem", + "signature": "private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection)", + "canonicalBodySha256": "f61ad17413f40c65437264b8ddf412de9184ebfcbd6fbc23cab5c2a7617e9674", + "originalLines": [ + 758, + 777 + ], + "domain": "batchGeneration" + }, + { + "name": "inspectBatchItem", + "signature": "private async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise", + "canonicalBodySha256": "e04d7e20b23ac726eff80d6847290676b390b016013315523f05d58bf5fbece9", + "originalLines": [ + 779, + 858 + ], + "domain": "batchGeneration" + }, + { + "name": "claimBatchOperation", + "signature": "private async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string)", + "canonicalBodySha256": "c14878a5728ae576e703f7429bc83e947f922e85aeae349e62bb52ae3cf230d4", + "originalLines": [ + 860, + 873 + ], + "domain": "batchOperation" + }, + { + "name": "completeBatchOperation", + "signature": "private async completeBatchOperation(operationId: string, batchId: string, result: Record)", + "canonicalBodySha256": "f4500197f33a4d023fbd735b9f38fbcd18fdd8fdca4419174b04f903531abdfb", + "originalLines": [ + 875, + 877 + ], + "domain": "batchOperation" + }, + { + "name": "failBatchOperation", + "signature": "private async failBatchOperation(operationId: string, message: string, batchId?: string)", + "canonicalBodySha256": "eb33a3102646844242592cb51dde16a455ca0055ee61e9c97f43ce07e51c12f8", + "originalLines": [ + 879, + 882 + ], + "domain": "batchOperation" + }, + { + "name": "exportChannelBatch", + "signature": "private async exportChannelBatch(batchId: string, channelId: string, items: Array>>)", + "canonicalBodySha256": "ea0df6daed595dfd4bea01ab6e4f51f3dd0bb6883e6e18c1c38e5997c0a9007c", + "originalLines": [ + 884, + 943 + ], + "domain": "channelExport" + }, + { + "name": "recordTask", + "signature": "private recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string)", + "canonicalBodySha256": "9d1ce8db45c7623800d3117e2d246823dd7eb13e0970a5fa14f0392e8e4489eb", + "originalLines": [ + 945, + 947 + ], + "domain": "channelExport" + } + ], + "contracts": [ + { + "name": "ImportMapping", + "sha256": "a0d06f96f2f99f135d8d86eaeb3f3a7525fa24224d5664e80567da75cdecee06" + }, + { + "name": "CreateImportProfileDto", + "sha256": "6ffebbc39754d4b9df71a47d09c55f139ea33f9c4c0e26d8fa9c9756f7d642e6" + }, + { + "name": "ImportCommitDto", + "sha256": "7c4c7b25184101b781037d97e93c5b4c1f836259153cc7cb26ded7f9bcf504c1" + }, + { + "name": "ReviewImportItemsDto", + "sha256": "73be0348645a4316428eec4bd39d0ef02f84cef43c24530200d6c896cfae40e7" + }, + { + "name": "PagedQuery", + "sha256": "78ddbb3a41b07a37729b80c84e969cf3344d44c19bdebff93b593bacf2b51293" + }, + { + "name": "CreateReportBatchDto", + "sha256": "530b306e1f237c522e4a202ed1d43bca6e527eb6dd44afa113988de54fdad35f" + }, + { + "name": "ReportBatchTarget", + "sha256": "a91a765ad37d69e0f797c2b9c4b692789765b539a6b13d212f1a93cd5b1b4e65" + }, + { + "name": "ReportBatchInspection", + "sha256": "e00a2a0750dad4c413466426a2710a2ea59c2c8b369e61726db3f175b700cc00" + }, + { + "name": "AnalyzeImportOptions", + "sha256": "74e5e84a09befe64caada9f8574a52dde7e30770a7ffe4b99e417c60c6eda40b" + }, + { + "name": "EmbeddedImage", + "sha256": "9db22983b69311e82b0899663cd1534dd72510f14259ba37693bd63bc9bad59b" + } + ], + "helpers": [ + { + "name": "profileData", + "sha256": "e61848d9a038403295c40d26588ca44c32d4372529726ad9f2e22232474efc69" + }, + { + "name": "validateProfile", + "sha256": "6fe77bdd3830c93b40d9f3d3282f0fdd2c5afa8f025504ba57f9f8113a725947" + }, + { + "name": "loadWorkbook", + "sha256": "46ce8da23dce75f31ab29d00c0dae8e063d14cb72ccfbec95333bb1064bbcf39" + }, + { + "name": "assertSafeWorkbook", + "sha256": "5ed20a1e12793355949b5cf951b4113c9c61517bf5a97491cc22671ee6ccaa56" + }, + { + "name": "safeSpreadsheetText", + "sha256": "5a7db626de509e78628fc87ebf20e43d227e6ce3fefdf48bd82dd040ffd35683" + }, + { + "name": "readEmbeddedImages", + "sha256": "9fbd0df019528353aa7fc5220a279b23711d36fa61f78e0880d8bb2cc6228f4d" + }, + { + "name": "suggestMappings", + "sha256": "79abe1b569ffb867fd39ace7e731f1885dd113ac470d8b8dea315775d0eb3c28" + }, + { + "name": "remapProfileColumns", + "sha256": "4b58e11d2e3d97a1b352109a9be7c26aa25bdecdbb5e542a0fb241e46c7f657a" + }, + { + "name": "signatureCoreMapping", + "sha256": "5d4b0bdc68886cb022eecbf4eb4721613df74210d3165fd737b9badea00e5729" + }, + { + "name": "drainageCoreMapping", + "sha256": "256323027325923786e84c308eeebcd0dde0e0b5d27a563d10e794d318b7bddd" + }, + { + "name": "normalizeHeader", + "sha256": "b30ab7e6903b8e8411e3195c23581379badb6241bd518e327eb10ad0358e0c5c" + }, + { + "name": "normalizeFieldCode", + "sha256": "49387c9ca040ea2cbc5a4732652c4389f32be8169536f9e132225579dff839fa" + }, + { + "name": "clamp", + "sha256": "0aaed2dc249288c7f197e033b858c35aa10a0a2642203d5d92c94bd5db323926" + }, + { + "name": "normalizePage", + "sha256": "f04be3697001d814b86457cdd93eb6a83cff1b712327a4e74960c2436b784b19" + }, + { + "name": "normalizePageSize", + "sha256": "af1c28386f9802cb9c7cc3dddc712c1559d92d2c777b2bbce65d12cb1c5ecc38" + }, + { + "name": "dateRange", + "sha256": "a0029a9c2eac718e2c7d63b30a7b3b65c504f859838ac9de7499ec1103ae1e89" + }, + { + "name": "cellText", + "sha256": "a6f5fd540e5cbcbc8fc1ef1dfb4b8203e16212bb2d7c4a8b3aa0068f296f8981" + }, + { + "name": "transformValue", + "sha256": "584b33bf430cd5c47e6e9a36b88a75519335125e80b0b63e3139e5f156333118" + }, + { + "name": "mappedCoreValue", + "sha256": "a34b22e2cfbe93445eeedcfbf4f0b60d9ee0f5ed872c069e6db260a58dda723f" + }, + { + "name": "dynamicValues", + "sha256": "a955844529a42a1f0bd73645bb4407980c7ae570bcd7d52a738fcae46f444d9b" + }, + { + "name": "jsonRecord", + "sha256": "2f7c1984863c82abc61e7f753bd5bf1d752802d4ab552dd7ee03d6ddd7844ce1" + }, + { + "name": "hasValue", + "sha256": "ae0550bd772c41447e7f975cb12a4812bc7ccde1ade718cd4fa1b3ae6af34583" + }, + { + "name": "isFileRef", + "sha256": "37413460e45e152f80c3b49e82addfc332dacf0d3585cffe8868e8b93f2da87b" + }, + { + "name": "resolveExportValue", + "sha256": "c6aae47afc4ece039eb97053ed491136a38563506deb0246a3d0c37784b7bccc" + }, + { + "name": "applyExportTransform", + "sha256": "197b0126b8a8cf69d0087e45ec221ad608c7e5e2e94d8a2735adebfdcd801fad" + }, + { + "name": "styleHeader", + "sha256": "8c419d0cd3fcbd2f84d4b079ae025557560fa479d59b5d3aca4970c65319d496" + }, + { + "name": "normalizeImageExtension", + "sha256": "8c2f57eedc5c5294001b6a842581cf4b3cb9a61dbe392c700e0672416692fddc" + }, + { + "name": "imageContentType", + "sha256": "589cd67492e900039830c1c6694cc910b726bf78e3b275d53664965842b122c7" + }, + { + "name": "safeFileName", + "sha256": "948e0371435a10f79bc24a9c70ed0911bff050c209cba794e42b713fecd50a34" + }, + { + "name": "normalizeBatchIdempotencyKey", + "sha256": "e2f921d15dc1300b15c23089fd63c6c1ecafdfb11fbce93cdb3f7dd7b3a7b760" + }, + { + "name": "jsonStringArray", + "sha256": "469f80e2ba2119bfa26c19e371224b239c2df8ab6bdbe75f5906ab2047744622" + }, + { + "name": "jsonSafe", + "sha256": "3f72a3704848a68c9488c3b692c4f6144e64a7f6cb51ca3ad2ba5d7d92cd3e4f" + } + ], + "domains": { + "officialExport": { + "className": "ReportOfficialExportService", + "file": "api/src/report-materials/official-export.service.ts", + "methods": [ + "buildOfficialTemplate", + "exportPending" + ] + }, + "importParser": { + "className": "ReportImportParserService", + "file": "api/src/report-materials/import-parser.service.ts", + "methods": [ + "listImportProfiles", + "saveImportProfile", + "analyzeImport" + ] + }, + "importReview": { + "className": "ReportImportReviewService", + "file": "api/src/report-materials/import-review.service.ts", + "methods": [ + "commitImport", + "listImportReviewBatches", + "reviewImportItems", + "stageSignatureRow", + "stageDrainageRow", + "applyImportItem" + ] + }, + "pendingQuery": { + "className": "ReportPendingQueryService", + "file": "api/src/report-materials/pending-query.service.ts", + "methods": [ + "listPending", + "findPendingItems" + ] + }, + "batchGeneration": { + "className": "ReportBatchGenerationService", + "file": "api/src/report-materials/batch-generation.service.ts", + "methods": [ + "listBatches", + "createBatch", + "preflightBatch", + "prepareBatchItem", + "inspectBatchItem" + ] + }, + "channelExport": { + "className": "ReportChannelExportService", + "file": "api/src/report-materials/channel-export.service.ts", + "methods": [ + "exportChannelBatch", + "recordTask" + ] + }, + "batchOperation": { + "className": "ReportBatchOperationService", + "file": "api/src/report-materials/batch-operation.service.ts", + "methods": [ + "claimBatchOperation", + "completeBatchOperation", + "failBatchOperation" + ] + } + } +} diff --git a/docs/contracts/send-chain-r10-completion.json b/docs/contracts/send-chain-r10-completion.json new file mode 100644 index 0000000..99d908a --- /dev/null +++ b/docs/contracts/send-chain-r10-completion.json @@ -0,0 +1,250 @@ +{ + "version": "R10", + "generatedAt": "2026-07-31", + "source": "api/src/send-chain/send-chain.service.ts at R9 local baseline", + "facade": "api/src/send-chain/send-completion.service.ts", + "methods": [ + { + "name": "handleSubmitSegmentResult", + "file": "send-gateway-result.service.ts", + "bodySha256": "1a508cba1eb3547bfeabdecdbc0e62bf4971807212439b0adf1f2b52a2699878" + }, + { + "name": "resolveSubmitRecordForGatewaySegmentResult", + "file": "send-gateway-result.service.ts", + "bodySha256": "a4e233c6b4c13858355573e3d61e77f07ef141e20d3a98c0108d6ff27157f3e2" + }, + { + "name": "handleSubmitResult", + "file": "send-gateway-result.service.ts", + "bodySha256": "6184170748eb3934496b060ea3b6704f1d1a831e7ffde7e7f8b73a7c223a869c" + }, + { + "name": "resolveSubmitRecordForGatewayResult", + "file": "send-gateway-result.service.ts", + "bodySha256": "a904de3a2fe6c9ad1a6aa50982a9dfb1633b6a27466f421c978afe72ba974b8b" + }, + { + "name": "smsMessageSegmentAuditDelegate", + "file": "send-gateway-result.service.ts", + "bodySha256": "c907ad9b162874735ecdf0d024f18a4ff15cf8f1be3a95c628b3bfe793230b65" + }, + { + "name": "recordSubmitSegments", + "file": "send-gateway-result.service.ts", + "bodySha256": "897c27559d20613b5fd87a22f2748658f31c4bf9435935a523e21d5d1100206f" + }, + { + "name": "findMessageByGatewayEvent", + "file": "send-gateway-result.service.ts", + "bodySha256": "8212cecfa19b3be90ce565c9f4446183bf06073c633683e0f94ea4e104b23760" + }, + { + "name": "requireMessageByGatewayEvent", + "file": "send-gateway-result.service.ts", + "bodySha256": "76757dad543ee12c9622a25fbfdace943e9be537d5e68f1592768c4504eb5617" + }, + { + "name": "intakeReceipt", + "file": "send-receipt.service.ts", + "bodySha256": "42cad611473ebb973beca6b4f5c05192dc1e2e35ee9f85e2d7cb86b35129a32b" + }, + { + "name": "processPendingUpstreamReceiptInbox", + "file": "send-receipt.service.ts", + "bodySha256": "3614b8aabb5640a94b4b2f8a65c9dff3425a7578e753410413eead52c33f747f" + }, + { + "name": "processUpstreamReceiptInboxRecord", + "file": "send-receipt.service.ts", + "bodySha256": "5882e14d9474e6c940a9a28d6a9b78f4e9e46184caa616971674dfb9efd1b70d" + }, + { + "name": "runUpstreamReceiptInboxScan", + "file": "send-receipt.service.ts", + "bodySha256": "a1bda2f26563c0cfe5cb5564aa5ef34f513e2c99bb302a0a0ad7917b4887c7ed" + }, + { + "name": "handleReceipt", + "file": "send-receipt.service.ts", + "bodySha256": "e3b63dc74af50db96d899c805124c7974067ca729e44a1ec8a558569e81fbd6c" + }, + { + "name": "recordReceiptSegment", + "file": "send-receipt.service.ts", + "bodySha256": "acb776e7ee67b07c9e635411ef2a4286db60366fd6b20083be870c9314dab8f0" + }, + { + "name": "aggregateReceiptSegments", + "file": "send-receipt.service.ts", + "bodySha256": "7dc565bd0847462bd0e23b5f072a4f82fd6daa88f164485138ce653d8cf936b8" + }, + { + "name": "resolveReceiptMessage", + "file": "send-receipt.service.ts", + "bodySha256": "caadd32d37d3b72e2b7e526f86cdb422c054c2ed9c910a4b4d1d1f3eb1c6b522" + }, + { + "name": "recordGatewaySubmitDeadLetter", + "file": "send-retry.service.ts", + "bodySha256": "85180d382ba99b9e714d406ab14f30de91445fa2a7a695acebf9ee359716fdff" + }, + { + "name": "requeueGatewaySubmitDeadLetter", + "file": "send-retry.service.ts", + "bodySha256": "e1092c28b657e522a4bf77d833c694460c871bb82424f2604b01d204a00dcdc0" + }, + { + "name": "recoverStaleGatewaySubmitRequeues", + "file": "send-retry.service.ts", + "bodySha256": "cc36a00787acfc00a1e1797ce83550d4de0441e1b83025d16624ba966577a6ba" + }, + { + "name": "retryMessageIfAllowed", + "file": "send-retry.service.ts", + "bodySha256": "6892322b81983c99ad9063e334b435ab369eb5a6fd0268e09ef3772cd56bc484" + }, + { + "name": "chargeAcceptedMessage", + "file": "send-accounting.service.ts", + "bodySha256": "4ca42fd6ea6e41509a1ff6d62164a1dcfa81f62256386cdb49294451162312dc" + }, + { + "name": "releaseMessageReservation", + "file": "send-accounting.service.ts", + "bodySha256": "0838524d280b3c9b0c0146ea53b8b35e77295d8f626325a80b62e8f2a9faa607" + }, + { + "name": "refundMessage", + "file": "send-accounting.service.ts", + "bodySha256": "4cca77a11bbdc419107f4e5c12a7d73190901c323dde0492f7489cac337759cb" + }, + { + "name": "listPendingDownstreamDeliveries", + "file": "send-downstream-state.service.ts", + "bodySha256": "1c5e0bc271fcbfdf8a0c55e2870e9ddb41ffbbf24d3fe0c2285b4806e4854012" + }, + { + "name": "markDownstreamDeliveryDelivered", + "file": "send-downstream-state.service.ts", + "bodySha256": "5fe521d4d5e50ec0a6b4ddda45f6dc67633da792161975837e6bb32c4025daaf" + }, + { + "name": "markDownstreamDeliverySent", + "file": "send-downstream-state.service.ts", + "bodySha256": "08bd1cbc9c5662c158b598acb80374f36b9b7f623907345ff6821fd65b843167" + }, + { + "name": "acknowledgeDownstreamDelivery", + "file": "send-downstream-state.service.ts", + "bodySha256": "5c53b04f37e891e77159256663e72071229ccb867b1d53ad1e0c9f6ec9595af6" + }, + { + "name": "markDownstreamDeliveryFailed", + "file": "send-downstream-state.service.ts", + "bodySha256": "1b072fef54577d5de077a7086386c218a20b6d79c2171319e2e4c7fee99eddc5" + }, + { + "name": "recordGatewayDownstreamRecoveryStatus", + "file": "send-downstream-state.service.ts", + "bodySha256": "0375019b79ceba79a39067c9d27b249c1a1554813ae8c272d9f7711d986d463d" + }, + { + "name": "requeueDownstreamDelivery", + "file": "send-downstream-state.service.ts", + "bodySha256": "1e680512bd7a02735a8fd3713ec22d83cbc70505d494fd06a57cd628d8df6e99" + }, + { + "name": "recoverStaleDownstreamManualRequeues", + "file": "send-downstream-state.service.ts", + "bodySha256": "0c6142754daff8df2e0bd7f1d323fd1ca0c9b2d83bc195cb664e21b119b8a972" + }, + { + "name": "batchRequeueDownstreamDeliveries", + "file": "send-downstream-state.service.ts", + "bodySha256": "00af2d3af7cde4791190d0eebb50d97fca6e9a1c1b595fefe2d13bdc18dffebd" + }, + { + "name": "handleUplink", + "file": "send-downstream-delivery.service.ts", + "bodySha256": "a0623ca0407ecab8b1b558e1784fe299a4a01008209bc1ff4bc60afed2c3e280" + }, + { + "name": "claimUplinkMatchCandidate", + "file": "send-downstream-delivery.service.ts", + "bodySha256": "4330127ec62d2653ba0411a824378dec30ea657d0b688927d867d750932b67b5" + }, + { + "name": "queueAndTryDownstreamDelivery", + "file": "send-downstream-delivery.service.ts", + "bodySha256": "0b5f6f5e9ffebad5ddda003962efdabb0daeb2ec5942a246bdf9d922f274d4ac" + }, + { + "name": "resolveUplinkMatch", + "file": "send-downstream-delivery.service.ts", + "bodySha256": "2f017f39bb6332b43f78da2cbc8195bcec9e926d585f95f05b1da4b5df51b9cb" + }, + { + "name": "recordCmppFailureReceipt", + "file": "send-downstream-delivery.service.ts", + "bodySha256": "d48ecabfcce62787668486a8f14b3ea7afc935d5d5d910651fc1ddf8d8ac3952" + }, + { + "name": "postGatewayControl", + "file": "send-downstream-delivery.service.ts", + "bodySha256": "0f991ecb7f58268b5d44d2b32ddd940f0ce16420d8a7c94534e2eaaf36e38d5f" + }, + { + "name": "markUnknownTimeout", + "file": "send-timeout.service.ts", + "bodySha256": "6f254ffbf05067483801cdd2c131f32cb9aaaf755d19f3aff065fe99b8d7753b" + }, + { + "name": "markExpiredDownstreamDeliveries", + "file": "send-timeout.service.ts", + "bodySha256": "6d06c11e68394df57b0cb981ba566e5ce34924c85b14b4add93e8d3856a0d02d" + }, + { + "name": "runReceiptTimeoutScan", + "file": "send-timeout.service.ts", + "bodySha256": "5e2942916813d62218dd63f51d05554ce9e455d1b3283704c3fd54dc124a4e5f" + } + ], + "domains": [ + { + "file": "send-gateway-result.service.ts", + "className": "SendGatewayResultService", + "methodCount": 8 + }, + { + "file": "send-receipt.service.ts", + "className": "SendReceiptService", + "methodCount": 8 + }, + { + "file": "send-retry.service.ts", + "className": "SendRetryService", + "methodCount": 4 + }, + { + "file": "send-accounting.service.ts", + "className": "SendAccountingService", + "methodCount": 3 + }, + { + "file": "send-downstream-state.service.ts", + "className": "SendDownstreamStateService", + "methodCount": 9 + }, + { + "file": "send-downstream-delivery.service.ts", + "className": "SendDownstreamDeliveryService", + "methodCount": 6 + }, + { + "file": "send-timeout.service.ts", + "className": "SendTimeoutService", + "methodCount": 3 + } + ] +} diff --git a/docs/contracts/send-chain-r8-pure-logic.json b/docs/contracts/send-chain-r8-pure-logic.json new file mode 100644 index 0000000..a415d77 --- /dev/null +++ b/docs/contracts/send-chain-r8-pure-logic.json @@ -0,0 +1,448 @@ +{ + "version": "R8", + "source": "api/src/send-chain/send-chain.service.ts@workspace-before-R8", + "contracts": [ + { + "name": "CreateBatchTaskDto", + "kind": "interface", + "sha256": "70414ba63f4da4a501e60c2ba69d785113692d14c775289e6740065e5124fdf8" + }, + { + "name": "CreateHttpBatchTaskDto", + "kind": "type", + "sha256": "a83aa71a11c756d84afa7b5ccb1af46d9008f59a77fd5329236beef752309abe" + }, + { + "name": "GatewayInboundAuthDto", + "kind": "interface", + "sha256": "1e55f6a2a393cd72c7aca2b4a1e18e293ae20f513412f627197515448c8da166" + }, + { + "name": "GatewayInboundSubmitDto", + "kind": "interface", + "sha256": "28f99a1e29385b64c3a6b4eab2e5befa3fc6cd8a6995090e0865f5af6871246a" + }, + { + "name": "GatewayInboundSingleSubmitResult", + "kind": "interface", + "sha256": "6e71836ba8c38cc0634e5540a70d8a2cfb98c1b11f0e5ef8749f57a81fd3443b" + }, + { + "name": "GatewaySubmitResultDto", + "kind": "interface", + "sha256": "a403995091710ac369b727fbcd5191515da7e10df5aad631dedb6dbcf751fe49" + }, + { + "name": "GatewaySubmitSegmentResultDto", + "kind": "interface", + "sha256": "11ecd1395daac263757bc364a5551a7c8b41307622f446d191b25628fa287410" + }, + { + "name": "GatewayReceiptEventDto", + "kind": "interface", + "sha256": "d2f68b459392e50e481dadaed9cd222087175b266613d265dd1b41a235cfd539" + }, + { + "name": "GatewayUplinkEventDto", + "kind": "interface", + "sha256": "4b81ac70af7584956c706877bbea932d62851c79fd2e8e333e4a8b1023eedf98" + }, + { + "name": "UplinkMatchCandidateInput", + "kind": "type", + "sha256": "a82efe64ba28740ad691ed645ed7e8ea205a555bf68ac3a18ffec9e36251b929" + }, + { + "name": "GatewayPendingDeliveryQueryDto", + "kind": "interface", + "sha256": "8208ddf3a047c684e513f1828d5be0af617b28b84f1bb6b75c3e8822f58341bd" + }, + { + "name": "GatewayDownstreamSentDto", + "kind": "interface", + "sha256": "a1d45c4dd241159afc02e87c3441c4e31cb06e6853ee13a5f22a75f9a5156464" + }, + { + "name": "GatewayDownstreamAcknowledgedDto", + "kind": "interface", + "sha256": "d91e0bebf4abcd7f5506b75b2106d7899fa4d6c120e28c5db5f7c8e4d472bf87" + }, + { + "name": "GatewayDownstreamFailureType", + "kind": "type", + "sha256": "f0d4da34c9194b679816527b87f7077c78f85ffde37aabb81f76f91ddcf5ef4a" + }, + { + "name": "GatewayControlDeliveryResult", + "kind": "type", + "sha256": "916a1700848b5dd369196c105bdc99928e186608baf140ae0860d34e0725ea34" + }, + { + "name": "GatewaySubmitDeadLetterDto", + "kind": "interface", + "sha256": "ee032743d02f7f76c073695b5579ccd9884b5d94e016a17e548227a6f36ed4e2" + }, + { + "name": "RequeueGatewaySubmitExceptionDto", + "kind": "interface", + "sha256": "0b0973656d0cecb25ac9c8c6ed863fbebfbb26178a6056c956f72e29f4d9dc94" + }, + { + "name": "GatewayDownstreamRecoveryStatusDto", + "kind": "interface", + "sha256": "0c3b31cc6cce50dec8454e8427ba1f369d2f81ad7e583e564d62bab1db46260f" + }, + { + "name": "TimeoutUnknownDto", + "kind": "interface", + "sha256": "231ca785c112103fa9432a19f0cb79745da52126b76055498678a6ebdc053a83" + }, + { + "name": "ImportPreviewDto", + "kind": "interface", + "sha256": "0123d0e98b1355a64c317254e9e0d8776ad7a4043da8e3ada245ea8a5ea2f352" + }, + { + "name": "ConfirmImportDto", + "kind": "interface", + "sha256": "ce98fe82a9d5052d8d8654b60985225240985c5473194f71987af92e311c0b89" + }, + { + "name": "SendJob", + "kind": "interface", + "sha256": "57a692f8dae8e37ba3758f7afd75056ea629ed58c20139ebd60144cb99fd4177" + }, + { + "name": "QueuePriority", + "kind": "type", + "sha256": "ca80dc4d2708054b7388426c1ca71a3e84db3ed08999ed89f9f791878fa03f70" + }, + { + "name": "RoutedChannel", + "kind": "type", + "sha256": "d82fe045e8ae72380f575ecbd47ab9849d6eb2b85e74a27e6f116a019da7f2a4" + } + ], + "helpers": [ + { + "name": "SEND_QUEUE", + "kind": "const", + "sha256": "11a3c2aaf41c2ffe7ae251b5b5da00591624e21d345324563844e59f517b67d3" + }, + { + "name": "GATEWAY_SUBMIT_QUEUE", + "kind": "const", + "sha256": "c69e4af1b3939a576aa63833947b10b86b87a646b373684d9726dfbaf4c95d8c" + }, + { + "name": "GATEWAY_SUBMIT_STREAM", + "kind": "const", + "sha256": "70d662152ad15ff017e1f61aca86525554c5b6a83f97686529dca1a0eaf28849" + }, + { + "name": "DEFAULT_DOWNSTREAM_RETRY_DELAY_MS", + "kind": "const", + "sha256": "5c216981884a2422ee8765d00745e99414d52976df4b68e90e5f2bc6bf22d090" + }, + { + "name": "DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS", + "kind": "const", + "sha256": "02a1e63eb1887f5f924a5ed2f87f09edaf7eed54e419a9ce4c5afe1f52a061fa" + }, + { + "name": "DEFAULT_DOWNSTREAM_MAX_RETRIES", + "kind": "const", + "sha256": "fa2107f33e9c2173a8cb91140c59a28193ca88d8c8bb9b14788af5427e406c38" + }, + { + "name": "DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS", + "kind": "const", + "sha256": "f7038a98205b1fe6f30df3e3af01a74336408cce7acc92f9254b737d608ca189" + }, + { + "name": "DEFAULT_RECEIPT_TIMEOUT_HOURS", + "kind": "const", + "sha256": "3ffc70f45997ee0a68bb4ff1a041bf4408c0c71a3a3dc8517a6c152d20e4c931" + }, + { + "name": "DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS", + "kind": "const", + "sha256": "833d0c26f3f6e57b7f451a4bd0472c33ba99fdb8dc2479bea7b50759efcbf948" + }, + { + "name": "RECEIPT_TIMEOUT_INITIAL_DELAY_MS", + "kind": "const", + "sha256": "d8ffa15508dad01de044034ddefbd379ac8a654a5f2e90dcfa05c0bb45990359" + }, + { + "name": "DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS", + "kind": "const", + "sha256": "86cc465a9fea068fa0f945d645a5129a17b021baa9daa05dc5ee666b669a6aca" + }, + { + "name": "DEFAULT_SCHEDULED_DISPATCH_STALE_MS", + "kind": "const", + "sha256": "3337c4f2b53a6ca4a3b65326482f08c4d04be84324acc109d39cca65d2cb89ea" + }, + { + "name": "SCHEDULED_DISPATCH_INITIAL_DELAY_MS", + "kind": "const", + "sha256": "61ee979c2d1d57cf977180c198cdb3e4a65683a6149619153db69ca72fa12d8b" + }, + { + "name": "DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS", + "kind": "const", + "sha256": "934da39a488b3a640c1649f8fb8a5a0a06b6e7c12896f4f7bf5756d1db53f6d7" + }, + { + "name": "DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS", + "kind": "const", + "sha256": "90e094f303fc32dbe7c5264b516cf69ae471ebe445344f991f38bff411dfe9fd" + }, + { + "name": "DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS", + "kind": "const", + "sha256": "a8e7362d181929843a1a4602f75077e22fd39a4566a435ea46edcd8a14289cca" + }, + { + "name": "INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS", + "kind": "const", + "sha256": "596cac172bef8509c819282e30e2312477148c9740ee3c818ec6428408695fb9" + }, + { + "name": "DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS", + "kind": "const", + "sha256": "9025a5ff2f673eabdd15d6f4a4a81af9485ea96680d4db637eb04a0287d7e9dc" + }, + { + "name": "DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS", + "kind": "const", + "sha256": "6f32cbb21fe7511234b8227fadca11e58ede0c902ce25c45ecbd6122c9cc9ded" + }, + { + "name": "UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS", + "kind": "const", + "sha256": "a763a37b4e3af5ba91267b8c901097cd384aa618b13b7e5459788c791e83520d" + }, + { + "name": "DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS", + "kind": "const", + "sha256": "dac7d3fd5cd652cef9b59fd7458b9af13e27e32748c72618934fe21cb89b96d3" + }, + { + "name": "DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS", + "kind": "const", + "sha256": "4b15ce126f631f5120298ff35a6e9a521db51c773b863914231cef27e4fa4d55" + }, + { + "name": "DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS", + "kind": "const", + "sha256": "1f58c45b6f69eec7b09c071d5cbc6b38332fdd5f7db08aa9e62f89cc6914c552" + }, + { + "name": "GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS", + "kind": "const", + "sha256": "a0c1f68b87266d20ab33df0192bb0cf0265276c7fa2ef2adc9b910efdc42f791" + }, + { + "name": "BULLMQ_PRIORITY", + "kind": "const", + "sha256": "6ad900e1addfdabe932080b8f7e8976f28257bd1d838c4490b4410dd669988fd" + }, + { + "name": "gatewaySubmitRequeueKey", + "kind": "function", + "sha256": "08f7ddf26ee168fe054ba96e425023b234447a6fe7ceb129e825eb2cbca6d5e5" + }, + { + "name": "drainageRejectionReason", + "kind": "function", + "sha256": "4bc0aa5e5c7f96549cd8bad93d3b25fd5e65e6b017ae9bf83baa5c47cab08c02" + }, + { + "name": "statusFromRisk", + "kind": "function", + "sha256": "4fb98f5add40278521c314dac20ec3c233b73c589714f11146964a3752ebce3a" + }, + { + "name": "parseSchedule", + "kind": "function", + "sha256": "e335aca59919111f2727b4e07cc8d50ab9443a3f06b83af807ea17d84ece861c" + }, + { + "name": "isObjectRecord", + "kind": "function", + "sha256": "c26e34d94f53b7befe62971546edd54d5a392b12aa10848e66a295624eff0c67" + }, + { + "name": "asDateOrNull", + "kind": "function", + "sha256": "f755f5ae6babe0e6626c07a9822190fb808a74fffefe69012c269da5b0650e43" + }, + { + "name": "downstreamRetryDelayMs", + "kind": "function", + "sha256": "8bab0089029e48a1027b4b4533b7c7fa0587b15e761bf566ce772b80c858f44a" + }, + { + "name": "downstreamAckTimeoutMs", + "kind": "function", + "sha256": "99d3c8f3192498f344f3bec496e0997c77740c56ea973081af1ac0acd8b52374" + }, + { + "name": "downstreamRetryBaseDelayMs", + "kind": "function", + "sha256": "2e4227ae9b787adfed98922c757e26d5b7776a225da2007d909d119476c391aa" + }, + { + "name": "downstreamRetryMaxDelayMs", + "kind": "function", + "sha256": "aa34e8a657c24224b0290222944c1b82302126921d268516d637301a4e21923f" + }, + { + "name": "downstreamMaxRetries", + "kind": "function", + "sha256": "0642b4d1609abd898d392af18ebfb074335e71fd8eea0f8fd695ae698672aaaa" + }, + { + "name": "downstreamPendingTimeoutHours", + "kind": "function", + "sha256": "41eefd652ad90dc265b22c42485f171dcd164038b69adfa1dd187fa6edfd53ea" + }, + { + "name": "downstreamControlFailureMessage", + "kind": "function", + "sha256": "1f98e86fb2dbb6cfbf48ee4e16edcad0517c4b0da9cd51bb05b7059056da03f3" + }, + { + "name": "parseImportRows", + "kind": "function", + "sha256": "abab46d40b8a7e9363dbbe7c0c7cee8412446099195ee58c08e5212855215fe7" + }, + { + "name": "splitImportLine", + "kind": "function", + "sha256": "574735eeefcc95e240fbcf9426025302382318a599fdd2207b23519637209bc8" + }, + { + "name": "cellByHeader", + "kind": "function", + "sha256": "b867ffb1936e164311b56d4735d45c8b4d9719f55e1bef4b2b3b6036d4a27470" + }, + { + "name": "normalizeCarrier", + "kind": "function", + "sha256": "d9cf3cca1702c7cd0dfc75f5aa58daa7759acbba66a1884471f710bb024ac06a" + }, + { + "name": "normalizeQueuePriority", + "kind": "function", + "sha256": "bcd2ddee1f71a873966e30bb6dcdb8b09c47998bd6c4846c8fa8db248e38df3c" + }, + { + "name": "getPositiveConfigInteger", + "kind": "function", + "sha256": "7bf2cf0a512143da511716327721a235ba60b40decb50a3dc28555e583af76f0" + }, + { + "name": "getNonNegativeConfigInteger", + "kind": "function", + "sha256": "b7782a614fa04dd858411ed86f39301313da4d35c5a66d96993266e939621cd8" + }, + { + "name": "isCarrierCompatible", + "kind": "function", + "sha256": "5353ca585ca05dd07f82eedd240a2a0c2e22d0361cc8636dd0a25ed010ccbdc5" + }, + { + "name": "normalizeRegion", + "kind": "function", + "sha256": "3775eb50779ce87f8982ce691b18f91d8ed145a5dc9c27c8a315b0f55cb09152" + }, + { + "name": "matchTemplateContent", + "kind": "function", + "sha256": "793a46db4cd6b5387214597f034bc14068d9733710b7a4d42124ee8503095243" + }, + { + "name": "escapeRegularExpression", + "kind": "function", + "sha256": "8c895b59e4a50a28f11a62883bf68be67a4672fca55774f3060c7992acce734a" + }, + { + "name": "isNationalChannel", + "kind": "function", + "sha256": "29f495b1163cc240bccbfc6a490fb04aa892ec65dc40d599f14fbd8f0628f625" + }, + { + "name": "isProvinceChannel", + "kind": "function", + "sha256": "ad1d1f53cfc50c474d455fb021fba6ae31887c911d3691d80e2dbfbf6731d73f" + }, + { + "name": "validateInboundApplicationSrcId", + "kind": "function", + "sha256": "35e9e29d174f3bc265fb26b1222480e071a16c02e8898fc6b88ba493145350cf" + }, + { + "name": "composeUpstreamSrcId", + "kind": "function", + "sha256": "f02d16cd4a722d53b3295d40b24fa0e5e8191475a050b08b99ea256d20746176" + }, + { + "name": "positiveInteger", + "kind": "function", + "sha256": "f461d0cb8b5dd9a131337cf2114f0db83ea5a449f752b03c429e8d19e5d95b78" + }, + { + "name": "parseOptionalSequenceId", + "kind": "function", + "sha256": "45c7cb2852b6e491ea4ca51c9198bf4e681a728b44ac71cdf226f119795d7ac6" + }, + { + "name": "normalizeSubmitStatus", + "kind": "function", + "sha256": "c63234b410942e86f3f8c8dfb65028f75f1e3fe9499e614d578d2c4f4a6ddcb9" + }, + { + "name": "normalizeReceiptStatus", + "kind": "function", + "sha256": "5db75635aac978593035af0746422d4842a01008a6d7223c960659120c91d740" + }, + { + "name": "downstreamDeliveryAttemptKey", + "kind": "function", + "sha256": "e62c0e52d6484f1599ffc3aaad21871efe8dd29d4cdc4bc6f49f5e95b9c7ad4b" + }, + { + "name": "shanghaiDateKey", + "kind": "function", + "sha256": "6bd70c1020b95c8013b29490754a8f2ac243c2d97c7b3fcf043d87cba6bb3296" + }, + { + "name": "bullmqConnection", + "kind": "function", + "sha256": "1fe9095c9ed383419f640913f033ff0715d4e241c8114543097a51dbf1acaf67" + }, + { + "name": "matchesApplicationSecret", + "kind": "function", + "sha256": "5ff5731f341a2a5ca427f3c232d74b2ef0d54280aba8bf4b4728274dde9b0e4e" + }, + { + "name": "octetString", + "kind": "function", + "sha256": "c3c7e6248530121ba0792d8918f43f0869d62fb94e1627f1c0173462fd0c4499" + }, + { + "name": "hasRecoveryAuditStateChanged", + "kind": "function", + "sha256": "de4073a3dfd772f0cbb9d57255618cc5f54543ac378325ab35558617749c28ca" + }, + { + "name": "normalizeRecoveryFailureCategory", + "kind": "function", + "sha256": "69f4f1dd9c09e4d84277a92eb28cf6d117ac10fa649faf7898ceb0cd0c68a9c5" + } + ] +} diff --git a/docs/contracts/send-chain-r9-submission.json b/docs/contracts/send-chain-r9-submission.json new file mode 100644 index 0000000..bb705a5 --- /dev/null +++ b/docs/contracts/send-chain-r9-submission.json @@ -0,0 +1,260 @@ +{ + "version": "R9", + "generatedAt": "2026-07-31", + "source": "api/src/send-chain/send-chain.service.ts at R8 local baseline", + "target": "api/src/send-chain/send-*.service.ts via send-submission.service.ts compatibility facade", + "methods": [ + { + "name": "createBatchTask", + "bodySha256": "c7baccb2b49a95ef4b36a97f6f56c987f0ac60c45ed634cd050201f2a9bce8b5", + "file": "send-batch-entry.service.ts" + }, + { + "name": "createHttpBatchTask", + "bodySha256": "9c5a8d4a1a257e418f8abf83b7ae4fc10148e966a77d45e78568207eeabf92a6", + "file": "send-batch-entry.service.ts" + }, + { + "name": "getBatchTask", + "bodySha256": "ce25b495f876e746e7b707410e699479bb7d9cb27c2a749c89dd45a956a23c07", + "file": "send-batch-entry.service.ts" + }, + { + "name": "previewImport", + "bodySha256": "c722c90e014ee4b7301e8c7a01591b0573574f15b49df5fc9498067235613013", + "file": "send-batch-entry.service.ts" + }, + { + "name": "confirmImport", + "bodySha256": "48ed35f8271892be111909dcce3ddf0705e3054ca38a236c416e9b8d41747864", + "file": "send-batch-entry.service.ts" + }, + { + "name": "enqueueBatchTask", + "bodySha256": "909972eb0accfb6e31b9fd6118750005c4a6671577ef5b5c9222a341dac59800", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "handleReviewDecision", + "bodySha256": "62f118fa543b8db59773300c5218008c25ae779ab9e0a888aac26c7e85ed3db1", + "file": "send-review-continuation.service.ts" + }, + { + "name": "dispatchDueScheduledTasks", + "bodySha256": "8fd154bd1318b362b844a97a42cde360623ecb372bd476b92baa54db8a4be768", + "file": "send-scheduled-dispatch.service.ts" + }, + { + "name": "runScheduledDispatchScan", + "bodySha256": "02167ffcf1972ee2d85e40d933f0c02fe031300e973fa4307dfd1678a14f27ba", + "file": "send-scheduled-dispatch.service.ts" + }, + { + "name": "startWorker", + "bodySha256": "82fe6e5b27f95b15f4a5c218cb6c30e99a9128c293192e7443657d8b245f84d6", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "processSendJob", + "bodySha256": "cec399a6552c118a77530f760e2c05b4d2d1fd01c9bdc477f342e0015d461207", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "authenticateInboundApplication", + "bodySha256": "2630dd1066b5a3e86c805d13102973e1c928ca2747bd0c3741481d03169eba2f", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "submitInboundMessage", + "bodySha256": "281e33a732f80e4ede6cc9d04a5b206a9348e17e66dee33d53f2d8d1d9bb9472", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "recoverCompletedInboundLongMessageResponse", + "bodySha256": "d98dd2a7606f45711f9dafbcbf4668c27a4d17a9343ee790ebe2c8b36ff5e49c", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "submitCompleteInboundMessage", + "bodySha256": "10e2140580e4bae2055853981de65f4adf82c228b647dbb56fce7c39469acaf3", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "collectInboundLongMessageFragment", + "bodySha256": "5b4e7f795966462fccb8c4c7a2cfae649ef19ee1b8176e7356eb2d621526ba41", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "expireInboundLongMessages", + "bodySha256": "2328633d6c410089b3be4e38245d238a783015e3411baee0f8202a9c953aa427", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "submitInboundSingleMessage", + "bodySha256": "df97548b1710b217f118cc297104d6c5cc845acc40947df4180036b8d284d847", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "evaluateRiskWithPhoneFrequency", + "bodySha256": "2e9fab56cd8ae85856fae2e88bb49c1ac1438feea79b7410069554c4fb494782", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "submitMessageToGateway", + "bodySha256": "4a928eab5727ba457ee1d4a202e719b6e389361ab2be8ad13ccf146dcebdc141", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "selectChannelForMessage", + "bodySha256": "3ab89dbfea42c99a898e7ea62742b200a18615e7aed541c2978af913b9689c68", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "findApplicationRoute", + "bodySha256": "110dd4b6d716ee0b08306a16818617b73fba8c6148ae6b7bc87480a2a3f75b54", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "identifyCarrier", + "bodySha256": "18e82894203d50d9b2371b9da0ac06fc34e83dd5600d3411f3a09a912955f0b4", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "identifyProvince", + "bodySha256": "56d1859733bfe44b6d32295f5706d06b64f1bb03e797e8c894f98b012a71fa35", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "resolveUnitPrice", + "bodySha256": "1d14202d83fcc9baa4e56ff2e33771db81f64159852142fadfeae4a89eb5e5c9", + "file": "send-batch-entry.service.ts" + }, + { + "name": "resolveQueuePriority", + "bodySha256": "752b13877a4b7a5f89f95cc7f52718e0c473a7c70cb0b99eec63370790540b89", + "file": "send-batch-entry.service.ts" + }, + { + "name": "resolveApplicationAccessNumber", + "bodySha256": "948dbcb3106c96c1c2aa3fe4e914590b38f3961d720357b2df3ede07e80663f5", + "file": "send-batch-entry.service.ts" + }, + { + "name": "findInboundApplication", + "bodySha256": "6d0bfcb122d0d824f7db542ed3d6a84d034d816f412c61cf96a38510d1738023", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "resolveInboundTemplateCandidate", + "bodySha256": "0bd1ccad86d18797132a4f142b24bcfff080021c76217f946301befc81b51b4e", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "resolveInboundSignatureCandidate", + "bodySha256": "716a526ea36f92012670c48e3f9cf4a2453435bed6b1943a310e603531998412", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "resolveTemplateMessageClassification", + "bodySha256": "f2b99e2e9d7fa00172b3cca72f080493fbf6f090f378784611388320254575f7", + "file": "send-batch-entry.service.ts" + }, + { + "name": "resolveDrainageInfoMatch", + "bodySha256": "31c393cd6b5b641776dc50cee46fe79cc5243adb57c6296a1b0cd8b346896021", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "attachMessageToReviewTask", + "bodySha256": "55485f011465ae62006552c1343822623a8d914d2989f74b3e809eea659539c2", + "file": "send-inbound-entry.service.ts" + }, + { + "name": "classifyRejectedPhones", + "bodySha256": "cd9fe84399dcf83d4e7926c4c3900f04fb84a526135009e5af17b417bf81b108", + "file": "send-batch-entry.service.ts" + }, + { + "name": "validateSendResources", + "bodySha256": "3c96b0edae411fb291cc9f52b0c3de8681c00f870c9e25cfd458703257639f00", + "file": "send-batch-entry.service.ts" + }, + { + "name": "reserveDailySendQuota", + "bodySha256": "2f3132ec25ad778d24ca8129263e2a4db9120697160f908fd247c9824dc7bc49", + "file": "send-batch-entry.service.ts" + }, + { + "name": "tryReserveDailySendQuota", + "bodySha256": "3de44b2c155cb25a8c327b465235d1ec627b375f5fa521e2c0fc0a9f3b62b3b5", + "file": "send-batch-entry.service.ts" + }, + { + "name": "ensureSignatureReportedForChannel", + "bodySha256": "8c2c5c6f4ef9a26a4643f215c5e590e25d91642946094705e6c1fc7070e0bbd3", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "resolveMessageSignatureId", + "bodySha256": "4c67a20f76e520afc23756ae475a365769c6eaea18c453a7516f2502c26b756a", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "waitForChannelRateLimit", + "bodySha256": "7706917a2535caf85fa17cbe8732179211ebca96ac233ba986c1e713f78f26b8", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "refreshTaskProgress", + "bodySha256": "3c5521193128d2d738abf37b7bd6213bfb7c60973d85daa28e4d9f28e2ea81e3", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "getSendQueue", + "bodySha256": "9dca5a9008ebd0c472db8807e64be409aed46e5dd289638149f56a4b23970d7e", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "getGatewayQueue", + "bodySha256": "0f24d7cbf91cf33b9620c6f6197edddfe64cc49e8d9e237eb656e84fb9965c66", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "getRedis", + "bodySha256": "f487638a6b656e0d297ed0f6f3181face99b71895dec42762c834bf1d20dc635", + "file": "send-gateway-submit.service.ts" + }, + { + "name": "publishGatewaySubmitCommand", + "bodySha256": "ad68171579e3790c3f9176f6a2c9fd900783da5ae6ded9593f3e156ea0e297de", + "file": "send-gateway-submit.service.ts" + } + ], + "domains": [ + { + "file": "send-batch-entry.service.ts", + "className": "SendBatchEntryService", + "methodCount": 13 + }, + { + "file": "send-inbound-entry.service.ts", + "className": "SendInboundEntryService", + "methodCount": 13 + }, + { + "file": "send-review-continuation.service.ts", + "className": "SendReviewContinuationService", + "methodCount": 1 + }, + { + "file": "send-scheduled-dispatch.service.ts", + "className": "SendScheduledDispatchService", + "methodCount": 2 + }, + { + "file": "send-gateway-submit.service.ts", + "className": "SendGatewaySubmitService", + "methodCount": 16 + } + ] +} diff --git a/docs/contracts/shared-components-r11.json b/docs/contracts/shared-components-r11.json new file mode 100644 index 0000000..2db3531 --- /dev/null +++ b/docs/contracts/shared-components-r11.json @@ -0,0 +1,146 @@ +{ + "version": "R11-step-7", + "entry": "src/main.tsx", + "componentsFile": "src/styles/components.css", + "legacyFile": "src/styles/global.css", + "minimumRuleCount": 259, + "minimumSelectorCount": 291, + "compatibilityMarker": "Shared table, form, and modal compatibility primitives migrated in R11 step 7.", + "canonicalMarker": "Canonical reusable component rules.", + "responsiveMarker": "Shared responsive table and form behavior migrated in R11 step 7.", + "genericClasses": [ + "table-cell-note", + "modal-footer-actions", + "icon-button", + "table-subline", + "table-link", + "table-actions", + "table-mono-id", + "table-strong-text", + "table-long-text", + "table-long-text--sms-template", + "template-modal-title", + "form-grid", + "form-grid--two", + "radio-row" + ], + "allowedStateClasses": [ + "has-dot", + "is-disabled" + ], + "requiredComponentBindings": { + "src/components/ui/Button.tsx": [ + "ui-button" + ], + "src/components/ui/Input.tsx": [ + "ui-field", + "ui-input" + ], + "src/components/ui/Select.tsx": [ + "ui-select", + "ui-select__dropdown" + ], + "src/components/ui/Table.tsx": [ + "ui-table-wrap", + "ui-table", + "ui-table__empty" + ], + "src/components/ui/Modal.tsx": [ + "ui-modal", + "ui-modal__mask", + "ui-modal__panel", + "ui-modal__header", + "ui-modal__body", + "ui-modal__footer" + ] + }, + "requiredRuleFragments": [ + { + "selector": ".icon-button", + "includes": [ + "display:inline-flex", + "height:var(--control-height-md)" + ] + }, + { + "selector": ".ui-table-wrap", + "includes": [ + "max-width:100%", + "border:1px solid var(--color-border)" + ] + }, + { + "selector": ".ui-table", + "includes": [ + "border-collapse:collapse", + "width:100%" + ] + }, + { + "selector": ".ui-modal", + "includes": [ + "display:grid", + "position:fixed", + "z-index:var(--z-modal)" + ] + }, + { + "selector": ".ui-modal__panel", + "includes": [ + "min-width:min(520px, calc(100vw - 48px))", + "transform:translate(-50%, -50%)" + ] + }, + { + "selector": ".form-grid", + "includes": [ + "display:grid", + "gap:var(--space-4)" + ] + }, + { + "selector": ".radio-row", + "includes": [ + "display:flex", + "gap:var(--space-6)" + ] + }, + { + "selector": ".form-grid--two", + "media": "(max-width: 780px)", + "includes": [ + "grid-template-columns:1fr" + ] + }, + { + "selector": ".table-actions", + "media": "(max-width: 780px)", + "includes": [ + "flex-wrap:wrap" + ] + }, + { + "selector": ".ui-table", + "media": "(max-width: 780px)", + "includes": [ + "display:block", + "min-width:0 !important" + ] + }, + { + "selector": ".ui-table td", + "media": "(max-width: 780px)", + "includes": [ + "display:grid", + "grid-template-columns:minmax(84px, 0.42fr) minmax(0, 1fr)" + ] + }, + { + "selector": ".ui-modal__panel", + "media": "(max-width: 780px)", + "includes": [ + "width:calc(100vw - 24px)" + ] + } + ] +} diff --git a/docs/contracts/sms-config-r3-methods.json b/docs/contracts/sms-config-r3-methods.json new file mode 100644 index 0000000..1b0d54b --- /dev/null +++ b/docs/contracts/sms-config-r3-methods.json @@ -0,0 +1,1107 @@ +{ + "version": "R3", + "source": "api/src/sms-config/sms-config.service.ts", + "generatedAt": "2026-07-31T01:56:36.226Z", + "publicMethods": [ + { + "name": "onModuleInit", + "signature": "onModuleInit()", + "bodySha256": "c30f57a3cea558edacd8780a679a1aebb675a21d3ad90a01536dd665e0670595", + "canonicalBodySha256": "6943ba60b0660595e9e190d4d7ea511397c3957d60c5dc367334c172f0cc6c07", + "originalLines": [ + 180, + 187 + ], + "domain": "lifecycle" + }, + { + "name": "onModuleDestroy", + "signature": "onModuleDestroy()", + "bodySha256": "6a1e10b1c5da7297e5ab3164acca870a5e7e0ba1c23fb6b64147ac2e280cc92f", + "canonicalBodySha256": "e2f18baed42b8be31ee9559bbef6c01dbf77a28c4ea762c619c12b5d05aa8137", + "originalLines": [ + 189, + 191 + ], + "domain": "lifecycle" + }, + { + "name": "listApplications", + "signature": "async listApplications(queryOrTenantId?: string | ApplicationListQuery)", + "bodySha256": "98e588be5605917237d84e9fe2abea69601d4fbedd0cab2650b01d921205803c", + "canonicalBodySha256": "c7bc98b10ea99bcf7cc9e4806ceef80e3e7bc4e3092ab6038031bcafa11cdaea", + "originalLines": [ + 193, + 257 + ], + "domain": "application" + }, + { + "name": "listApplicationsPage", + "signature": "async listApplicationsPage(query: ApplicationListQuery)", + "bodySha256": "fb342f9752e95ff5239da9547cf5164331bec4bc08443e91fb3f0e9411f65382", + "canonicalBodySha256": "3ca491766efd450e37e344944d0eb98782b5d42efbf3155fc4945c2128211936", + "originalLines": [ + 259, + 277 + ], + "domain": "application" + }, + { + "name": "listApplicationOptions", + "signature": "listApplicationOptions(tenantId?: string)", + "bodySha256": "19fbb320dc4957b286baf097c4aa55477ae99c799f2b37d6a2a8d702b0cf1648", + "canonicalBodySha256": "4f05e66eea8ca50bcf1ea2cbccc0f45e00f74124b80d47fc8b073da0bb2d48cb", + "originalLines": [ + 279, + 285 + ], + "domain": "application" + }, + { + "name": "getApplication", + "signature": "async getApplication(applicationId: string, tenantId?: string)", + "bodySha256": "c67cb208959f093143a58885f3896ab5b3cacbc9f259977818ef20537f060768", + "canonicalBodySha256": "d364b53964b2347270bed246ba7fc9d7c386f36daa06d95cd49b85a8faf096a1", + "originalLines": [ + 287, + 300 + ], + "domain": "application" + }, + { + "name": "getApplicationReportFields", + "signature": "async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage')", + "bodySha256": "cb609ca6bac384d8348a0b79c1796f5202639bbcebca6a7daed37ca6435e0964", + "canonicalBodySha256": "6389039d411b36ac8d3f22725a7b151ada317eee1e7a0f54c41b560e4501e98d", + "originalLines": [ + 302, + 421 + ], + "domain": "application" + }, + { + "name": "getClientApplicationReportFields", + "signature": "async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage')", + "bodySha256": "6f06196ca56a1c358761b21049ec3083031fb78fb013ba384eefe44377e0d999", + "canonicalBodySha256": "d6035e65e226fb3d0bd75e16bdd4c86b922c51ddad500c1c0872196d446d305e", + "originalLines": [ + 423, + 426 + ], + "domain": "application" + }, + { + "name": "createApplication", + "signature": "async createApplication(data: CreateSmsApplicationDto)", + "bodySha256": "bd79e6e8fea2cfada0b06a37a1a76cefc80b7674685d512a76df48467eee0826", + "canonicalBodySha256": "768808cf21ab34e0b8dd7ab982d22d18171f691095246a0366c42f46a0a531fe", + "originalLines": [ + 428, + 466 + ], + "domain": "application" + }, + { + "name": "updateApplication", + "signature": "async updateApplication(applicationId: string, data: UpdateSmsApplicationDto)", + "bodySha256": "58fdc4c89dd2eca735338ca1678e9866e7398b570786043aa4cb11cbb7491c5c", + "canonicalBodySha256": "2a205abeb0a12cc670c2aff322352d3044e861335ac57a38518c1513c13f3aac", + "originalLines": [ + 468, + 548 + ], + "domain": "application" + }, + { + "name": "replaceApplicationRouteRules", + "signature": "async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto)", + "bodySha256": "cc079b8a55f943aa52218516dacfb5d53cc541df726fbbc75bc124918f87abb9", + "canonicalBodySha256": "f7cebaa9757368596e754742fb9db2eabc0b57a7199dc16b727a0da591392f49", + "originalLines": [ + 550, + 609 + ], + "domain": "application" + }, + { + "name": "resetApplicationSecret", + "signature": "async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {})", + "bodySha256": "616157c9292232df4e127067d8e95b128b63490bdad559b9fbc35e50642baf1a", + "canonicalBodySha256": "47d9366ec916398dee1cf1b8a91c2459cd58564509800fd737a22fe6a83401a0", + "originalLines": [ + 611, + 625 + ], + "domain": "application" + }, + { + "name": "changeApplicationStatus", + "signature": "async changeApplicationStatus(applicationId: string, data: StatusChangeDto)", + "bodySha256": "b63dacb3e0f6c2950f9f56c48104e0678a417ca2e70e72c059b067e7ce0d4e6b", + "canonicalBodySha256": "d8b1f712cda2423487cfd920b57b4d9dd4988a25794d5d125bf2ee10f97f0e76", + "originalLines": [ + 627, + 681 + ], + "domain": "lifecycle" + }, + { + "name": "getApplicationDeactivationPreview", + "signature": "async getApplicationDeactivationPreview(applicationId: string)", + "bodySha256": "7246d6405d4ba4606f414936156a03857ef29470dba2064a91ec4c07098fb7cd", + "canonicalBodySha256": "8b205226b46658a46dc20466838baae344ddc53e72584b13631f92682abd9a73", + "originalLines": [ + 683, + 735 + ], + "domain": "lifecycle" + }, + { + "name": "listApplicationConnections", + "signature": "async listApplicationConnections(applicationId: string)", + "bodySha256": "f67cd499fcfcd2895c693830b44ada7e5da2f26e152b97871e25690059d31e09", + "canonicalBodySha256": "8a8b57f59bc6f96a32a17ce17a381233ac1a7d0d125e1c7bd04d136e965ceb13", + "originalLines": [ + 737, + 759 + ], + "domain": "lifecycle" + }, + { + "name": "getApplicationCmppParams", + "signature": "async getApplicationCmppParams(applicationId: string, tenantId?: string)", + "bodySha256": "72229b7f8d9974a7dda552b7ded91f495dac0b05fc2651dce470c3303b4b0942", + "canonicalBodySha256": "e118bdfd4199135f9123df86542f97013396a40d1aff0eb93ba91ac38b2e6ac9", + "originalLines": [ + 761, + 794 + ], + "domain": "application" + }, + { + "name": "recordDownstreamConnectionEvent", + "signature": "async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto)", + "bodySha256": "cc14430ec4a84917f4c22f0e64d07836f942bb638b2d57b6ffcd5efa6ecdfc9b", + "canonicalBodySha256": "740c1c91beed9412be441cb232b1720a02b89974742da143fef88e9d46f46cf3", + "originalLines": [ + 826, + 899 + ], + "domain": "lifecycle" + }, + { + "name": "markTimedOutDownstreamConnections", + "signature": "async markTimedOutDownstreamConnections(now = new Date())", + "bodySha256": "ef9cda1be9c5c2d74724453ba7603dcb0dc9ed35f8510fcde4f1460a3c3d5c32", + "canonicalBodySha256": "725e069b0008b596e25ca2c8172de34e3a252380365c0d8e2767febd0e9cdceb", + "originalLines": [ + 901, + 913 + ], + "domain": "lifecycle" + }, + { + "name": "listSignatures", + "signature": "async listSignatures(queryOrTenantId?: string | SignatureListQuery)", + "bodySha256": "9e8d363c2ee1022f429a9fa16e5e933f334a528e640f49ccd83e0cfc7686aea6", + "canonicalBodySha256": "91e26f503140018b00afe5563a4f86d22117c2805ac86532559ac44f1360d8a2", + "originalLines": [ + 915, + 1025 + ], + "domain": "signature" + }, + { + "name": "listSignaturesPage", + "signature": "async listSignaturesPage(query: SignatureListQuery)", + "bodySha256": "24e161ae5049af0689d3a9d0e4ab802964b87b1f2d407d8309b8e6cbe8afdd56", + "canonicalBodySha256": "34ee94d016bdc986d98b01612f4c64fdc03b0e921abedeff8fe84bd64dec958c", + "originalLines": [ + 1027, + 1058 + ], + "domain": "signature" + }, + { + "name": "listSignatureOptions", + "signature": "listSignatureOptions(tenantId?: string)", + "bodySha256": "569da5f46530cde78cde8d461747ec9c47c5254bacafbf10aa62437a511627b5", + "canonicalBodySha256": "163e71f0d4a41e889cba5bb7e039acf6f912d1bea92bf22cfaf018f79e8a7768", + "originalLines": [ + 1060, + 1066 + ], + "domain": "signature" + }, + { + "name": "listClientSignatures", + "signature": "async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {})", + "bodySha256": "d0221226748c1d1b02046ce37272f1aaae73b6f0452530486869099586e9e00d", + "canonicalBodySha256": "752f39c7e40cabe0a892fcde31b5b0d49a568f17b9062e770bc0d468ccb29572", + "originalLines": [ + 1068, + 1149 + ], + "domain": "signature" + }, + { + "name": "getClientSignatureView", + "signature": "async getClientSignatureView(signatureId: string, tenantId?: string)", + "bodySha256": "95f76a7bbde9bae7a4deed2d3d56db14aadcaec35b02937b59ff19e9827ea549", + "canonicalBodySha256": "a20d6d8249de848192c9a7ba9c321b107c1f8561e8aec61017cabac0b80486d4", + "originalLines": [ + 1151, + 1155 + ], + "domain": "signature" + }, + { + "name": "getClientSignatureWorkspace", + "signature": "async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {})", + "bodySha256": "9c31a8174915709c998360aca2283d3e693a33e2a3544407a87c907fe5d002f4", + "canonicalBodySha256": "78428847cbed785d1166f4527f41d8146e349b91866191ff951a20353bcb6e62", + "originalLines": [ + 1157, + 1188 + ], + "domain": "signature" + }, + { + "name": "listClientDrainageInfos", + "signature": "async listClientDrainageInfos(tenantId?: string, itemId?: string)", + "bodySha256": "80c2563c008e147393585090e693556ff5d873b2375c94e08cec18e91f4d63c9", + "canonicalBodySha256": "9bd4155a3740160df1c0fd6785db3770d8593791169d57c7a8012a8d9662252e", + "originalLines": [ + 1190, + 1213 + ], + "domain": "drainage" + }, + { + "name": "getClientDrainageInfoView", + "signature": "async getClientDrainageInfoView(itemId: string, tenantId?: string)", + "bodySha256": "1faf455e8fa75bc501fd3d24099f9e6f0094f5f34785a38058ddea39d6cbfe76", + "canonicalBodySha256": "16127f71bb932c3fd465a26810b59728d54d29f65d94c4af39921555e6784fae", + "originalLines": [ + 1215, + 1219 + ], + "domain": "drainage" + }, + { + "name": "createSignature", + "signature": "async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {})", + "bodySha256": "932f6925736a32ba8f7b58965d0783b9edf9221b6f125dbc3934119101993838", + "canonicalBodySha256": "90008a7bb6bbdba5bdfc6b3bcc4cdc7a5a788bba3ff2999f4b86451818b7f68b", + "originalLines": [ + 1221, + 1247 + ], + "domain": "signature" + }, + { + "name": "updateSignature", + "signature": "async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string)", + "bodySha256": "81b091446ba19c2000fade3d993f7dd2bd5c2bedc50f7aa5288aa83ecb8b3414", + "canonicalBodySha256": "1a6d8d86276acb4c672d39527d9425b82322b5e284defe51fa6fb81ee92151a7", + "originalLines": [ + 1249, + 1282 + ], + "domain": "signature" + }, + { + "name": "updateClientSignature", + "signature": "async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string)", + "bodySha256": "1427937f13985b7fa3e22b31b3444a7b6685569fe9fdacfc00bb77e63691989e", + "canonicalBodySha256": "19412d5d325703b0db640ffa81bc0949e23a8b7bd0db3c5950732c77329811e1", + "originalLines": [ + 1284, + 1300 + ], + "domain": "signature" + }, + { + "name": "listDrainageInfos", + "signature": "listDrainageInfos(query: DrainageInfoListQuery = {})", + "bodySha256": "2841e83c70180cdce83943d9d6ae212a9d29c6eae701ecf928bdbaedbdc93457", + "canonicalBodySha256": "0de53393ca5f91da7a521c13a96813b1f8211ded00f0fa797b752b6a87b73148", + "originalLines": [ + 1302, + 1319 + ], + "domain": "drainage" + }, + { + "name": "createDrainageInfo", + "signature": "async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string)", + "bodySha256": "f77412d029aeb6eca848a40d513b8897125df69a5143f77af3196e8f6daf3025", + "canonicalBodySha256": "a6826819c6f892a30169b4b61863001c674184c3674ecd5eaf74c41d45d03e0f", + "originalLines": [ + 1321, + 1353 + ], + "domain": "drainage" + }, + { + "name": "updateDrainageInfo", + "signature": "async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string)", + "bodySha256": "ba6332a3c497df57869e05cd6e967ecc6c5d38581542ce64391536075970c6a9", + "canonicalBodySha256": "390666bbfda431d5429dd405d1dbabd3f30f63e0db308cc6bbb55bef0b6541a3", + "originalLines": [ + 1355, + 1395 + ], + "domain": "drainage" + }, + { + "name": "approveDrainageInfo", + "signature": "approveDrainageInfo(itemId: string, data: ReviewDto)", + "bodySha256": "a656ed6a433993dfdf6eb804a790ec777ad4fab3cdd91378a250c72b687e5fdc", + "canonicalBodySha256": "6cffb4a5cd77d05027cd98edf7155b893d8f5d1dd08ca6fbd1833e38a688657a", + "originalLines": [ + 1397, + 1399 + ], + "domain": "drainage" + }, + { + "name": "rejectDrainageInfo", + "signature": "rejectDrainageInfo(itemId: string, data: ReviewDto)", + "bodySha256": "f4c140646e1c9656db7e3b6d0d685ed480616d5d6ce4c2cd193138730918d8cd", + "canonicalBodySha256": "c42917d21d226b724e7d0ab83787b9d7cb2c5bcb781e6878165a4d61ad446d2c", + "originalLines": [ + 1401, + 1403 + ], + "domain": "drainage" + }, + { + "name": "changeDrainageInfoStatus", + "signature": "async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string)", + "bodySha256": "efaa60a4b4574ef2f60d8ff17939fd028fccf298c13d2575f06a9d87527c04aa", + "canonicalBodySha256": "1f7830c892386c8728df1f6115c00d3e6045bfc39c0b3352b540a498642af347", + "originalLines": [ + 1405, + 1415 + ], + "domain": "drainage" + }, + { + "name": "createSignatureMaterial", + "signature": "createSignatureMaterial(data: CreateSignatureMaterialDto)", + "bodySha256": "78c98a4154ac83935ece8d72d03c6b986052ad1bf6bdf78d5457ff561f99d105", + "canonicalBodySha256": "1b36c83549382bd4c0736bfd266827cc67708c72f6a26e0f4f26a947c12f2b55", + "originalLines": [ + 1525, + 1535 + ], + "domain": "signature" + }, + { + "name": "submitSignature", + "signature": "async submitSignature(signatureId: string, tenantId?: string)", + "bodySha256": "5609ad83d7313ab175f74aa59a07e67376d7fd4bb1b700549e083c2130cd8edd", + "canonicalBodySha256": "4f54351268761a0534fd0f83080035bd8dea4a7ef89fb0a0529a9014795a07d7", + "originalLines": [ + 1537, + 1556 + ], + "domain": "signature" + }, + { + "name": "listTemplates", + "signature": "listTemplates(queryOrTenantId?: string | TemplateListQuery)", + "bodySha256": "c7c9b9b6f03c046c94459d021604dcce3b5d9d2ce192d547bce9d31310b27a7f", + "canonicalBodySha256": "e276e9f029b2cb5d5cf9551959f0525a9c501fefc97bf7a9300ba9d6554aa714", + "originalLines": [ + 1558, + 1583 + ], + "domain": "template" + }, + { + "name": "listTemplatesPage", + "signature": "async listTemplatesPage(query: TemplateListQuery)", + "bodySha256": "a0a7baafc68189e2daf93b970826f7d60ee60aa35662f48eeb7b4cb4e119b021", + "canonicalBodySha256": "2e836e44a397557b95cb0d978745df82c9c86cf77360157e78382ca71c0156a0", + "originalLines": [ + 1585, + 1608 + ], + "domain": "template" + }, + { + "name": "listClientTemplates", + "signature": "listClientTemplates(tenantId: string | undefined, includeHistory = false)", + "bodySha256": "2fce41c2e06bcedd8e637dab8bef00f25847ddaf610350a183ef92f9198dfd5f", + "canonicalBodySha256": "8f463d10d53041fab07dd133c10ee82df1a6eff85bdb3991ad480f2b97d7eb31", + "originalLines": [ + 1610, + 1612 + ], + "domain": "template" + }, + { + "name": "createTemplate", + "signature": "async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {})", + "bodySha256": "d33977d41c4231e09aa16e350ac89616abf6dc1815eb18bac63c8f327f0d7329", + "canonicalBodySha256": "5796882b15a1589aa8281ffb87e1e2d7b50a958c786db93b11d6b199e65e42ed", + "originalLines": [ + 1614, + 1641 + ], + "domain": "template" + }, + { + "name": "updateTemplate", + "signature": "async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string)", + "bodySha256": "aa8c75e9bfa63396d8dc210a71d1d4b3da3cdc3f2b5622a456b5eeea660e6aa7", + "canonicalBodySha256": "8d770fb51bab6d2de53bed962e655af443b6ecc23c8995c3ff33e4e450bfa994", + "originalLines": [ + 1643, + 1697 + ], + "domain": "template" + }, + { + "name": "updateClientTemplate", + "signature": "async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string)", + "bodySha256": "2eab2db7bda4335d307c53e04abc8a36531bf62295989004249dc276ed64a184", + "canonicalBodySha256": "a9b899a2bc38262fce7b553398bce605d91542b1fdcad6d09f6e16145330628b", + "originalLines": [ + 1699, + 1715 + ], + "domain": "template" + }, + { + "name": "submitTemplate", + "signature": "async submitTemplate(templateId: string, tenantId?: string)", + "bodySha256": "beff0f88506ab029c39bf82649dd48e02c7b385e74c9d98f8b9ef954819c927a", + "canonicalBodySha256": "716cc03c7642d629209d06fc380240a82465d8566674accd270cdfb39b1f25e4", + "originalLines": [ + 1717, + 1737 + ], + "domain": "template" + }, + { + "name": "listAuditRecords", + "signature": "listAuditRecords(targetType?: string, targetId?: string)", + "bodySha256": "dad0752994e67f04343b0851361d4e2afebc775ffc8cf5f2037fe8ba754db0e4", + "canonicalBodySha256": "d18fe1d8fc16cd7678341b5974cc719a86e4baa4f180e5e4e87e96107aa96468", + "originalLines": [ + 1759, + 1770 + ], + "domain": "audit" + }, + { + "name": "approveSignature", + "signature": "approveSignature(signatureId: string, data: ReviewDto)", + "bodySha256": "ab9520fd882bfde08e37de2ccc9898aa975a734e6fe56a397932bb76ae67dfb2", + "canonicalBodySha256": "d3f340e6c2fe9b5f902f74524efa58895e1d002c7137328447325b7a0a414c3f", + "originalLines": [ + 1772, + 1774 + ], + "domain": "audit" + }, + { + "name": "rejectSignature", + "signature": "rejectSignature(signatureId: string, data: ReviewDto)", + "bodySha256": "0afbc6b3aefadea12b18bb4710b62ed5b9f11362384696fac81154ba6fe89921", + "canonicalBodySha256": "46e234d3a88530a44ef5516fbf4e1bd20449bff3d294949bb810b658431e9f76", + "originalLines": [ + 1776, + 1778 + ], + "domain": "audit" + }, + { + "name": "approveTemplate", + "signature": "approveTemplate(templateId: string, data: ReviewDto)", + "bodySha256": "d09e7033d6209e8586dfcae9ee0a62348915db23dfcaf454a83fc4d4ab638583", + "canonicalBodySha256": "abcd0a5548f315f6acee0b7aeaca52693a4ed252f909b1cdd0382edeb0089386", + "originalLines": [ + 1780, + 1782 + ], + "domain": "audit" + }, + { + "name": "rejectTemplate", + "signature": "rejectTemplate(templateId: string, data: ReviewDto)", + "bodySha256": "2d38a038dcfc39b14181fe23729301cdbf930f73851a4b32a243f3b2a5ae376a", + "canonicalBodySha256": "7e4d4c8bce5a35e6684e8beb316bb8731904e9a06ae179ab03abc2e482a2f4b5", + "originalLines": [ + 1784, + 1786 + ], + "domain": "audit" + }, + { + "name": "changeSignatureStatus", + "signature": "async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string)", + "bodySha256": "f9de73bab6885c4191f8836dce06b3c5c84e7b9ee90562ff4811699730b322eb", + "canonicalBodySha256": "0a3d21c26c3d6b9469dbd1962c4ed264fd5d604721023967c5b64c82ae6f3855", + "originalLines": [ + 1788, + 1801 + ], + "domain": "audit" + }, + { + "name": "changeTemplateStatus", + "signature": "async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string)", + "bodySha256": "bb7697bb90ea6d08c2f34b158bc86ad332f8f39e69818fc944e44385e356d3f5", + "canonicalBodySha256": "8f77531cd1512d3532ada244c827e63778ee16964830b9e74ec82509d18dfd3d", + "originalLines": [ + 1803, + 1816 + ], + "domain": "audit" + } + ], + "internalMethods": [ + { + "name": "validateAndReserveCmppAccount", + "signature": "private async validateAndReserveCmppAccount(cmppAccount: string, currentApplicationId?: string)", + "bodySha256": "fba9b1555bb2dd8f5e6a550ba08671cbed7b33b761318756730497d33a001d8a", + "canonicalBodySha256": "593f73b53f5f47360515e911fe29c6b4134cafe638751596a34670ac254e8ed1", + "originalLines": [ + 796, + 805 + ], + "domain": "application" + }, + { + "name": "validateClientSrcIdAvailable", + "signature": "private async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string)", + "bodySha256": "f8a26f5dd1bfcac9f2f9a944207ebd53ffdaff230b5049b40149de192e8c4d7e", + "canonicalBodySha256": "57846fc3a758c79cbbecc73376c0c3a478ca9514fbd9572a13cea3133e8e07bb", + "originalLines": [ + 807, + 813 + ], + "domain": "application" + }, + { + "name": "generateCmppAccount", + "signature": "private async generateCmppAccount()", + "bodySha256": "989b2d9c99e504e55a004c6e58908c5270499dab95579e1d7b2aa05c67bc04dc", + "canonicalBodySha256": "823f5cf818eb6f90efe1a408242a164943a145c9cd71fdaad2cc5a074d6ef8f9", + "originalLines": [ + 815, + 824 + ], + "domain": "application" + }, + { + "name": "withReportRequirementSnapshot", + "signature": "private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record)", + "bodySha256": "9baec27d14b1fc5d24587aa935fc32eafc7567fdf90f1e5403361e5bef506ac9", + "canonicalBodySha256": "a9bf312ace21a7fab22454e840c587727e2e904f5bf34eab627ad8b64f919c2e", + "originalLines": [ + 1417, + 1437 + ], + "domain": "reportValidation" + }, + { + "name": "syncSignatureReportValues", + "signature": "private async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record)", + "bodySha256": "9f2e79cc7c5590dc56a4a7f80a3c1b95c846e9de415b3450c57c5a4b78e10e67", + "canonicalBodySha256": "72a1687b079f5e9cd9cab23f146fb394ef8660d06aa69e2d0d616e844d5cbb39", + "originalLines": [ + 1439, + 1453 + ], + "domain": "reportValidation" + }, + { + "name": "validateSignatureReportValues", + "signature": "private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record)", + "bodySha256": "b475dcdccc4991c7304f4054a5964afda7e612fc1285ad8500061c3713e5bdd4", + "canonicalBodySha256": "74a7a691326165c487743e6e87a805d76b6dc85041a5aa4af65e876906db2506", + "originalLines": [ + 1455, + 1464 + ], + "domain": "reportValidation" + }, + { + "name": "validateDrainageReportValues", + "signature": "private async validateDrainageReportValues(applicationId?: string, reportValues: Record = {})", + "bodySha256": "ab7388c346dca9f55c7d6e444712c87df6bb0726e9a04e2c6604a2e64c437346", + "canonicalBodySha256": "95fe92a9cee4987e23ea27e8dbf2dd279c6d3748e388efe60ccabde8f2adb223", + "originalLines": [ + 1466, + 1472 + ], + "domain": "reportValidation" + }, + { + "name": "activateDrainageReporting", + "signature": "private async activateDrainageReporting(itemId: string)", + "bodySha256": "27c88f4ee9ac171e52c22adf449fff2acd490922d3cc4e11dd1c22f276a355bf", + "canonicalBodySha256": "ba167f16ffae77ab5fc37ae1a938165b58c1d8e0fd058fc532980f581bf7f8e8", + "originalLines": [ + 1474, + 1510 + ], + "domain": "reportValidation" + }, + { + "name": "suspendDrainageReporting", + "signature": "private async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review')", + "bodySha256": "534a7ebf7967470f79f5e756bcaf90821102bbba05eccbf77187590f3cf28cde", + "canonicalBodySha256": "0f872c96d0d221af37ac596a7c35f28df3d62d027d2a094a0832e2228831dde7", + "originalLines": [ + 1512, + 1523 + ], + "domain": "reportValidation" + }, + { + "name": "validateTemplateSignature", + "signature": "private async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string)", + "bodySha256": "dda2fb9aa607ab63cbfc8c9dbdfb3e72fc2a1cfb0d4fd708d97af8bc93d8f68c", + "canonicalBodySha256": "bb1f70f1885e0e0e7ff5838fc4d3903517938d1f712569e3f9d11264e78de439", + "originalLines": [ + 1739, + 1757 + ], + "domain": "template" + }, + { + "name": "reviewSignature", + "signature": "private async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto)", + "bodySha256": "a4bb6bce9d3fac5e0e86acf34fbbef713712535d5c995fb0e1efbe4e33da99a6", + "canonicalBodySha256": "12ea38cc0f6d978998cf32ddddd1d99183fe3a3c3d768d43e8042d2057c1f0db", + "originalLines": [ + 1818, + 1843 + ], + "domain": "audit" + }, + { + "name": "reviewDrainageInfo", + "signature": "private async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto)", + "bodySha256": "314570eddbd28358f2c7e0ce5c746948f52a9938e5387f414dd6947c1326fc1c", + "canonicalBodySha256": "1d8e51edf56ca78ce0574a31610fb7728157b23fc7c13e683beddcead4c626a9", + "originalLines": [ + 1845, + 1877 + ], + "domain": "audit" + }, + { + "name": "reviewTemplate", + "signature": "private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto)", + "bodySha256": "82300ff9a2d7433f3c8eefb4581942e9c6a7b0bcc63bd3a03828b2bc860cefcf", + "canonicalBodySha256": "f1c297ab31b6fba710ab6d68363250288a786c2205ebe7e86cebbe220ed6f32c", + "originalLines": [ + 1879, + 1904 + ], + "domain": "audit" + }, + { + "name": "resolveReviewerId", + "signature": "private async resolveReviewerId(reviewerId?: string)", + "bodySha256": "4e54634e4487eb6878f5f283e4db5cd4b50d59c524f1522fce892db33bb00a77", + "canonicalBodySha256": "438e3c368502b7d0f85c725b5db9931e1293880d64dc105eb6fe1c9167f5db13", + "originalLines": [ + 1906, + 1915 + ], + "domain": "audit" + }, + { + "name": "createAuditRecord", + "signature": "private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput)", + "bodySha256": "d41562ebf77ab2253ed55249a282b17222f6d82e73118afe3f6fe154d95aa33e", + "canonicalBodySha256": "c0316fa857c37634889ad782377e9704cf81866ab50dc3e06806e30971ff9afa", + "originalLines": [ + 1917, + 1919 + ], + "domain": "audit" + }, + { + "name": "abandonApplicationDeliveries", + "signature": "private async abandonApplicationDeliveries(applicationId: string, reason: string)", + "bodySha256": "eb2d036033d9128e2b04aefbee74099f3b7de21cf1cde7177fd07e8de5ba582b", + "canonicalBodySha256": "ae95dedd6b5e3ad3180505b8b3e3a9024cd8f0f397bb1aacdfb092ea2df58f4e", + "originalLines": [ + 1921, + 1948 + ], + "domain": "lifecycle" + }, + { + "name": "disconnectDownstreamAccount", + "signature": "private async disconnectDownstreamAccount(account: string, reason: string)", + "bodySha256": "61b1f7015669ed401c7803647f0a5e0bff03b821805675248f71abfe0e2058c7", + "canonicalBodySha256": "00e2882d9ca3dc1d1504bedb9c6f8dfe8f28cef5258e92a3be725117656fc707", + "originalLines": [ + 1950, + 1969 + ], + "domain": "lifecycle" + }, + { + "name": "writeApplicationStatusLog", + "signature": "private writeApplicationStatusLog(\n application: { id: string; tenantId: string; status: string },\n data: StatusChangeDto,\n statusAfter: string,\n detail: Record,\n )", + "bodySha256": "395136cf31da282097cb7fc1f96ef63462be3ec8ef1e3a046c8903b52dec9d53", + "canonicalBodySha256": "c38f420bf7e0424cdad7545bb70621dcd473ee102953f82df6b44182acecc893", + "originalLines": [ + 1971, + 1991 + ], + "domain": "lifecycle" + }, + { + "name": "runApplicationDisableScan", + "signature": "private async runApplicationDisableScan()", + "bodySha256": "d0c33c95ddec7e7f46a233ec1532de7fc3114afd80f0ad3deecaf559d7cc9dc8", + "canonicalBodySha256": "a4438662ce9f8beea8850d6f4e85a00aaf15d0150cd31cf8dfd2a072fd93e1c7", + "originalLines": [ + 1993, + 2016 + ], + "domain": "lifecycle" + }, + { + "name": "finalizeDisablingApplication", + "signature": "private async finalizeDisablingApplication(\n application: { id: string; tenantId: string; cmppAccount: string; status: string },\n abandonOutstanding: boolean,\n reason: string,\n preview: Awaited>,\n )", + "bodySha256": "5ee4c86a34fbb0c07fae7fdd07bcbef382bf885401c74a6ae0a3f5016ab6b16e", + "canonicalBodySha256": "35a1629a2349a2de3f2ba3914c1ff886b9de30f4aa3045b44fa53076f29ca483", + "originalLines": [ + 2018, + 2045 + ], + "domain": "lifecycle" + }, + { + "name": "writeOperationLog", + "signature": "private writeOperationLog(\n tenantId: string,\n userId: string | undefined,\n action: string,\n resource: string,\n resourceId: string,\n detail: Record,\n )", + "bodySha256": "8da6dde13c5a64b2eec9a37db70d8c6537544a33492684d2e6c669e4164e900f", + "canonicalBodySha256": "5673c1ac15c63474ea80b4fca77d91e7d1b9ddd5737e8d44bf60bc4c5376da99", + "originalLines": [ + 2047, + 2065 + ], + "domain": "lifecycle" + } + ], + "contracts": [ + { + "name": "CreateSmsApplicationDto", + "sha256": "5fc4e8d393955a0b1baf3f41fd69a0edf7e3c42e5388354e1e5a2fa23ef1d297" + }, + { + "name": "UpdateSmsApplicationDto", + "sha256": "ea280831e1dac7b4d5d13a6efa1cac22ab8c51c9be54b6716aa6dae23e2bfddb" + }, + { + "name": "ReplaceApplicationRouteRulesDto", + "sha256": "2e95713d5ed962f210a4ca4af57e4ee373984e05e26c53aee12a61792ecce406" + }, + { + "name": "CreateSmsSignatureDto", + "sha256": "20b0507b72c8e6388592fdd3f80e5d2e7bd805c7717fef5e90e68c178e40a4c1" + }, + { + "name": "CreateSmsSignatureOptions", + "sha256": "b3b38d8d1e91a919ca6afe479b41d3954df98786fba8584326a96b822d049747" + }, + { + "name": "UpdateSmsSignatureDto", + "sha256": "5368648a60f8bdd9e0d8bc3c210a66f040ea3ef0ac3928c0a6652bf501a063f3" + }, + { + "name": "CreateSmsDrainageInfoDto", + "sha256": "4ba1629bb535084e398639ea74fb4545669cf49879649bc0df77ee001611308e" + }, + { + "name": "UpdateSmsDrainageInfoDto", + "sha256": "9b979f73116262a6ad19aece513ad595e40dfbaf865137e49205f33fefdf0c1e" + }, + { + "name": "DrainageInfoListQuery", + "sha256": "9cd9cfe5af9422ed64d5d00071c9b4c58b0ae820c43eeb7dad604b67fd5f6fc1" + }, + { + "name": "CreateSignatureMaterialDto", + "sha256": "562d57b494a2dd2c5d2bc6555f758f5a1ed65dce4e28b6e63c0034163ddfb914" + }, + { + "name": "CreateSmsTemplateDto", + "sha256": "1d7a58b9675be7af98c9db41c0d01441d73a0f9f98948132981128a892a7b2dd" + }, + { + "name": "CreateSmsTemplateOptions", + "sha256": "f8c51240a93266948109a2b29a2264a268187da32e47e4649f60acddcd445a46" + }, + { + "name": "UpdateSmsTemplateDto", + "sha256": "05c2f518ba9f0ad6fe32d89bffcdec8915d7218b3ad7c0b6689a8b7e1d5053eb" + }, + { + "name": "ReviewDto", + "sha256": "7682ae81faf304bb7dac7f7e2e46a57d5b3d8b1665e715e7b059fa3bc9167549" + }, + { + "name": "StatusChangeDto", + "sha256": "841e697d6e01da532752c3011a8aa94b55e837922a6ad964d1a60ba65e5a43b8" + }, + { + "name": "TemplateListQuery", + "sha256": "06c34488ffaa2d870d781fc2f666e294c408eefce1acb1ece11727f6aabc76c2" + }, + { + "name": "ApplicationListQuery", + "sha256": "a11e4932daea6c990cadfd9a80d14581026e2b839765f138d95525a43f8da39d" + }, + { + "name": "SignatureListQuery", + "sha256": "619cbff41cf63f18e5732ebfc8f899c5fd5c3394f7c54d9788e0561bc25eb027" + }, + { + "name": "GatewayDownstreamConnectionEventDto", + "sha256": "cbfc6b878cbe12dc767cd34522e013c53f1873c07a29bdc6293667a3d664b606" + } + ], + "helpers": [ + { + "name": "TemplateVariableInput", + "sha256": "f08a1a2a534294dfeff6ebc78372cb708607c4c2b1ae4772bb592d41965d8044" + }, + { + "name": "normalizeApplicationPassword", + "sha256": "301b7601dc1b93427682c6d24398e1db37f96f98f72db459cbe0131ebeeb1cfe" + }, + { + "name": "generateApplicationPassword", + "sha256": "09a1b560d0b842e34605d8fbe86001ce348342bd8b58333a4f99e959610e5806" + }, + { + "name": "estimateBillingUnits", + "sha256": "a344eb8896ceaf35cbe7f5fffa9cafabf9a84ace6dde6e1dc07a53ac46c19aee" + }, + { + "name": "inferTemplateVariables", + "sha256": "811fba3faa66cb84cf437b9f52975f17663a738d5ddbd8c4424c995849cd218e" + }, + { + "name": "validateAndNormalizeTemplateVariables", + "sha256": "607c129a8a59b1973e28f73836cc286c1c2795b8ef7a6f4d55d4b8852b5a262a" + }, + { + "name": "normalizeSmsSignature", + "sha256": "7f96d77c4b7f2aff7fbf3750fda0dce3037d815272bf0adc849bbbab19f103d8" + }, + { + "name": "validateCompleteSmsSignature", + "sha256": "9a312221a2bcd27a13fef646b4d34eb4e1193f30d2b721b9e5c632ab05b6479e" + }, + { + "name": "startOfToday", + "sha256": "294518614b2760c1cfee40f943a4c4ccc1834574dd7b79e2a49329624e1e8627" + }, + { + "name": "normalizeApplicationQueuePriority", + "sha256": "3d3f92239a994a17bf4db7402088b5b78efce6d2d3b3823249155d1602f221de" + }, + { + "name": "normalizeApplicationInterfaceType", + "sha256": "10215425b1d7c404d80c7a2dba7b884b517719b621088acdb8ff57a7905dad2f" + }, + { + "name": "normalizeCmppAccessNumberConfig", + "sha256": "9f0c2e394e4873f1b53e3a884d27adad0b0eb76d4ef3ed4bee57faf14f628181" + }, + { + "name": "getPositiveInteger", + "sha256": "f7268c3ff73e7251156adec7f19ddefe3a7ceba25dc20971b430782c21f333ab" + }, + { + "name": "normalizeApplicationCmppStatus", + "sha256": "caa872c1d7a9a73944bcc991ed9efb37e49901100a81e8855223dd0d6a13e371" + }, + { + "name": "getPositiveIntegerEnv", + "sha256": "3d8bcb859ec1b93cbdd9567fa3dd3e4ee7a5265489c979a426e61acffc6f950a" + }, + { + "name": "parseGatewayDate", + "sha256": "0a34065267284bf8638ca658149020098d68ea6913151ffddd70da1bd89ae85b" + }, + { + "name": "isRecord", + "sha256": "ebf101aa93b06991a45a57308602d07f76c494af9585637967a642acd455470b" + }, + { + "name": "reportValueParts", + "sha256": "562fd6d37f08b4060bb5c68cc9a244fe71b2e98190a6e51a9e5dc89c11ae6330" + }, + { + "name": "hasReportValue", + "sha256": "b00c257acdcb2e8470f2001cc553234c749f2c2a5f8c0ca2e14349c448af0562" + } + ], + "sharedDeclarations": [ + { + "name": "APPLICATION_QUEUE_PRIORITIES", + "sha256": "3a397de1e676cbb7c2a844c397a94075b8b70db6c1761c690b13c558abac6042" + }, + { + "name": "ApplicationQueuePriority", + "sha256": "dbb627a7c3de9e6277eb42e40dd9921b2b4f3bf3f121be98e9beb2a18fd1fdb2" + }, + { + "name": "APPLICATION_INTERFACE_TYPES", + "sha256": "ad420e12670b256aed7079aedd556d703b3017ad792191eea676d27059e7d297" + }, + { + "name": "ApplicationInterfaceType", + "sha256": "a8b861223fcb0d52e9862555f5877a018ad079cda98814d7f248b39ee4e1d194" + }, + { + "name": "DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS", + "sha256": "f5fce6e59749bdd9fe37a384cfa53cc7748b6464fe5661c7c604bb3c6c23a6c9" + }, + { + "name": "APPLICATION_DISABLE_GRACE_MS", + "sha256": "6a2c7b5c4533c567249c313b3b983273bc5a69391211929898db1e628f52f350" + }, + { + "name": "DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS", + "sha256": "2158fc1f75cefa235acd2a4c6015dd13dea7063187976af0703a37d2dbd3c427" + }, + { + "name": "UNRESOLVED_DOWNSTREAM_STATUSES", + "sha256": "af2a7557d994e3c42f1d4451f263fc0665ae52842b3a9dae232eae4aeded121d" + } + ], + "domains": { + "application": { + "className": "SmsApplicationConfigService", + "file": "api/src/sms-config/application-config.service.ts", + "methods": [ + "listApplications", + "listApplicationsPage", + "listApplicationOptions", + "getApplication", + "getApplicationReportFields", + "getClientApplicationReportFields", + "createApplication", + "updateApplication", + "replaceApplicationRouteRules", + "resetApplicationSecret", + "getApplicationCmppParams", + "validateAndReserveCmppAccount", + "validateClientSrcIdAvailable", + "generateCmppAccount" + ] + }, + "lifecycle": { + "className": "SmsApplicationLifecycleService", + "file": "api/src/sms-config/application-lifecycle.service.ts", + "methods": [ + "onModuleInit", + "onModuleDestroy", + "changeApplicationStatus", + "getApplicationDeactivationPreview", + "listApplicationConnections", + "recordDownstreamConnectionEvent", + "markTimedOutDownstreamConnections", + "abandonApplicationDeliveries", + "disconnectDownstreamAccount", + "writeApplicationStatusLog", + "runApplicationDisableScan", + "finalizeDisablingApplication", + "writeOperationLog" + ] + }, + "signature": { + "className": "SmsSignatureService", + "file": "api/src/sms-config/signature.service.ts", + "methods": [ + "listSignatures", + "listSignaturesPage", + "listSignatureOptions", + "listClientSignatures", + "getClientSignatureView", + "getClientSignatureWorkspace", + "createSignature", + "updateSignature", + "updateClientSignature", + "createSignatureMaterial", + "submitSignature" + ] + }, + "drainage": { + "className": "SmsDrainageService", + "file": "api/src/sms-config/drainage.service.ts", + "methods": [ + "listClientDrainageInfos", + "getClientDrainageInfoView", + "listDrainageInfos", + "createDrainageInfo", + "updateDrainageInfo", + "approveDrainageInfo", + "rejectDrainageInfo", + "changeDrainageInfoStatus" + ] + }, + "template": { + "className": "SmsTemplateService", + "file": "api/src/sms-config/template.service.ts", + "methods": [ + "listTemplates", + "listTemplatesPage", + "listClientTemplates", + "createTemplate", + "updateTemplate", + "updateClientTemplate", + "submitTemplate", + "validateTemplateSignature" + ] + }, + "audit": { + "className": "SmsAuditService", + "file": "api/src/sms-config/audit.service.ts", + "methods": [ + "listAuditRecords", + "approveSignature", + "rejectSignature", + "approveTemplate", + "rejectTemplate", + "changeSignatureStatus", + "changeTemplateStatus", + "reviewSignature", + "reviewDrainageInfo", + "reviewTemplate", + "resolveReviewerId", + "createAuditRecord" + ] + }, + "reportValidation": { + "className": "SmsReportValidationService", + "file": "api/src/sms-config/report-validation.service.ts", + "methods": [ + "withReportRequirementSnapshot", + "syncSignatureReportValues", + "validateSignatureReportValues", + "validateDrainageReportValues", + "activateDrainageReporting", + "suspendDrainageReporting" + ] + } + } +} diff --git a/docs/contracts/upstream-r7-declarations.json b/docs/contracts/upstream-r7-declarations.json new file mode 100644 index 0000000..99a2cb8 --- /dev/null +++ b/docs/contracts/upstream-r7-declarations.json @@ -0,0 +1,455 @@ +{ + "version": "R7", + "source": "gateway/internal/upstream/manager.go@HEAD-before-R7", + "declarations": [ + { + "name": "connection", + "kind": "type", + "file": "connection.go", + "sha256": "4df1abc8aaf424b2a5f500dc795a481d22b3759ca4c1a23fb4c619d4104b9ac4" + }, + { + "name": "submitPartResponse", + "kind": "type", + "file": "connection.go", + "sha256": "98f6612c555a8189ff6b32811807cbb566a21de3889359afc11fdebec0a4dd1f" + }, + { + "name": "close", + "kind": "func", + "receiver": "connection", + "file": "connection.go", + "sha256": "c56f9c0839b6efad46968e5b7669b60dc757b1aa3b3db4793101667be90e88c7" + }, + { + "name": "ensureConnected", + "kind": "func", + "receiver": "connection", + "file": "connection.go", + "sha256": "588082992ce35ee45ef1635c1332d50ab2d642313b65c176edf5c169a3e84d82" + }, + { + "name": "handleConnectionLoss", + "kind": "func", + "receiver": "connection", + "file": "connection.go", + "sha256": "c6fdad8cc5cead7b87490d4075b2dbd7a4abdb693669b8dac6484d87f42c1762" + }, + { + "name": "identity", + "kind": "func", + "receiver": "connection", + "file": "connection.go", + "sha256": "755d4df355d64e7faf4d75f09ad64a52efecb2770724a1af1f3efa8862eb5456" + }, + { + "name": "matches", + "kind": "func", + "receiver": "connection", + "file": "connection.go", + "sha256": "7ebd52a39510f1108159a1666357a0e71ffe2f95df7cd1612d63a95ef600e81b" + }, + { + "name": "readLoop", + "kind": "func", + "receiver": "connection", + "file": "connection.go", + "sha256": "5a4cd84fb6eb546dce10790fb8e09872a8308608ca2c7d6dbd773d6af4fbbade" + }, + { + "name": "sendResponse", + "kind": "func", + "receiver": "connection", + "file": "connection.go", + "sha256": "af80603adc9ad0a9757c042b2bfcc88582f4e4ad96ac4142e67833ddcb348bce" + }, + { + "name": "decodeContent", + "kind": "func", + "file": "deliver.go", + "sha256": "a66a6cc45a405c028cf41c7946dc89c6b1a313019863d6aa4b0a468d3ce7ef3f" + }, + { + "name": "deliverPacket", + "kind": "type", + "file": "deliver.go", + "sha256": "ec8b102d29392592af2b840927171a8037b9254fa1e02c50840b552db0f4efb4" + }, + { + "name": "deliverPacketFromCMPP2", + "kind": "func", + "file": "deliver.go", + "sha256": "d8a8210f0a756eaa4354ca74089b8af02c7839c35434e49e616c69c3fe22f02f" + }, + { + "name": "deliverPacketFromCMPP3", + "kind": "func", + "file": "deliver.go", + "sha256": "31354345d9e3ed87658fc8f8b353a4a3607eff191e836b1f10913a8a2f22e34c" + }, + { + "name": "receiptStatus", + "kind": "func", + "file": "deliver.go", + "sha256": "28f650601bb209e123371e8f04cd2e7103686c3b425ea60a6abf5440595c8fb8" + }, + { + "name": "commandFor", + "kind": "func", + "receiver": "connection", + "file": "deliver.go", + "sha256": "ec725550863714d1a18497ff7fea01dfa8987fa81d72dd7bbcb65c168a7dde5b" + }, + { + "name": "decodeUplinkContent", + "kind": "func", + "receiver": "connection", + "file": "deliver.go", + "sha256": "49bef95beb72d7b780c95d54d7fe7264e820610277a01bc38575d27cc1758b6d" + }, + { + "name": "handleDeliver", + "kind": "func", + "receiver": "connection", + "file": "deliver.go", + "sha256": "a4fe7d49043e2454548bdf4efdd2e312688f467e83ae9c4a4c0beff158471a52" + }, + { + "name": "handleHeartbeatResponse", + "kind": "func", + "receiver": "connection", + "file": "flow_control.go", + "sha256": "8b677a42a3d62d16e59f0e8bf1faaeae170bcf8f17e00c9b4c0cb3f6f05b52ed" + }, + { + "name": "heartbeatLoop", + "kind": "func", + "receiver": "connection", + "file": "flow_control.go", + "sha256": "b89ac640cf9b3b888ecf73c4848119b347e0fda2edb4ac1983ebda0dc7da8e57" + }, + { + "name": "releaseWindow", + "kind": "func", + "receiver": "connection", + "file": "flow_control.go", + "sha256": "5bb5aa24192d85ad7a0619ef2f702b0f76975a8e73dbe75e8179af5c767da53d" + }, + { + "name": "sendHeartbeat", + "kind": "func", + "receiver": "connection", + "file": "flow_control.go", + "sha256": "9c0164b3257072cc7c4c1827f3213baf2e8d73a077323b83e0278d0f1213be28" + }, + { + "name": "tryAcquireWindow", + "kind": "func", + "receiver": "connection", + "file": "flow_control.go", + "sha256": "e766bd3e3554d6b03496652ffb3e16779cc1a5743ab2e016c030f16858651b8c" + }, + { + "name": "acquireConnection", + "kind": "func", + "receiver": "connectionPool", + "file": "flow_control.go", + "sha256": "63edb09faf3d5fe19dc1f1e80251e11188707a79e4cb97b854e7a2642653844d" + }, + { + "name": "tryAcquireConnection", + "kind": "func", + "receiver": "connectionPool", + "file": "flow_control.go", + "sha256": "11378d52c134e485d434cc396d31d6188a1d2abb322e92cadfd6ec2eddb73c23" + }, + { + "name": "ConnectionState", + "kind": "type", + "file": "manager.go", + "sha256": "adc946f31d60738834305e2d1b2c94514ffd57decc6dcf49069eabe6f5adc61c" + }, + { + "name": "Manager", + "kind": "type", + "file": "manager.go", + "sha256": "c7ead8b037bc87ad9800650ce191f5fcee9648b8805a133b9506743c762ea608" + }, + { + "name": "defaultChannelConnectionID", + "kind": "func", + "file": "manager.go", + "sha256": "1344ae4c7831f4b85b01ab0b69a1d01ff6029c3475973e55a694227bd0081de8" + }, + { + "name": "defaultConnectTimeout", + "kind": "const", + "file": "manager.go", + "sha256": "4cd56f6b6b4d26dce6682233198d91f895440b0945c16dc2e734419de269a9e0" + }, + { + "name": "normalizeUpstreamConfig", + "kind": "func", + "file": "manager.go", + "sha256": "6922f679a80f15b0562408284baa1633530321748f82064b4599486ebccf33e9" + }, + { + "name": "validateConnectChannelCommand", + "kind": "func", + "file": "manager.go", + "sha256": "e74d75116b7189530160608369ee9fa0c11b8aea3608b1da039cc032a51a3738" + }, + { + "name": "ConnectChannel", + "kind": "func", + "receiver": "Manager", + "file": "manager.go", + "sha256": "376f150d5375e8068768e5ac6910e96336ae72b7a8967c65de68c44e0d66832f" + }, + { + "name": "DisconnectChannel", + "kind": "func", + "receiver": "Manager", + "file": "manager.go", + "sha256": "68eba625ebce875254dd12d9140675bd80a2aac02dbbda947ebb2795cd6aaa8d" + }, + { + "name": "connectionFor", + "kind": "func", + "receiver": "Manager", + "file": "manager.go", + "sha256": "90fd44cb9a3a3bc5860fa4c41b1bc230f1a8b49150948a6482f3a3e16341d2a2" + }, + { + "name": "ensureDefaultsLocked", + "kind": "func", + "receiver": "Manager", + "file": "manager.go", + "sha256": "1050738b55672cee730f87d7c2e00f555e2c59242319b8970009e318480f7b80" + }, + { + "name": "newConnectionPool", + "kind": "func", + "receiver": "Manager", + "file": "manager.go", + "sha256": "0e4ce662f1d076aaad3ee28415395f9447b2675c5f9ac21daf5e188857f3f95b" + }, + { + "name": "connectionPool", + "kind": "type", + "file": "pool.go", + "sha256": "6b89ef24af5a726ace99d569dda5b7d8d48530337924d8eb8fd5712d41147098" + }, + { + "name": "close", + "kind": "func", + "receiver": "connectionPool", + "file": "pool.go", + "sha256": "2e88c346d26e134071e1d5235249521e3a87df25ef4b441cb04baa549e485d83" + }, + { + "name": "countActiveConnections", + "kind": "func", + "receiver": "connectionPool", + "file": "pool.go", + "sha256": "fe0888a6894a67b9558711e4ae8ab81953fe49d9bd3e07548dcaacdf75b208dc" + }, + { + "name": "ensureConnected", + "kind": "func", + "receiver": "connectionPool", + "file": "pool.go", + "sha256": "471b79729cb8a28ff77837a42a8fef87a7f9ca638ef05fcb6d584195deffbc33" + }, + { + "name": "matches", + "kind": "func", + "receiver": "connectionPool", + "file": "pool.go", + "sha256": "cef5bfa15168f238cd67f459796764a573553923de9392af55341e961c614fc7" + }, + { + "name": "protocolLogEvent", + "kind": "type", + "file": "protocol_log.go", + "sha256": "5be7ca704c11594b6b72edbd83b2baac11d22bffd9a1e6c9698a6c07d909a008" + }, + { + "name": "emitDeliverResponse", + "kind": "func", + "receiver": "connection", + "file": "protocol_log.go", + "sha256": "96e9ea5776f20d97663b0c1502a65c5d49e9dc53ac28bad5b8df54bac18825d9" + }, + { + "name": "emitProtocolLog", + "kind": "func", + "receiver": "connection", + "file": "protocol_log.go", + "sha256": "d44dcf7c538cfd3578a6756d89e23dcad53aa76627a52b781fd7e59e6e3ed302" + }, + { + "name": "connectionErrorCategory", + "kind": "func", + "file": "reconnect.go", + "sha256": "6598e63e63ede1035651cee20e085ee5408b328391d2a0c49a59d4c1d74a4d04" + }, + { + "name": "isTemporaryReadTimeout", + "kind": "func", + "file": "reconnect.go", + "sha256": "4d25d0d0ab0650d549518bca0b1a8f766dff8d6346aec288bfdfff5357350fe6" + }, + { + "name": "reconnectDelay", + "kind": "func", + "file": "reconnect.go", + "sha256": "30acd31250bac730a3a561a3b91829ffc28890c9d94718db524ffb20ab8cbc5d" + }, + { + "name": "resetReconnectState", + "kind": "func", + "receiver": "connectionPool", + "file": "reconnect.go", + "sha256": "5373b80c609ab33f99296c5a69b8d1e7a35309eeaabff0160a7ee110d472cf60" + }, + { + "name": "scheduleReconnect", + "kind": "func", + "receiver": "connectionPool", + "file": "reconnect.go", + "sha256": "d5a5b113f90b81192d1e2e9e08cc0105ea66044eaf13834cc7cffd9393d46c9f" + }, + { + "name": "signalReconnect", + "kind": "func", + "receiver": "connectionPool", + "file": "reconnect.go", + "sha256": "d8d747a45e8f8a7647e73482ad8057e36bd081489d2e8181fe905b2cb5808faa" + }, + { + "name": "startSupervisor", + "kind": "func", + "receiver": "connectionPool", + "file": "reconnect.go", + "sha256": "e4d0455ac0c62f5e3e5dee516415d200e7e33e419df8e9ff5e5c741648bd65da" + }, + { + "name": "stopped", + "kind": "func", + "receiver": "connectionPool", + "file": "reconnect.go", + "sha256": "4dd236bbdf5e30f3d188a5de75e0c8d1b781d758f50287b8fe4ee55a02136368" + }, + { + "name": "superviseReconnects", + "kind": "func", + "receiver": "connectionPool", + "file": "reconnect.go", + "sha256": "081deef84986116b80d635c1ab202a90a668f1e6ea4b556d0222e217b9867df8" + }, + { + "name": "encodeContent", + "kind": "func", + "file": "submit.go", + "sha256": "961071e5ad611ee1c9f55f68dbb2d6ba5d3dbddc7daf9027b76e7292cb492d89" + }, + { + "name": "protocolVersion", + "kind": "func", + "file": "submit.go", + "sha256": "aaef75fad9276b48e18ed22a23bd2a6b05e983b9a8ec8c3de216656b1f81e38d" + }, + { + "name": "submitRequestFields", + "kind": "type", + "file": "submit.go", + "sha256": "dbf2cb6c377f487d543d8f08a1167e0ca77f038fd0d86dcff1d982542b9cbc7f" + }, + { + "name": "submitResult", + "kind": "func", + "file": "submit.go", + "sha256": "01a16e0be0695baab3a5cb7056b3d8b2fa51dcaa56caec75a8f277c490d9d8c8" + }, + { + "name": "submitSegmentResult", + "kind": "func", + "file": "submit.go", + "sha256": "a5dfdaf6b4a9b5f222d3a370f4310d9ba271ebb3546f0b3969ba679821e6c815" + }, + { + "name": "validateSubmitCommand", + "kind": "func", + "file": "submit.go", + "sha256": "b4786c6e421a7fd188d2c2e84b2acfa39dc3e62c79ac8f32262f1a6f078ee14c" + }, + { + "name": "Submit", + "kind": "func", + "receiver": "Manager", + "file": "submit.go", + "sha256": "81d3554f05c38d91cde37b4fa918c2d30d7f4e6da2265c2fe0f9b499650375f3" + }, + { + "name": "submitPart", + "kind": "func", + "receiver": "connection", + "file": "submit.go", + "sha256": "38407f46b9d5626ba5c854a17d5831727e920e7cc881bb1004a9167c53420363" + }, + { + "name": "submitRequestPacket", + "kind": "func", + "receiver": "connection", + "file": "submit.go", + "sha256": "14b2fd65a4c407817bc86d0e79524296ccb8b325a289cc44fb2227b9c5834a2a" + }, + { + "name": "submit", + "kind": "func", + "receiver": "connectionPool", + "file": "submit.go", + "sha256": "9efe1c726215a512d7039c852fdca06544f603ed810daae3fe291cc51038315b" + }, + { + "name": "defaultInt", + "kind": "func", + "file": "transport.go", + "sha256": "cec47c9a91ab21247e5c62b0c36f24449f622f338bc449e14aaa8cbb9888e295" + }, + { + "name": "defaultString", + "kind": "func", + "file": "transport.go", + "sha256": "e3f1c7ba3d2aa985b2df53748e307f4e2769815d3cb8500dc616b929cee1e38a" + }, + { + "name": "postJSON", + "kind": "func", + "file": "transport.go", + "sha256": "f173638019db247e64783989125e3b66dbb258e086b08d6049fcb1bc3178f22b" + }, + { + "name": "post", + "kind": "func", + "receiver": "Manager", + "file": "transport.go", + "sha256": "0c93f5e542efa601b46f7917ed226dc89d22c0a9a5b37e6a4b73d7134fab8d66" + }, + { + "name": "reportState", + "kind": "func", + "receiver": "connectionPool", + "file": "transport.go", + "sha256": "b9d7b1bb926d9d38cbc04c5d08c7fe29acf8bc3534b193e5394b4a739ab49499" + }, + { + "name": "snapshotState", + "kind": "func", + "receiver": "connectionPool", + "file": "transport.go", + "sha256": "d11c9e2197c8023946979487e53969babf01afa0773d0cdf7aa11286f95bd4b3" + } + ] +} diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 41c8e3c..66710b8 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1827,6 +1827,7 @@ ## 运营看板与短信记录运营商筛选(2026-07-29) - 运营看板“今日发送趋势”按 `Asia/Shanghai` 自然日固定返回 00:00 至 23:00 共 24 个小时桶,以折线图同时展示每小时业务短信提交总条数和最终状态为 `delivered` 的成功条数;无数据小时必须补零,不使用前端静态数据。 +- `SmsMessageRecord.queuedAt` 为 PostgreSQL `timestamp without time zone` 且保存 UTC 时间,小时分桶必须先按 UTC 解释该字段,再转换为 `Asia/Shanghai` 提取小时;结果不得依赖 PostgreSQL 会话时区,不得把北京时间 09:00 的记录显示在 01:00。 - 运营看板“审核处理趋势”更名为“审核处理速度”。按企业认证、短信审核、模板、签名、引流信息五类展示北京时间当天已完成审核数量及平均处理时长;处理时长为同一审核事项本轮审核完成时间减本轮提交时间,无有效提交时间或出现负时长的记录不参与平均值。 - 签名通道发送质量明细的运营商概览固定按移动、联通、电信排列;未识别运营商如有数据排在三大运营商之后,数据库聚合返回顺序不得直接作为展示顺序。 - 运营端短信记录新增运营商筛选,选项为全部、移动、联通、电信、未识别。筛选必须由真实后端和 PostgreSQL 执行,并同时作用于分页总数、当前页和 CSV 导出;“未识别”包含空运营商及不属于三大运营商标准/兼容值的历史记录。 @@ -1836,3 +1837,36 @@ - 短信审核列表将“发送企业 / 企业应用”、“提交时间 / 审核来源”和“号码数量 / 状态”分别合并为三个上下分层的信息格,相关信息不得删除或改为仅在详情中展示。 - 短信内容列应设置为列表主要宽列,桌面端目标宽度不小于 440px;当可用宽度不足时由表格容器横向滚动,不得通过压缩内容列造成短信正文难以阅读。 - 号码数量继续使用真实审核任务关联短信记录数量,并保留打开真实号码分页列表的交互;本次仅调整展示布局,不改变审核查询、批量选择、通过或驳回流程。 + +## 号码发送频次风控(2026-07-30) + +- 系统内置并启用两条全局兜底规则:同一企业应用、同一手机号码在北京时间自然日内最多提交 10 条业务短信;在按北京时间时钟对齐的固定 5 分钟周期内最多提交 5 条业务短信。达到阈值的短信仍允许提交,下一条开始直接拒绝。 +- 两条规则独立计数、独立命中;任一规则命中即直接拒绝该号码。首版处理动作固定为直接拒绝,不进入人工审核,也不提供放行后继续发送的自动动作。 +- 隔离键固定为`applicationId + phoneNumber + ruleCode`。同一号码在不同企业应用下互不影响;同一企业的不同应用也分别计数。 +- 计数单位为业务短信号码记录:一次任务向同一号码提交一条短信计 1 条,长短信分片和通道失败补发不重复计数。格式非法、黑名单命中或整个任务已被前置风控拒绝的号码不进入号码频次计数。 +- 24 小时规则使用`Asia/Shanghai`自然日 00:00:00 至次日 00:00:00;5 分钟规则使用 00、05、10 等整 5 分钟对齐周期。进入下一周期时该周期计数自动从 0 开始,不采用命中时刻向后滚动的滑动窗口。 +- 运营可分别为企业应用新增两条规则的个性化阈值;应用规则启用时覆盖同码全局兜底规则,未配置或停用时继续使用全局兜底。号码频控阈值只能是大于 0 的整数,周期、时区、对齐方式及直接拒绝动作首版不可修改。 +- 命中时必须持久化企业、应用、号码、规则、阈值、触发值、周期起止、来源和触发时间。命中后的同周期提交继续拒绝,但不得重复生成相同的活跃触发记录。 +- 风控规则页提供真实后端分页的号码频次触发记录,支持按当前应用范围、号码和拦截中/周期已到期/已人工解除状态查询。 +- 运营可对单条触发记录执行“解除并清零”,必须填写原因并记录操作人、时间和审计日志。解除只清零该应用、号码、规则的当前计数,不删除历史记录,也不影响另一条频控规则;同周期再次超过阈值时生成新一代触发记录。 +- 计数占用、首次命中记录及活跃命中关联必须由 PostgreSQL 原子完成,并能承受同一应用、同一号码的并发提交;不得使用进程内 Map、Redis 临时键、浏览器 localStorage、静态数据或 Mock 作为正式实现。 + +## 平台级号码频控白名单(2026-07-30) + +- 运营端在风控规则页维护平台级号码频控白名单。处于启用状态的号码,在全平台所有企业及所有应用下均豁免`PHONE_FREQUENCY_24H`和`PHONE_FREQUENCY_5M`两类号码频控,包括企业应用个性化覆盖规则。 +- 白名单不豁免号码格式校验、企业/应用黑名单、敏感词和内容审核、模板与签名审核、余额、应用日限额、路由、报备及其他风控规则;首版不提供企业级或应用级白名单。 +- 白名单提供真实后端和数据库分页的增删改查,号码在平台内唯一,支持启用、停用、号码筛选和已删除历史查询。用途说明必填,备注选填;新增、修改、启停和删除均记录操作人、时间、修改前后内容及操作审计。 +- 新增或恢复启用白名单时,清零该号码在所有企业应用及两类规则下的当前计数,并解除尚未到期的活跃命中;修改号码时同时清零旧号码和新号码;启停切换或删除时同样清零,确保号码退出白名单后从零开始重新计数。历史命中和白名单记录不得物理删除。 +- 白名单有效期间的提交不写入号码频控状态、不增加计数、不产生号码频控命中。白名单写操作事务返回后发起的新请求必须遵循新状态;已经在写操作前进入频控事务的并发请求允许按其已读取的事务快照完成。 +- 批量发送时白名单必须按号码集合分块查询,不得逐号码访问数据库;正式实现不得使用 Mock、静态数组或浏览器 localStorage。 + +## 客户端工作台与短信发送体验完善(2026-07-30) + +- 客户端短信服务工作台的账户状态必须基于当前企业真实认证记录展示“已认证”或“未认证”,不得使用账户启停状态代替认证状态;企业主体展示真实企业名称。签名数量统计当前企业未删除、未禁用的真实签名。 +- 模板状态和签名状态分别展示当前企业真实待审核数量并可进入对应菜单;“服务提醒”改为“批量任务”,展示当前企业来源为客户端且状态为`pending_review`的真实批量任务数,并可进入批量任务菜单。 +- 客户端“今日发送趋势”复用真实 Dashboard 24 个北京时间小时桶,同时展示提交量和成功量;UTC 存储时间必须先按 UTC 解释再转换为`Asia/Shanghai`,不得依赖数据库或浏览器本地时区。 +- 签名与引流信息页点击“重置”时,即使筛选条件已经为空,也必须回到第一页并重新请求真实后端,以获取最新签名审核、报备和引流信息状态。 +- 运营端企业认证审核和短信模板审核首次打开及点击重置后,默认查询待审核状态。 +- 短信发送页的字数和预计条数必须随编辑后的实际短信内容即时重新计算;单条短信不超过70个Unicode字符计1条,长短信按每67个字符计费,预计条数为单号码计费条数乘有效号码数。单价单位显示为“元/条”。 +- 提交发送任务失败时使用页面中央弹窗展示真实后端错误;定时发送控件提供按北京时间计算的“今天”按钮,并以浅色背景标出北京时间当天。提交给后端的定时时间必须显式携带`+08:00`时区。 +- 提交任务成功后弹窗展示真实任务编号和发送号码数。“继续发送短信”清空当前发送表单和导入内容;“查看任务进度”进入批量任务页面并按任务编号定位本次任务。 diff --git a/docs/global-css-progressive-split-plan.md b/docs/global-css-progressive-split-plan.md new file mode 100644 index 0000000..1bec24e --- /dev/null +++ b/docs/global-css-progressive-split-plan.md @@ -0,0 +1,535 @@ +# `global.css` 渐进式拆分计划 + +更新日期:2026-07-31 +适用仓库:CMPP 平台主仓库 +计划编号:R12 +当前状态:仅完成方案设计,暂不执行 + +> 本计划必须等当前工作区代码完成周末测试、问题修复和基线确认后再启动。 +> 在此之前不得为了本计划移动 CSS、调整选择器、改变样式加载顺序或顺带修改页面。 + +## 1. 背景 + +R11 已完成基础样式、AppShell、通用组件、运营端共享样式和客户端共享样式的 +本地拆分,但 R11 的完成不代表所有单页面样式已经从 `src/styles/global.css` +迁出。 + +截至 2026-07-31 的本地工作区快照: + +- `src/styles/global.css` 约 9049 行; +- 页面专属样式和跨门户兼容样式仍然混合存在; +- 当前工作区还有大量尚未提交的 R1-R11 重构及业务修改; +- 当前代码尚待周末完成集中测试; +- 本计划未实施、未提交、未推送、未部署。 + +以上数据只表示编写本计划时的快照。正式执行 R12 前必须重新统计,不能直接把 +本文记录视为届时事实。 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. 按页面所有权逐步迁出 `global.css` 中的单页面样式。 +2. 每个步骤形成一个可独立构建、测试、发布、观察和回滚的版本。 +3. 保持现有页面视觉、交互、真实 API、数据库和业务行为不变。 +4. 降低继续开发新页面和新功能时对全局样式的依赖。 +5. 最终让 `global.css` 中每一组保留规则都有明确的全局或兼容用途。 + +### 2.2 非目标 + +1. 不以一次性清空 `global.css` 为目标。 +2. 不以减少行数作为拆分成功的唯一标准。 +3. 不在纯 CSS 拆分步骤中重做页面视觉设计。 +4. 不在拆分时顺带修改业务功能、接口、文案或数据结构。 +5. 不为了统一命名而一次性批量修改多个页面的 className。 +6. 不把跨页面规则简单复制到多个页面文件中。 + +## 3. 启动前置条件 + +必须同时满足以下条件后,才能执行 R12: + +1. 当前工作区代码已经完成用户计划的周末测试。 +2. 测试中发现的 Bug 已经处理,或已经明确登记为与 CSS 拆分无关的遗留问题。 +3. 本地工作区的修改归属已经确认,不会误覆盖其他会话的改动。 +4. 已明确 R1-R11 及当前业务修改的提交、推送和部署状态。 +5. 已重新执行并记录: + - `git status --short --branch` + - `git diff` + - `git fetch` + - 本地 `HEAD` + - `origin/main` + - 预生产 `.deployed-commit` +6. 已保存目标页面拆分前的桌面端和移动端视觉基线。 +7. 已确认目标页面可以使用真实后端数据进行验收。 + +如果周末测试仍有未定位的布局、弹窗、表格或响应式问题,应先修复这些问题,再启动 +CSS 迁移,避免把原有 Bug 错误归因给拆分。 + +## 4. 核心拆分原则 + +### 4.1 每一步只拆一个页面或一个紧密子域 + +低风险页面可以在一个发布版本中安排两到三个,但实际代码移动仍要保持独立步骤。 +中高风险页面每个版本只处理一个页面或一个子页面。 + +### 4.2 按选择器所有权拆分,不按行数硬切 + +迁移前必须搜索选择器的全部引用。只有确认属于目标页面的规则才能迁移。 + +以下情况不得直接移动: + +- 同时被运营端和客户端使用; +- 同时被两个以上业务页面使用; +- 选择器名称像页面专属,但实际由公共组件渲染; +- 逗号组合选择器中包含其他页面的分支; +- 媒体查询中混合了多个页面的响应式规则。 + +### 4.3 完整迁移一个样式族 + +迁移某个页面样式时,必须同时处理: + +- 默认规则; +- hover、focus、active、disabled 等状态; +- 加载、空数据、错误和完成状态; +- 桌面端及全部响应式断点; +- 页面专属动画和 `@keyframes`; +- 打印规则; +- 同一组件的弹窗、抽屉、表格和分页规则。 + +禁止只移动桌面样式,把移动端覆盖遗留在 `global.css`。 + +### 4.4 保持级联顺序 + +页面 CSS 的导入位置必须保证迁移前后的覆盖顺序一致。迁移步骤中原则上不同时做: + +- 选择器权重调整; +- className 重命名; +- `!important` 清理; +- CSS Layers 重排; +- 公共组件视觉改版。 + +如果发现迁移必须改变权重,应停止纯迁移,把权重治理拆成单独版本。 + +### 4.5 新增页面样式不得继续进入 `global.css` + +R12 启动后建立强制约束: + +- 新页面必须有自己的页面 CSS; +- 已有页面的新专属样式写入该页面 CSS; +- 真正公共的样式进入对应的基础、壳层、组件、运营端或客户端共享文件; +- 暂时无法判定归属的规则必须写明使用方和保留原因。 + +## 5. 分阶段实施路线 + +### R12.0:建立拆分基线和所有权门禁 + +本步骤不移动生产 CSS。 + +工作内容: + +1. 重新统计各样式文件行数和导入顺序。 +2. 建立 `global.css` 选择器所有权清单。 +3. 记录每个候选选择器在 React 源码中的引用。 +4. 标记单页面、跨页面、跨门户、公共组件和疑似无引用规则。 +5. 建立后续步骤使用的选择器归属检查脚本。 +6. 保存关键页面视觉基线和构建产物中的 CSS 顺序。 + +完成标准: + +- 能够明确识别已经迁出的选择器是否重新进入 `global.css`; +- 能够识别页面 CSS 是否缺少对应的响应式规则; +- 不改变现有页面和生产代码行为。 + +### 第一阶段:低风险、前缀清晰的页面 + +#### R12.1:客户端短信发送明细 + +候选样式: + +- `.send-detail-*` +- 对应媒体查询分支 +- 仅由发送明细页面使用的状态规则 + +目标文件: + +`src/apps/client/send-detail/ClientSendDetailPage.css` + +这是建议的第一个实际迁移步骤,原因是选择器前缀清晰、页面以只读明细为主、 +交互状态少、与其他页面耦合较低。 + +预计迁出规模:约 120-200 行,以正式执行时重新盘点为准。 + +#### R12.2:客户端上行短信 + +候选样式: + +- `.uplink-*` +- 页面表格、详情和响应式规则 + +目标文件: + +`src/apps/client/uplink/ClientUplinkMessagesPage.css` + +#### R12.3:客户端应用管理 + +候选样式: + +- `.sms-app-*` +- 应用卡片、状态、操作区和响应式规则 + +目标文件: + +`src/apps/client/applications/ClientApplicationsPage.css` + +#### R12.4:运营看板首页 + +候选样式: + +- `.admin-dashboard-*` +- `.admin-workload-*` +- `.admin-alert-*` + +目标文件: + +`src/apps/admin/home/AdminHome.css` + +第一阶段完成后,应确认拆分方法、检查脚本、加载顺序和视觉验收流程可靠,再进入 +更复杂的客户端页面。 + +### 第二阶段:客户端中高风险页面 + +#### R12.5:批量任务页面 + +候选样式: + +- `.batch-filter-*` +- `.batch-table-*` +- `.batch-task-*` +- `.batch-progress` +- `.batch-actions` +- `.batch-pagination` + +进度条、操作区、分页以及所有响应式分支必须作为一个完整页面样式族迁移。 + +#### R12.6:客户端模板页面 + +只迁移模板列表页面实际拥有的样式,例如: + +- `.template-toolbar` +- `.template-card-grid` +- `.template-card*` +- `.template-content` +- `.template-vars` +- `.template-card-footer` + +以下规则不能因为名称相似而一起迁移: + +- 短信发送页使用的 `.template-trigger`; +- 短信发送页使用的 `.template-picker-*`; +- 通用组件使用的弹窗标题和表单规则。 + +#### R12.7:客户端短信发送页面 + +候选样式族: + +- `.sms-send-*` +- `.send-card` +- `.receiver-*` +- `.send-form-*` +- `.add-recipient` +- `.import-panel` +- `.send-tip` +- `.send-submit-*` +- `.preview-*` +- `.phone-preview` +- 页面专属模板选择器 + +本页面交互状态多,涉及号码录入、批量导入、模板选择、发送预览和移动端布局, +必须单独形成一个版本。 + +#### R12.8:企业认证页面 + +候选样式: + +- `.enterprise-page` +- 企业认证表单和步骤状态 +- `.enterprise-result*` +- 对应的全部响应式规则 + +该页面存在多状态切换,迁移时必须验证未认证、审核中、通过、驳回和重新提交等状态。 + +### 第三阶段:运营端企业和审核页面 + +#### R12.9:企业列表和查询区域 + +只处理企业列表、筛选条件、查询区域和列表卡片,不同时拆企业详情和编辑表单。 + +#### R12.10:企业详情 + +候选样式: + +- `.admin-enterprise-detail` +- `.admin-enterprise-profile` +- 企业摘要和业务页签 +- 企业应用展示区域 + +#### R12.11:企业新增和编辑表单 + +处理企业表单、营业执照、联系人和账户配置等页面专属样式。 + +#### R12.12:企业应用新增和编辑 + +候选样式: + +- `.admin-app-form-*` +- 应用协议配置 +- 路由和连接相关表单 +- 页面专属开关和提示区域 + +此步骤涉及复杂表单,应与企业列表和详情分开。 + +#### R12.13:企业审核 + +迁移企业审核详情和页面专属布局,保留真正跨审核页面共享的表格、筛选、分页和 +状态标志。 + +#### R12.14:短信、签名、模板和引流审核 + +优先按具体审核页面逐一迁移。只有确认多个审核页面的结构和视觉完全一致后, +才能把对应规则留在或迁入运营端共享样式。 + +### 第四阶段:通道和报备页面 + +这是 R12 风险最高的一组,不得合并成一次大迁移。 + +#### R12.15:通道组列表 + +只处理通道组列表、状态、基础操作和响应式布局。 + +#### R12.16:通道组表单及路由配置 + +处理通道组编辑、通道路由、优先级和相关动态表单。不得同时修改路由业务规则。 + +#### R12.17:通道报备列表和详情 + +处理通道报备列表、签名状态、详情和操作区域。 + +#### R12.18:待生成报备批次 + +处理“待生成资料”和“已生成批次”两个 Tab 的筛选、表格、分页和详情弹窗。 +拆分时必须保持现有搜索条件宽度和统一按钮规范。 + +#### R12.19:报备资料导入、审核和预览 + +处理导入结果、逐行审核、勾选批量审核、整批审核、导出预览和状态修改等样式。 +本步骤如果 diff 过大,应继续拆成: + +1. 导入和解析结果; +2. 审核列表和批量操作; +3. 导出与预览。 + +### 第五阶段:统计及其他运营页面 + +#### R12.20:签名通道发送质量 + +独立迁移筛选条件、质量卡片、表格、明细弹窗和运营商概览,不与其他统计页面合并。 + +#### R12.21:下游投递记录 + +迁移查询、记录表格、详情和响应式布局。 + +#### R12.22:恢复状态管理 + +迁移筛选、状态、错误信息、操作区和详情弹窗。 + +#### R12.23:网关异常及相关记录 + +按实际页面引用拆分异常记录、协议日志和详情样式。 + +#### R12.24:充值管理 + +迁移充值列表、充值操作、凭证和页面专属弹窗。公共回执组件样式不得重复复制。 + +#### R12.25:运营端上行短信 + +与客户端上行短信页面分别确认所有权,不能仅因业务名称相同就共用页面专属规则。 + +#### R12.26:系统用户、操作日志和安全配置 + +这些页面可能与客户端系统页面共用 `.system-*` 规则。先迁移明确的页面专属规则, +跨门户规则留到第六阶段统一治理。 + +#### R12.27:引流资料及其他剩余页面 + +对未覆盖页面重新进行引用盘点,继续遵循“一页一步”的原则,不设置一次性清仓步骤。 + +### 第六阶段:跨门户兼容样式治理 + +在所有主要单页面样式完成迁移后,再处理同时被运营端和客户端使用的历史规则,例如: + +- `.system-page` +- `.system-page-toolbar` +- `.system-filter-row` +- `.system-table-card` +- `.inline-actions` +- `.system-user-form` +- 其他经引用扫描确认的跨门户兼容类名 + +逐项采用以下处理方式之一: + +1. 确认真正公共,迁入公共组件或公共布局样式; +2. 只有布局公共,抽取新的公共布局类; +3. 名称相同但实际表现不同,分别改为页面专属类; +4. 暂时不能确认,继续保留在 `global.css` 并注明使用方和原因。 + +跨门户样式必须最后处理,避免一次变化同时影响多个已拆页面。 + +### R12.28:最终残留审计 + +工作内容: + +1. 删除经静态扫描和运行时验证确认无引用的规则。 +2. 合并确认声明和作用域均等价的重复规则。 +3. 检查响应式分支、关键帧、打印样式和浏览器兼容规则。 +4. 为必须保留的规则记录用途、使用方和不能下沉的原因。 +5. 启用“页面专属样式不得新增到 `global.css`”的持续门禁。 + +最终成功标准不是 `global.css` 变成零行,而是其剩余规则全部具有明确、可验证的 +全局职责。 + +## 6. 风险分级和建议节奏 + +### 6.1 风险分级 + +低风险: + +- 客户端发送明细; +- 客户端上行短信; +- 客户端应用管理; +- 运营看板首页。 + +中风险: + +- 批量任务; +- 客户端模板; +- 审核页面; +- 签名通道发送质量; +- 充值和系统页面。 + +高风险: + +- 客户端短信发送; +- 企业认证; +- 企业和应用复杂表单; +- 通道组路由; +- 通道报备和资料导入; +- 下游恢复类页面。 + +### 6.2 发布节奏 + +- 低风险页面:一个发布版本最多安排 2-3 个,但分别实施和验证。 +- 中风险页面:一个发布版本只处理 1 个页面。 +- 高风险页面:一个发布版本只处理 1 个页面或 1 个子页面。 +- 不在同一版本中同时拆高风险页面和修改该页面业务功能。 +- 每个版本至少稳定观察一个发布周期,再进入下一个高风险步骤。 + +预计整个计划约 28 个小步骤。步骤数量允许在 R12.0 重新盘点后调整,但不得通过 +合并高风险页面来人为压缩版本数量。 + +## 7. 每一步的固定执行流程 + +### 7.1 开始前 + +1. 检查 Git 分支、差异、未跟踪文件和其他会话修改。 +2. 记录目标页面当前 CSS 导入链。 +3. 搜索候选选择器的全部源码引用。 +4. 保存目标页面桌面端和移动端视觉基线。 +5. 明确本步骤允许移动和禁止移动的选择器清单。 + +### 7.2 实施中 + +1. 新建或使用目标页面 CSS 文件。 +2. 从稳定页面入口导入页面 CSS。 +3. 迁移完整样式族和所有媒体查询分支。 +4. 遇到跨页面的逗号选择器时先拆开规则,再迁移目标分支。 +5. 不修改 API、数据库、业务判断、文案和用户操作流程。 +6. 不清理与本步骤无关的历史 CSS。 + +### 7.3 本地门禁 + +至少执行: + +- 目标选择器所有权检查; +- CSS 导入顺序检查; +- 前端 TypeScript 检查; +- Vite 生产构建; +- 已建立的 R0-R11 重构门禁; +- `git diff --check`。 + +CSS 拆分原则上不影响后端,但在准备形成可发布版本时仍应执行项目既定的完整发布 +门禁,包括 API、Prisma、Gateway 和依赖安全检查,防止把工作区中的其他修改漏测。 + +### 7.4 页面验收 + +使用真实后端和真实接口,至少检查: + +- 1280px 及以上桌面端; +- 1024px; +- 768px; +- 375px 移动端; +- 正常数据; +- 空数据; +- 加载状态; +- 错误状态; +- 表格、分页、弹窗和抽屉; +- 浏览器控制台; +- 页面有无横向溢出。 + +不得使用 mock、静态数据或 `localStorage` 伪造验收结果。涉及生产环境时,不得发送 +真实短信或修改真实通道、余额和连接状态。 + +### 7.5 记录和发布 + +1. 更新本计划的实际进度。 +2. 同步更新相关测试用例和 `docs/testing-progress.md`。 +3. 记录迁移前后行数、选择器数量、目标文件和验证结果。 +4. 未经明确授权不提交、不推送、不部署。 +5. 获得部署授权后,继续遵守项目既定备份、精确提交打包和发布后检查流程。 + +## 8. 必须暂停拆分的情况 + +出现以下任一情况,应停止当前步骤并先诊断: + +1. 迁移必须改变选择器权重才能保持视觉一致。 +2. 一个候选样式被多个未纳入本步骤的页面使用。 +3. 页面在迁移前已经存在无法解释的视觉差异。 +4. 目标页面真实后端不可用,无法完成关键状态验收。 +5. diff 已经大到无法逐段人工复核。 +6. 必须同时修改业务逻辑才能继续迁移。 +7. 发现其他会话正在修改相同页面或相同样式区段。 + +暂停后应把问题拆成单独 Bug 或更小的重构步骤,不能通过扩大当前版本范围继续推进。 + +## 9. 预期结果 + +计划完成后: + +- 页面专属样式由对应页面目录维护; +- 公共样式按基础、壳层、组件、运营端、客户端和跨门户职责维护; +- 新增菜单和页面不再继续膨胀 `global.css`; +- 修改一个页面时能够明确判断受影响范围; +- 每次 CSS 调整都有可执行的静态门禁和真实页面回归范围; +- `global.css` 即使仍有一定规模,也不再承担无法解释的页面样式集合职责。 + +## 10. 后续启动建议 + +周末现有代码测试完成后,按以下顺序启动: + +1. 重新确认 Git、预生产版本和测试基线; +2. 执行 R12.0,不移动生产 CSS; +3. 评审 R12.0 生成的所有权清单; +4. 执行 R12.1 客户端短信发送明细页; +5. 单独完成构建、真实页面验收和发布观察; +6. 确认方法稳定后再继续 R12.2。 + +在用户明确要求启动前,本计划保持“待执行”状态。 diff --git a/docs/refactoring/r0-release-gate.md b/docs/refactoring/r0-release-gate.md new file mode 100644 index 0000000..995a3d1 --- /dev/null +++ b/docs/refactoring/r0-release-gate.md @@ -0,0 +1,100 @@ +# R0 渐进式拆分发布与回滚门禁 + +更新日期:2026-07-30 + +## 1. 单版本所有权 + +- 一个拆分版本只能指定一个“结构修改会话”。 +- 其他会话可以查阅、测试和报告问题,但不得同时移动同一职责域的文件。 +- 开始前记录:负责人、目标域、基线提交、允许修改的文件、明确不修改的文件。 +- 发现重叠未提交修改时立即停止移动,先由修改所有者确认边界。 + +## 2. 开始前检查 + +- [ ] `git status --short --branch` +- [ ] `git diff` +- [ ] `git fetch` +- [ ] 分别核对 `HEAD` 和 `origin/main` +- [ ] 完整阅读 `docs/testing-progress.md` 最新记录 +- [ ] 标记其他会话修改、未跟踪文件和构建产物 +- [ ] 选择一个业务域,列出稳定门面、调用者、表、队列和副作用 +- [ ] 运行目标域定向特征测试并保存基线结果 +- [ ] 确认该版本不混入业务规则修改 +- [ ] 确认回滚只需回到本版本前精确提交,不依赖手工修库 + +## 3. 实施中检查 + +- [ ] 原公开类、方法、导出名和控制器调用保持兼容 +- [ ] 纯移动不同时重命名、格式化或改写逻辑 +- [ ] 原事务仍由同一顶层用例持有 +- [ ] 子服务接收同一 `Prisma.TransactionClient` +- [ ] 数据库锁顺序、唯一约束、幂等键未改变 +- [ ] Redis Stream 名称、消费者组和 JSON 字段未改变 +- [ ] CMPP Sequence_Id、Msg_Id、版本和分片规则未改变 +- [ ] 错误码、中文提示、金额精度和上海时区口径未改变 +- [ ] 新模块依赖单向,不新增 `forwardRef` 循环 +- [ ] 每完成一个委托点即运行对应定向测试 + +## 4. 提交前检查 + +- [ ] `node tools/quality/verify-refactor-r0.mjs` +- [ ] `node tools/spike/validate-gateway-queue-contract.mjs` +- [ ] 目标域定向测试 +- [ ] API 全量测试 +- [ ] Prisma format、validate、generate 和 migrate status +- [ ] API TypeScript 正式构建 +- [ ] 前端 TypeScript 与 Vite 生产构建 +- [ ] Gateway `go test ./...` 和 `go vet ./...` +- [ ] 依赖安全门禁 +- [ ] `git diff --check` +- [ ] 逐文件人工核对 diff,确认只有目标域 +- [ ] 更新需求、测试用例和 `docs/testing-progress.md` + +构建缓存、`outputs/` 和临时文件不能因为“提交全部代码”而混入提交。 + +## 5. 发布与观察 + +- [ ] 只使用精确 Git 提交制作发布包 +- [ ] 部署前备份 PostgreSQL、运行源码和环境文件 +- [ ] 校验本地和服务器发布包 SHA-256 +- [ ] 只使用 `tools/deploy/production-deploy.sh` +- [ ] Gateway 先于 API 重启 +- [ ] 检查 migration、服务、端口、health、Redis、Stream pending/lag +- [ ] 检查供应商通道和下游客户连接,不修改真实凭据或状态 +- [ ] 检查 API/Gateway error 日志 +- [ ] 不为重构验收发送、重投或补发真实短信 +- [ ] 观察至少一个完整发布周期后再开始同一门面的下一阶段 + +## 6. 立即停止条件 + +出现任一情况,停止继续拆分并回到诊断: + +- 测试数量减少但没有明确删除用例的依据; +- 同一输入的 API/队列/CMPP 契约发生变化; +- 事务被拆成多个独立提交; +- 账务重复、漏记或余额出现非预期变化; +- 并发测试出现重复提交、重复补发、重复 Deliver 或重复退款; +- Gateway 重连、ACK、分片或 Msg_Id 映射行为变化; +- 页面需要靠 mock、静态数据或 localStorage 才能展示; +- diff 同时跨越两个业务域且无法逐段人工复核。 + +## 7. 回滚记录模板 + +```text +版本: +目标域: +结构修改提交: +发布前提交: +数据库 migration:无 / 列表 +发布前备份目录: +触发回滚的证据: +是否涉及业务数据修复: +回滚命令/发布包: +回滚后服务与端口: +回滚后 Redis Stream pending/lag: +回滚后通道连接: +回滚后错误日志: +验证人和时间: +``` + +纯结构拆分原则上不新增 migration。若必须改 schema,应拆成独立业务版本,不与文件移动同批发布。 diff --git a/docs/refactoring/r0-responsibility-index.md b/docs/refactoring/r0-responsibility-index.md new file mode 100644 index 0000000..c892d16 --- /dev/null +++ b/docs/refactoring/r0-responsibility-index.md @@ -0,0 +1,144 @@ +# R0 大文件职责与副作用索引 + +更新日期:2026-07-30 +基线提交:`0af671b4ed4713912e703defd08791f164d4eb25` + +## 1. 使用方式 + +本索引是后续拆分版本的行为地图,不是目标架构。开始任何 R1~R11 拆分前,必须: + +1. 重新核对 Git 与部署基线,不能直接沿用本文提交号。 +2. 找到目标方法所属的职责组、调用者、数据表和副作用。 +3. 先运行该职责组的特征测试,再移动代码。 +4. 保留原门面、公开方法、事务边界、幂等键和消息结构。 +5. 如果实际代码与本文不一致,先更新索引再开始拆分。 + +## 2. 核心文件总览 + +| 文件 | 稳定门面 | 直接调用者 | 主要外部副作用 | 拆分优先级 | +|---|---|---|---|---| +| `api/src/send-chain/send-chain.service.ts` | `SendChainService` | Admin/Client SendChain Controller、GatewayEvents、RiskReview、Operations、OpenAPI | PostgreSQL、Redis Stream、Gateway 控制 API、账务与下游回执 | 最高 | +| `gateway/internal/inbound/server.go` | `inbound.Server` | Gateway 启动入口、控制面 | CMPP TCP、NestJS Gateway API、Redis 在线状态、ACK 定时器 | 最高 | +| `gateway/internal/upstream/manager.go` | `upstream.Manager` | Submit Worker、控制面 | 供应商 CMPP TCP、连接池、重连、回执和上行事件 | 最高 | +| `api/src/channels/channels.service.ts` | `ChannelsService` | Channels Controller、Report/SmsConfig 间接调用 | PostgreSQL、Gateway 连接控制、导入导出文件 | 高 | +| `api/src/sms-config/sms-config.service.ts` | `SmsConfigService` | Admin/Client SmsConfig Controller、GatewayEvents | PostgreSQL、Gateway 下游连接状态、审核日志 | 高 | +| `api/src/operations/operations.service.ts` | `OperationsService` | Admin/Client Operations Controller | PostgreSQL 聚合查询、CSV 导出、恢复控制 | 中高 | +| `src/api/adminApi.ts` | `adminApi` | 运营端页面和组件 | HTTP、文件上传下载、认证失败跳转 | 中高 | +| `api/src/report-materials/report-materials.service.ts` | `ReportMaterialsService` | ReportMaterials Controller | PostgreSQL、XLSX/CSV、MinIO/FileObject | 中高 | +| `src/styles/global.css` | 全局 class 名和 CSS 变量 | 全部页面 | 全局级联、响应式覆盖 | 高 | + +## 3. SendChainService + +### 3.1 公开职责组 + +| 职责组 | 公开入口(同组方法不得在一次拆分中漏项) | 主要表 | 队列/外部调用 | 不变量 | +|---|---|---|---|---| +| 客户批量发送 | 创建、预览导入、确认导入、取消、任务/号码分页 | `SmsBatchTask`、`SmsMessageRecord`、`SmsSendTask` | Redis 任务队列 | 去重、非法号码不计费、任务进度与真实记录一致 | +| HTTP/OpenAPI 发送 | API 请求受理、查询请求结果 | `SmsApiRequest`、`SmsMessageRecord` | Redis 任务队列 | 请求幂等、签名模板校验、同步错误不进入计费 | +| CMPP 入站 | 鉴权、Submit、长短信分片、重启恢复 | `CmppInboundLongMessage`、`SmsBatchTask`、`SmsMessageRecord` | 下游 SubmitResp/Deliver | 一个目标号码一条真实记录;原 `Msg_Id` 可恢复;冲突分片拒绝 | +| 审核恢复 | 审核通过/驳回、聚合任务展开 | `SmsSendTask`、`SmsMessageRecord` | Redis 任务队列、下游 Deliver | 每条消息只恢复一次;驳回必须释放冻结 | +| 路由与提交 | 入队、选择通道、创建提交记录 | `ChannelRouteRule`、`SmsChannel`、`SmsSubmitRecord` | `gateway.submit.commands` | 运营商/省份快照;仅走已报备通道;重试不重复扣费 | +| SubmitResult | 接收分片/聚合结果、提交异常重排 | `SmsSubmitRecord`、`SmsMessageRecord`、`GatewaySubmitDeadLetter` | Redis 重排 | 优先按 `submitId`;模糊匹配必须拒绝;相同幂等键恢复 | +| 最终回执 | 回执持久化、匹配、分片聚合、补发 | `UpstreamReceiptInbox`、`SmsReceiptRecord`、`SmsMessageSegmentAudit` | 重试提交、下游 Deliver | terminal 状态不可被旧事件降级;并发失败只产生一次补发 | +| 账务 | 冻结、扣费、释放、退款 | `SmsBillingRecord`、`AccountTransaction` | PostgreSQL 事务锁 | 同一业务键只记账一次;失败/拒绝路径释放或退款 | +| 下游投递 | 创建 Deliver、ACK、人工重排、超时扫描 | `CmppDownstreamDelivery`、`CmppDownstreamDeliveryAttempt` | Gateway control API、HTTP webhook | `Result=0` 且非零 `Msg_Id` 才是业务 ACK;并发重排只执行一次 | +| 上行短信 | 保存、候选匹配、人工认领 | `SmsUplinkMessage`、`SmsUplinkMatchCandidate` | 下游 Deliver/Webhook | 共享接入号歧义不得自动错配 | +| 恢复与扫描 | 定时任务、过期回执、长短信恢复、恢复状态 | 多表 | Redis/Gateway | 扫描必须可重复;抢占超时可恢复;不得覆盖已完成状态 | + +### 3.2 事务、锁与幂等边界 + +- 顶层发送用例持有 Prisma 事务;未来子服务只能接收同一个 `TransactionClient`。 +- 定时任务、补发、下游人工重排使用数据库原子抢占;禁止改回“先查再写”。 +- `SmsSubmitRecord`、`SmsBillingRecord`、`CmppDownstreamDelivery` 和队列消息之间的关联 ID 是跨进程恢复依据,不能只保存在内存。 +- 最终回执以当前尝试和分片审计聚合;旧尝试不得覆盖新尝试的终态。 +- 发送链拆分必须运行第 8 节中 SendChain、Billing、Gateway queue 和 Gateway protocol 四组门禁。 + +## 4. Gateway 入站 Server + +| 职责组 | 当前入口/处理器 | 外部状态 | 不变量 | +|---|---|---|---| +| Bind 与鉴权 | CMPP 2.0/3.0 CONNECT、账号鉴权 | NestJS API、Redis presence | disabled 新提交与 receipt-draining 状态必须区分 | +| Submit | 报文归一化、长短信字段解析、调用 API、返回 SubmitResp | NestJS `/inbound/submit` | Sequence_Id 原样响应;API 结果与协议状态一致 | +| 会话与心跳 | ACTIVE_TEST、连接注册/移除 | Redis、API connection events | 心跳超时不保留伪在线连接 | +| 下游 Deliver | 选择会话、写报文、注册 ACK | 内存 ACK registry、API | CMPP 2.0/3.0 报文分别正确;非零 Msg_Id | +| ACK | DELIVER_RESP 匹配、成功/失败回调、超时 | ACK timer、API | 连接/Sequence_Id/Msg_Id 不得串线;超时只回调一次 | +| 恢复 | 账号在线状态与历史待投递恢复 | Redis recovery lock | 锁、退避和 stale owner 不得互相覆盖 | +| 协议日志 | 入站/出站报文安全日志 | API protocol log | 方向、命令、Sequence_Id 正确;不记录明文密码 | + +## 5. Gateway 上游 Manager + +| 职责组 | 当前入口/处理器 | 外部状态 | 不变量 | +|---|---|---|---| +| 连接池 | connect/disconnect、desired connections | 供应商 TCP、状态回调 | 连接数量与状态回调一致 | +| 重连 | supervisor、退避、鉴权失败分类 | timer、连接状态 | 网络退避封顶;鉴权失败慢速重试;人工停用不自动拉起 | +| Submit | 窗口、Sequence_Id、长短信分片 | CMPP TCP、tracker | 窗口不超限;每分片映射独立;结果可跨重启关联 | +| 回执 | DELIVER receipt 解析与映射 | API/Redis Stream | 通道、目标号码、Msg_Id 联合约束,歧义不误配 | +| 上行 | MO 解析与事件发布 | API/Redis Stream | 内容编码、源/目的号码和协议版本保持 | +| 心跳 | ACTIVE_TEST 和断线判定 | timer、连接池 | 心跳超时只触发一次连接丢失处理 | + +## 6. 其余大服务 + +### ChannelsService + +- 公开职责:通道/通道组 CRUD、组成员、路由规则、连接状态、通道测试、健康指标、报备字段、报备任务/记录、回执导入、删除治理。 +- 主要表:`SmsChannel`、`SmsChannelGroup`、`SmsChannelGroupItem`、`ChannelRouteRule`、`CmppConnectionState`、`ChannelHealthMetric`、`ChannelReportField`、`ChannelSignatureReportTask/Record`。 +- 副作用:Gateway connect/disconnect、通道测试真实提交、报备文件导入导出。 +- 禁区:连接控制与数据库状态不可只完成一侧;通道测试不得混入业务计费;删除前依赖检查必须保留。 + +### SmsConfigService + +- 公开职责:应用、签名、模板、引流信息、通用资料字段、客户安全视图、下游连接事件与停用生命周期。 +- 主要表:`SmsApplication`、`SmsSignature`、`SmsTemplate`、`SmsDrainageInfo`、`SignatureMaterial`、`SignatureReportMaterial`、`CmppDownstreamConnection/Delivery`、`AuditRecord`。 +- 副作用:审核/操作日志、Gateway 连接生命周期。 +- 禁区:客户端响应必须继续使用专用安全选择字段;审核状态与通道报备状态不得混为一列。 + +### OperationsService + +- 公开职责:短信/上行/任务分页与导出、运营看板、发送质量、签名通道质量、操作日志、死信、下游投递与恢复、分片审计、全链路追踪、对账。 +- 主要表:短信、提交、回执、账务、连接、审计及企业配置相关读模型。 +- 副作用:CSV 输出、下游恢复查询/操作;其余应保持只读。 +- 禁区:分页总数必须来自数据库;上海时区边界不可依赖数据库会话时区;客户端查询不得泄露通道和内部路由。 + +### ReportMaterialsService + +- 公开职责:官方模板、待生成资料分页/导出、导入配置、导入预览/提交、逐行/批量/整批审核、生成报备批次、批次导出和状态更新。 +- 主要表:`ReportMaterialImportProfile/Batch/Item`、`ReportMaterialBatch/Item`、`ReportExportFile/Item`、签名/引流/通道报备表。 +- 副作用:文件解析、导出文件、对象存储。 +- 禁区:导入不得自动审核通过;逐行、勾选和整批审核使用同一状态机;批次生成必须幂等。 + +## 7. 前端门面与样式 + +### adminApi.ts + +`adminApi` 继续作为稳定导出。其方法按以下域归档,R1 拆分时只能在内部委托,不批量修改页面 import: + +- auth/users/roles/permissions +- tenants/applications/certification +- channels/channelGroups/routes/connections/tests +- signatures/templates/drainage/reportMaterials +- riskReview/blacklist/phoneFrequency +- operations/dashboard/messages/uplink/tracing +- reports/reconciliation/profit/quality +- billing/accounts/recharges +- files/audit/deletion governance + +HTTP 基址、认证头、错误转换、下载和上传只能由 core 层统一实现;业务域文件不得各写一套。 + +### global.css + +R3 前只允许新增带页面或组件命名空间的规则。拆分时按 token、基础组件、布局、页面域、响应式五层迁移;每批迁移后做桌面和窄屏截图对比。禁止一边迁移一边重新设计页面。 + +## 8. R0 固定特征测试矩阵 + +| 门禁 | 命令 | 覆盖 | +|---|---|---| +| 队列契约 | `node tools/spike/validate-gateway-queue-contract.mjs` | Redis Stream 四类消息 | +| R0 清单 | `node tools/quality/verify-refactor-r0.mjs` | 核心门面、契约样例、关键测试名称 | +| 发送链 | `npm --prefix api test -- --runInBand --forceExit send-chain.service.spec.ts` | 并发、幂等、路由、回执、下游 | +| 账务 | `npm --prefix api test -- --runInBand --forceExit billing.service.spec.ts` | 冻结、扣费、释放、退款、并发重放 | +| Gateway | `go test ./...`(`gateway` 目录) | CMPP、ACK、重连、队列和 tracker | +| Gateway 静态检查 | `go vet ./...`(`gateway` 目录) | Go 静态错误 | +| API 全量 | `npm --prefix api test -- --runInBand --forceExit` | NestJS 全量行为 | +| 构建 | `npm --prefix api run build`、`npm run build` | API/前端类型及生产产物 | + +R0 本身不移动生产代码;后续版本若修改了上述不变量,必须先把它作为独立 Bug/需求处理,不得伪装为“纯拆分”。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index c5293ce..7527ae6 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -2610,10 +2610,12 @@ 1. 在客户端和运营端分别选择今日、近 7 天、近 30 天。 2. 查看发送趋势图和成功率趋势。 3. 与数据库或明细列表按时间范围聚合结果核对。 + 4. 写入 UTC `01:00`~`01:59`(北京时间 `09:00`~`09:59`)的 `SmsMessageRecord.queuedAt` 样本,并分别在 PostgreSQL 会话时区为 UTC 和 `Asia/Shanghai` 时核对小时桶。 - 预期结果: - 今日使用平台配置时区,不错算跨日数据。 - 近 7 天和近 30 天边界包含/排除规则明确。 - 趋势图每个点位与明细聚合一致。 + - UTC `01:00`~`01:59` 的样本只计入北京时间 `09:00` 小时桶,不计入 `01:00`;结果不随 PostgreSQL 会话时区变化。 ### TC-BILLING-006 运营端人工充值闭环 @@ -4049,3 +4051,293 @@ npm run verify:phase8 | TC-SMS-AUDIT-LAYOUT-002 | 查看包含较长短信正文的审核任务 | 短信内容列宽不小于 440px,正文获得明显更大的展示空间,不被其他元信息列无意义挤压 | | TC-SMS-AUDIT-LAYOUT-003 | 点击合并格中的“查看列表”并操作待审核任务 | 真实号码分页弹窗正常打开;详情、勾选、批量通过和驳回入口不受布局调整影响 | | TC-SMS-AUDIT-LAYOUT-004 | 在窄窗口打开短信审核列表 | 表格保持信息格内部上下层级,宽度不足时允许容器横向滚动,不发生文字重叠或操作按钮遮挡 | + +## 2026-07-30 号码发送频次风控用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-PHONE-FREQ-001 | 同一应用、同一号码在同一北京时间自然日依次提交 11 条业务短信 | 前 10 条通过号码频控,第 11 条直接拒绝;触发记录保存阈值 10、触发值 11 和当日周期起止 | +| TC-PHONE-FREQ-002 | 同一应用、同一号码在固定 5 分钟周期依次提交 6 条,第 6 条同时未达到日阈值 | 前 5 条通过,第 6 条仅命中 5 分钟规则并直接拒绝;同周期继续提交仍拒绝且不重复生成活跃触发记录 | +| TC-PHONE-FREQ-003 | 在北京时间 12:04:59 提交至阈值,12:05:00 再提交;另在 23:59:59 达到日阈值后次日 00:00:00 提交 | 5 分钟计数在 12:05:00 进入新周期;日计数在次日 00:00:00 进入新周期,均不是从上次提交时刻滚动计算 | +| TC-PHONE-FREQ-004 | 同一号码分别在应用 A、应用 B 提交,或同一企业的两个应用提交 | 两个应用分别计数,一方命中不会拦截另一方 | +| TC-PHONE-FREQ-005 | 为应用 A 分别配置日阈值 3、5 分钟阈值 2,并保持应用 B 无覆盖 | 应用 A 各自使用个性化阈值;应用 B 继续使用全局 10 条/日和 5 条/5 分钟兜底 | +| TC-PHONE-FREQ-006 | 批量任务含一个已命中号码和一个未命中号码 | 只将命中号码保存为`submit_failed/PHONE_FREQUENCY_LIMIT`且金额为 0;未命中号码正常入队,计费、冻结和日配额只按可发送号码计算 | +| TC-PHONE-FREQ-007 | 发送长短信并发生多个上游分片及失败补发 | 初次业务号码提交只计 1 条;分片、重投和补发不增加号码频次计数 | +| TC-PHONE-FREQ-008 | 提交非法号码、黑名单号码,或任务先被内容/任务级风控整体拒绝 | 这些号码不占用号码频次;数据库状态计数不增加 | +| TC-PHONE-FREQ-009 | 多个并发请求以同一应用、同一号码冲击阈值 | PostgreSQL 原子计数不丢失、不重复放行;超过阈值的请求直接拒绝,并且同周期只有一条活跃触发记录 | +| TC-PHONE-FREQ-010 | 运营在触发记录填写原因后执行解除并清零 | 当前规则的活跃关联解除、计数清零并增加代次;历史命中保留操作人、时间和原因,另一条频控规则不受影响 | +| TC-PHONE-FREQ-011 | 人工解除后在原周期再次连续提交至超阈值 | 按清零后的计数重新计算,再次超阈值时生成同周期新一代触发记录,不与历史唯一键冲突 | +| TC-PHONE-FREQ-012 | 在触发记录页按应用范围、部分号码和拦截中/已到期/已解除筛选并翻页 | 条件、总数和目标页均由真实后端及 PostgreSQL 返回;刷新页面结果保持,不依赖 Mock 或 localStorage | +| TC-PHONE-FREQ-013 | 新增或编辑号码频控规则,尝试阈值 0、小数或动作“人工审核” | 后端拒绝非法配置;有效规则的动作始终为直接拒绝,周期固定为北京时间自然日或固定 5 分钟 | +## 2026-07-30 平台级号码频控白名单用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-PHONE-WL-001 | 将一个已产生频控计数和活跃命中的号码新增为启用白名单 | 该号码在所有企业应用及两类频控规则下的计数清零,活跃命中标记为已解除,历史命中保留 | +| TC-PHONE-WL-002 | 启用白名单号码分别通过两个企业应用连续提交超过24小时和5分钟阈值 | 两个应用均不产生频控计数或命中,提交仍继续经过号码格式、黑名单、内容、余额及其他风控 | +| TC-PHONE-WL-003 | 停用或删除白名单后再次提交 | 停用/删除事务清零全部应用的旧状态;事务完成后的第一条业务短信从1开始计数 | +| TC-PHONE-WL-004 | 将白名单号码A修改为号码B | A、B在所有应用及两类规则下的状态均清零;B按白名单豁免,A恢复受频控约束 | +| TC-PHONE-WL-005 | 新增重复号码、非法号码或空用途说明 | 后端拒绝请求且不写入白名单、频控状态或审计成功记录 | +| TC-PHONE-WL-006 | 查询启用、停用和已删除白名单并翻页 | 后端数据库筛选和分页总数准确;默认列表排除已删除历史,指定已删除状态可查询 | +| TC-PHONE-WL-007 | 删除白名单但未填写删除原因 | 后端拒绝删除;原白名单状态和频控状态保持不变 | +| TC-PHONE-WL-008 | 一批大量号码中仅部分号码在白名单 | 后端分块批量查询白名单,无逐号码N+1查询;仅非白名单号码进入原子频控计数 | + +## 2026-07-30 客户端工作台与短信发送体验用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-CLIENT-HOME-001 | 当前企业存在已通过认证、3个有效签名 | 账户状态显示“已认证”,企业主体显示真实企业名称,签名数量显示3,不展示账户启停状态或“当前租户” | +| TC-CLIENT-HOME-002 | 当前企业不存在任何已通过认证 | 账户状态显示“未认证”,其他账户和企业数据仍正常展示 | +| TC-CLIENT-HOME-003 | 分别准备待审核模板、签名和客户端待审核批量任务 | 三个卡片分别显示各自真实数量;点击后进入模板、签名和批量任务菜单 | +| TC-CLIENT-HOME-004 | 当天多个北京时间小时存在短信记录 | 客户端趋势固定展示00:00~23:00提交量和成功量;UTC 01:00记录归入北京时间09:00 | +| TC-CLIENT-SIGNATURE-005 | 筛选条件为空时后台更新签名报备状态,再点击重置 | 页面回到第一页并重新请求后端,展示更新后的报备、审核和引流信息 | +| TC-ADMIN-AUDIT-006 | 首次打开或重置企业认证审核、模板审核页 | 默认筛选“待审核”,后端请求只返回对应待审核记录 | +| TC-CLIENT-SEND-007 | 选择模板后将正文分别编辑为70、71、134和135个Unicode字符 | 单号码预计计费条数依次为1、2、2、3;总预计条数随有效号码数同步变化 | +| TC-CLIENT-SEND-008 | 查看短信发送单价 | 单位显示“元/条”,不显示“元/人” | +| TC-CLIENT-SEND-009 | 后端返回企业账户余额不足或其他提交失败 | 页面中央错误弹窗展示真实错误信息,不只在页面顶部显示 | +| TC-CLIENT-SEND-010 | 打开定时发送日期控件 | 存在“今天”按钮,北京时间当天有浅色背景;选择今天后提交值显式携带`+08:00` | +| TC-CLIENT-SEND-011 | 成功提交真实任务 | 成功弹窗展示后端任务编号和号码数;继续发送可清空全部表单,查看任务进度可定位批量任务 | + +## 2026-07-30 R0 渐进式拆分安全护栏用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-REFACTOR-R0-001 | 执行 `node tools/quality/verify-refactor-r0.mjs` | 8 个稳定门面、4 组跨进程契约和 8 项关键特征测试均存在;任一项丢失时非零退出 | +| TC-REFACTOR-R0-002 | 执行 Gateway 队列契约校验 | SubmitCommand、SubmitResult、ReceiptEvent、UplinkEvent 四类真实消息样例全部通过 | +| TC-REFACTOR-R0-003 | 运行 SendChain 与 Billing 特征测试 | 并发抢占、重试、最终回执、下游重排、冻结/扣费/释放/退款和幂等重放行为保持 | +| TC-REFACTOR-R0-004 | 运行 Gateway 全量测试与 `go vet` | CMPP 2.0/3.0、SubmitResp、Deliver ACK、超时、自动重连和 tracker 行为保持 | +| TC-REFACTOR-R0-005 | 检查 R0 代码差异 | 不移动或修改任何生产代码;仅新增路线图、责任索引、门禁清单、契约清单和验证脚本 | +| TC-REFACTOR-R0-006 | 后续拆分开始前执行发布门禁 | 能明确目标域、唯一结构修改会话、稳定门面、事务、表、队列、副作用、停止条件和回滚依据 | + +## 2026-07-31 R1 前端 API 兼容门面拆分用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-REFACTOR-R1-001 | 执行 `node tools/quality/verify-admin-api-r1.mjs` | 183个运营端方法、60个客户端方法、7个会话方法和9个HTTP核心函数全部存在,拆分前后实现哈希一致 | +| TC-REFACTOR-R1-002 | 对全部前端源码执行 TypeScript 检查 | 现有页面继续从`@/api/adminApi`导入,类型和方法不丢失,无需批量修改页面 | +| TC-REFACTOR-R1-003 | 执行 Vite 生产构建 | 新领域模块全部进入生产依赖图,构建成功且没有循环依赖或重复导出错误 | +| TC-REFACTOR-R1-004 | 打开本地运营登录页并刷新验证码 | 页面非空、标题和表单正常;验证码真实API返回新算式;控制台无相关warning/error | +| TC-REFACTOR-R1-005 | 模拟或触发401、SESSION_LOCKED和RECENT_AUTHENTICATION_REQUIRED | 继续使用同一HTTP核心处理退出跳转、会话锁定和最近认证重试,不允许各业务域自行实现 | +| TC-REFACTOR-R1-006 | 检查文件上传、Blob导出和查询参数 | 文件大小校验、multipart、租户头、Blob响应及`all`/空值过滤规则与拆分前一致 | +| TC-REFACTOR-R1-007 | 运行R0门禁、API全量测试和Gateway全量测试 | R1没有改变后端、数据库、Redis Stream或CMPP协议行为 | + +## 2026-07-31 R2 运营查询服务拆分用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-REFACTOR-R2-001 | 执行`node tools/quality/verify-operations-r2.mjs` | 29个公开方法、3个私有方法、9个查询契约和36个辅助函数全部存在,拆分前后签名及实现哈希一致 | +| TC-REFACTOR-R2-002 | 控制器继续注入并调用`OperationsService` | 路由、参数、返回结构不变,控制器不直接依赖七个内部查询类 | +| TC-REFACTOR-R2-003 | 查询短信记录第一页5条 | PostgreSQL真实总数与分页items独立返回,客户端安全视图继续剔除通道成本、提交和内部路由信息 | +| TC-REFACTOR-R2-004 | 查询北京时间小时发送趋势 | UTC存储值先按UTC解释再转换为`Asia/Shanghai`,不受数据库会话时区影响 | +| TC-REFACTOR-R2-005 | 查询签名通道发送质量 | 分页、运营商固定顺序、业务短信条数和最终成功率口径保持 | +| TC-REFACTOR-R2-006 | 查询系统日志并导出CSV | 关键字、级别、模块、时间范围、CSV转义、时间格式和客户端企业隔离保持 | +| TC-REFACTOR-R2-007 | 查询下游投递、恢复状态和详情 | 告警窗口、分页、应用汇总、失败分类和导出字段保持,不触发重投 | +| TC-REFACTOR-R2-008 | 执行Operations定向及API全量测试 | 页面查询、真实数据库、客户端安全映射和下游恢复既有行为全部通过 | + +## 2026-07-31 R3 短信配置兼容门面拆分用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-REFACTOR-R3-001 | 执行`node tools/quality/verify-sms-config-r3.mjs` | 51个公开方法、21个内部方法、19个DTO/查询契约和27个辅助声明全部存在,领域归属正确,迁移前后实现一致 | +| TC-REFACTOR-R3-002 | 检查运营端、客户端和Gateway控制器导入 | 控制器继续只注入`SmsConfigService`;DTO从独立`contracts`导入,不再从实现类文件导入 | +| TC-REFACTOR-R3-003 | 按当前企业查询应用、签名、引流信息和模板 | 继续读取真实PostgreSQL;客户端租户边界、分页和历史数据可见性与拆分前一致 | +| TC-REFACTOR-R3-004 | 创建或编辑应用并验证CMPP账号、接入号和路由配置 | CMPP账号与接入号唯一性、密码规范、路由规则事务和操作日志保持原行为 | +| TC-REFACTOR-R3-005 | 执行应用停用、72小时扫描、连接超时和下游断连测试 | 停用状态机、未决投递处理、Gateway断连调用、自动扫描和审计保持原行为 | +| TC-REFACTOR-R3-006 | 新建、编辑、提交及审核签名、引流信息和模板 | 报备字段快照、审核状态、审核人、驳回原因、操作日志及删除治理接口保持 | +| TC-REFACTOR-R3-007 | 执行短信配置与审核治理定向测试 | 既有68项测试全部通过,覆盖客户端租户边界、停用扫描、审核和报备资料校验 | +| TC-REFACTOR-R3-008 | 执行R0~R3门禁、API全量、前端构建及Gateway全量测试 | 数据库schema、migration、Redis Stream、CMPP协议、页面API契约和其他业务行为不因R3改变 | + +## 2026-07-31 R4 报备资料与企业签名页面拆分用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-REFACTOR-R4-001 | 执行`node tools/quality/verify-report-materials-r4.mjs` | 12个公开方法、11个内部方法、10个契约和32个辅助函数完整存在,领域归属与迁移前实现一致 | +| TC-REFACTOR-R4-002 | 控制器和其他模块继续注入`ReportMaterialsService` | 路由、DTO、请求和响应不变;控制器DTO改从独立contracts导入,内部领域服务不暴露给调用方 | +| TC-REFACTOR-R4-003 | 查询待生成资料、导入配置、导入审核批次和已生成批次 | 真实PostgreSQL分页、关键字和时间筛选保持,不创建批次、文件或操作记录 | +| TC-REFACTOR-R4-004 | 分析并提交Excel导入、逐行或批量审核 | 公式防护、图片提取、字段映射、暂存、审核人、原快照和错误隔离保持 | +| TC-REFACTOR-R4-005 | 使用同一幂等键预检并生成报备批次 | 批次预检、事务认领、完成/失败记录、通道导出文件和任务轨迹保持幂等 | +| TC-REFACTOR-R4-006 | 执行`node tools/quality/verify-enterprise-signatures-r4.mjs` | 原20个函数、签名/引流表格JSX、8个真实API调用和25个页面状态保持,页面容器只负责查询和协调 | +| TC-REFACTOR-R4-007 | 在企业签名页查询、展开签名、编辑签名/引流资料并查看报备弹窗 | 组件拆分后筛选、分页、展开、上传、保存、删除治理及通道状态操作保持真实后端链路 | +| TC-REFACTOR-R4-008 | 执行报备资料定向测试、API全量、前端构建和Gateway测试 | 既有测试全部通过,数据库schema、migration、Redis Stream、CMPP协议和其他页面不因R4改变 | + +## 2026-07-31 R5 通道服务兼容门面拆分用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-REFACTOR-R5-001 | 执行`node tools/quality/verify-channels-r5.mjs` | 37个公开方法、14个内部方法、17个契约和60个辅助声明完整存在,领域归属和迁移前实现一致 | +| TC-REFACTOR-R5-002 | 检查控制器、模块和既有测试调用 | 继续只依赖稳定`ChannelsService`;DTO从独立contracts导入,路由、参数和响应结构不变 | +| TC-REFACTOR-R5-003 | 编辑名称、说明、成本等非连接参数 | 不调用Gateway断开/连接;只有连接参数实际变化且通道启用时才执行既有重连逻辑 | +| TC-REFACTOR-R5-004 | 执行通道连接、断开、状态同步和超时处理测试 | Gateway控制路径、Redis队列与Stream、定时器、慢重连和状态审计顺序保持 | +| TC-REFACTOR-R5-005 | 检查测试短信入口但不发送真实短信 | 手机号规范化、单次提交`maxAttempts=1`及结果口径保持;未获单独授权不得调用 | +| TC-REFACTOR-R5-006 | 查询和维护通道组、成员及路由规则 | 顺序、权重、主备、运营商/省份兼容性、补发和操作日志语义保持 | +| TC-REFACTOR-R5-007 | 查询报备字段、任务、导出、回执和记录 | 真实PostgreSQL分页、状态汇总、文件字段及历史记录保持,不因只读查询生成文件或写操作记录 | +| TC-REFACTOR-R5-008 | 执行Channels定向测试、API全量及R0~R5完整门禁 | 既有测试全部通过,数据库schema、migration、Redis Stream、Gateway、CMPP协议和其他业务不因R5改变 | + +## 2026-07-31 R6 Gateway 入站服务拆分用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-REFACTOR-R6-001 | 执行`go run tools/quality/verify-inbound-r6.go` | 迁移前93个声明在9个领域文件中全部存在,文件归属和实现哈希一致,`server.go`保持稳定入口 | +| TC-REFACTOR-R6-002 | 使用CMPP 2.0、2.1和3.0账号登录 | 版本协商、AuthSource、密码校验、企业代码、最大连接数和连接状态回调保持 | +| TC-REFACTOR-R6-003 | 分别提交单号码、多号码、8位及16位UDH长短信 | 每个目标号码保留独立内部消息映射;客户端每次Submit只收到一次SubmitResp,内容解码和Msg_Id保持 | +| TC-REFACTOR-R6-004 | Submit业务拒绝或API失败 | 同步返回原错误Result,不生成待投递失败回执,不把SubmitResp误记为成功 | +| TC-REFACTOR-R6-005 | Submit成功后立即存在排队回执 | Submit屏障保证SubmitResp先写出,回执Deliver不得抢先;原始Sequence_Id和Msg_Id映射可用于恢复 | +| TC-REFACTOR-R6-006 | 下发回执或上行并收到CMPP_DELIVER_RESP | 按连接、Sequence_Id和Msg_Id精确确认;成功、拒绝、超时及协议日志回调口径保持 | +| TC-REFACTOR-R6-007 | 当前进程找不到消息会话 | 只按原始Submit映射恢复,不允许退化为任意同账号连接;不可恢复时返回既有失败分类 | +| TC-REFACTOR-R6-008 | 多Gateway实例同时扫描待恢复投递 | Redis presence、恢复锁、退避、waiting_connection及完成状态保持,不重复投递 | +| TC-REFACTOR-R6-009 | 执行入站定向、Gateway全量测试和`go vet ./...` | 入站32项及Gateway所有package通过,没有竞态表现、编译错误或静态检查问题 | +| TC-REFACTOR-R6-010 | 执行API全量、前后端构建及R0~R6门禁 | 既有381项API测试和全部门禁通过,数据库schema、migration、Redis Stream及其他业务不因R6改变 | + +## 2026-07-31 R7 Gateway 上游管理拆分用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-REFACTOR-R7-001 | 执行`go run tools/quality/verify-upstream-r7.go` | 迁移前68个声明在9个领域文件中全部存在,接收者、文件归属和实现哈希一致,Manager入口保持 | +| TC-REFACTOR-R7-002 | 通过控制接口连接、重复连接及断开通道 | 连接池按通道唯一注册;配置变化才替换池;手工断开关闭stopCh并阻止后续自动重连 | +| TC-REFACTOR-R7-003 | 配置多物理连接和不同窗口大小并并发获取 | 轮询使用可用连接,每个连接独立占用和释放窗口,窗口满时等待且不突破Submit超时 | +| TC-REFACTOR-R7-004 | 供应商端点连接失败后恢复 | 临时网络错误按既有上限退避自动重连;鉴权失败使用5分钟慢重试;恢复后清零重连状态 | +| TC-REFACTOR-R7-005 | 心跳请求成功、响应不匹配或连续超时 | 只清除匹配Sequence_Id;超过阈值关闭连接、唤醒未决提交并进入重连状态机 | +| TC-REFACTOR-R7-006 | 分别向CMPP 2.0和3.0通道提交短信 | 使用对应Submit报文、企业代码、Src_Id、MsgFmt和接收号码;SubmitID及回调字段保持 | +| TC-REFACTOR-R7-007 | 提交UCS2长短信并逐分片返回不同结果 | 拆分、UDH、分片顺序、逐片回调、首Sequence_Id/Msg_Id及聚合停止条件保持 | +| TC-REFACTOR-R7-008 | 收到状态报告、普通上行及乱序长上行 | 回执状态映射、Submit tracker关联、内容解码、长上行组装和API事件保持 | +| TC-REFACTOR-R7-009 | 记录Submit和Deliver协议日志 | 只记录既有安全字段、方向、结果和长度,不泄露密码或完整敏感内容 | +| TC-REFACTOR-R7-010 | 执行上游定向、Gateway全量测试和`go vet ./...` | 上游18项及Gateway全部package通过,连接、重连、窗口、心跳和回执行为不变 | +| TC-REFACTOR-R7-011 | 执行API全量、前后端构建及R0~R7门禁 | 既有381项API测试和全部门禁通过,数据库、Redis Stream、通道配置及业务规则不因R7改变 | + +## 2026-07-31 R8 发送链纯逻辑拆分用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-REFACTOR-R8-001 | 执行`node tools/quality/verify-send-chain-r8.mjs` | 24个DTO/事件/队列契约和64个迁移纯声明实现哈希一致;98个编排方法及数据库、队列、重试和分片审计副作用仍由`SendChainService`持有 | +| TC-REFACTOR-R8-002 | 检查运营端、客户端和Gateway事件控制器 | 路由、参数和响应保持;控制器继续注入`SendChainService`,DTO改从独立contracts导入 | +| TC-REFACTOR-R8-003 | 给定已按数据库优先级排序的省内和全国通道候选 | 过滤排除、未报备、运营商不兼容及不可连接通道后,优先选择匹配省份,省内不可用时使用全国兜底 | +| TC-REFACTOR-R8-004 | 分片审计尚未收齐、全部成功、存在明确失败或全部为未知 | 未收齐不产生最终成功;全部预期分片成功才成功;任一明确失败优先;收齐但无明确成功/失败时保持未知口径 | +| TC-REFACTOR-R8-005 | 比较上游端点并重复生成回执事件键 | 账号、主机、端口、协议和CMPP版本全部相同才视为同一端点;相同回执输入稳定生成相同事件键 | +| TC-REFACTOR-R8-006 | 运行纯逻辑与既有SendChain定向测试 | 省内/全国选择、分片最终状态、端点身份和事件键新增8项测试通过;既有104项发送链特征测试保持 | +| TC-REFACTOR-R8-007 | 对真实本地PostgreSQL执行只读核验 | 使用真实短信、分片审计和通道连接数据调用纯函数;无可用分片样本时明确记为不适用,不用mock或造数冒充通过 | +| TC-REFACTOR-R8-008 | 执行API全量、前后端构建、Gateway检查及R0~R8门禁 | 389项API测试和全部门禁通过;不改变schema、migration、事务范围、查询、日志字段、队列消息、CMPP协议或业务数据 | + +## 2026-07-31 R9 发送入口与提交编排拆分用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-REFACTOR-R9-001 | 执行`node tools/quality/verify-send-chain-r9.mjs` | 45个迁移方法在五个职责文件中的实现哈希保持,`SendChainService`98个稳定方法及内部兼容门面逐项委托 | +| TC-REFACTOR-R9-002 | 创建客户端批量任务或确认号码文件导入 | 非法、重复及黑名单号码先被剔除且不计频控;剩余号码按企业应用隔离执行任务风控、号码频控、日限额和余额预占 | +| TC-REFACTOR-R9-003 | 通过HTTP API提交模板短信 | 模板、签名、变量、企业和应用校验保持;每个号码独立消息记录,仍进入同一批量入口,不形成静态或旁路实现 | +| TC-REFACTOR-R9-004 | CMPP单号码、多号码和长短信入站 | 每个目标号码独立内部记录;同一业务号码只计一次频控和日限额,分片不重复计数;原SubmitResp和Msg_Id恢复语义保持 | +| TC-REFACTOR-R9-005 | 审核通过或拒绝待审核任务 | 通过时恢复消息并按内部批次逐一入队;拒绝时释放未扣预占并生成既有平台失败回执;不重复处理已完成任务 | +| TC-REFACTOR-R9-006 | 两个调度扫描器并发认领到期任务或恢复陈旧认领 | PostgreSQL条件更新保证只冻结和入队一次;入队失败保持可恢复状态,不丢失零资费任务 | +| TC-REFACTOR-R9-007 | Worker处理排队消息并发布Gateway提交 | 通道路由、报备校验、限速、Submit记录事务、BullMQ命令、Redis Stream消息和日志字段保持原顺序及内容 | +| TC-REFACTOR-R9-008 | 替换稳定门面的入队、调度、限速或队列方法后调用上层入口 | 新实现的内部跨方法调用仍经过稳定门面,既有测试缝和可观察边界不被绕过 | +| TC-REFACTOR-R9-009 | 检查R10事故高风险方法归属 | Gateway结果、分片提交/回执、最终聚合、补发、退款和下游投递仍保留在`SendChainService`,R9不得提前迁移 | +| TC-REFACTOR-R9-010 | 对真实本地PostgreSQL调用任务查询和导入预检 | 使用真实服务与黑名单查询;任务、短信、频控状态和账户流水调用前后不变,不发送或创建短信 | +| TC-REFACTOR-R9-011 | 执行SendChain定向、API全量、前后端构建、Gateway及R0~R9门禁 | 112项定向和389项API测试通过;schema、migration、Redis契约、CMPP协议及其他业务不因R9改变 | + +## 2026-07-31 R10 发送完成链编排拆分用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-REFACTOR-R10-001 | 执行`node tools/quality/verify-send-chain-r10.mjs` | 41个迁移方法在七个职责文件中的实现哈希保持;内部完成链门面和`SendChainService`98个稳定方法逐项委托 | +| TC-REFACTOR-R10-002 | Gateway逐分片返回提交成功、拒绝或异常 | 每个预期分片只形成对应审计;提交结果按既有聚合规则更新,不把SubmitResp误认为最终送达 | +| TC-REFACTOR-R10-003 | 重复接收相同上游回执事件 | 稳定事件键和数据库唯一约束保证收件箱、分片审计与最终回执幂等,不重复扣费或投递 | +| TC-REFACTOR-R10-004 | 当前补发尝试和迟到旧尝试分别返回回执 | 只有当前尝试能够推进最终状态;迟到旧尝试保留历史但不得覆盖当前结果 | +| TC-REFACTOR-R10-005 | 分片未收齐、全部成功、明确失败或全部未知 | 未收齐不提前成功;全部成功才最终成功;明确失败优先;未知按既有超时和失败口径处理 | +| TC-REFACTOR-R10-006 | 两个执行者并发抢占同一失败短信补发 | 来源提交唯一关系、事务条件更新和P2002唯一冲突处理保证最多创建一个新提交尝试 | +| TC-REFACTOR-R10-007 | 重复执行成功扣费、失败退款或预占释放 | 账务使用原稳定幂等键,同一业务事件只产生一次账户流水,余额和预占不重复变化 | +| TC-REFACTOR-R10-008 | 创建、认领、发送并ACK最终CMPP/HTTP回执 | 每短信只有一条最终回执语义;下游去重、attempt记录、ACK确认、超时恢复和失败分类保持 | +| TC-REFACTOR-R10-009 | 人工重排失败下游投递或恢复陈旧认领 | 使用稳定重排键和原状态条件,已完成或正由其他执行者处理的记录不得重复投递 | +| TC-REFACTOR-R10-010 | 执行回执超时扫描 | 仅处理满足既有时间和状态条件的当前记录;扫描并发保护和终态聚合保持 | +| TC-REFACTOR-R10-011 | 检查七个完成链领域的持久化操作 | 不存在删除历史事故记录的`deleteMany`路径;提交、回执、attempt、死信和账务历史继续保留 | +| TC-REFACTOR-R10-012 | 对真实本地PostgreSQL查询R10八类表 | 查询前后计数完全一致;无分片或attempt样本时如实记为0,不造数、不发短信、不触发补发或重投 | +| TC-REFACTOR-R10-013 | 执行SendChain定向、API全量、前后端构建、Gateway及R0~R10门禁 | 112项定向和389项API测试通过;schema、migration、事务语义、Redis契约、CMPP协议及其他业务不因R10改变 | + +## 2026-07-31 R11 页面域与专属样式拆分用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-REFACTOR-R11-001 | 执行`node tools/quality/verify-admin-channels-r11.mjs` | 稳定页面入口不超过300行;五个聚焦模块、真实API调用、交互文案和页面CSS归属完整 | +| TC-REFACTOR-R11-002 | 打开运营端短信通道管理页 | 页面使用真实API返回通道、连接状态和质量数据;标题、筛选区、表头、行操作和分页正常渲染 | +| TC-REFACTOR-R11-003 | 输入通道名称后查询并执行重置 | 查询向真实后端传递筛选条件,列表和总数同步更新;重置恢复全部筛选和真实列表 | +| TC-REFACTOR-R11-004 | 打开编辑通道弹窗后取消 | 原通道名称、运营商、单价、地区、CMPP和心跳参数正常回填;取消不调用保存接口 | +| TC-REFACTOR-R11-005 | 打开连接日志并筛选 | 真实连接状态和日志数据正常展示;关键词只过滤当前结果,不改变通道或连接状态 | +| TC-REFACTOR-R11-006 | 检查发送测试弹窗但不提交 | 手机号、短信内容、接入号、计费条数和结果区域保持;未获授权不得点击最终发送按钮 | +| TC-REFACTOR-R11-007 | 检查复制、启停、删除和添加入口但不确认 | 所有既有入口和确认提示保持;页面拆分不会旁路原API或自动执行写操作 | +| TC-REFACTOR-R11-008 | 检查`global.css`、`admin.css`与页面CSS | 通道页专属选择器只存在于`AdminChannelsPage.css`;共享`.channel-confirm`由admin层托管,移动端连接摘要规则保持 | +| TC-REFACTOR-R11-009 | 在默认桌面与375×812视口检查页面 | 页面非空、无框架错误覆盖、控制台无相关warning/error;窄屏继续使用既有横向表格浏览方式 | +| TC-REFACTOR-R11-010 | 执行前端生产构建、API/Gateway全量及R0~R11门禁 | 真实后端契约、数据库、Redis Stream、Gateway和CMPP协议不因前端结构迁移改变 | +| TC-REFACTOR-R11-011 | 执行`node tools/quality/verify-admin-sms-task-progress-r11.mjs` | 稳定页面入口不超过220行;六个聚焦模块、真实任务/号码/终止API、交互文案和页面CSS归属完整 | +| TC-REFACTOR-R11-012 | 打开运营端短信任务进度页 | 页面使用真实API返回客户批量任务、企业和应用筛选项;批次号、企业应用、提交时间、号码数、进度、状态和分页正常渲染 | +| TC-REFACTOR-R11-013 | 输入不存在的发送批次号查询,再重置并查询 | 不存在条件返回真实空列表;重置清空全部条件,再次查询恢复真实任务和总数 | +| TC-REFACTOR-R11-014 | 打开真实任务详情 | 提交、发送、计费和成功率指标以及模板、进度、运营商和省份聚合保持原口径,不在前端伪造数据 | +| TC-REFACTOR-R11-015 | 打开号码列表并按部分手机号查询 | 调用真实批次号码分页接口;手机号、归属地、运营商和短信状态与后端一致,筛选后总数同步变化 | +| TC-REFACTOR-R11-016 | 打开终止确认后取消 | 确认文案和已提交部分提示保持;取消不调用终止接口、不改变任务状态 | +| TC-REFACTOR-R11-017 | 检查`global.css`、`admin.css`与短信任务页面CSS | 页面详情、运营商卡片和移动端专属选择器只存在于页面CSS;报备/下游等运营页面共用的筛选、表格、标识和卡片选择器由admin层托管 | +| TC-REFACTOR-R11-018 | 在默认桌面与375×812视口检查短信任务页面 | 页面、详情弹窗和窄屏卡片均非空且无框架错误覆盖;控制台无warning/error,长表格保持既有可浏览方式 | +| TC-REFACTOR-R11-019 | 执行前端生产构建、API/Gateway全量及全部R0~R11门禁 | 389项API测试、Prisma/API构建、Gateway测试/vet和依赖门禁通过;短信、任务、数据库和协议行为不因页面结构迁移改变 | +| TC-REFACTOR-R11-020 | 执行`node tools/quality/verify-admin-sms-records-r11.mjs` | 稳定页面入口不超过220行;四个聚焦模块、五个真实API调用、交互文案、专属与共享CSS归属完整 | +| TC-REFACTOR-R11-021 | 打开运营端短信记录页 | 默认日期范围、企业/应用/运营商/状态选项、内容与通道筛选、后端导出入口、记录列表和分页正常渲染 | +| TC-REFACTOR-R11-022 | 清空默认日期并查询全部真实记录 | 后端返回实际记录与总数,前端不使用静态数组;超过25条时真实分页总页数和上下页状态正确 | +| TC-REFACTOR-R11-023 | 输入完整或部分手机号查询 | 查询参数传给真实记录API,结果、总数和分页同步收窄;不存在的号码显示真实空状态 | +| TC-REFACTOR-R11-024 | 展开运营商筛选 | 固定提供全部、移动、联通、电信、未识别,选择后使用后端`carrier`条件查询 | +| TC-REFACTOR-R11-025 | 打开真实记录的发送详情 | 最终/提交/回执状态、号码归属、接入号、短信内容、通道尝试及回执时间/码与后端数据一致 | +| TC-REFACTOR-R11-026 | 查看有分片与无分片的详情 | 分片审计调用真实接口;有数据按时间、分片序号和ID排序,无数据明确显示暂无分片审计,不造数 | +| TC-REFACTOR-R11-027 | 检查后端CSV导出边界 | 导出继续调用`exportOperationMessages`并使用当前筛选条件,不退化为仅导出当前页的浏览器静态数据 | +| TC-REFACTOR-R11-028 | 检查`global.css`、`components.css`与短信记录页面CSS | 页面列表、详情、路由、分片和响应式选择器只存在于页面CSS;共享弹窗标题和空状态由components托管,弱化文本样式仍在global | +| TC-REFACTOR-R11-029 | 在默认桌面与375×812视口检查短信记录页面 | 筛选区、记录卡片、详情弹窗和分页非空且无框架错误覆盖;控制台无相关warning/error,窄屏字段不重叠 | +| TC-REFACTOR-R11-030 | 执行前端生产构建、API/Gateway全量及全部R0~R11门禁 | 389项API测试、Prisma/API构建、Gateway测试/vet和依赖门禁通过;短信记录、回执和协议行为不因页面拆分改变 | +| TC-REFACTOR-R11-031 | 执行`node tools/quality/verify-admin-enterprise-applications-r11.mjs` | 稳定页面入口不超过300行;六个聚焦模块、六类真实API调用、交互文案、筛选状态分层和页面CSS归属完整 | +| TC-REFACTOR-R11-032 | 打开运营端企业应用管理页 | 页面使用真实分页API返回应用、企业、今日发送、到达率、单价、客户连接状态和应用状态,不使用静态数组 | +| TC-REFACTOR-R11-033 | 输入不存在的企业或应用名称查询,再重置 | 查询条件传给真实后端,列表和总数同步变为空;重置恢复全部条件、第一页和真实应用列表 | +| TC-REFACTOR-R11-034 | 点击添加应用并取消 | 弹窗通过真实企业接口加载未删除企业;未选择企业时下一步禁用,取消不导航、不创建应用 | +| TC-REFACTOR-R11-035 | 打开CMPP连接详情 | 当前连接数、配置连接数、AppID、连接状态和真实已连接会话保持;无会话时显示真实空状态 | +| TC-REFACTOR-R11-036 | 打开CMPP或HTTP参数弹窗后关闭 | 参数由真实详情接口返回;标题、企业/应用上下文和复制入口保持,关闭不修改接口凭据或应用配置 | +| TC-REFACTOR-R11-037 | 打开启用、停用或删除确认后取消 | 停用先调用真实影响预检;等待清算与强制停用文案、未决项统计和确认边界保持,取消不调用状态变更接口 | +| TC-REFACTOR-R11-038 | 检查短信/彩信页签 | 短信应用显示真实表格与分页;彩信继续明确为后端能力待确认,不用mock或静态数据伪造功能 | +| TC-REFACTOR-R11-039 | 检查`global.css`、`admin.css`、`components.css`、`shell.css`与企业应用页面CSS | 筛选、连接、参数和新增提示专属选择器只存在于页面CSS;共享筛选/确认由admin托管,弹窗标题/表单布局由components托管,section布局由shell托管 | +| TC-REFACTOR-R11-040 | 在默认桌面与375×812视口检查企业应用页面 | 页面、筛选、横向表格和安全只读弹窗非空且无框架错误覆盖;控制台无相关warning/error | +| TC-REFACTOR-R11-041 | 执行前端生产构建、API/Gateway全量及全部R0~R11门禁 | 389项API测试、Prisma/API构建、Gateway测试/vet和依赖门禁通过;企业应用、连接、数据库和协议行为不因页面拆分改变 | +| TC-REFACTOR-R11-042 | 执行`node tools/quality/verify-foundation-styles-r11.mjs` | 76个设计令牌、13组reset/base规则、逐规则声明哈希、选择器所有权和当前`tokens → reset → shell → global → admin → client → components → AppRoutes`依赖顺序完整 | +| TC-REFACTOR-R11-043 | 检查`tokens.css`、`reset.css`和`global.css`职责 | tokens只保留`:root`设计令牌;reset只含元素级基础规则;原global不再重复拥有这些规则,AppShell、组件及页面类未在本步骤迁移 | +| TC-REFACTOR-R11-044 | 执行前端TypeScript及Vite生产构建并检查产物CSS位置 | 新增reset入口进入生产CSS依赖图,实际产物按tokens、reset、global、components/页面依赖顺序拼接;CSS总体体积和既有单chunk提示无异常增长 | +| TC-REFACTOR-R11-045 | 打开已登录运营端代表页面 | 页面背景、字体、标题、链接、按钮、输入框、下拉框、禁用态、表格和弹窗保持,真实API数据正常渲染 | +| TC-REFACTOR-R11-046 | 打开客户端登录页 | Logo、标题、说明、账号/密码/验证码输入、登录按钮和链接保持;不提交登录表单、不改变会话 | +| TC-REFACTOR-R11-047 | 使用键盘聚焦可交互控件 | `:focus-visible`继续显示令牌化焦点环;普通焦点不出现浏览器默认outline,禁用控件仍为不可操作光标 | +| TC-REFACTOR-R11-048 | 在默认桌面与375×812视口检查运营端和客户端代表页面 | 页面非空、无框架错误覆盖或页面级横向溢出,排版和控件没有因reset加载顺序产生跳变;控制台无相关warning/error | +| TC-REFACTOR-R11-049 | 执行API/Gateway全量、Prisma、安全门禁、全部R0~R11结构门禁和`git diff --check` | 389项API测试、API构建、Gateway测试/vet及全部门禁通过;数据库、Redis、CMPP和业务行为不因基础CSS迁移改变 | +| TC-REFACTOR-R11-050 | 执行`node tools/quality/verify-app-shell-styles-r11.mjs` | 118组壳层规则、44个壳层类、九组通用布局选择器、三类响应式/动效边界和样式所有权完整 | +| TC-REFACTOR-R11-051 | 检查`main.tsx`及生产产物CSS | 依赖顺序为`tokens → reset → shell → global → admin → client → components → AppRoutes`,壳层代表规则位于各页面域和components之前 | +| TC-REFACTOR-R11-052 | 检查`global.css`、`shell.css`和组件样式职责 | AppShell和通用布局原语只由shell托管;在第六步时复用的`.icon-button`、页签、表格、表单和弹窗尚未迁移,第七步迁移后由components托管,页面域样式仍不越界 | +| TC-REFACTOR-R11-053 | 在已登录运营端桌面宽屏点击收起/展开导航 | 主区列宽在标准侧栏和76px折叠栏间切换;完整/紧凑Logo、菜单图标和活动态正常,无内容遮挡 | +| TC-REFACTOR-R11-054 | 在已登录运营端打开并关闭通知及用户菜单 | 弹层定位、层级、计数和空状态保持;只读开关弹层不触发退出、审核、配置或其他写操作 | +| TC-REFACTOR-R11-055 | 在375×812视口打开并关闭移动导航 | 顶栏显示移动入口,侧栏以抽屉和遮罩呈现;关闭按钮/遮罩可收起,页面无横向溢出 | +| TC-REFACTOR-R11-056 | 在系统减少动画偏好下检查侧栏 | 侧栏过渡被关闭,布局和可操作性不变 | +| TC-REFACTOR-R11-057 | 无可用运营端登录态或遇到验证码 | 不伪造会话、不读取浏览器存储、不绕过验证码;将真实点击验收标记为受阻并保留代码级结果 | +| TC-REFACTOR-R11-058 | 执行前端生产构建、API/Gateway全量、Prisma、安全门禁、全部R0~R11结构门禁和`git diff --check` | 29个API套件/389项测试、API构建、Gateway测试/vet及全部门禁通过;数据库、Redis、CMPP和业务行为不因壳层CSS迁移改变 | +| TC-REFACTOR-R11-059 | 执行`node tools/quality/verify-shared-components-r11.mjs` | 259组规则、291个选择器、14组通用类族、桌面/780px所有权和Button/Input/Select/Table/Modal真实绑定完整 | +| TC-REFACTOR-R11-060 | 检查`global.css`与`components.css`职责 | 纯通用表格、表单、弹窗、页签、图标按钮和表格辅助类只由components托管;页面域复合选择器不被误迁 | +| TC-REFACTOR-R11-061 | 检查组件级联和入口加载顺序 | 兼容规则位于规范组件规则之前,响应式规则位于对应桌面规则之后;最终`tokens → reset → shell → global → admin → client → components → AppRoutes`顺序完整 | +| TC-REFACTOR-R11-062 | 在已登录运营端打开代表性表格并检查空/有数据状态 | 真实后端数据、表头、行、操作区、分页和空状态保持;移动端卡片标签和字段值不重叠 | +| TC-REFACTOR-R11-063 | 打开并关闭普通、宽版和XL代表弹窗 | 遮罩、标题、关闭按钮、正文滚动、页脚操作和宽度保持;仅打开/取消不调用写接口 | +| TC-REFACTOR-R11-064 | 检查代表性表单、单选、页签和图标按钮 | 输入、选择、双列表单、单选行、活动页签、图标按钮及焦点/禁用/悬停状态保持 | +| TC-REFACTOR-R11-065 | 在375×812视口检查表格、表单和弹窗 | 表格使用既有移动卡片布局,表单双列改为单列,弹窗不超出视口,操作按钮可见且页面无横向溢出 | +| TC-REFACTOR-R11-066 | 在PostgreSQL停止但API/Redis仍运行时用有效运营会话恢复 | 会话中间件查用户时应暴露数据库连接异常;当前实现会返回500,不能误归因为前端会话拆分,后续可单独评估health与错误映射增强 | +| TC-REFACTOR-R11-067 | 恢复PostgreSQL后检查会话边界 | 真实Prisma用户查询恢复;无cookie访问`/api/admin/auth/session`返回401,运营端跳转登录页而非500;不得绕过验证码伪造登录态 | +| TC-REFACTOR-R11-068 | 执行前端生产构建、API/Gateway全量、Prisma、安全门禁、全部R0~R11结构门禁和`git diff --check` | 29个API套件/389项测试、API构建、Gateway测试/vet及全部门禁通过;业务、数据库、Redis、Gateway和CMPP行为不因通用组件CSS迁移改变 | +| TC-REFACTOR-R11-069 | 执行`node tools/quality/verify-admin-shared-styles-r11.mjs` | 117组规则、141个选择器、11项跨页面使用下限、admin/client所有权和780px/360px响应式边界完整 | +| TC-REFACTOR-R11-070 | 检查`main.tsx`及生产CSS依赖图 | 最终加载顺序为`tokens → reset → shell → global → admin → client → components → AppRoutes`,admin/client域位于global之后、通用组件之前 | +| TC-REFACTOR-R11-071 | 检查`global.css`与`admin.css`所有权 | 纯admin共享选择器只由admin托管;global只保留带单页面上下文的覆盖规则,client源码不引用admin所有权类 | +| TC-REFACTOR-R11-072 | 打开审核中心的企业、短信、模板、签名和引流审核页 | 审核筛选卡、响应式网格、查询/重置操作和审核操作布局保持,真实后端数据与原API不变 | +| TC-REFACTOR-R11-073 | 打开任务进度、报备记录及下游记录代表页面 | 共用筛选区、任务表格卡、批次标识、企业信息、分页和详情入口保持,真实分页与筛选参数不变 | +| TC-REFACTOR-R11-074 | 打开全局黑名单、企业黑名单和敏感词页面 | 安全页标题、筛选、表单、表格及移动端布局保持;只读验收不新增、删除或导入数据 | +| TC-REFACTOR-R11-075 | 打开用户、号段和引流字段等系统管理代表页面 | 系统工具栏、表格卡、弹窗双列表单和操作区保持;打开后取消,不执行创建、编辑或删除 | +| TC-REFACTOR-R11-076 | 打开质量、利润和对账统计页 | 三类统计筛选网格及查询按钮在桌面和780px下保持,仍调用真实统计接口,不使用静态数据 | +| TC-REFACTOR-R11-077 | 在375×812视口检查审核、任务、安全和系统代表页面 | 筛选区转为单列或既有紧凑布局,按钮宽度、表格/卡片和弹窗不发生页面级横向溢出 | +| TC-REFACTOR-R11-078 | 执行前端生产构建、API/Gateway全量、Prisma、安全门禁、全部R0~R11结构门禁和`git diff --check` | 29个API套件/389项测试、API构建、Gateway测试/vet及全部门禁通过;业务、数据库、Redis、Gateway和CMPP行为不因admin共享CSS迁移改变 | +| TC-REFACTOR-R11-079 | 执行`node tools/quality/verify-client-shared-styles-r11.mjs` | client唯一共享规则、两页面使用下限、三组跨门户兼容边界和五类单页面保留边界完整 | +| TC-REFACTOR-R11-080 | 核对client/admin源码类引用 | `.eyebrow`至少由客户端首页和账单页使用且运营端不引用;没有为了扩大迁移量而错误处理单页面类 | +| TC-REFACTOR-R11-081 | 检查`global.css`与`client.css`所有权 | 纯`.eyebrow`声明只由client托管;`.overview-hero .eyebrow`页面覆盖继续留在global | +| TC-REFACTOR-R11-082 | 检查跨门户兼容类 | `.sms-send-title`、`.system-page-toolbar`和`.system-table-card`继续留在global,客户端与运营端系统日志页面均不丢失样式 | +| TC-REFACTOR-R11-083 | 打开客户端首页和账户账单页 | eyebrow颜色、字号、字重和间距保持;真实首页与账单API数据正常渲染 | +| TC-REFACTOR-R11-084 | 打开签名、发送、企业认证、发送详情和模板代表页面 | 各单页面样式仍由原global规则托管,页面结构、真实接口和交互不因第九步改变 | +| TC-REFACTOR-R11-085 | 在375×812视口检查首页、账单及一个单页面代表 | 标题、卡片、表格和操作区无新增横向溢出,控制台无相关warning/error | +| TC-REFACTOR-R11-086 | 执行前端生产构建、API/Gateway全量、Prisma、安全门禁、全部R0~R11结构门禁和`git diff --check` | 29个API套件/389项测试、API构建、Gateway测试/vet及全部门禁通过;R11九步完成且业务行为不因client共享CSS迁移改变 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index a4e5f0e..be701df 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2748,3 +2748,295 @@ git diff --check - 部署后使用真实 Prisma Client 和 PostgreSQL 只读执行新路径:运营看板返回 00:00~23:00 共 24 个小时桶和企业认证、短信审核、模板、签名、引流信息 5 类审核速度;短信记录运营商筛选真实计数为移动 630、联通 145、电信 138、未识别 87。共享号码路由服务并发识别 3 次只加载 1 次运营商规则,真实号码和未知号码各用 1 次号段查询,均返回预期结果。 - 尝试受保护 HTTP 验证时,部署管理员凭据文件的 `password=unchanged` 标记被误当成密码提交一次并返回 401;未继续猜测或绕过认证。随后使用部署自带 `ensure-production-admin.mjs` 将该部署管理员失败计数恢复为 0,确认未锁定。发布后 API/Gateway error 级 journal 和 panic/fatal/unhandled/Prisma 关键错误匹配均为 0。 - 本次未发送、重投或补发真实短信,未修改供应商通道凭据或启停状态、企业余额、客户连接和短信业务数据。 + +## 2026-07-29 运营看板今日发送趋势小时分桶时区修复(本地未提交) + +- 预生产只读核验确认“01:00 提交 128 条”并非真实凌晨发送:对应 `SmsMessageRecord.queuedAt` 存储值为 UTC `2026-07-29 01:19:41`~`01:50:42`,正确北京时间为 `09:19:41`~`09:50:42`。 +- 根因是 `queuedAt` 在 PostgreSQL 中为 `timestamp without time zone` 并按 UTC 保存,原聚合 SQL 直接执行 `queuedAt AT TIME ZONE 'Asia/Shanghai'`,把 UTC 墙上时间错误解释为上海本地时间,小时桶整体提前 8 小时且受数据库会话时区影响。 +- 小时分桶改为先用 `AT TIME ZONE 'UTC'` 将存储值解释为 UTC,再用 `AT TIME ZONE 'Asia/Shanghai'` 转为北京时间后提取小时;新增 SQL 结构回归断言,并在需求和 `TC-DASHBOARD-007` 中明确 UTC 存储及会话时区无关性。 +- Node.js v24.14.0 下 Operations 定向 1 suite / 26 tests 全部通过,API TypeScript 正式构建通过。预生产真实 PostgreSQL 只读执行修正后的等价 SQL,在会话时区分别设置为 UTC 和 `Asia/Shanghai` 时,128 条记录均稳定归入北京时间 `09:00`,未落入 `01:00`。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署;既有构建缓存、`outputs/`、空文件 `=` 和 `tsconfig.tsbuildinfo` 继续原样保留。 + +## 2026-07-30 号码发送频次风控(本地未提交) + +- 按用户确认口径新增两条全局兜底:北京时间自然日 10 条、固定 5 分钟 5 条;隔离键为企业应用、号码和规则,两条规则独立覆盖与命中,首版统一直接拒绝。 +- 新增`PhoneFrequencyState`实时状态和`PhoneFrequencyHit`历史触发模型及 migration `20260730093000_add_phone_frequency_controls`。计数通过 PostgreSQL `INSERT ... ON CONFLICT DO UPDATE ... RETURNING`原子占用;大批量号码按 1000 条分块写入但保持同一数据库事务,避免 PostgreSQL 参数数量上限。 +- 客户端批量、公开 HTTP 和 CMPP 单号码入口已接入同一频控服务。非法/黑名单号码和任务级已拒绝记录不计数;命中号码按业务短信记录直接失败且金额为 0,未命中号码继续发送,长短信分片与通道补发不重复计数。 +- 风控规则页新增两类规则定义,应用级阈值可分别覆盖全局规则,后端强制正整数阈值和直接拒绝动作;新增真实触发记录分页、号码/状态/应用范围查询及“解除并清零”操作。解除原因必填,保留历史触发记录并写操作审计。 +- Prisma format、validate、generate通过;API TypeScript正式构建和前端 TypeScript/Vite生产构建通过。新增频控服务 1 suite / 2 tests、发送链 1 suite / 104 tests、原风控 1 suite / 15 tests均通过;API 全量 28 suites / 380 tests全部通过。发送链及全量测试需沿用项目既有`--forceExit`开放句柄处理,首次未带该参数运行在工具时限内未自行退出,未将超时计为通过。 +- 本地真实 PostgreSQL 已应用全部 77 条 migration,其中包含`20260730093000_add_phone_frequency_controls`。6 个同应用同号码并发占用的结果为 5 个放行、1 个拒绝,状态计数 6、活跃命中 1 条;后续提交继续拒绝且计数冻结为 6。人工解除后计数归零、代次从 0 增至 1,同周期再次越线成功生成新代次命中;日规则与 5 分钟规则独立生效。验证使用专用测试号码,状态、命中和操作日志均已清理。 +- 本地 API 关闭发送 Worker 后连接真实 PostgreSQL 启动成功,Vite production preview 可正常渲染且控制台无错误;访问风控规则目标路由被真实鉴权跳转至图形验证码登录页。`codex_local_admin`在本地数据库中复核为 active、平台管理员、失败次数 0 且未锁定,但本轮未请求用户授权代解验证码,因此未把登录页冒充规则页视觉和解除弹窗交互验收。Vite dev 模式另出现既有`cookie.parse`导出不兼容白屏,改用成功生产构建的 preview 后消失,未修改依赖。 +- 验收启动的本地 API、Vite dev/preview 和 PostgreSQL 已停止;原先已运行的 Redis 保持不变。当前仍缺登录后规则页桌面/窄屏视觉和解除弹窗交互验收,未提前宣称该项通过。 +- 本轮按用户要求保持本地未提交、未推送、未部署。另一会话既有的运营看板小时分桶时区修复及其文档修改继续保留,不归因于本需求;构建缓存、`outputs/`、空文件`=`和`tsconfig.tsbuildinfo`继续原样保留。 + +## 2026-07-30 平台级号码频控白名单(本地未提交) + +- 按用户确认口径新增平台级号码白名单:启用号码在全平台所有企业应用下均豁免24小时和5分钟号码频控,其他号码校验、黑名单、内容审核、余额、路由及其他风控不受影响。 +- 新增`PhoneFrequencyWhitelist`模型及 migration `20260730114500_add_phone_frequency_whitelist`,号码全平台唯一,记录启用/停用/软删除状态、用途说明、备注、创建人、最后操作人和时间;创建人、更新人及状态时间均建立相应索引。 +- 后端提供真实数据库分页、状态/号码/关键字/更新时间查询及新增、修改、停用和软删除接口。批量发送在频控事务内按1000个号码分块读取启用白名单,不使用逐号码查询、Mock、静态数据或localStorage。 +- 新增/恢复启用、改号、启停和删除会在同一事务中清零相关号码在所有应用和两类规则下的当前状态,并解除活跃命中;白名单及频控命中历史均保留,所有写操作写入运营审计。 +- 运营端风控规则页新增“平台级号码频控白名单”区域,提供号码/状态查询、真实分页、新增、编辑、启停和填写原因后删除,页面明确说明仅豁免两类号码频控及跨应用生效范围。 +- Prisma format、validate、generate通过;API TypeScript正式构建、前端TypeScript检查及Vite v8.0.16生产构建通过。号码频控服务定向1 suite / 3 tests、API全量28 suites / 381 tests全部通过;全量Jest仅保留项目既有`--forceExit`开放句柄提示,Vite仅保留既有约1.99MB单chunk提示。 +- 本地真实PostgreSQL已应用全部78条migration。专用验收号码先在应用A形成24小时计数6和5分钟计数6/活跃命中1条;新增启用白名单后两类状态均归零且活跃命中被解除;在应用B连续占用12次未产生任何频控状态;删除白名单后应用B两类计数均从1重新开始。白名单、状态、命中和审计验收数据均已清理。 +- 本地API以关闭发送Worker、扫描任务和通道重连的安全配置连接真实PostgreSQL运行,`/api/health`返回HTTP 200;前端production preview的目标路由返回HTTP 200且控制台无warning/error。应用内浏览器无既有登录会话,真实鉴权将目标路由跳转至图形验证码登录页,未获本轮授权代解验证码,因此未把登录页冒充白名单区域的登录后视觉验收。 +- 本地API继续监听3000,前端production preview继续监听4173,PostgreSQL和Redis分别继续监听5432、6379,供用户本地查看。当前按用户要求保持未提交、未推送、未部署;另一会话的运营看板小时分桶修改继续保留且不归因于本需求,构建缓存、`outputs/`、空文件`=`和`tsconfig.tsbuildinfo`继续原样保留。 + +## 2026-07-30 客户端工作台与短信发送体验完善(本地未提交) + +- 客户端 Dashboard 新增真实企业概览:企业名称来自`Tenant`,认证状态按是否存在已通过`EnterpriseCertification`判定,签名数量排除已删除和已禁用签名,待审核批量任务独立统计当前企业`sourceType=client/status=pending_review`的`SmsBatchTask`。这些独立查询与原 Dashboard 聚合并行执行。 +- 工作台账户状态改为已认证/未认证,企业主体展示企业名称,默认签名改为签名数量;快捷操作移除“真实”字样。模板、签名、批量任务卡片分别展示真实待审核数量并可进入对应菜单。 +- 客户端今日发送趋势改为消费后端00:00~23:00共24个北京时间小时桶;继续包含另一会话尚未提交的 UTC 存储时间到上海时区双重转换修复,不将其归因于本需求。 +- 签名与引流信息页的“重置”增加刷新代次,即使筛选条件已经为空也会重新请求真实签名工作区;运营端企业认证审核和短信模板审核的初始及重置状态改为待审核。 +- 短信发送预计条数改为70字符内1条、长短信每67个Unicode字符一条,并随正文和有效号码数即时计算;单价单位改为元/条。提交失败使用中央弹窗,提交成功弹窗展示真实任务编号和号码数,可清空表单继续发送或携带任务编号进入批量任务页。 +- 定时发送日期控件新增按`Asia/Shanghai`计算的“今天”按钮和当天浅色标识,提交时把页面选择值显式规范为`+08:00`;不依赖用户浏览器或服务器默认时区解释业务时间。 +- Operations 定向1 suite / 26 tests、API全量28 suites / 381 tests全部通过;API TypeScript正式构建、前端TypeScript检查和Vite v8.0.16生产构建通过,2442个模块完成转换。Jest仅保留项目既有`--forceExit`开放句柄提示,Vite仅保留既有约2.00MB单chunk提示,`git diff --check`通过。 +- 本地真实PostgreSQL只读执行企业名称、已通过认证、有效签名和待审核客户端批量任务等价查询成功;样本同时覆盖已认证和未认证企业,未写入或修改认证、签名、任务和短信数据。 +- 本地API health和production preview均HTTP 200,浏览器加载前端无框架错误覆盖层且控制台无warning/error;目标客户端路由被真实鉴权跳转到图形验证码登录页。未获授权处理验证码,因此未把登录页当作工作台、短信发送、日期控件和成功/失败弹窗的登录后视觉及交互验收。 +- 本轮未提交发送任务、未发送真实短信,也未修改企业余额、通道、客户连接或预生产数据。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。号码频控、平台白名单及其migration、发送链、风控页面和文档是另一会话既有修改,继续完整保留且不归因于本需求。 + +## 2026-07-30 R0 渐进式拆分安全护栏(本地未提交) + +- R0 已按“先锁定行为、不移动生产代码”的范围完成。新增大文件职责与副作用索引,覆盖 `SendChainService`、Gateway 入站/上游、Channels、SmsConfig、Operations、ReportMaterials、`adminApi` 和全局样式,并记录直接调用者、主要数据库表、队列/外部副作用、事务与幂等不变量。 +- 新增单版本唯一结构修改会话规则、开始前/实施中/提交前/发布观察清单、立即停止条件和回滚记录模板。后续 R1~R11 每个版本仍需重新核对 Git 与部署事实,不能把本轮基线提交视为永久事实。 +- 新增机器可执行的 `tools/quality/verify-refactor-r0.mjs` 和 `docs/contracts/refactoring-r0-manifest.json`,固定 8 个稳定门面、Redis Stream 四类契约样例、CMPP 2.0/3.0 报文、Gateway ACK/重连以及发送链和账务并发幂等测试入口。R0 门禁和现有队列契约校验均通过。 +- Node.js v24.16.0 下 API 全量 28 suites / 381 tests 全部通过;API TypeScript 正式构建、前端 TypeScript 与 Vite v8.0.16 生产构建、Prisma format/validate/generate、Gateway `go test ./...` 与 `go vet ./...`、依赖安全门禁和 `git diff --check` 全部通过。Jest 仅保留项目既有 `--forceExit` 开放句柄提示,Vite 仅保留既有约 2.00MB 单 chunk 提示。 +- R0 没有新增或修改业务接口、数据库 schema、migration、Redis Stream 消息或 CMPP 行为,没有启动新服务、写数据库或发送短信。当前工作区既有号码频控/白名单、客户端体验、运营看板等其他修改及构建产物均完整保留,不归因于 R0。 +- 本轮按用户要求仅保留本地未提交修改,不提交、不推送、不部署。 + +## 2026-07-31 R1 前端 API 兼容门面拆分(本地未提交) + +- 按路线图完成 R1.1~R1.5。原`src/api/adminApi.ts`从2292行缩减为19行稳定兼容门面;现有页面仍统一从该文件导入,不批量修改73处调用位置。 +- 通用HTTP、错误解析、401退出、`SESSION_LOCKED`、最近认证、租户请求头、Blob、multipart和查询参数逻辑移动到`src/api/core/httpClient.ts`;函数体与拆分前逐项一致。运营端按身份与企业、通道与报备、运营查询、审核风控账务、文件上传拆成五个领域对象;客户端API和会话API独立。 +- 104个公开类型按common、identity-config、channels-reports、operations、governance五个领域拆分并由统一barrel重导出。最大业务API文件194行,最大类型文件580行;没有新增循环运行时依赖,跨领域引用均为type-only import。 +- 新增`docs/contracts/admin-api-r1-methods.json`和`tools/quality/verify-admin-api-r1.mjs`。门禁确认183个运营端方法、60个客户端方法、7个会话方法及9个HTTP核心函数的实现哈希与拆分前一致,同时确认`adminApi`、`clientApi`、`portalSessionApi`和`fileDownloadUrl`稳定导出仍存在;R0门禁继续通过。 +- Node.js v24.16.0下前端TypeScript检查及Vite v8.0.16生产构建通过,2456个模块完成转换;API全量28 suites / 381 tests、API TypeScript正式构建、Gateway`go test ./...`与`go vet ./...`、依赖安全门禁和`git diff --check`全部通过。Jest仅保留既有`--forceExit`开放句柄提示,Vite仅保留既有约2.00MB单chunk提示。 +- 应用内浏览器访问`http://localhost:4173/#/admin/login`,页面标题为“聆界短信管理平台”,登录表单和验证码正常渲染;点击验证码后真实算式发生变化,控制台无warning/error且无框架错误覆盖。未获授权求解验证码和登录,因此登录后关键菜单真实API冒烟仍未执行,未将登录页验证冒充该项通过。 +- R1只移动前端API与类型代码,没有修改URL、HTTP方法、请求体、返回类型、页面交互、后端、数据库schema、migration、Redis Stream或CMPP协议;没有写数据库或发送短信。工作区中既有号码频控/白名单、客户端体验、运营看板及其他会话产物继续完整保留,不归因于R1。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R2 运营查询服务兼容门面拆分(本地未提交) + +- 按路线图完成R2。`api/src/operations/operations.service.ts`从2368行缩减为150行稳定门面,运营端和客户端控制器继续只注入`OperationsService`,29个公开方法的名称、参数、异步标记和返回推断保持不变。 +- 原实现按短信记录与CSV、上行与监控、运营看板、发送/签名质量、系统日志、下游投递恢复、追踪对账七个领域迁移;3个私有方法随唯一调用域移动。查询契约独立到`operations.contracts.ts`,36个查询构造、时区、CSV、安全视图和响应汇总函数集中到`operations.helpers.ts`。 +- 新增`docs/contracts/operations-r2-methods.json`和`tools/quality/verify-operations-r2.mjs`。R2门禁确认29个公开方法、3个私有方法、9个查询契约和36个辅助函数与拆分前实现哈希一致;SQL、Prisma查询参数、分页、CSV、上海时区和客户端安全映射未改写。 +- Operations定向1 suite / 26 tests、API全量28 suites / 381 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript与Vite v8.0.16生产构建、Gateway`go test ./...`与`go vet ./...`、依赖安全门禁和`git diff --check`全部通过。Jest仅保留既有`--forceExit`开放句柄提示,Vite仅保留既有约2.00MB单chunk及本次构建解析耗时提示。 +- 真实本地PostgreSQL只读调用拆分后的服务:短信总数56、第一页5条;签名质量和恢复状态当前均为0条;客户端看板返回真实企业“测试客户A”、未认证、有效签名0和待审核任务0。首次只读验证误用了Tenant不存在的`deletedAt`字段,Prisma在SQL执行前拒绝,修正为当前schema字段后通过;全程未写数据库。 +- R2没有修改控制器、路由、数据库schema、migration、Redis Stream、Gateway或CMPP协议,没有触发下游重投、文件导出落库或短信发送。当前工作区既有号码频控/白名单、客户端体验、R0/R1及其他会话产物继续完整保留,不归因于R2。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R3 短信配置兼容门面拆分(本地未提交) + +- 按路线图完成R3。`api/src/sms-config/sms-config.service.ts`从2268行缩减为约244行稳定门面,运营端、客户端、Gateway事件和报备资料服务继续依赖`SmsConfigService`;51个公开方法名称、参数、异步标记和返回推断保持不变。路线图中的“第一阶段”表示保留统一门面,不表示短信配置只拆了一部分。 +- 原实现按应用配置与接入参数、应用停用生命周期和下游连接、签名、引流信息、模板、审核记录与动作、共享报备资料校验七个领域迁移。21个内部方法随职责移动;既有私有停用扫描测试入口由门面继续委托生命周期服务,未复制业务实现。 +- 19个DTO和查询类型移到`sms-config.contracts.ts`,运营端、客户端和Gateway控制器不再从实现类文件导入DTO。27个规范化、CMPP参数、模板变量及报备值辅助声明集中到`sms-config.helpers.ts`;`SmsConfigModule`仍只注册并导出稳定门面,不扩大NestJS provider图。 +- 新增`docs/contracts/sms-config-r3-methods.json`和`tools/quality/verify-sms-config-r3.mjs`。R3门禁确认51个公开方法、21个内部方法、19个契约和27个辅助声明领域归属正确,迁移前后方法体在受控依赖委托还原后完全一致;R0、R1、R2门禁继续通过。 +- Node.js v24.14.0下短信配置与审核治理定向2 suites / 68 tests、API全量28 suites / 381 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript和Vite v8.0.16生产构建、Gateway`go test ./...`与`go vet ./...`、依赖安全门禁和`git diff --check`通过。Jest只保留既有`--forceExit`开放句柄提示,Vite只保留既有约2.00MB单chunk提示。 +- 真实本地PostgreSQL只读调用拆分后的门面成功:读取真实应用并确认租户匹配,应用报备字段当前0条、引流信息0条、模板1条、签名审核记录0条。第一次只读校验脚本按错误的对象结构读取报备字段长度而抛出TypeError,修正为当前数组响应后通过;两次均未执行写操作。 +- R3没有修改控制器路由、请求/响应DTO内容、数据库schema、migration、Redis Stream、Gateway或CMPP协议,没有调用创建、编辑、审核、停用、下游重投或短信发送路径。现有号码频控/白名单、客户端体验、R0~R2和其他工作区修改继续完整保留,不归因于R3。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R4 报备资料与企业签名页面拆分(本地未提交) + +- 按路线图完成R4后端全部边界。`api/src/report-materials/report-materials.service.ts`从1134行缩减为82行稳定门面,12个公开方法的名称、参数、异步标记和返回推断保持;11个内部方法按官方模板与导出、导入解析映射、暂存与审核、待生成查询、批次预检生成、通道文件导出、幂等操作记录七个领域迁移。 +- 10个DTO、查询和内部数据类型移到`report-materials.contracts.ts`,32个工作簿安全、字段映射、分页、日期、文件和导出辅助函数移到`report-materials.helpers.ts`。控制器DTO不再从实现类文件导入,模块和其他调用方仍只依赖稳定门面。 +- 前端遵守“一个版本只拆一个大页面”,选择`AdminEnterpriseSignaturesPage.tsx`,从884行缩减为238行页面容器。类型、纯展示辅助、动态报备资料、签名编辑、引流编辑、报备状态弹窗和签名/引流表格拆为7个文件,最大132行;页面容器继续统一维护筛选、分页、加载、保存和删除协调。 +- 新增`docs/contracts/report-materials-r4-methods.json`、`docs/contracts/admin-enterprise-signatures-r4.json`、`tools/quality/verify-report-materials-r4.mjs`和`tools/quality/verify-enterprise-signatures-r4.mjs`。后端门禁确认12个公开方法、11个内部方法、10个契约和32个辅助函数实现一致;前端门禁确认20个移出函数、表格JSX、8个真实API调用和25个页面状态保持。 +- Node.js v24.14.0下报备资料定向1 suite / 10 tests、API全量28 suites / 381 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript和Vite v8.0.16生产构建、Gateway`go test ./...`与`go vet ./...`、依赖安全门禁通过。Jest只保留既有`--forceExit`开放句柄提示,Vite只保留既有约2.00MB单chunk提示。 +- 真实本地PostgreSQL只读调用拆分后门面成功:待生成资料、导入审核批次和已生成批次当前均0条,导入映射配置0条;未调用模板生成、文件上传、导入提交、审核、批次生成、幂等认领或通道导出,没有写数据库或对象存储。 +- 应用内浏览器访问`http://localhost:4173/#/admin/enterprise-signatures`,真实鉴权将失效会话跳转运营登录页;页面标题、非空DOM、登录提示和表单正常,控制台无warning/error,点击验证码后算式从`25 + 1`变为`36 + 9`。当前浏览器运行时不支持页面或元素截图命令,未切换到未授权的Playwright回退;没有求解验证码或把登录页当成企业签名主体的登录后视觉验收。 +- R4没有修改数据库schema、migration、Redis Stream、Gateway、CMPP协议或其他大页面,没有发送短信、生成报备文件或修改业务数据。号码频控/白名单、客户端体验、R0~R3和其他工作区修改继续完整保留,不归因于R4。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R5 通道服务兼容门面拆分(本地未提交) + +- 按路线图完整拆分R5七个领域。`api/src/channels/channels.service.ts`从2572行缩减为204行稳定门面,控制器、模块和既有测试继续依赖同一入口;37个公开方法的名称、参数、异步标记和返回推断保持不变。 +- 原实现按通道配置与状态、连接/重连与Gateway控制、测试短信、通道组与路由、报备字段/任务/回执/记录、通道复制、删除入口七个领域迁移。连接服务统一持有定时器、Redis客户端、Gateway请求和队列副作用;配置服务仅在既有连接参数变化条件满足时委托重连;既有私有Gateway重启恢复测试入口由门面继续委托。 +- 17个DTO和查询契约移到`channels.contracts.ts`,控制器不再从实现类文件导入DTO;60个共享常量、类型和纯辅助声明移到`channels.helpers.ts`。`ChannelsModule`仍只注册并导出稳定门面,没有扩大NestJS provider图。 +- 新增`docs/contracts/channels-r5-methods.json`和`tools/quality/verify-channels-r5.mjs`。门禁确认37个公开方法、14个内部方法、17个契约和60个辅助声明领域归属正确,并锁定连接参数重连条件、Gateway连接/断开路径、定时器、Redis队列和Stream、测试号码规范化及单次提交语义。首次生成版本因统一缩进改变模板字符串内SQL空白,被门禁在`listReportTasks`处拒绝;改为只缩进方法首行后,迁移实现哈希全部一致。 +- Channels定向测试首次有1项失败,原因是稳定门面遗漏既有私有`reconnectActiveChannelsAfterGatewayRestart`测试缝;补回纯委托入口后1 suite / 41 tests全部通过。API全量28 suites / 381 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript和Vite v8.0.16生产构建、Gateway`go test ./...`与`go vet ./...`及依赖安全门禁通过。Jest只保留项目既有`--forceExit`开放句柄提示,Vite只保留既有约2.00MB单chunk提示。 +- 真实本地PostgreSQL只读调用拆分后门面成功:通道分页返回5/5条、通道组2条、路由规则1条、报备任务1/1条,连接、连接日志、报备字段和报备记录查询均正常完成。验证未调用`onModuleInit`、重连、状态同步、创建、编辑、复制、删除、测试短信、报备导出或其他写路径。 +- R5没有修改数据库schema、migration、控制器路由、Redis Stream契约、Gateway或CMPP协议,没有发送真实短信,也没有修改真实通道账号、密码、启停状态或连接。号码频控/白名单、客户端体验、R0~R4及其他工作区修改继续完整保留,不归因于R5。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R6 Gateway 入站服务拆分(本地未提交) + +- 按路线图在`gateway/internal/inbound`同一Go package内完整拆分R6。原`server.go`从1671行缩减为37行稳定启动入口;`Server.ListenAndServe`、`DisconnectAccount`、`PushReceiptWithResult`和`PushUplinkWithResult`等公开入口、`cmd/gateway`及控制服务调用方式保持不变。 +- 原实现按登录认证、Submit与报文转换、下游会话注册、回执/上行Deliver、ACK追踪与SubmitResp顺序屏障、待投递恢复扫描、协议日志、共享HTTP传输八个职责迁移到9个聚焦文件。现有`presence.go`和`recovery.go`保持原样;没有跨package改接口或形成新的运行时依赖。 +- 新增`docs/contracts/inbound-r6-declarations.json`和`tools/quality/verify-inbound-r6.go`。门禁逐项确认迁移前93个声明的文件归属和实现哈希一致,并锁定稳定启动入口、控制服务三个调用入口及12项关键协议测试。SubmitResp先于排队回执、会话唯一所有权、原始Msg_Id恢复和多实例恢复锁等复杂边界补充了原因注释,没有改写实现。 +- 入站定向测试32项全部通过;Gateway`go test ./... -count=1`全部package通过,`go vet ./...`通过。`go test -race ./internal/inbound`因当前Windows Go环境`CGO_ENABLED=0`而在执行测试前拒绝启动,未将竞态检测记为通过,也未为本轮临时安装C工具链。 +- API全量28 suites / 381 tests全部通过;API TypeScript正式构建、Prisma format/validate/generate、前端TypeScript和Vite v8.0.16生产构建、依赖安全门禁及R0~R6全部结构门禁通过。Vite只保留既有约2.00MB单chunk提示,Jest只保留既有`--forceExit`开放句柄提示。 +- 第一次API全量命令误在仓库根目录直接启动Jest,没有加载`api`目录的TypeScript配置,28个suite均在解析阶段退出且0项测试执行;回到正确`api`工作目录后381项全部通过,该误调用不属于产品失败。 +- Gateway测试覆盖真实本地TCP监听及CMPP 2.0/3.0登录、单/多号码、长短信、SubmitResp顺序、Deliver ACK、ACK超时、连接关闭与恢复锁。额外的独立本地Gateway进程冒烟命令被当前执行策略在启动前拦截,因此没有把它记录为通过;本轮没有连接预生产CMPP端口。 +- R6没有修改CMPP报文、HTTP回调内容、Redis键、数据库schema、migration、队列或业务规则,没有发送短信、触发补发/重投、登录真实下游账号,也没有修改通道、余额或客户连接。号码频控/白名单、客户端体验、R0~R5及其他工作区修改继续完整保留,不归因于R6。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R7 Gateway 上游管理拆分(本地未提交) + +- 按路线图在`gateway/internal/upstream`同一Go package内完整拆分R7。原`manager.go`从1495行缩减为198行稳定管理入口;`Manager.Submit`、`ConnectChannel`、`DisconnectChannel`和`ConnectionState`等公开契约,以及`cmd/gateway`、控制服务和Submit Worker调用方式保持不变。 +- 原实现按Manager与连接池注册、连接池生命周期、物理连接与读循环、重连状态机、窗口/心跳、Submit与分片、回执/上行Deliver、协议日志、ConnectionState与API回调九个职责迁移。既有`long_message.go`已独立承担长短信拆分和上行组装,本轮保持原样。 +- 新增`docs/contracts/upstream-r7-declarations.json`和`tools/quality/verify-upstream-r7.go`。门禁按接收者类型分别确认迁移前68个声明的文件归属和实现哈希一致,避免混淆连接池与物理连接的同名方法;同时锁定Manager稳定入口、控制服务调用、既有长短信函数及15项关键状态机测试。 +- 为慢鉴权重连、手工断开停止条件、每连接窗口所有权、长短信逐分片结果、未决提交唤醒和回执tracker等复杂边界补充原因注释,未修改业务实现、常量或时序。 +- 上游定向测试18项全部通过;其中本地重连集成测试使用真实TCP监听模拟供应商端点,先确认连接失败,再启动端点并验证自动恢复连接。Gateway`go test ./... -count=1`全部package通过,`go vet ./...`通过;没有连接任何真实供应商通道。 +- API全量28 suites / 381 tests全部通过;API TypeScript正式构建、Prisma format/validate/generate、前端TypeScript和Vite v8.0.16生产构建、依赖安全门禁及R0~R7全部结构门禁通过。Vite只保留既有约2.00MB单chunk提示,Jest只保留既有`--forceExit`开放句柄提示。 +- R7没有修改连接数、窗口、心跳、重连延迟、鉴权失败分类、长短信分片、回执状态、HTTP回调、CMPP协议、Redis Stream、数据库schema或migration,没有发送短信、触发补发/重投,也没有修改通道凭据、启停状态、余额或客户连接。号码频控/白名单、客户端体验、R0~R6及其他工作区修改继续完整保留,不归因于R7。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R8 发送链纯逻辑拆分(本地未提交) + +- 按路线图完成R8。`api/src/send-chain/send-chain.service.ts`从5345行缩减为4578行;24个DTO、Gateway事件和队列契约迁移到`send-chain.contracts.ts`,64个既有常量、状态/错误映射、号码/资源判定、模板/签名/引流分类及事件键等纯声明迁移到`send-chain.helpers.ts`。 +- `SendChainService`仍保留98个数据库事务、队列发布、Gateway调用和顶层编排方法。通道候选选择、通道可发送性、分片最终状态聚合、上游端点身份比较和回执事件键生成改为显式纯函数;既有数据库查询、事务范围、日志字段、幂等键、队列消息及调用顺序未改变。 +- 为省内优先/全国兜底、排除或未报备通道、分片未收齐、全部成功、明确失败优先、上游端点身份和稳定回执事件键新增8项纯逻辑测试。纯逻辑与既有SendChain定向2 suites / 112 tests全部通过,其中既有104项特征测试保持。 +- 新增`docs/contracts/send-chain-r8-pure-logic.json`和`tools/quality/verify-send-chain-r8.mjs`。门禁逐项锁定24个契约与64个迁移声明的实现哈希,确认98个编排方法以及数据库事务、队列、补发和分片审计副作用仍在原服务;R0~R8全部结构门禁继续通过。 +- Node.js v24.16.0下API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript和Vite v8.0.16生产构建、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁和`git diff --check`全部通过。Jest只保留项目既有`--forceExit`开放句柄提示,Vite只保留既有约2.00MB单chunk提示。 +- 真实本地运行态只读核验:API health返回HTTP 200,PostgreSQL与Redis端口监听,Redis返回PONG;数据库现有56条短信记录、0条分片审计,因此真实分片聚合样本记为不适用,没有造数冒充通过。使用真实5条通道记录调用可发送性纯函数,当前2条active、0条满足本地连接可发送条件;未调用模块初始化、重连、状态同步或任何写路径。 +- R8没有修改数据库schema、migration、控制器路由、Redis Stream、Gateway、CMPP协议或业务规则,没有发送短信、触发补发/重投,也没有修改通道账号、密码、启停状态、余额或客户连接。号码频控/白名单、客户端体验、R0~R7及其他工作区修改继续完整保留,不归因于R8。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署;R9、R10再分别拆入口/提交和回执/补发/下游投递编排。 + +## 2026-07-31 R9 发送入口与提交编排拆分(本地未提交) + +- 按路线图完成R9五类入口。`api/src/send-chain/send-chain.service.ts`从R8的4578行缩减为2983行,98个稳定方法继续供控制器、Open API、审核中心和其他模块调用;`send-submission.service.ts`为305行内部兼容门面。 +- 45个方法按批量/HTTP入口、CMPP入站、审核续发、定时调度和Gateway提交拆到五个实现文件,行数分别为552、840、109、160和491。没有把最初1939行的单一提交域文件作为终点,避免只移动大文件而不形成职责边界。 +- 迁移方法体保持原实现;内部跨方法调用返回`SendChainService`稳定门面,再经内部兼容门面分派,以保留既有覆盖点和测试可观察性。日志上下文继续是`SendChainService`,没有改变控制器路由、公开参数、响应或NestJS provider边界。 +- 新增`docs/contracts/send-chain-r9-submission.json`和`tools/quality/verify-send-chain-r9.mjs`。门禁锁定45个方法体哈希、五个领域归属、双层门面委托和Worker/事务/回调/Redis Stream副作用;确认Gateway结果、回执、补发、退款及下游投递仍留在R10边界。R0~R9全部结构门禁通过。 +- 第一次定向测试为97/112通过,15项失败均源于新域内部直接互调,导致既有测试替换稳定门面的`enqueueBatchTask`、调度、队列和限速方法时无法观察内部调用;改为跨方法调用统一返回稳定门面后,2 suites / 112 tests全部通过。该问题在本地门禁阶段发现,没有进入提交或部署。 +- Node.js v24.14.0下API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript和Vite v8.0.16生产构建、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁和`git diff --check`全部通过。Jest仅保留既有`--forceExit`提示,Vite仅保留既有约2.00MB单chunk提示。 +- 真实本地后端只读核验使用实际`SendChainService`、Prisma/PostgreSQL和现有任务:拆分后的任务查询成功;号码导入预检通过真实企业/全局黑名单查询,3行数据返回1条有效、2条错误。调用前后批量任务1条、短信56条、频控状态0条、账户流水3条完全不变。API health HTTP 200,PostgreSQL/Redis端口监听,Redis返回PONG。 +- R9没有调用创建任务、确认导入、HTTP/CMPP真实提交、审核动作、定时调度、Worker或Gateway发布,没有发送短信、触发补发/重投,也没有修改schema、migration、队列消息、通道、余额或客户连接。号码频控/白名单、客户端体验、R0~R8及其他工作区修改继续完整保留,不归因于R9。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署;R10再处理Gateway结果、分片回执、最终聚合、补发、退款和下游投递。 + +## 2026-07-31 R10 发送完成链编排拆分(本地未提交) + +- 按路线图完成R10八项边界。`api/src/send-chain/send-chain.service.ts`从R9的2983行缩减为878行,98个稳定方法继续供控制器、Open API、Gateway事件和其他模块调用;`send-completion.service.ts`为323行内部兼容门面。 +- 41个方法按Gateway提交结果与分片提交审计、上游回执收件箱/分片回执/最终聚合、失败补发、扣费退款与预占释放、下游最终状态、CMPP/HTTP最终回执投递、回执超时扫描七个领域迁移,领域文件分别为390、593、359、142、510、489和104行。 +- 迁移方法体保持原实现;内部跨领域调用返回`SendChainService`稳定门面,再经完成链兼容门面分派,保留R9提交域依赖方向、既有测试替换点和调用可观察性。日志上下文继续使用`SendChainService`,NestJS模块仍只注册稳定门面。 +- 新增`docs/contracts/send-chain-r10-completion.json`和`tools/quality/verify-send-chain-r10.mjs`。门禁锁定41个方法体哈希、七个领域归属、双层门面委托和98个稳定方法,并检查来源提交唯一补发关系、P2002唯一冲突、当前尝试判定、分片聚合、账务幂等键、最终回执键、下游去重/ACK/人工重排键及历史记录不删除。为适配R10物理归属,R8门禁改为在全部`send-*.service.ts`实现中检查既有副作用不变量。 +- 第一次API TypeScript构建发现完成链门面包装方法可见性及迁移领域的少量显式导入缺失;补齐公开委托和`UplinkMatchCandidateInput`、超时/规范化辅助导入后正式构建通过。问题在本地编译门禁发现,没有进入提交或部署。 +- SendChain定向2 suites / 112 tests、API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript和Vite v8.0.16生产构建、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁、R0~R10全部结构门禁及`git diff --check`通过。Jest仅保留既有`--forceExit`提示,Vite仅保留既有约2.00MB单chunk与插件耗时提示。 +- 真实本地运行态只读核验:API `/api/health`返回正常,3000、5432、6379端口监听,Redis返回PONG。真实PostgreSQL查询前后均为提交记录61、分片审计0、回执53、下游投递2、下游attempt 0、上游回执收件箱0、Gateway提交死信0、账户流水3,最新提交、回执和下游投递路径均可读,前后计数完全一致。当前没有真实分片审计和下游attempt样本,因此对应数据态验证记为不适用,不造数冒充通过。 +- R10没有调用模块初始化、Gateway结果写入、回执入箱、补发抢占、扣费、退款、预占释放、最终回执创建、下游认领、ACK、人工重排或超时扫描,没有发送短信、重试、补发或重投,也没有修改数据库schema、migration、Redis Stream、CMPP协议、通道、余额或客户连接。号码频控/白名单、客户端体验、R0~R9和其他工作区修改继续完整保留,不归因于R10。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。 + +## 2026-07-31 R11 通道管理页面与专属样式拆分(本地未提交) + +- 按路线图“每次只迁移一个页面域”执行R11第一页面域,选择剩余最大页面`AdminChannelsPage.tsx`,从782行缩减为197行稳定协调层。类型、纯映射、表格、编辑弹窗、测试短信弹窗和连接日志弹窗拆入`src/apps/admin/channels/`,各TypeScript文件为51~153行。 +- 页面继续直接调用真实`adminApi`:列表分页、发送质量、连接状态、创建/编辑、启停、复制、连接日志和测试短信接口均保留;没有引入barrel、mock、静态数据或localStorage。React组件不在页面函数内部定义,查询和写操作仍由页面统一协调。 +- 将通道列表、质量指标、编辑表单、测试结果和连接日志的467行专属样式迁入`AdminChannelsPage.css`,`global.css`从12270行缩减为11800行;共享给通道组的`.channel-confirm`保留全局,375px下连接摘要规则随页面迁移。`main.tsx`的tokens/global/components加载顺序未变。 +- 新增`docs/contracts/admin-channels-r11.json`和`tools/quality/verify-admin-channels-r11.mjs`,门禁确认198行稳定入口、五个聚焦模块、全部真实API调用、八个交互入口和页面样式归属。第一次门禁用`.sms-channel-`宽前缀误匹配仍属通道组的`.sms-channel-group-*`,改为逐项页面选择器后通过;不是产品故障。 +- 前端TypeScript检查通过。本地应用内浏览器使用真实运营账号登录,真实API返回5条通道;按`Smoke`查询得到1条,重置恢复5条。编辑弹窗正常回填业务信息、单价、地区和CMPP参数,点击取消未保存;连接日志弹窗正常加载真实连接状态/日志并提供关键词筛选。 +- 默认桌面和375×812视口页面均非空、无框架错误覆盖,控制台无warning/error。桌面列表、筛选、质量指标和操作按钮布局正常;窄屏保持既有横向表格浏览方式。浏览器运行时截图通过Tab截图接口获取,未写入仓库。 +- 前端TypeScript和Vite v8.0.16生产构建通过,2468个模块完成转换;API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁、R0~R11全部结构门禁及`git diff --check`通过。Vite仅保留既有约2.00MB单chunk提示,Jest使用项目既有的`--forceExit`验收口径并保留开放句柄提示。 +- 第一次并行Gateway全量测试中`TestInboundServerAuthenticatesAndSubmits`因模拟API服务提前关闭而失败;该用例单独重跑通过,随后Gateway全量及vet通过,未修改Gateway代码。两次直接执行未带`--forceExit`的API全量命令在测试完成后留下Jest进程并达到超时,确认并只终止本轮创建的两个全量Jest进程后,按既有`--forceExit`口径重跑,389项在21.381秒全部通过;未终止其他会话长期存在的定向Jest进程。 +- 验证没有点击编辑确认、添加确认、复制确认、启停确认、删除确认或测试短信最终发送,没有修改通道账号、密码、启停状态、连接或任何业务数据;没有发送短信。 +- 本轮按用户要求仅保留本地修改,不提交、不推送、不部署。其他剩余大页面及专属样式必须继续按独立小版本迁移,不在本次R11中批量处理。 + +## 2026-07-31 R11 短信任务进度页面与专属样式拆分(本地未提交) + +- 按路线图“每次只迁移一个页面域”执行R11第二页面域。`AdminSmsTaskProgressPage.tsx`从657行缩减为163行稳定协调层,只保留真实任务查询、企业/应用筛选项加载、选中状态和终止操作;纯任务映射、筛选区、主表、详情弹窗、真实号码分页弹窗和终止确认拆入`src/apps/admin/sms-task-progress/`,各TypeScript文件为28~170行。 +- 页面继续直接使用真实`adminApi`:任务分页、企业与应用选项、批次号码分页和终止接口均保留;任务状态归一化、提交/成功/失败计数、计费条数、运营商/省份聚合、进度和成功率公式保持原实现。未引入barrel、mock、静态数据或localStorage,也未把组件定义嵌回页面函数。 +- 将详情标题、指标、任务详情、运营商卡片和移动端详情布局共273行页面专属样式迁入`AdminSmsTaskProgressPage.css`,`global.css`从11800行缩减为11527行。报备、下游记录等页面仍使用的`.admin-task-filter`、`.admin-task-table-card`、`.admin-task-id`、`.admin-task-enterprise`、`.admin-task-card`和`.batch-progress`保留全局。 +- 新增`docs/contracts/admin-sms-task-progress-r11.json`和`tools/quality/verify-admin-sms-task-progress-r11.mjs`,门禁确认164行文本入口、六个聚焦模块、全部真实API边界、十一项交互文案、专属/共享样式归属和375px详情布局。入口物理行数为163,门禁按末尾换行计为164,均低于220行上限。 +- 本地应用内浏览器使用已存在的真实运营账号会话,解锁后真实API返回1条客户批量任务`BT-1783075838138-a886dd56`。不存在批次号查询返回真实空列表,重置再查询恢复1条;详情展示2个号码、2条计费、中国移动2个和未识别省份2个;号码列表真实返回`13800138000`、`13900139000`,按`138`查询后返回1条。 +- 默认桌面详情弹窗和375×812窄屏列表均非空、无框架错误覆盖,控制台0条warning/error;手机端筛选区、查询/重置按钮和任务卡片正常显示。终止确认弹窗的风险提示正常,验收只点击取消,没有调用确认终止。 +- Node.js v24.14.0下前端TypeScript和Vite v8.0.16生产构建通过,2475个模块完成转换;API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁、R0~R11全部结构门禁及`git diff --check`通过。Vite仅保留既有约2.00MB单chunk提示,Jest使用既有`--forceExit`口径并保留开放句柄提示。 +- 首次前端构建由系统旧Node.js v14.17.4执行,Vite因不支持`??=`语法产生未处理Promise警告但错误返回0;未将其计为通过,切换到Node.js v24.14.0后重新构建并看到实际产物。第一次并行回归中的系统npm 6不支持`npm exec`,Prisma子任务未启动;随后改用项目本地Prisma可执行文件完整补跑并通过,属于工具链编排问题,不是产品代码失败。 +- 本轮没有发送短信、创建或终止任务、触发补发/重投,也没有修改数据库、Redis Stream、Gateway、通道、余额或客户连接。R0~R11第一页面域、号码频控/白名单、运营看板和其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11当前完成2个页面域;其余页面和共享CSS域继续按独立小步骤推进。 + +## 2026-07-31 R11 短信记录页面与专属样式拆分(本地未提交) + +- 按路线图“每次只迁移一个页面域”执行R11第三页面域。`AdminSmsRecordsPage.tsx`从640行缩减为195行稳定协调层,仅保留真实记录分页、企业/应用筛选项、分片审计、后端CSV导出和选中详情状态;时间/状态/运营商/路由映射、筛选区、记录卡片与分页、发送详情弹窗拆入`src/apps/admin/sms-records/`,各TypeScript文件为33~219行。 +- 五个真实`adminApi`调用保持:`listOperationMessages`、`listTenants`、`listEnterpriseApplicationOptions`、`listMessageSegmentAudits`和`exportOperationMessages`。默认昨日到今日、提交失败覆盖、平台失败回执说明、运营端测试说明、通道尝试排序、发送接入号拼接和分片审计排序均保持原实现;没有引入barrel、mock、静态列表或localStorage。 +- 将短信记录筛选、卡片、状态、详情、通道路由、分片审计和900px/780px响应式规则迁入433行`AdminSmsRecordsPage.css`,`global.css`从11527行缩减为11098行。多个运营页面共享的`.template-modal-title`、`.muted`和`.ui-table__empty`保留全局。 +- 新增`docs/contracts/admin-sms-records-r11.json`和`tools/quality/verify-admin-sms-records-r11.mjs`,门禁确认196行文本入口、四个聚焦模块、五个真实API边界、十五项交互文案、专属/共享样式归属和两级响应式规则。入口物理行数为195,门禁按末尾换行计196,低于220行上限。 +- 本地应用内浏览器访问真实运营端和本地API:默认2026-07-30至2026-07-31返回0条,清空日期后真实返回56条、3页。按手机号`13900000054`查询收窄为1条;运营商下拉保留全部、移动、联通、电信、未识别五项;翻到第2页后仍显示真实总数56和3页分页。 +- 真实详情`LOCAL-SIGSTAT-20260728-054`展示发送成功、accepted、delivered、上海/中国联通、发送接入号10690000、两次通道尝试和对应UNDELIV/DELIVRD回执;真实分片接口返回0条,页面明确显示“暂无分片审计”,未造数冒充覆盖。现有56条为本地数据库已存在的统计演示数据,本步骤没有执行演示数据脚本或写入任何记录。 +- 默认桌面列表/详情和375×812记录卡片均非空、无框架错误覆盖,控制台0条warning/error;窄屏企业应用、状态、时间、内容、号码、计费、通道和详情入口未重叠。浏览器运行时截图未写入仓库。 +- Node.js v24.14.0下前端TypeScript和Vite v8.0.16生产构建通过,2480个模块完成转换;API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、依赖安全门禁和R0~R11全部结构门禁通过。Vite仅保留既有约2.00MB单chunk提示,Jest使用既有`--forceExit`口径并保留开放句柄提示。 +- 第一次并行Gateway全量测试中`TestSubmitResponsePrecedesQueuedFailureReceipt`因等待模拟失败回执超时而失败,日志同时显示模拟API连接被提前关闭。本步骤未修改Gateway;该用例定向重跑通过,随后Gateway`go test ./... -count=1`与`go vet ./...`全量通过,按既有时序型测试波动记录而不掩盖。 +- 验收没有点击导出下载、发送、补发、重投或任何写操作,没有修改数据库、Redis Stream、Gateway、通道、余额或客户连接。R0~R11前两个页面域及其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11当前完成3个页面域;其余页面和共享CSS域继续按独立小步骤推进。 + +## 2026-07-31 R11 企业应用管理页面与专属样式拆分(本地未提交) + +- 按路线图“每次只迁移一个页面域”执行R11第四页面域。`AdminEnterpriseApplicationsPage.tsx`从616行缩减为273行稳定协调层,仅保留真实应用分页、企业选项、查询草稿/已应用条件、生命周期操作、参数详情和弹窗选中状态;筛选区、主表、生命周期弹窗、CMPP/HTTP参数弹窗、连接详情及纯映射拆入`src/apps/admin/enterprise-applications/`,各TypeScript文件为50~164行。 +- 六类真实`adminApi`调用继续由页面协调:应用分页、企业列表、状态变更、停用影响预检、CMPP参数和HTTP参数。分页、查询后回第一页、重置、停用等待/强制模式、启用、删除、CMPP/HTTP能力禁用规则和参数加载时序保持原实现;没有引入barrel、mock、静态数据或localStorage。 +- 将企业应用筛选、连接状态、操作区、连接详情、参数详情、新增应用提示和响应式规则迁入222行`AdminEnterpriseApplicationsPage.css`,`global.css`从11098行缩减为10881行。共享筛选、确认文案、弹窗标题、堆叠和表单布局继续保留全局。 +- 窄屏视觉初检发现页面CSS后加载可能覆盖原全局780px筛选单列规则;在页面CSS显式恢复该规则并加入结构门禁。修正后375×812视口实测页面宽度375px、文档滚动宽度375px,筛选区单列、两个输入均未造成页面级横向溢出。这是拆分阶段发现并修复的样式加载顺序风险,未进入提交或部署。 +- 新增`docs/contracts/admin-enterprise-applications-r11.json`和`tools/quality/verify-admin-enterprise-applications-r11.mjs`,门禁确认274行文本入口、六个聚焦模块、六类真实API边界、十六项交互文案、专属/共享样式归属、查询状态分层以及780px、900px、520px响应式规则。R0~R11全部结构门禁和依赖安全门禁通过。 +- 本地应用内浏览器使用既有真实运营账号会话,真实应用分页返回2条。不存在企业名称查询显示真实空状态,重置恢复2条;新增应用弹窗由真实企业接口加载并保持未选择时“下一步”禁用,随后取消。 +- 对真实`Smoke SMS App`只读打开连接详情,显示当前连接0、配置连接1、离线及无已连接会话;CMPP参数通过真实详情接口成功打开后直接关闭,未读取、复制或记录敏感参数。停用影响预检返回2项未清算义务,弹窗展示等待/强制停用边界,验收只点击取消,没有调用状态变更。 +- 默认桌面与375×812窄屏页面均非空、无框架错误覆盖;桌面筛选、页签、真实表格和横向滚动正常,窄屏筛选单列及记录卡片正常。控制台0条warning/error,桌面和窄屏截图写入系统临时目录,未写入仓库。 +- Node.js v24环境下前端TypeScript和Vite v8.0.16生产构建通过,2487个模块完成转换;API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁、R0~R11全部结构门禁和`git diff --check`通过。Vite仅保留既有约2.00MB单chunk提示,Jest使用既有`--forceExit`口径并保留开放句柄提示。 +- 本轮没有创建、编辑、启停或删除企业应用,没有复制接口参数、发送短信、触发补发/重投,也没有修改数据库业务数据、Redis Stream、Gateway、通道、余额或客户连接。R0~R11前三个页面域、号码频控/白名单和其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11当前完成4个页面域;剩余5个页面/共享CSS域步骤继续按独立小版本推进。 + +## 2026-07-31 R11 tokens与reset基础样式域拆分(本地未提交) + +- 按既定9步计划执行R11第5步,范围严格限定为“tokens和reset”。现有97行`tokens.css`继续作为唯一设计令牌入口,76个颜色、排版、间距、形状、阴影、布局和组件基础变量未改名、未改值;新增84行`reset.css`,从`global.css`迁出13组通配符、文档、链接、表单控件、禁用态、焦点和标题基础规则。 +- `main.tsx`将基础样式依赖顺序固定为`tokens.css → reset.css → global.css → components.css → AppRoutes`。`global.css`从10881行缩减为10801行;AppShell、通用组件、admin/client共享域、响应式页面规则及单页面样式均未在本步骤迁移,避免一次改变多个级联边界。 +- 第一次生产产物位置检查发现,仅在`AppRoutes`之后书写四个CSS导入不足以约束打包依赖图,组件CSS会先于reset/global进入产物。将`AppRoutes`导入移到四层CSS之后并重建,最终产物关键位置为tokens 76、reset 1969、global 3030、components 33841、页面样式219684,确认实际拼接顺序与目标一致。该风险在本地门禁阶段发现并修复,没有进入提交或部署。 +- 新增`docs/contracts/foundation-styles-r11.json`和`tools/quality/verify-foundation-styles-r11.mjs`。门禁锁定76个令牌、13组reset/base规则逐规则声明哈希、reset选择器白名单、原`global.css`所有权清理和四层确定性加载顺序;R0~R11全部结构门禁及依赖安全门禁通过。 +- Node.js v24环境下前端TypeScript与Vite v8.0.16生产构建通过,2488个模块完成转换,产物CSS约237.03kB、gzip约34.91kB;只保留既有约2.00MB单chunk及一次插件耗时提示,没有新增CSS错误。 +- 本地应用内浏览器在最终依赖顺序调整后重新完整验收。使用真实运营会话打开企业应用管理页,真实API继续返回2条应用。页面URL和标题正确,非空且无框架错误覆盖;背景、14px基础字号、21px行高、字体族、链接无下划线、按钮/禁用态光标和输入继承字体的计算样式均符合原令牌。 +- 键盘聚焦输入控件时继续得到`rgba(37, 99, 235, 0.18) 0 0 0 3px`焦点环,浏览器默认outline保持关闭。新增应用弹窗正常打开,“下一步”在未选择企业时保持禁用,随后只点击取消,没有创建或修改应用。 +- 客户端登录页在未提交表单的情况下完成只读验收:Logo、标题、说明、账号、密码、图形验证码和登录按钮正常,未刷新验证码、未登录。运营端和客户端在默认桌面及375×812视口均无页面级横向溢出,控制台均为0条warning/error;四张截图写入系统临时目录,未写入仓库。 +- API全量29 suites / 389 tests全部通过;Prisma format/validate/generate、API TypeScript正式构建、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁、R0~R11全部结构门禁和`git diff --check`通过。Jest使用既有`--forceExit`口径并保留开放句柄提示。 +- 本步骤没有修改React业务组件、API、数据库schema或migration,没有登录客户端、创建/编辑/启停/删除应用、发送短信、触发补发/重投,也没有修改Redis Stream、Gateway、通道、余额或客户连接。R0~R11前四步及其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11当前完成5/9;下一步为AppShell与通用布局基础域,剩余4步继续独立推进。 + +## 2026-07-31 R11 AppShell与通用布局基础域拆分(本地未提交) + +- 按既定9步计划执行R11第6步,范围严格限定为`AppShell`和通用页面布局。新增811行`src/styles/shell.css`,从`global.css`迁出侧栏、折叠导航、顶栏、通知/用户菜单、移动端抽屉/遮罩、减少动画规则,以及`.page-content`、`.page-stack`、`.page-heading`、`.page-heading__actions`、`.page-actions`、`.surface`、`.section-stack`、`.section-heading`九组布局原语;`global.css`从10801行缩减为10001行。 +- `.icon-button`仍被企业应用表单和用户页面复用,继续保留`global.css`;`.ui-tabs__tab`及通用表格、表单、弹窗样式也未提前迁移。混合的`.page-actions, .table-actions`规则按选择器拆开但声明保持一致,没有修改React业务组件、选择器权重、业务文案、API或交互逻辑。 +- `main.tsx`加载顺序更新为`tokens.css → reset.css → shell.css → global.css → components.css → AppRoutes`。Node.js v24.14.0下Vite v8.1.5生产构建通过,2530个模块完成转换,产物CSS 237.11kB、gzip 34.75kB;tokens、reset、shell、global和components代表标记依次位于76、1971、3030、15675和36609,只保留既有约2.00MB单chunk及插件耗时提示。 +- 新增`docs/contracts/app-shell-styles-r11.json`和`tools/quality/verify-app-shell-styles-r11.mjs`,门禁锁定118组规则、44个当前/兼容壳层类、九组共享布局选择器、桌面折叠、781px边界、780px移动抽屉和减少动画规则。基础样式契约同步加入`shell.css`加载层;企业应用契约同步确认`.section-stack`改由`shell.css`托管。全部15个R0~R11结构/行为门禁和依赖安全门禁通过。 +- Prisma format、validate、generate和API TypeScript正式构建通过;API全量29 suites / 389 tests全部通过。默认Jest命令在断言结束后因既有开放句柄未退出,本次终止的仅为本轮15:44创建的npm/Jest进程,未触碰2026-07-30遗留的其他会话进程;随后按既有`--forceExit`口径获得全量通过并保留开放句柄提示。Gateway`go test ./... -count=1`与`go vet ./...`通过。 +- 本地前端`http://127.0.0.1:4173/`和API`/api/health`均HTTP 200。应用内浏览器访问企业应用页时恢复运营会话返回`Internal server error`并跳转登录页,Chrome也没有已登录运营端标签;遵守验证码边界,没有填写或提交登录表单、读取/伪造浏览器存储。因此桌面展开/折叠、通知/用户菜单和375×812移动抽屉的真实点击验收本轮受阻,需在可用登录态下补测,不能写成已通过。 +- 本步骤没有创建、编辑、启停或删除业务数据,没有发送短信、触发补发/重投,也没有修改数据库业务数据、Redis Stream、Gateway、通道、余额或客户连接。R0~R11前五步、号码频控/白名单和其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11第6步代码与文档完成,当前实施进度6/9;下一步为通用表格、表单和弹窗域,真实AppShell点击验收仍列为待补风险。 + +## 2026-07-31 运营端恢复会话500诊断与R11通用组件样式域拆分(本地未提交) + +- 恢复运营会话500已按真实基础设施链路定位。无cookie直接访问API与Vite代理的`/api/admin/auth/session`均正常返回401,验证码接口返回200;Redis返回PONG且存在有效运营会话,但当时本地PostgreSQL 5432未监听。有效会话通过Redis校验后执行`prisma.user.findUnique`,真实Prisma请求以`ECONNREFUSED`失败,Nest因而返回500;`/api/health`仍为200是因为当前health未执行数据库查询。根因是本地PostgreSQL停止/崩溃而API、Redis仍运行,不是前端恢复会话或R11拆分逻辑缺陷。 +- `logs/postgres.log`记录此前进程以Windows异常`0xC0000142`终止,随后完成自动恢复。使用`tools/start-local.ps1 -SkipMigrate -SkipApi -SkipWeb`仅恢复本地PostgreSQL、Redis和MinIO,没有执行migration,也没有重启API或前端;启动脚本遗留的等待进程已精确停止,基础服务继续运行。恢复后5432、6379、9000、9001、3000、4173均监听,真实Prisma查询返回`codex_local_admin`及其active/sessionVersion/角色数据,API health返回200,无cookie会话接口返回401。 +- 按既定9步计划执行R11第7步,范围严格限定为“通用表格、表单和弹窗”。从`global.css`迁出52组规则、57个选择器,包括通用`.ui-table*`、`.ui-modal*`、`.form-grid*`、`.radio-row`、`.table-actions`、表格文本辅助类、`.icon-button`、`.ui-tabs__tab`、XL弹窗及780px移动端规则;`global.css`从10001行缩减为9660行,`components.css`从1333行增加为1694行。页面域复合选择器继续留在原层,没有修改React业务组件、API、文案、数据口径或写操作。 +- 为保持原级联结果,迁入的旧兼容规则位于现有规范组件规则之前,响应式规则位于对应桌面规则之后;`main.tsx`的`tokens → reset → shell → global → components → AppRoutes`顺序不变。新增`docs/contracts/shared-components-r11.json`和`tools/quality/verify-shared-components-r11.mjs`,锁定259组规则、291个选择器、14组通用类族、桌面/780px所有权和五类真实UI组件绑定;短信记录及企业应用旧契约同步更新为components所有权。全部15个R0~R11结构门禁通过。 +- Node.js v24.14.0下前端TypeScript和Vite v8.1.5生产构建通过,2530个模块完成转换,CSS 237.11kB(gzip 34.70kB)、JS 2003.23kB(gzip 596.71kB),仅保留既有大chunk提示。Prisma format/validate/generate、API TypeScript正式构建、29 suites / 389 tests、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁均通过;Jest继续使用既有`--forceExit`口径并保留开放句柄提示。 +- 应用内浏览器确认数据库恢复后访问企业应用页不再显示`Internal server error`,而是正常跳转运营端登录页并提示“请先登录”,控制台无warning/error;Edge没有已登录运营端标签。遵守验证码边界,没有自动求解验证码、读取浏览器存储、伪造cookie或绕过登录。因此第6步AppShell交互及第7步登录后桌面/375px表格、表单、弹窗点击验收仍待可用登录态补做,不能表述为已通过。 +- 本步骤没有创建、编辑、启停或删除业务数据,没有发送短信、触发补发/重投,也没有修改数据库业务数据、Redis Stream、Gateway、通道、余额或客户连接。R0~R11前六步、号码频控/白名单和其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11当前完成7/9;下一步为admin共享样式域,client共享域和单页面样式继续独立推进。 + +## 2026-07-31 R11 admin共享样式域拆分(本地未提交) + +- 按既定9步计划执行R11第8步,范围严格限定为运营端跨页面共享样式。以“至少被两个`src/apps/admin`源码文件复用、且`src/apps/client`不依赖”为主要判定,再按同一共享模式补齐变体和响应式规则;没有迁移client域或业务逻辑。 +- 新增678行`src/styles/admin.css`,从`global.css`迁出117组规则、141个选择器,覆盖审核筛选、任务/报备记录、安全页、系统管理、质量/利润/对账筛选、跨页面确认与表单提示、下游明细和通道字段配置等运营端共享模式;`global.css`从9660行缩减为9056行。 +- `.report-task-detail .admin-task-card`和`.gateway-exception-page .report-task-table-card > .ui-pagination`两个带单页面上下文的覆盖规则继续留在`global.css`,没有因类名命中而误迁。`ui-*`只作为admin所有者的后代上下文,客户端源码不引用admin所有权类。 +- `main.tsx`加载顺序更新为`tokens → reset → shell → global → admin → components → AppRoutes`。前端TypeScript和Vite v8.1.5生产构建通过,2531个模块完成转换,CSS 237.70kB(gzip 34.79kB)、JS 2003.23kB(gzip 596.71kB),仅保留既有大chunk提示。 +- 新增`docs/contracts/admin-shared-styles-r11.json`和`tools/quality/verify-admin-shared-styles-r11.mjs`,门禁锁定117组规则、141个选择器、11项跨页面使用下限、admin/client所有权、纯共享规则不得残留global以及780px/360px响应式边界;通道、企业应用、短信任务进度和foundation旧契约同步到新的样式归属与加载顺序。 +- Prisma format/validate/generate、API TypeScript正式构建、29 suites / 389 tests、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁、全部17个R0~R11结构门禁和`git diff --check`通过。Jest继续使用既有`--forceExit`口径并保留开放句柄提示。 +- 应用内浏览器访问真实本地运营端短信审核路由时,因无可复用运营端登录态正常跳转登录页;未求解验证码或伪造会话。运行时生产CSS已包含`.admin-task-filter`和`.admin-report-filter-grid`规则,页面宽度与1280px视口一致,控制台0条warning/error。登录后的审核、任务、安全、系统及统计代表页面与375px交互验收仍待有效登录态补做,不能表述为已通过。 +- 本步骤没有修改React业务组件、API、数据库schema或migration,没有创建、编辑、启停、删除、导入或导出业务数据,没有发送短信、触发补发/重投,也没有修改Redis Stream、Gateway、通道、余额或客户连接。R0~R11前七步、号码频控/白名单和其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11当前完成8/9;下一步为client共享样式域,单页面覆盖规则继续保留原所有权。 + +## 2026-07-31 R11 client共享样式域拆分(本地未提交) + +- 按既定9步计划执行R11第9步,范围严格限定为client跨页面共享样式。静态引用盘点覆盖`global.css`和全部admin/client TypeScript源码;真正满足“至少两个client文件使用且admin完全不依赖”的全局类只有`.eyebrow`,由`ClientHome.tsx`和`ClientBillingPage.tsx`使用。 +- 新增8行`src/styles/client.css`并迁移`.eyebrow`一组规则,`global.css`从9056行缩减为9049行。客户端首页专属的`.overview-hero .eyebrow`覆盖继续留在global,没有把单页面上下文误判为共享。 +- `.sms-send-title`、`.system-page-toolbar`和`.system-table-card`虽然分别被9、3、2个客户端文件使用,但运营端系统日志页也真实依赖,因此继续作为跨门户兼容样式留在global。签名、发送、企业认证、发送详情和模板等代表性单页面样式也继续保留原所有权,没有为扩大改动而批量迁移。 +- `main.tsx`最终加载顺序为`tokens → reset → shell → global → admin → client → components → AppRoutes`。新增`docs/contracts/client-shared-styles-r11.json`和`tools/quality/verify-client-shared-styles-r11.mjs`,锁定唯一client所有权规则、两页面使用下限、三组跨门户兼容边界和五类单页面保留边界;foundation契约同步加入client层。 +- Node.js v24.14.0下前端TypeScript和Vite v8.1.5生产构建通过,2532个模块完成转换,CSS 237.70kB(gzip 34.79kB)、JS 2003.23kB(gzip 596.71kB),与第8步产物体积一致,仅保留既有大chunk提示。全部18个R0~R11结构门禁通过。 +- Prisma format/validate/generate、API TypeScript正式构建、29 suites / 389 tests、Gateway`go test ./... -count=1`与`go vet ./...`、依赖安全门禁和`git diff --check`全部通过。Jest继续使用既有`--forceExit`口径并保留开放句柄提示。 +- 应用内浏览器访问真实本地客户端首页路由时,因无可复用客户端登录态正常跳转登录页;未求解验证码或伪造会话。运行时生产CSS确认纯`.eyebrow`、`.overview-hero .eyebrow`页面覆盖及`.sms-send-title`跨门户规则均存在,默认1280px和375×812视口均无页面级横向溢出,控制台0条warning/error。登录后的首页、账单和代表单页面验收仍待有效登录态补做,不能表述为已通过。 +- 本步骤没有修改React业务组件、API、数据库schema或migration,没有创建、编辑、删除或导出业务数据,没有发送短信、触发补发/重投,也没有修改Redis Stream、Gateway、通道、余额或客户连接。R0~R11前八步、号码频控/白名单和其他工作区修改继续完整保留,不归因于本步骤。 +- 按用户要求继续仅保留本地修改,不提交、不推送、不部署。R11既定九步当前完成9/9;这表示本轮计划范围完成,不代表剩余单页面CSS已被一次性清空,后续应另立小版本继续。 + +## 2026-07-31 工作区业务功能与R0-R11汇总提交门禁 + +- 用户已明确授权提交并推送当前工作区全部有效代码,但不部署。提交范围包括号码频次风控和平台级白名单、运营看板与短信记录改进、客户端发送体验、R0-R11渐进式拆分、两条新migration、结构契约、测试脚本、需求/测试/进度文档以及后续`global.css`拆分计划。 +- 提交前重新执行`git fetch --prune`,本地分支为`main`,`HEAD`与`origin/main`均为`0af671b4ed4713912e703defd08791f164d4eb25`,没有远端分叉。工作区未发现相较前次停止时新增的未知业务修改。 +- 使用Node.js v24.14.0执行Prisma format、validate、generate和migrate status;本地PostgreSQL仅恢复服务用于只读状态检查,没有执行migration或写入业务数据。源码目录共78条migration,本地数据库schema up to date。 +- API全量测试首次因本地Redis未运行出现1 suite/3项队列等待超时;启动本地Redis并确认`PONG`后,原命令复跑为29 suites / 389 tests全部通过。没有修改或放宽测试来绕过失败,Jest继续使用既有`--forceExit`口径。 +- API TypeScript正式构建、前端TypeScript与Vite生产构建通过;前端2532个模块完成转换,CSS 237.70kB(gzip 34.79kB)、JS 2003.23kB(gzip 596.71kB),仅保留既有大chunk提示。 +- 19个Node结构门禁以及R6/R7两个Go结构门禁全部通过;Gateway`go test ./... -count=1`、`go vet ./...`、依赖缓解安全门禁和Git差异检查通过。 +- `api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续作为构建缓存或临时产物保留在本地,不纳入提交、不删除、不错误归因。未发送、重投或补发真实短信,未修改真实通道账号、密码、启停状态、企业余额或客户连接,本轮明确不部署。 diff --git a/gateway/internal/inbound/acknowledgement.go b/gateway/internal/inbound/acknowledgement.go new file mode 100644 index 0000000..3bf4ab3 --- /dev/null +++ b/gateway/internal/inbound/acknowledgement.go @@ -0,0 +1,187 @@ +package inbound + +import ( + "fmt" + cmpp "github.com/bigwhite/gocmpp" + "log" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// The Submit barrier is intentionally colocated with ACK tracking: a queued +// receipt must never overtake the SubmitResp that establishes its Msg_Id. + +const defaultDownstreamAckTimeout = 30 * time.Second + +type downstreamAckTracker struct { + deliveryID string + connectionID string + sequenceID uint32 + messageID uint64 + session *downstreamSession + timer *time.Timer +} + +var downstreamAckRegistry = struct { + sync.Mutex + items map[string]*downstreamAckTracker +}{items: make(map[string]*downstreamAckTracker)} + +var downstreamSubmitBarrier = struct { + sync.RWMutex + byConn map[*cmpp.Conn]int +}{byConn: make(map[*cmpp.Conn]int)} + +func beginDownstreamSubmitBarrier(conn *cmpp.Conn) func() { + if conn == nil { + return func() {} + } + downstreamSubmitBarrier.Lock() + downstreamSubmitBarrier.byConn[conn]++ + downstreamSubmitBarrier.Unlock() + var once sync.Once + return func() { + once.Do(func() { + downstreamSubmitBarrier.Lock() + if downstreamSubmitBarrier.byConn[conn] <= 1 { + delete(downstreamSubmitBarrier.byConn, conn) + } else { + downstreamSubmitBarrier.byConn[conn]-- + } + downstreamSubmitBarrier.Unlock() + }) + } +} + +func downstreamSubmitResponsePending(conn *cmpp.Conn) bool { + if conn == nil { + return false + } + downstreamSubmitBarrier.RLock() + defer downstreamSubmitBarrier.RUnlock() + return downstreamSubmitBarrier.byConn[conn] > 0 +} + +func downstreamAckKey(conn *cmpp.Conn, sequenceID uint32) string { + return fmt.Sprintf("%p:%d", conn, sequenceID) +} + +func registerDownstreamAck(session *downstreamSession, deliveryID string, sequenceID uint32, messageID uint64, deadline time.Time) *downstreamAckTracker { + if session == nil || session.conn == nil || strings.TrimSpace(deliveryID) == "" { + return nil + } + tracker := &downstreamAckTracker{ + deliveryID: deliveryID, connectionID: session.connectionID, + sequenceID: sequenceID, messageID: messageID, session: session, + } + key := downstreamAckKey(session.conn, sequenceID) + downstreamAckRegistry.Lock() + downstreamAckRegistry.items[key] = tracker + downstreamAckRegistry.Unlock() + tracker.timer = time.AfterFunc(time.Until(deadline), func() { + timedOut := takeDownstreamAck(session.conn, sequenceID) + if timedOut == nil || timedOut.session == nil || timedOut.session.deliveryReport == nil { + return + } + timedOut.session.deliveryReport(downstreamDeliveryLifecycleEvent{ + Kind: "failed", DeliveryID: timedOut.deliveryID, ConnectionID: timedOut.connectionID, + SequenceID: timedOut.sequenceID, MessageID: timedOut.messageID, ObservedAt: time.Now().UTC(), + FailureType: "ack_timeout", ErrorMessage: "CMPP_DELIVER_RESP timeout", + }) + }) + return tracker +} + +func takeDownstreamAck(conn *cmpp.Conn, sequenceID uint32) *downstreamAckTracker { + key := downstreamAckKey(conn, sequenceID) + downstreamAckRegistry.Lock() + tracker := downstreamAckRegistry.items[key] + delete(downstreamAckRegistry.items, key) + downstreamAckRegistry.Unlock() + if tracker != nil && tracker.timer != nil { + tracker.timer.Stop() + } + return tracker +} + +func removeDownstreamAck(tracker *downstreamAckTracker) { + if tracker == nil || tracker.session == nil { + return + } + _ = takeDownstreamAck(tracker.session.conn, tracker.sequenceID) +} + +func handleDownstreamAcknowledgement(conn *cmpp.Conn, sequenceID uint32, messageID uint64, result uint32, logger *log.Logger) { + tracker := takeDownstreamAck(conn, sequenceID) + if tracker == nil { + if logger != nil { + logger.Printf("cmpp inbound event=deliver_ack_unmatched seq=%d message_id=%d result=%d", sequenceID, messageID, result) + } + if session := findSessionByConn(conn); session != nil && session.protocolLog != nil { + session.protocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "client_to_platform", + EventType: "deliver_resp", + Status: "failed", + TenantID: session.tenantID, + ApplicationID: session.applicationID, + Account: session.account, + MessageID: session.messageID, + GatewayMessageID: strconv.FormatUint(messageID, 10), + Phone: session.phoneNumber, + ResultCode: strconv.FormatUint(uint64(result), 10), + Detail: map[string]any{"sequenceId": sequenceID, "unmatched": true}, + }) + } + return + } + if tracker.messageID != messageID { + if logger != nil { + logger.Printf("cmpp inbound event=deliver_ack_message_mismatch delivery_id=%s seq=%d expected_message_id=%d actual_message_id=%d", tracker.deliveryID, sequenceID, tracker.messageID, messageID) + } + result = 1 + } + if logger != nil { + logger.Printf("cmpp inbound event=deliver_acknowledged delivery_id=%s connection_id=%s seq=%d message_id=%d result=%d", tracker.deliveryID, tracker.connectionID, sequenceID, messageID, result) + } + if tracker.session != nil && tracker.session.deliveryReport != nil { + go tracker.session.deliveryReport(downstreamDeliveryLifecycleEvent{ + Kind: "acknowledged", DeliveryID: tracker.deliveryID, ConnectionID: tracker.connectionID, + SequenceID: sequenceID, MessageID: messageID, Result: result, ObservedAt: time.Now().UTC(), + }) + } + if tracker.session != nil && tracker.session.protocolLog != nil { + status := "success" + if result != 0 { + status = "failed" + } + tracker.session.protocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "client_to_platform", + EventType: "deliver_resp", + Status: status, + TenantID: tracker.session.tenantID, + ApplicationID: tracker.session.applicationID, + Account: tracker.session.account, + MessageID: tracker.session.messageID, + GatewayMessageID: strconv.FormatUint(messageID, 10), + Phone: tracker.session.phoneNumber, + ResultCode: strconv.FormatUint(uint64(result), 10), + Detail: map[string]any{"sequenceId": sequenceID, "deliveryId": tracker.deliveryID}, + }) + } +} + +func downstreamAckTimeout() time.Duration { + configured, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS"))) + if err != nil || configured <= 0 { + return defaultDownstreamAckTimeout + } + if configured < 5 { + configured = 5 + } + return time.Duration(configured) * time.Second +} diff --git a/gateway/internal/inbound/authentication.go b/gateway/internal/inbound/authentication.go new file mode 100644 index 0000000..81e91f4 --- /dev/null +++ b/gateway/internal/inbound/authentication.go @@ -0,0 +1,127 @@ +package inbound + +import ( + "context" + "encoding/base64" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + "log" + "net" + "strings" + "sync" + "time" +) + +type authRequest struct { + Account string `json:"account"` + AuthSource string `json:"authSource"` + Timestamp uint32 `json:"timestamp"` + RemoteIP string `json:"remoteIp,omitempty"` +} + +type authResponse struct { + PasswordCipher string `json:"passwordCipher"` + ApplicationID string `json:"applicationId"` + TenantID string `json:"tenantId"` + Account string `json:"account"` + EnterpriseCode string `json:"enterpriseCode"` + MaxConnections int `json:"maxConnections"` +} + +func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) { + req, ok := packet.Packer.(*cmpp.CmppConnReqPkt) + if !ok { + return true, nil + } + account := strings.TrimRight(req.SrcAddr, "\x00") + if account == "" { + setInboundConnectResponse(response.Packer, cmpp.ErrnoConnInvalidSrcAddr, req.AuthSrc, "", req.Version) + return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr] + } + if req.Version != cmpp.V20 && req.Version != cmpp.V21 && req.Version != cmpp.V30 { + setInboundConnectResponse(response.Packer, cmpp.ErrnoConnVerTooHigh, req.AuthSrc, "", cmpp.V30) + return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnVerTooHigh] + } + auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp) + if err != nil { + logger.Printf("cmpp inbound auth failed account=%s remote=%s err=%v", account, packet.Conn.Conn.RemoteAddr(), err) + setInboundConnectResponse(response.Packer, cmpp.ErrnoConnAuthFailed, req.AuthSrc, "", req.Version) + return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed] + } + now := time.Now().UTC() + session := &downstreamSession{ + account: strings.TrimSpace(defaultString(auth.Account, account)), + tenantID: strings.TrimSpace(auth.TenantID), + applicationID: strings.TrimSpace(auth.ApplicationID), + enterpriseCode: strings.TrimSpace(auth.EnterpriseCode), + protocol: cmppVersionName(req.Version), + srcID: strings.TrimSpace(auth.Account), + remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()), + connectedAt: now, + connectionID: fmt.Sprintf("%s-%d", s.gatewayInstanceID(), now.UnixNano()), + conn: packet.Conn, + mu: &sync.Mutex{}, + presence: s.PresenceStore, + instanceID: s.gatewayInstanceID(), + report: s.reportConnection, + deliveryReport: s.reportDownstreamDelivery, + protocolLog: s.emitProtocolLog, + } + if !rememberAccount(session, auth.MaxConnections) { + logger.Printf("cmpp inbound auth failed account=%s remote=%s err=connection limit exceeded max=%d", account, packet.Conn.Conn.RemoteAddr(), auth.MaxConnections) + setInboundConnectResponse(response.Packer, cmpp.ErrnoConnOthers, req.AuthSrc, "", req.Version) + return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnOthers] + } + setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version) + go s.reportConnectionOrDisconnect(session, "connected", "") + response.AfterSend = func(sendErr error) { + if sendErr == nil { + go s.flushPending(defaultString(auth.Account, account), logger) + } + } + logger.Printf( + "cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x%02x account=%s remote=%s", + cmppVersionName(req.Version), uint8(req.Version), uint8(req.Version), account, packet.Conn.Conn.RemoteAddr(), + ) + return false, nil +} + +func setInboundConnectResponse(packet any, status uint8, authSource string, secret string, version cmpp.Type) { + switch resp := packet.(type) { + case *cmpp.Cmpp2ConnRspPkt: + resp.Status = status + resp.AuthSrc = authSource + resp.Secret = secret + resp.Version = version + case *cmpp.Cmpp3ConnRspPkt: + resp.Status = uint32(status) + resp.AuthSrc = authSource + resp.Secret = secret + resp.Version = version + } +} + +func cmppVersionName(version cmpp.Type) string { + switch version { + case cmpp.V20: + return "cmpp20" + case cmpp.V21: + return "cmpp21" + case cmpp.V30: + return "cmpp30" + default: + return fmt.Sprintf("unknown_0x%02x", uint8(version)) + } +} + +func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32) (authResponse, error) { + payload := authRequest{ + Account: account, + AuthSource: base64.StdEncoding.EncodeToString([]byte(authSource)), + Timestamp: timestamp, + RemoteIP: remoteIP(remote), + } + var result authResponse + err := s.post(context.Background(), "/gateway/events/inbound/authenticate", payload, &result) + return result, err +} diff --git a/gateway/internal/inbound/delivery.go b/gateway/internal/inbound/delivery.go new file mode 100644 index 0000000..a83e607 --- /dev/null +++ b/gateway/internal/inbound/delivery.go @@ -0,0 +1,342 @@ +package inbound + +import ( + "context" + "errors" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + cmpputils "github.com/bigwhite/gocmpp/utils" + "strconv" + "strings" + "time" +) + +// Receipt delivery requires the original Submit mapping. Falling back to an +// arbitrary account session would acknowledge a message with the wrong Msg_Id. + +type DownstreamReceipt struct { + DeliveryID string `json:"deliveryId,omitempty"` + Account string `json:"account,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + MessageID string `json:"messageId"` + GatewayMessageID string `json:"gatewayMessageId,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + ReceiptStatus string `json:"receiptStatus"` + RawStatus string `json:"rawStatus,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"` + SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"` + DeliveredAt string `json:"deliveredAt,omitempty"` +} + +type DownstreamUplink struct { + DeliveryID string `json:"deliveryId,omitempty"` + Account string `json:"account,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + MessageID string `json:"messageId,omitempty"` + PhoneNumber string `json:"phoneNumber"` + DestID string `json:"destId"` + Content string `json:"content"` + ReceivedAt string `json:"receivedAt,omitempty"` +} + +type DownstreamSendResult struct { + Sent bool `json:"sent"` + Retryable bool `json:"retryable"` + ReasonCode string `json:"reasonCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + ConnectionID string `json:"connectionId,omitempty"` + SequenceID string `json:"sequenceId,omitempty"` + MessageID string `json:"messageId,omitempty"` + SentAt string `json:"sentAt,omitempty"` + AckDeadlineAt string `json:"ackDeadlineAt,omitempty"` +} + +type downstreamDeliveryLifecycleEvent struct { + Kind string + DeliveryID string + ConnectionID string + SequenceID uint32 + MessageID uint64 + Result uint32 + ObservedAt time.Time + AckDeadlineAt time.Time + FailureType string + ErrorMessage string +} + +func (s Server) reportDownstreamDelivery(event downstreamDeliveryLifecycleEvent) { + if strings.TrimSpace(event.DeliveryID) == "" { + return + } + payload := map[string]any{ + "id": event.DeliveryID, "connectionId": event.ConnectionID, + "sequenceId": strconv.FormatUint(uint64(event.SequenceID), 10), + "messageId": strconv.FormatUint(event.MessageID, 10), + } + switch event.Kind { + case "sent": + payload["sentAt"] = formatRFC3339Nano(event.ObservedAt) + payload["ackDeadlineAt"] = formatRFC3339Nano(event.AckDeadlineAt) + _ = s.post(context.Background(), "/gateway/events/downstream/sent", payload, nil) + case "acknowledged": + payload["result"] = event.Result + payload["acknowledgedAt"] = formatRFC3339Nano(event.ObservedAt) + _ = s.post(context.Background(), "/gateway/events/downstream/acknowledged", payload, nil) + case "failed": + payload["failureType"] = event.FailureType + payload["errorMessage"] = event.ErrorMessage + _ = s.post(context.Background(), "/gateway/events/downstream/failed", payload, nil) + } +} + +func PushReceipt(event DownstreamReceipt) (bool, error) { + result, err := PushReceiptWithResult(event) + return result.Sent, err +} + +func PushReceiptWithResult(event DownstreamReceipt) (DownstreamSendResult, error) { + return pushReceiptWithResult(event, true) +} + +func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (DownstreamSendResult, error) { + session := findReceiptSession(event.MessageID, event.Account) + if session == nil && allowRecovery { + session = recoverReceiptSession(event) + } + if session == nil { + if event.SubmitSequenceID == 0 { + return DownstreamSendResult{ + Retryable: false, + ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID", + ErrorMessage: "历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id,系统已终止重投", + }, nil + } + if strings.TrimSpace(event.MessageID) == "" || strings.TrimSpace(event.Account) == "" { + return DownstreamSendResult{ + Retryable: false, + ReasonCode: "INVALID_RECEIPT_PAYLOAD", + ErrorMessage: "状态回执缺少平台消息 ID 或客户账号,系统已终止重投", + }, nil + } + return DownstreamSendResult{ + Retryable: true, + ReasonCode: "CLIENT_DISCONNECTED", + ErrorMessage: "下游客户端当前未连接,等待自动重试", + }, nil + } + if downstreamSubmitResponsePending(session.conn) { + return DownstreamSendResult{ + Retryable: true, + ReasonCode: "SUBMIT_RESPONSE_PENDING", + ErrorMessage: "客户 SubmitResp 尚未完成写出,回执已保留并等待响应后投递", + }, nil + } + stat := strings.TrimSpace(event.RawStatus) + if stat == "" { + stat = cmppReceiptStatus(event.ReceiptStatus) + } + when := time.Now() + if event.DeliveredAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, event.DeliveredAt); err == nil { + when = parsed + } + } + receipt := &cmpp.CmppReceiptPkt{ + MsgId: session.gatewayMsgID, + Stat: stat, + SubmitTime: when.Format("0601021504"), + DoneTime: when.Format("0601021504"), + DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber), + SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff), + } + receiptBytes, err := receipt.Pack() + if err != nil { + return DownstreamSendResult{}, err + } + deliver := downstreamDeliverPacket(session, session.gatewayMsgID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes)) + return sendDownstream(session, deliver, event.DeliveryID) +} + +func findReceiptSession(messageID string, account string) *downstreamSession { + downstreamRegistry.RLock() + defer downstreamRegistry.RUnlock() + if messageID != "" { + return downstreamRegistry.byMessageID[messageID] + } + if account != "" { + return downstreamRegistry.byAccount[account] + } + return nil +} + +func recoverReceiptSession(event DownstreamReceipt) *downstreamSession { + if event.MessageID == "" || event.SubmitSequenceID == 0 || event.Account == "" { + return nil + } + downstreamRegistry.RLock() + accountSession := downstreamRegistry.byAccount[event.Account] + downstreamRegistry.RUnlock() + if accountSession == nil || accountSession.conn == nil { + return nil + } + recovered := *accountSession + recovered.messageID = event.MessageID + recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) + return &recovered +} + +func PushUplink(event DownstreamUplink) (bool, error) { + result, err := PushUplinkWithResult(event) + return result.Sent, err +} + +func PushUplinkWithResult(event DownstreamUplink) (DownstreamSendResult, error) { + session := findSession(event.MessageID, event.Account) + if session == nil { + return DownstreamSendResult{ + Retryable: true, + ReasonCode: "CLIENT_DISCONNECTED", + ErrorMessage: "下游客户端当前未连接,等待自动重试", + }, nil + } + content, err := cmpputils.Utf8ToUcs2(event.Content) + if err != nil { + return DownstreamSendResult{}, err + } + deliver := downstreamDeliverPacket( + session, + messageIDFrom(defaultString(event.MessageID, event.Account), uint32(time.Now().UnixNano())), + defaultString(event.DestID, session.srcID), + event.PhoneNumber, + 8, + 0, + content, + ) + return sendDownstream(session, deliver, event.DeliveryID) +} + +func errorMessageWithCode(message string, code string) string { + message = strings.TrimSpace(message) + code = strings.TrimSpace(code) + if message == "" { + message = "gateway did not complete downstream delivery" + } + if code == "" { + return message + } + return fmt.Sprintf("%s (%s)", message, code) +} + +func downstreamDeliverPacket(session *downstreamSession, messageID uint64, destID string, sourceTerminalID string, msgFmt uint8, registerDelivery uint8, content string) cmpp.Packer { + if session != nil && (session.protocol == "cmpp20" || session.protocol == "cmpp21") { + return &cmpp.Cmpp2DeliverReqPkt{ + MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt, + SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery, + MsgLength: uint8(len(content)), MsgContent: content, + } + } + return &cmpp.Cmpp3DeliverReqPkt{ + MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt, + SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery, + MsgLength: uint8(len(content)), MsgContent: content, + } +} + +func findSession(messageID string, account string) *downstreamSession { + downstreamRegistry.RLock() + defer downstreamRegistry.RUnlock() + if messageID != "" { + if session := downstreamRegistry.byMessageID[messageID]; session != nil { + return session + } + } + if account != "" { + return downstreamRegistry.byAccount[account] + } + return nil +} + +func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID string) (DownstreamSendResult, error) { + session.mu.Lock() + defer session.mu.Unlock() + messageID := downstreamDeliverMessageID(deliver) + if messageID == 0 { + return DownstreamSendResult{}, errors.New("refusing downstream CMPP_DELIVER with Msg_Id=0") + } + sequenceID := <-session.conn.SeqId + sentAt := time.Now().UTC() + ackDeadlineAt := sentAt.Add(downstreamAckTimeout()) + result := DownstreamSendResult{ + ConnectionID: session.connectionID, + SequenceID: strconv.FormatUint(uint64(sequenceID), 10), + MessageID: strconv.FormatUint(messageID, 10), + SentAt: formatRFC3339Nano(sentAt), + AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt), + } + tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt) + if err := session.conn.SendPkt(deliver, sequenceID); err != nil { + removeDownstreamAck(tracker) + session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err) + if session.report != nil { + go session.report(session, "disconnected", err.Error()) + } + forgetDownstream(session) + result.Retryable = true + result.ReasonCode = "SEND_FAILED" + result.ErrorMessage = err.Error() + return result, nil + } + session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "success", "", nil) + result.Sent = true + if deliveryID != "" && session.deliveryReport != nil { + go session.deliveryReport(downstreamDeliveryLifecycleEvent{ + Kind: "sent", DeliveryID: deliveryID, ConnectionID: session.connectionID, + SequenceID: sequenceID, MessageID: messageID, ObservedAt: sentAt, AckDeadlineAt: ackDeadlineAt, + }) + } + session.touchPresence("connected", false, true) + if session.report != nil { + go session.report(session, "deliver", "") + } + return result, nil +} + +func downstreamDeliverMetadata(deliver cmpp.Packer) (string, string) { + switch packet := deliver.(type) { + case *cmpp.Cmpp2DeliverReqPkt: + if packet.RegisterDelivery == 1 { + return "deliver_receipt", packet.SrcTerminalId + } + return "deliver_uplink", packet.SrcTerminalId + case *cmpp.Cmpp3DeliverReqPkt: + if packet.RegisterDelivery == 1 { + return "deliver_receipt", packet.SrcTerminalId + } + return "deliver_uplink", packet.SrcTerminalId + default: + return "deliver", "" + } +} + +func downstreamDeliverMessageID(deliver cmpp.Packer) uint64 { + switch packet := deliver.(type) { + case *cmpp.Cmpp2DeliverReqPkt: + return packet.MsgId + case *cmpp.Cmpp3DeliverReqPkt: + return packet.MsgId + default: + return 0 + } +} + +func cmppReceiptStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "delivered": + return "DELIVRD" + case "unknown": + return "UNKNOWN" + default: + return "UNDELIV" + } +} diff --git a/gateway/internal/inbound/pending_recovery.go b/gateway/internal/inbound/pending_recovery.go new file mode 100644 index 0000000..f592ec4 --- /dev/null +++ b/gateway/internal/inbound/pending_recovery.go @@ -0,0 +1,281 @@ +package inbound + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" +) + +// Recovery only replays API-owned pending deliveries; Redis recovery locks and +// presence snapshots prevent multiple Gateway instances from racing the replay. + +type pendingDeliveryRequest struct { + Account string `json:"account"` + Limit int `json:"limit,omitempty"` +} + +type pendingDelivery struct { + ID string `json:"id"` + DeliveryType string `json:"deliveryType"` + Payload json.RawMessage `json:"payload"` + CreatedAt time.Time `json:"createdAt"` +} + +type pendingFlushResult struct { + Account string + Deliveries int + DeliveredCount int + FailedCount int + WaitingCount int + LastError string +} + +func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushResult, error) { + result := pendingFlushResult{Account: account} + if account == "" { + return result, nil + } + var deliveries []pendingDelivery + if err := s.post(context.Background(), "/gateway/events/downstream/pending", pendingDeliveryRequest{Account: account, Limit: 100}, &deliveries); err != nil { + logger.Printf("cmpp inbound pending delivery fetch failed account=%s err=%v", account, err) + result.LastError = err.Error() + return result, err + } + result.Deliveries = len(deliveries) + for _, delivery := range deliveries { + sendResult, err := s.pushPendingDelivery(account, delivery) + if err != nil { + result.FailedCount++ + result.LastError = err.Error() + _ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{ + "id": delivery.ID, "errorMessage": err.Error(), "failureType": "send_failed", + "connectionId": sendResult.ConnectionID, "sequenceId": sendResult.SequenceID, + "messageId": sendResult.MessageID, "sentAt": sendResult.SentAt, + }, nil) + continue + } + if sendResult.Sent { + result.DeliveredCount++ + continue + } + if sendResult.ReasonCode == "SUBMIT_RESPONSE_PENDING" { + result.WaitingCount++ + continue + } + errorMessage := defaultString(sendResult.ErrorMessage, "gateway did not complete downstream delivery") + failureType := "unrecoverable" + if sendResult.Retryable { + failureType = "send_failed" + result.WaitingCount++ + } else { + result.FailedCount++ + } + result.LastError = errorMessage + _ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{ + "id": delivery.ID, "errorMessage": errorMessageWithCode(errorMessage, sendResult.ReasonCode), + "failureType": failureType, "connectionId": sendResult.ConnectionID, + "sequenceId": sendResult.SequenceID, "messageId": sendResult.MessageID, "sentAt": sendResult.SentAt, + }, nil) + } + return result, nil +} + +func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (DownstreamSendResult, error) { + switch delivery.DeliveryType { + case "receipt": + var event DownstreamReceipt + if err := json.Unmarshal(delivery.Payload, &event); err != nil { + return DownstreamSendResult{}, err + } + event.DeliveryID = delivery.ID + event.Account = defaultString(event.Account, account) + allowRecovery := !delivery.CreatedAt.IsZero() && time.Since(delivery.CreatedAt) >= 5*time.Second + return pushReceiptWithResult(event, allowRecovery) + case "uplink": + var event DownstreamUplink + if err := json.Unmarshal(delivery.Payload, &event); err != nil { + return DownstreamSendResult{}, err + } + event.DeliveryID = delivery.ID + event.Account = defaultString(event.Account, account) + return PushUplinkWithResult(event) + default: + return DownstreamSendResult{}, fmt.Errorf("unsupported downstream delivery type %q", delivery.DeliveryType) + } +} + +func (s Server) runPendingFlusher(logger *log.Logger) { + ticker := time.NewTicker(s.pendingFlushInterval()) + defer ticker.Stop() + for range ticker.C { + s.flushOnlineAccounts(logger) + s.recoverPendingCandidates(logger) + } +} + +func (s Server) flushOnlineAccounts(logger *log.Logger) { + for _, account := range onlineAccounts() { + _, _ = s.flushPending(account, logger) + } +} + +func (s Server) recoverPendingCandidates(logger *log.Logger) { + candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore) + if err != nil { + logger.Printf("cmpp inbound recovery candidate refresh failed err=%v", err) + return + } + for _, candidate := range candidates { + account := strings.TrimSpace(candidate.Account) + if account == "" { + continue + } + activeRecovery := DownstreamRecoveryStatus{ + Account: account, + GatewayInstanceID: s.gatewayInstanceID(), + } + if s.RecoveryStore != nil { + decision, recoveryErr := s.RecoveryStore.StartAccountRecovery(context.Background(), account, s.gatewayInstanceID()) + if recoveryErr != nil { + logger.Printf("cmpp inbound recovery start failed account=%s err=%v", account, recoveryErr) + continue + } + if !decision.Allowed { + logger.Printf("cmpp inbound recovery skipped account=%s reason=%s", account, decision.SkipReason) + decision.Status.Account = account + decision.Status.GatewayInstanceID = s.gatewayInstanceID() + decision.Status.State = defaultString(decision.Status.State, "failed") + decision.Status.LastSkipReason = defaultString(decision.Status.LastSkipReason, decision.SkipReason) + if decision.Status.FailureCategory == "" { + decision.Status.FailureCategory = recoveryFailureCategory(decision.Status.State, "", decision.Status.LastSkipReason) + } + s.syncRecoveryStatus(logger, account, decision.Status) + continue + } + activeRecovery = decision.Status + activeRecovery.Account = account + activeRecovery.GatewayInstanceID = s.gatewayInstanceID() + } + result, flushErr := s.flushPending(account, logger) + if s.RecoveryStore != nil { + status := DownstreamRecoveryStatus{ + Account: account, + GatewayInstanceID: s.gatewayInstanceID(), + LockToken: activeRecovery.LockToken, + LockOwner: defaultString(activeRecovery.LockOwner, s.gatewayInstanceID()), + LockAcquiredAt: activeRecovery.LockAcquiredAt, + LockExpiresAt: activeRecovery.LockExpiresAt, + LastAttemptAt: activeRecovery.LastAttemptAt, + } + switch { + case flushErr != nil: + status.State = "failed" + status.LastError = flushErr.Error() + status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) + case result.WaitingCount > 0 && result.DeliveredCount == 0 && result.FailedCount == 0: + status.State = "waiting_connection" + status.LastError = "downstream client is not connected" + status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) + case result.FailedCount > 0 && result.DeliveredCount > 0: + status.State = "partial" + status.LastError = result.LastError + status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) + case result.FailedCount > 0: + status.State = "failed" + status.LastError = result.LastError + status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) + default: + status.State = "success" + status.LastError = "" + status.FailureCategory = "" + } + if err := s.RecoveryStore.CompleteAccountRecovery(context.Background(), status); err != nil { + logger.Printf("cmpp inbound recovery completion failed account=%s err=%v", account, err) + if err == ErrRecoveryLockLost { + status.State = "failed" + status.LastSkipReason = "lock_lost" + status.FailureCategory = "lock_lost" + s.syncRecoveryStatus(logger, account, status) + } + } else if persisted, err := s.RecoveryStore.GetAccountRecoveryStatus(context.Background(), account); err != nil { + logger.Printf("cmpp inbound recovery status fetch failed account=%s err=%v", account, err) + } else { + s.syncRecoveryStatus(logger, account, persisted) + } + } + } +} + +func (s Server) syncRecoveryStatus(logger *log.Logger, account string, status DownstreamRecoveryStatus) { + if err := s.post(context.Background(), "/gateway/events/downstream/recovery-status", map[string]any{ + "account": status.Account, + "gatewayInstanceId": status.GatewayInstanceID, + "state": status.State, + "lockOwner": status.LockOwner, + "lockExpiresAt": formatRFC3339Nano(status.LockExpiresAt), + "lastAttemptAt": formatRFC3339Nano(status.LastAttemptAt), + "lastSuccessAt": formatRFC3339Nano(status.LastSuccessAt), + "lastFailureAt": formatRFC3339Nano(status.LastFailureAt), + "nextRetryAt": formatRFC3339Nano(status.NextRetryAt), + "attemptCount": status.AttemptCount, + "failureCategory": status.FailureCategory, + "lastError": status.LastError, + "lastSkipReason": status.LastSkipReason, + }, nil); err != nil { + logger.Printf("cmpp inbound recovery status sync failed account=%s err=%v", account, err) + } +} + +func recoveryFailureCategory(state string, lastError string, lastSkipReason string) string { + if state == "success" || state == "running" { + return "" + } + if lastSkipReason == "backoff" { + return "backoff" + } + if lastSkipReason == "locked" { + return "lock_contended" + } + if lastSkipReason == "lock_lost" { + return "lock_lost" + } + if state == "waiting_connection" { + return "client_disconnected" + } + if state == "partial" { + return "partial_delivery_failed" + } + if state == "failed" && strings.TrimSpace(lastError) != "" { + return "flush_failed" + } + return "unknown" +} + +func (s Server) pendingFlushInterval() time.Duration { + if s.PendingFlushInterval > 0 { + return s.PendingFlushInterval + } + return time.Minute +} + +func (s Server) logRecoveryCandidates(logger *log.Logger) { + candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore) + if err != nil { + logger.Printf("cmpp inbound recovery candidates load failed err=%v", err) + return + } + if len(candidates) == 0 { + logger.Printf("cmpp inbound recovery candidates loaded count=0") + return + } + accounts := make([]string, 0, len(candidates)) + for _, item := range candidates { + if strings.TrimSpace(item.Account) != "" { + accounts = append(accounts, item.Account) + } + } + logger.Printf("cmpp inbound recovery candidates loaded count=%d accounts=%s", len(candidates), strings.Join(accounts, ",")) +} diff --git a/gateway/internal/inbound/protocol_log.go b/gateway/internal/inbound/protocol_log.go new file mode 100644 index 0000000..ce91900 --- /dev/null +++ b/gateway/internal/inbound/protocol_log.go @@ -0,0 +1,114 @@ +package inbound + +import ( + "context" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + "log" + "strconv" +) + +type protocolLogEvent struct { + Protocol string `json:"protocol"` + Direction string `json:"direction"` + EventType string `json:"eventType"` + Status string `json:"status"` + TenantID string `json:"tenantId,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + Account string `json:"account,omitempty"` + MessageID string `json:"messageId,omitempty"` + GatewayMessageID string `json:"gatewayMessageId,omitempty"` + Phone string `json:"phone,omitempty"` + ResultCode string `json:"resultCode,omitempty"` + Detail map[string]any `json:"detail,omitempty"` +} + +func (s Server) submitResponseProtocolLogger( + account string, + protocol string, + sequenceID uint32, + phone string, + messageID string, + gatewayMessageID uint64, + result uint32, +) func(error) { + return func(sendErr error) { + s.emitProtocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "platform_to_client", + EventType: "submit_resp", + Status: protocolSendStatus(sendErr), + Account: account, + MessageID: messageID, + GatewayMessageID: fmt.Sprint(gatewayMessageID), + Phone: phone, + ResultCode: protocolSendResultCode(sendErr, result), + Detail: protocolSubmitResponseDetail(sequenceID, sendErr), + }) + } +} + +func protocolSendStatus(sendErr error) string { + if sendErr != nil { + return "failed" + } + return "success" +} + +func protocolSendResultCode(sendErr error, result uint32) string { + if sendErr != nil { + return "SEND_FAILED" + } + return fmt.Sprint(result) +} + +func protocolSubmitResponseDetail(sequenceID uint32, sendErr error) map[string]any { + detail := map[string]any{"sequenceId": sequenceID} + if sendErr != nil { + detail["error"] = sendErr.Error() + } + return detail +} + +func (s Server) emitProtocolLog(event protocolLogEvent) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + if err := s.post(ctx, "/gateway/events/protocol-log", event, nil); err != nil { + log.Printf("cmpp inbound protocol_event direction=%s event=%s status=telemetry_failed account=%s message_id=%s error=%q", event.Direction, event.EventType, event.Account, event.MessageID, err) + } + }() +} + +func (session *downstreamSession) recordDownstreamProtocol( + deliver cmpp.Packer, + deliveryID string, + sequenceID uint32, + messageID uint64, + status string, + resultCode string, + sendErr error, +) { + if session == nil || session.protocolLog == nil { + return + } + eventType, phone := downstreamDeliverMetadata(deliver) + detail := map[string]any{"sequenceId": sequenceID, "deliveryId": deliveryID} + if sendErr != nil { + detail["error"] = sendErr.Error() + } + session.protocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "platform_to_client", + EventType: eventType, + Status: status, + TenantID: session.tenantID, + ApplicationID: session.applicationID, + Account: session.account, + MessageID: session.messageID, + GatewayMessageID: strconv.FormatUint(messageID, 10), + Phone: defaultString(phone, session.phoneNumber), + ResultCode: resultCode, + Detail: detail, + }) +} diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index 73c223f..2a9e11c 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -1,29 +1,14 @@ package inbound import ( - "bytes" - "context" - "crypto/md5" - "encoding/base64" - "encoding/json" - "errors" - "fmt" + cmpp "github.com/bigwhite/gocmpp" "io" "log" - "net" "net/http" - "os" - "strconv" - "strings" - "sync" "time" - - cmpp "github.com/bigwhite/gocmpp" - cmpputils "github.com/bigwhite/gocmpp/utils" ) const defaultHTTPTimeout = 10 * time.Second -const defaultDownstreamAckTimeout = 30 * time.Second type Server struct { Addr string @@ -36,184 +21,6 @@ type Server struct { GatewayInstanceID string } -type authRequest struct { - Account string `json:"account"` - AuthSource string `json:"authSource"` - Timestamp uint32 `json:"timestamp"` - RemoteIP string `json:"remoteIp,omitempty"` -} - -type submitRequest struct { - Account string `json:"account"` - PhoneNumber string `json:"phoneNumber,omitempty"` - PhoneNumbers []string `json:"phoneNumbers,omitempty"` - Content string `json:"content"` - SrcID string `json:"srcId,omitempty"` - DestID string `json:"destId,omitempty"` - SequenceID uint32 `json:"sequenceId,omitempty"` - RemoteIP string `json:"remoteIp,omitempty"` - LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"` -} - -type inboundLongMessageFragment struct { - Reference int `json:"reference"` - Total int `json:"total"` - Index int `json:"index"` - Format int `json:"format"` -} - -type submitResponseMessage struct { - PhoneNumber string `json:"phoneNumber"` - MessageID string `json:"messageId"` -} - -type submitResponse struct { - Accepted bool `json:"accepted"` - Result uint32 `json:"result,omitempty"` - TenantID string `json:"tenantId,omitempty"` - ApplicationID string `json:"applicationId,omitempty"` - MessageID string `json:"messageId"` - Messages []submitResponseMessage `json:"messages,omitempty"` -} - -type protocolLogEvent struct { - Protocol string `json:"protocol"` - Direction string `json:"direction"` - EventType string `json:"eventType"` - Status string `json:"status"` - TenantID string `json:"tenantId,omitempty"` - ApplicationID string `json:"applicationId,omitempty"` - Account string `json:"account,omitempty"` - MessageID string `json:"messageId,omitempty"` - GatewayMessageID string `json:"gatewayMessageId,omitempty"` - Phone string `json:"phone,omitempty"` - ResultCode string `json:"resultCode,omitempty"` - Detail map[string]any `json:"detail,omitempty"` -} - -type authResponse struct { - PasswordCipher string `json:"passwordCipher"` - ApplicationID string `json:"applicationId"` - TenantID string `json:"tenantId"` - Account string `json:"account"` - EnterpriseCode string `json:"enterpriseCode"` - MaxConnections int `json:"maxConnections"` -} - -type DownstreamReceipt struct { - DeliveryID string `json:"deliveryId,omitempty"` - Account string `json:"account,omitempty"` - ApplicationID string `json:"applicationId,omitempty"` - MessageID string `json:"messageId"` - GatewayMessageID string `json:"gatewayMessageId,omitempty"` - PhoneNumber string `json:"phoneNumber,omitempty"` - ReceiptStatus string `json:"receiptStatus"` - RawStatus string `json:"rawStatus,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` - SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"` - SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"` - DeliveredAt string `json:"deliveredAt,omitempty"` -} - -type DownstreamUplink struct { - DeliveryID string `json:"deliveryId,omitempty"` - Account string `json:"account,omitempty"` - ApplicationID string `json:"applicationId,omitempty"` - MessageID string `json:"messageId,omitempty"` - PhoneNumber string `json:"phoneNumber"` - DestID string `json:"destId"` - Content string `json:"content"` - ReceivedAt string `json:"receivedAt,omitempty"` -} - -type DownstreamSendResult struct { - Sent bool `json:"sent"` - Retryable bool `json:"retryable"` - ReasonCode string `json:"reasonCode,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - ConnectionID string `json:"connectionId,omitempty"` - SequenceID string `json:"sequenceId,omitempty"` - MessageID string `json:"messageId,omitempty"` - SentAt string `json:"sentAt,omitempty"` - AckDeadlineAt string `json:"ackDeadlineAt,omitempty"` -} - -type downstreamDeliveryLifecycleEvent struct { - Kind string - DeliveryID string - ConnectionID string - SequenceID uint32 - MessageID uint64 - Result uint32 - ObservedAt time.Time - AckDeadlineAt time.Time - FailureType string - ErrorMessage string -} - -type downstreamAckTracker struct { - deliveryID string - connectionID string - sequenceID uint32 - messageID uint64 - session *downstreamSession - timer *time.Timer -} - -type downstreamConnectionEvent struct { - Account string `json:"account"` - ConnectionID string `json:"connectionId"` - Status string `json:"status"` - RemoteIP string `json:"remoteIp,omitempty"` - Protocol string `json:"protocol,omitempty"` - ConnectedAt string `json:"connectedAt,omitempty"` - ObservedAt string `json:"observedAt,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` -} - -type downstreamSession struct { - messageID string - account string - tenantID string - applicationID string - enterpriseCode string - protocol string - srcID string - phoneNumber string - gatewayMsgID uint64 - remoteIP string - connectedAt time.Time - connectionID string - conn *cmpp.Conn - mu *sync.Mutex - presence PresenceStore - instanceID string - report func(*downstreamSession, string, string) - deliveryReport func(downstreamDeliveryLifecycleEvent) - protocolLog func(protocolLogEvent) -} - -var downstreamRegistry = struct { - sync.RWMutex - byMessageID map[string]*downstreamSession - byAccount map[string]*downstreamSession - byConn map[*cmpp.Conn]*downstreamSession -}{ - byMessageID: make(map[string]*downstreamSession), - byAccount: make(map[string]*downstreamSession), - byConn: make(map[*cmpp.Conn]*downstreamSession), -} - -var downstreamAckRegistry = struct { - sync.Mutex - items map[string]*downstreamAckTracker -}{items: make(map[string]*downstreamAckTracker)} - -var downstreamSubmitBarrier = struct { - sync.RWMutex - byConn map[*cmpp.Conn]int -}{byConn: make(map[*cmpp.Conn]int)} - func (s Server) ListenAndServe() error { addr := s.Addr if addr == "" { @@ -228,1444 +35,3 @@ func (s Server) ListenAndServe() error { cmpp.HandlerFunc(s.handleActivity), ) } - -func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) { - req, ok := packet.Packer.(*cmpp.CmppConnReqPkt) - if !ok { - return true, nil - } - account := strings.TrimRight(req.SrcAddr, "\x00") - if account == "" { - setInboundConnectResponse(response.Packer, cmpp.ErrnoConnInvalidSrcAddr, req.AuthSrc, "", req.Version) - return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr] - } - if req.Version != cmpp.V20 && req.Version != cmpp.V21 && req.Version != cmpp.V30 { - setInboundConnectResponse(response.Packer, cmpp.ErrnoConnVerTooHigh, req.AuthSrc, "", cmpp.V30) - return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnVerTooHigh] - } - auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp) - if err != nil { - logger.Printf("cmpp inbound auth failed account=%s remote=%s err=%v", account, packet.Conn.Conn.RemoteAddr(), err) - setInboundConnectResponse(response.Packer, cmpp.ErrnoConnAuthFailed, req.AuthSrc, "", req.Version) - return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed] - } - now := time.Now().UTC() - session := &downstreamSession{ - account: strings.TrimSpace(defaultString(auth.Account, account)), - tenantID: strings.TrimSpace(auth.TenantID), - applicationID: strings.TrimSpace(auth.ApplicationID), - enterpriseCode: strings.TrimSpace(auth.EnterpriseCode), - protocol: cmppVersionName(req.Version), - srcID: strings.TrimSpace(auth.Account), - remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()), - connectedAt: now, - connectionID: fmt.Sprintf("%s-%d", s.gatewayInstanceID(), now.UnixNano()), - conn: packet.Conn, - mu: &sync.Mutex{}, - presence: s.PresenceStore, - instanceID: s.gatewayInstanceID(), - report: s.reportConnection, - deliveryReport: s.reportDownstreamDelivery, - protocolLog: s.emitProtocolLog, - } - if !rememberAccount(session, auth.MaxConnections) { - logger.Printf("cmpp inbound auth failed account=%s remote=%s err=connection limit exceeded max=%d", account, packet.Conn.Conn.RemoteAddr(), auth.MaxConnections) - setInboundConnectResponse(response.Packer, cmpp.ErrnoConnOthers, req.AuthSrc, "", req.Version) - return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnOthers] - } - setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version) - go s.reportConnectionOrDisconnect(session, "connected", "") - response.AfterSend = func(sendErr error) { - if sendErr == nil { - go s.flushPending(defaultString(auth.Account, account), logger) - } - } - logger.Printf( - "cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x%02x account=%s remote=%s", - cmppVersionName(req.Version), uint8(req.Version), uint8(req.Version), account, packet.Conn.Conn.RemoteAddr(), - ) - return false, nil -} - -func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) { - req, ok := normalizeInboundSubmit(packet.Packer) - if !ok { - return true, nil - } - session := findSessionByConn(packet.Conn) - if session == nil || strings.TrimSpace(session.account) == "" { - logger.Printf( - "cmpp inbound event=submit_rejected protocol=%s packet_type=%s remote=%s seq=%d result=9 stage=session reason=%q", - req.protocol, req.protocol, packet.Conn.Conn.RemoteAddr(), req.sequenceID, "authenticated connection session not found", - ) - setInboundSubmitResponse(response.Packer, 0, 9) - response.AfterSend = s.submitResponseProtocolLogger("", req.protocol, req.sequenceID, "", "", 0, 9) - return false, nil - } - account := session.account - enterpriseCode := strings.TrimRight(req.msgSrc, "\x00") - if session.enterpriseCode != "" && enterpriseCode != session.enterpriseCode { - logger.Printf( - "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d result=9 stage=protocol reason=%q", - defaultString(session.protocol, req.protocol), req.protocol, account, enterpriseCode, packet.Conn.Conn.RemoteAddr(), req.sequenceID, - fmt.Sprintf("enterprise code mismatch: expected %s", session.enterpriseCode), - ) - setInboundSubmitResponse(response.Packer, 0, 9) - response.AfterSend = s.submitResponseProtocolLogger(account, defaultString(session.protocol, req.protocol), req.sequenceID, "", "", 0, 9) - return false, nil - } - phones := make([]string, len(req.destTerminalIDs)) - for index, destination := range req.destTerminalIDs { - phones[index] = strings.TrimSpace(strings.TrimRight(destination, "\x00")) - } - phone := "" - if len(phones) > 0 { - phone = phones[0] - } - remote := packet.Conn.Conn.RemoteAddr() - clientProtocol := defaultString(session.protocol, req.protocol) - logger.Printf( - "cmpp inbound event=submit_received protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d phone=%s src_id=%s msg_fmt=%d pk=%d/%d dest_count=%d content_bytes=%d", - clientProtocol, req.protocol, account, enterpriseCode, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt, - req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent), - ) - content, longMessage, err := decodeInboundSubmitContent(req) - if err != nil { - logger.Printf( - "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=decode reason=%q", - clientProtocol, req.protocol, account, remote, req.sequenceID, phone, err, - ) - setInboundSubmitResponse(response.Packer, 0, 9) - response.AfterSend = s.submitResponseProtocolLogger(account, clientProtocol, req.sequenceID, phone, "", 0, 9) - return false, nil - } - contentHash := fmt.Sprintf("%x", md5.Sum([]byte(content))) - startedAt := time.Now() - releaseSubmitBarrier := beginDownstreamSubmitBarrier(packet.Conn) - result, err := s.submit(remote, submitRequest{ - Account: account, - PhoneNumber: phone, - PhoneNumbers: phones, - Content: content, - SrcID: req.srcID, - DestID: phone, - SequenceID: req.sequenceID, - RemoteIP: remoteIP(remote), - LongMessage: longMessage, - }) - if err != nil || !result.Accepted { - reason := "api returned accepted=false" - if err != nil { - reason = err.Error() - } - responseResult := result.Result - if responseResult == 0 { - responseResult = 9 - } - logger.Printf( - "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=%d stage=business duration_ms=%d content_chars=%d content_hash=%s reason=%q", - clientProtocol, req.protocol, account, remote, req.sequenceID, phone, responseResult, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, reason, - ) - setInboundSubmitResponse(response.Packer, 0, responseResult) - protocolLogger := s.submitResponseProtocolLogger(account, clientProtocol, req.sequenceID, phone, result.MessageID, 0, responseResult) - response.AfterSend = func(sendErr error) { - releaseSubmitBarrier() - protocolLogger(sendErr) - } - return false, nil - } - gatewayMsgID := messageIDFrom(result.MessageID, req.sequenceID) - setInboundSubmitResponse(response.Packer, gatewayMsgID, 0) - responseMessages := result.Messages - if len(responseMessages) == 0 { - responseMessages = []submitResponseMessage{{PhoneNumber: phone, MessageID: result.MessageID}} - } - for index, acceptedMessage := range responseMessages { - acceptedPhone := strings.TrimSpace(acceptedMessage.PhoneNumber) - if acceptedPhone == "" && index < len(phones) { - acceptedPhone = phones[index] - } - rememberDownstream(downstreamSession{ - messageID: acceptedMessage.MessageID, - account: account, - tenantID: result.TenantID, - applicationID: result.ApplicationID, - enterpriseCode: session.enterpriseCode, - protocol: clientProtocol, - srcID: strings.TrimSpace(req.srcID), - phoneNumber: acceptedPhone, - gatewayMsgID: gatewayMsgID, - remoteIP: remoteIP(remote), - connectedAt: time.Now().UTC(), - connectionID: session.connectionID, - conn: packet.Conn, - mu: &sync.Mutex{}, - presence: s.PresenceStore, - instanceID: s.gatewayInstanceID(), - report: session.report, - deliveryReport: session.deliveryReport, - protocolLog: session.protocolLog, - }) - } - if current := findSessionByConn(packet.Conn); current != nil && current.report != nil { - go current.report(current, "submit", "") - } - response.AfterSend = func(sendErr error) { - releaseSubmitBarrier() - s.emitProtocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "platform_to_client", - EventType: "submit_resp", - Status: protocolSendStatus(sendErr), - TenantID: result.TenantID, - ApplicationID: result.ApplicationID, - Account: account, - MessageID: result.MessageID, - GatewayMessageID: fmt.Sprint(gatewayMsgID), - Phone: phone, - ResultCode: protocolSendResultCode(sendErr, 0), - Detail: protocolSubmitResponseDetail(req.sequenceID, sendErr), - }) - if sendErr != nil { - return - } - go func() { - if _, err := s.flushPending(account, logger); err != nil { - logger.Printf("cmpp inbound event=post_submit_pending_flush_failed account=%s message_id=%s error=%q", account, result.MessageID, err.Error()) - } - }() - } - logger.Printf( - "cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s dest_count=%d accepted_count=%d result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s", - clientProtocol, req.protocol, account, remote, req.sequenceID, phone, len(phones), len(responseMessages), result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, - ) - return false, nil -} - -func (s Server) submitResponseProtocolLogger( - account string, - protocol string, - sequenceID uint32, - phone string, - messageID string, - gatewayMessageID uint64, - result uint32, -) func(error) { - return func(sendErr error) { - s.emitProtocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "platform_to_client", - EventType: "submit_resp", - Status: protocolSendStatus(sendErr), - Account: account, - MessageID: messageID, - GatewayMessageID: fmt.Sprint(gatewayMessageID), - Phone: phone, - ResultCode: protocolSendResultCode(sendErr, result), - Detail: protocolSubmitResponseDetail(sequenceID, sendErr), - }) - } -} - -func protocolSendStatus(sendErr error) string { - if sendErr != nil { - return "failed" - } - return "success" -} - -func protocolSendResultCode(sendErr error, result uint32) string { - if sendErr != nil { - return "SEND_FAILED" - } - return fmt.Sprint(result) -} - -func protocolSubmitResponseDetail(sequenceID uint32, sendErr error) map[string]any { - detail := map[string]any{"sequenceId": sequenceID} - if sendErr != nil { - detail["error"] = sendErr.Error() - } - return detail -} - -func (s Server) emitProtocolLog(event protocolLogEvent) { - go func() { - ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) - defer cancel() - if err := s.post(ctx, "/gateway/events/protocol-log", event, nil); err != nil { - log.Printf("cmpp inbound protocol_event direction=%s event=%s status=telemetry_failed account=%s message_id=%s error=%q", event.Direction, event.EventType, event.Account, event.MessageID, err) - } - }() -} - -func (s Server) handleActivity(_ *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) { - session := findSessionByConn(packet.Conn) - if session == nil { - return true, nil - } - switch response := packet.Packer.(type) { - case *cmpp.CmppActiveTestReqPkt, *cmpp.CmppActiveTestRspPkt: - if session.report != nil { - go s.reportConnectionOrDisconnect(session, "heartbeat", "") - } - case *cmpp.Cmpp2DeliverRspPkt: - handleDownstreamAcknowledgement(packet.Conn, response.SeqId, response.MsgId, uint32(response.Result), logger) - case *cmpp.Cmpp3DeliverRspPkt: - handleDownstreamAcknowledgement(packet.Conn, response.SeqId, response.MsgId, response.Result, logger) - } - return true, nil -} - -func (s Server) reportConnection(session *downstreamSession, status string, errorMessage string) { - _ = s.reportConnectionChecked(session, status, errorMessage) -} - -func (s Server) reportConnectionChecked(session *downstreamSession, status string, errorMessage string) error { - if session == nil || strings.TrimSpace(session.account) == "" || strings.TrimSpace(session.connectionID) == "" { - return nil - } - event := downstreamConnectionEvent{ - Account: session.account, ConnectionID: session.connectionID, Status: status, - RemoteIP: session.remoteIP, Protocol: session.protocol, - ConnectedAt: formatRFC3339Nano(session.connectedAt), ObservedAt: formatRFC3339Nano(time.Now()), - ErrorMessage: errorMessage, - } - if err := s.post(context.Background(), "/gateway/events/inbound/connection", event, nil); err != nil { - log.Printf("cmpp inbound connection state callback failed account=%s connection_id=%s status=%s err=%v", session.account, session.connectionID, status, err) - return err - } - return nil -} - -func (s Server) reportConnectionOrDisconnect(session *downstreamSession, status string, errorMessage string) { - if err := s.reportConnectionChecked(session, status, errorMessage); err != nil && status != "disconnected" { - _ = s.reportConnectionChecked(session, "disconnected", err.Error()) - forgetDownstream(session) - if session.conn != nil { - session.conn.Close() - } - } -} - -func (s Server) handleConnectionClosed(conn *cmpp.Conn) { - downstreamSubmitBarrier.Lock() - delete(downstreamSubmitBarrier.byConn, conn) - downstreamSubmitBarrier.Unlock() - session := findSessionByConn(conn) - if session == nil { - return - } - forgetDownstream(session) - _ = s.reportConnectionChecked(session, "disconnected", "CMPP client connection closed") -} - -func (s Server) reportDownstreamDelivery(event downstreamDeliveryLifecycleEvent) { - if strings.TrimSpace(event.DeliveryID) == "" { - return - } - payload := map[string]any{ - "id": event.DeliveryID, "connectionId": event.ConnectionID, - "sequenceId": strconv.FormatUint(uint64(event.SequenceID), 10), - "messageId": strconv.FormatUint(event.MessageID, 10), - } - switch event.Kind { - case "sent": - payload["sentAt"] = formatRFC3339Nano(event.ObservedAt) - payload["ackDeadlineAt"] = formatRFC3339Nano(event.AckDeadlineAt) - _ = s.post(context.Background(), "/gateway/events/downstream/sent", payload, nil) - case "acknowledged": - payload["result"] = event.Result - payload["acknowledgedAt"] = formatRFC3339Nano(event.ObservedAt) - _ = s.post(context.Background(), "/gateway/events/downstream/acknowledged", payload, nil) - case "failed": - payload["failureType"] = event.FailureType - payload["errorMessage"] = event.ErrorMessage - _ = s.post(context.Background(), "/gateway/events/downstream/failed", payload, nil) - } -} - -type inboundSubmitPacket struct { - protocol string - pkTotal uint8 - pkNumber uint8 - tpUdhi uint8 - msgFmt uint8 - msgSrc string - srcID string - destTerminalIDs []string - msgContent string - sequenceID uint32 -} - -func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) { - switch req := packet.(type) { - case *cmpp.Cmpp2SubmitReqPkt: - return inboundSubmitPacket{ - protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt, - msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, - msgContent: req.MsgContent, sequenceID: req.SeqId, - }, true - case *cmpp.Cmpp3SubmitReqPkt: - return inboundSubmitPacket{ - protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt, - msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, - msgContent: req.MsgContent, sequenceID: req.SeqId, - }, true - default: - return inboundSubmitPacket{}, false - } -} - -func setInboundSubmitResponse(packet any, messageID uint64, result uint32) { - switch resp := packet.(type) { - case *cmpp.Cmpp2SubmitRspPkt: - resp.MsgId = messageID - resp.Result = uint8(result) - case *cmpp.Cmpp3SubmitRspPkt: - resp.MsgId = messageID - resp.Result = result - } -} - -func setInboundConnectResponse(packet any, status uint8, authSource string, secret string, version cmpp.Type) { - switch resp := packet.(type) { - case *cmpp.Cmpp2ConnRspPkt: - resp.Status = status - resp.AuthSrc = authSource - resp.Secret = secret - resp.Version = version - case *cmpp.Cmpp3ConnRspPkt: - resp.Status = uint32(status) - resp.AuthSrc = authSource - resp.Secret = secret - resp.Version = version - } -} - -func findSessionByConn(conn *cmpp.Conn) *downstreamSession { - downstreamRegistry.RLock() - defer downstreamRegistry.RUnlock() - return downstreamRegistry.byConn[conn] -} - -func cmppVersionName(version cmpp.Type) string { - switch version { - case cmpp.V20: - return "cmpp20" - case cmpp.V21: - return "cmpp21" - case cmpp.V30: - return "cmpp30" - default: - return fmt.Sprintf("unknown_0x%02x", uint8(version)) - } -} - -func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32) (authResponse, error) { - payload := authRequest{ - Account: account, - AuthSource: base64.StdEncoding.EncodeToString([]byte(authSource)), - Timestamp: timestamp, - RemoteIP: remoteIP(remote), - } - var result authResponse - err := s.post(context.Background(), "/gateway/events/inbound/authenticate", payload, &result) - return result, err -} - -func (s Server) submit(remote net.Addr, payload submitRequest) (submitResponse, error) { - payload.RemoteIP = remoteIP(remote) - var result submitResponse - err := s.post(context.Background(), "/gateway/events/inbound/submit", payload, &result) - return result, err -} - -type pendingDeliveryRequest struct { - Account string `json:"account"` - Limit int `json:"limit,omitempty"` -} - -type pendingDelivery struct { - ID string `json:"id"` - DeliveryType string `json:"deliveryType"` - Payload json.RawMessage `json:"payload"` - CreatedAt time.Time `json:"createdAt"` -} - -type pendingFlushResult struct { - Account string - Deliveries int - DeliveredCount int - FailedCount int - WaitingCount int - LastError string -} - -func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushResult, error) { - result := pendingFlushResult{Account: account} - if account == "" { - return result, nil - } - var deliveries []pendingDelivery - if err := s.post(context.Background(), "/gateway/events/downstream/pending", pendingDeliveryRequest{Account: account, Limit: 100}, &deliveries); err != nil { - logger.Printf("cmpp inbound pending delivery fetch failed account=%s err=%v", account, err) - result.LastError = err.Error() - return result, err - } - result.Deliveries = len(deliveries) - for _, delivery := range deliveries { - sendResult, err := s.pushPendingDelivery(account, delivery) - if err != nil { - result.FailedCount++ - result.LastError = err.Error() - _ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{ - "id": delivery.ID, "errorMessage": err.Error(), "failureType": "send_failed", - "connectionId": sendResult.ConnectionID, "sequenceId": sendResult.SequenceID, - "messageId": sendResult.MessageID, "sentAt": sendResult.SentAt, - }, nil) - continue - } - if sendResult.Sent { - result.DeliveredCount++ - continue - } - if sendResult.ReasonCode == "SUBMIT_RESPONSE_PENDING" { - result.WaitingCount++ - continue - } - errorMessage := defaultString(sendResult.ErrorMessage, "gateway did not complete downstream delivery") - failureType := "unrecoverable" - if sendResult.Retryable { - failureType = "send_failed" - result.WaitingCount++ - } else { - result.FailedCount++ - } - result.LastError = errorMessage - _ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{ - "id": delivery.ID, "errorMessage": errorMessageWithCode(errorMessage, sendResult.ReasonCode), - "failureType": failureType, "connectionId": sendResult.ConnectionID, - "sequenceId": sendResult.SequenceID, "messageId": sendResult.MessageID, "sentAt": sendResult.SentAt, - }, nil) - } - return result, nil -} - -func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (DownstreamSendResult, error) { - switch delivery.DeliveryType { - case "receipt": - var event DownstreamReceipt - if err := json.Unmarshal(delivery.Payload, &event); err != nil { - return DownstreamSendResult{}, err - } - event.DeliveryID = delivery.ID - event.Account = defaultString(event.Account, account) - allowRecovery := !delivery.CreatedAt.IsZero() && time.Since(delivery.CreatedAt) >= 5*time.Second - return pushReceiptWithResult(event, allowRecovery) - case "uplink": - var event DownstreamUplink - if err := json.Unmarshal(delivery.Payload, &event); err != nil { - return DownstreamSendResult{}, err - } - event.DeliveryID = delivery.ID - event.Account = defaultString(event.Account, account) - return PushUplinkWithResult(event) - default: - return DownstreamSendResult{}, fmt.Errorf("unsupported downstream delivery type %q", delivery.DeliveryType) - } -} - -func (s Server) post(ctx context.Context, path string, payload any, result any) error { - client := s.HTTPClient - if client == nil { - client = &http.Client{Timeout: defaultHTTPTimeout} - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(apiBaseURL(s.APIBaseURL), "/")+path, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) - if err != nil { - return fmt.Errorf("read api response: %w", err) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - detail := strings.TrimSpace(string(responseBody)) - if detail == "" { - return fmt.Errorf("api returned %s", resp.Status) - } - return fmt.Errorf("api returned %s: %s", resp.Status, detail) - } - if result != nil { - if len(responseBody) == 0 { - return io.EOF - } - return json.Unmarshal(responseBody, result) - } - return nil -} - -func decodeContent(format uint8, content string) (string, error) { - switch format { - case 8: - return cmpputils.Ucs2ToUtf8(content) - case 15: - return cmpputils.GB18030ToUtf8(content) - default: - return content, nil - } -} - -func decodeInboundSubmitContent(req inboundSubmitPacket) (string, *inboundLongMessageFragment, error) { - raw := []byte(req.msgContent) - if req.tpUdhi == 0 && req.pkTotal <= 1 { - content, err := decodeContent(req.msgFmt, req.msgContent) - return content, nil, err - } - if len(raw) == 0 { - return "", nil, errors.New("UDH message content is empty") - } - - headerLength := int(raw[0]) + 1 - if headerLength > len(raw) { - return "", nil, fmt.Errorf("UDH length %d exceeds message content length %d", headerLength, len(raw)) - } - - var reference, total, index int - switch { - case len(raw) >= 6 && raw[0] == 0x05 && raw[1] == 0x00 && raw[2] == 0x03: - reference = int(raw[3]) - total = int(raw[4]) - index = int(raw[5]) - case len(raw) >= 7 && raw[0] == 0x06 && raw[1] == 0x08 && raw[2] == 0x04: - reference = int(raw[3])<<8 | int(raw[4]) - total = int(raw[5]) - index = int(raw[6]) - default: - if req.pkTotal > 1 { - return "", nil, errors.New("concatenated CMPP submit is missing a supported 8-bit or 16-bit UDH") - } - content, err := decodeContent(req.msgFmt, string(raw[headerLength:])) - return content, nil, err - } - if total < 2 || index < 1 || index > total { - return "", nil, fmt.Errorf("invalid concatenated UDH total/index %d/%d", index, total) - } - if req.pkTotal > 0 && int(req.pkTotal) != total { - return "", nil, fmt.Errorf("PkTotal %d does not match UDH total %d", req.pkTotal, total) - } - if req.pkNumber > 0 && int(req.pkNumber) != index { - return "", nil, fmt.Errorf("PkNumber %d does not match UDH index %d", req.pkNumber, index) - } - - content, err := decodeContent(req.msgFmt, string(raw[headerLength:])) - if err != nil { - return "", nil, err - } - return content, &inboundLongMessageFragment{ - Reference: reference, - Total: total, - Index: index, - Format: int(req.msgFmt), - }, nil -} - -func apiBaseURL(value string) string { - if value == "" { - return "http://127.0.0.1:3000/api" - } - return value -} - -func remoteIP(addr net.Addr) string { - if tcp, ok := addr.(*net.TCPAddr); ok { - return tcp.IP.String() - } - host, _, err := net.SplitHostPort(addr.String()) - if err == nil { - return host - } - return addr.String() -} - -func messageIDFrom(value string, seq uint32) uint64 { - hash := md5.Sum([]byte(value)) - result := uint64(seq) - for _, item := range hash[:6] { - result = (result << 8) + uint64(item) - } - if result == 0 { - return uint64(time.Now().UnixNano()) - } - return result -} - -func rememberDownstream(session downstreamSession) { - if session.messageID == "" || session.conn == nil { - return - } - session.touchPresence("connected", true, false) - downstreamRegistry.Lock() - if existing := downstreamRegistry.byMessageID[session.messageID]; existing != nil && existing.conn == session.conn { - // A downstream long message returns one SUBMIT_RESP per fragment but is - // persisted as one platform message. Keep the first fragment Msg_Id so - // online delivery and restart recovery (which persists the first - // Sequence_Id) address the same client-side message. - session.gatewayMsgID = existing.gatewayMsgID - } - downstreamRegistry.byMessageID[session.messageID] = &session - downstreamRegistry.byConn[session.conn] = &session - if session.account != "" { - downstreamRegistry.byAccount[session.account] = &session - } - downstreamRegistry.Unlock() -} - -func rememberAccount(session *downstreamSession, maxConnections int) bool { - if session == nil || session.account == "" || session.conn == nil { - return false - } - if maxConnections <= 0 { - maxConnections = 1 - } - session.touchPresence("connected", false, false) - downstreamRegistry.Lock() - defer downstreamRegistry.Unlock() - active := 0 - for _, current := range downstreamRegistry.byConn { - if current != nil && current.account == session.account { - active++ - } - } - if active >= maxConnections { - return false - } - downstreamRegistry.byAccount[session.account] = session - downstreamRegistry.byConn[session.conn] = session - return true -} - -func forgetDownstream(session *downstreamSession) { - if session == nil { - return - } - downstreamRegistry.Lock() - for messageID, current := range downstreamRegistry.byMessageID { - if current != nil && current.conn == session.conn { - delete(downstreamRegistry.byMessageID, messageID) - } - } - if session.account != "" { - if current := downstreamRegistry.byAccount[session.account]; current == session { - delete(downstreamRegistry.byAccount, session.account) - } - } - if current := downstreamRegistry.byConn[session.conn]; current == session { - delete(downstreamRegistry.byConn, session.conn) - } - downstreamRegistry.Unlock() - _ = session.removePresence() -} - -func (s Server) runPendingFlusher(logger *log.Logger) { - ticker := time.NewTicker(s.pendingFlushInterval()) - defer ticker.Stop() - for range ticker.C { - s.flushOnlineAccounts(logger) - s.recoverPendingCandidates(logger) - } -} - -func (s Server) flushOnlineAccounts(logger *log.Logger) { - for _, account := range onlineAccounts() { - _, _ = s.flushPending(account, logger) - } -} - -func (s Server) recoverPendingCandidates(logger *log.Logger) { - candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore) - if err != nil { - logger.Printf("cmpp inbound recovery candidate refresh failed err=%v", err) - return - } - for _, candidate := range candidates { - account := strings.TrimSpace(candidate.Account) - if account == "" { - continue - } - activeRecovery := DownstreamRecoveryStatus{ - Account: account, - GatewayInstanceID: s.gatewayInstanceID(), - } - if s.RecoveryStore != nil { - decision, recoveryErr := s.RecoveryStore.StartAccountRecovery(context.Background(), account, s.gatewayInstanceID()) - if recoveryErr != nil { - logger.Printf("cmpp inbound recovery start failed account=%s err=%v", account, recoveryErr) - continue - } - if !decision.Allowed { - logger.Printf("cmpp inbound recovery skipped account=%s reason=%s", account, decision.SkipReason) - decision.Status.Account = account - decision.Status.GatewayInstanceID = s.gatewayInstanceID() - decision.Status.State = defaultString(decision.Status.State, "failed") - decision.Status.LastSkipReason = defaultString(decision.Status.LastSkipReason, decision.SkipReason) - if decision.Status.FailureCategory == "" { - decision.Status.FailureCategory = recoveryFailureCategory(decision.Status.State, "", decision.Status.LastSkipReason) - } - s.syncRecoveryStatus(logger, account, decision.Status) - continue - } - activeRecovery = decision.Status - activeRecovery.Account = account - activeRecovery.GatewayInstanceID = s.gatewayInstanceID() - } - result, flushErr := s.flushPending(account, logger) - if s.RecoveryStore != nil { - status := DownstreamRecoveryStatus{ - Account: account, - GatewayInstanceID: s.gatewayInstanceID(), - LockToken: activeRecovery.LockToken, - LockOwner: defaultString(activeRecovery.LockOwner, s.gatewayInstanceID()), - LockAcquiredAt: activeRecovery.LockAcquiredAt, - LockExpiresAt: activeRecovery.LockExpiresAt, - LastAttemptAt: activeRecovery.LastAttemptAt, - } - switch { - case flushErr != nil: - status.State = "failed" - status.LastError = flushErr.Error() - status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) - case result.WaitingCount > 0 && result.DeliveredCount == 0 && result.FailedCount == 0: - status.State = "waiting_connection" - status.LastError = "downstream client is not connected" - status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) - case result.FailedCount > 0 && result.DeliveredCount > 0: - status.State = "partial" - status.LastError = result.LastError - status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) - case result.FailedCount > 0: - status.State = "failed" - status.LastError = result.LastError - status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason) - default: - status.State = "success" - status.LastError = "" - status.FailureCategory = "" - } - if err := s.RecoveryStore.CompleteAccountRecovery(context.Background(), status); err != nil { - logger.Printf("cmpp inbound recovery completion failed account=%s err=%v", account, err) - if err == ErrRecoveryLockLost { - status.State = "failed" - status.LastSkipReason = "lock_lost" - status.FailureCategory = "lock_lost" - s.syncRecoveryStatus(logger, account, status) - } - } else if persisted, err := s.RecoveryStore.GetAccountRecoveryStatus(context.Background(), account); err != nil { - logger.Printf("cmpp inbound recovery status fetch failed account=%s err=%v", account, err) - } else { - s.syncRecoveryStatus(logger, account, persisted) - } - } - } -} - -func (s Server) syncRecoveryStatus(logger *log.Logger, account string, status DownstreamRecoveryStatus) { - if err := s.post(context.Background(), "/gateway/events/downstream/recovery-status", map[string]any{ - "account": status.Account, - "gatewayInstanceId": status.GatewayInstanceID, - "state": status.State, - "lockOwner": status.LockOwner, - "lockExpiresAt": formatRFC3339Nano(status.LockExpiresAt), - "lastAttemptAt": formatRFC3339Nano(status.LastAttemptAt), - "lastSuccessAt": formatRFC3339Nano(status.LastSuccessAt), - "lastFailureAt": formatRFC3339Nano(status.LastFailureAt), - "nextRetryAt": formatRFC3339Nano(status.NextRetryAt), - "attemptCount": status.AttemptCount, - "failureCategory": status.FailureCategory, - "lastError": status.LastError, - "lastSkipReason": status.LastSkipReason, - }, nil); err != nil { - logger.Printf("cmpp inbound recovery status sync failed account=%s err=%v", account, err) - } -} - -func recoveryFailureCategory(state string, lastError string, lastSkipReason string) string { - if state == "success" || state == "running" { - return "" - } - if lastSkipReason == "backoff" { - return "backoff" - } - if lastSkipReason == "locked" { - return "lock_contended" - } - if lastSkipReason == "lock_lost" { - return "lock_lost" - } - if state == "waiting_connection" { - return "client_disconnected" - } - if state == "partial" { - return "partial_delivery_failed" - } - if state == "failed" && strings.TrimSpace(lastError) != "" { - return "flush_failed" - } - return "unknown" -} - -func onlineAccounts() []string { - downstreamRegistry.RLock() - defer downstreamRegistry.RUnlock() - accounts := make([]string, 0, len(downstreamRegistry.byAccount)) - for account := range downstreamRegistry.byAccount { - if strings.TrimSpace(account) != "" { - accounts = append(accounts, account) - } - } - return accounts -} - -// DisconnectAccount closes every live downstream CMPP session for an -// application account. The normal connection-close callback removes registry -// and presence state and reports the disconnect to the API. -func DisconnectAccount(account string) int { - account = strings.TrimSpace(account) - if account == "" { - return 0 - } - downstreamRegistry.RLock() - sessions := make([]*downstreamSession, 0) - for _, session := range downstreamRegistry.byConn { - if session != nil && session.account == account && session.conn != nil { - sessions = append(sessions, session) - } - } - downstreamRegistry.RUnlock() - for _, session := range sessions { - session.conn.Close() - } - return len(sessions) -} - -func PushReceipt(event DownstreamReceipt) (bool, error) { - result, err := PushReceiptWithResult(event) - return result.Sent, err -} - -func PushReceiptWithResult(event DownstreamReceipt) (DownstreamSendResult, error) { - return pushReceiptWithResult(event, true) -} - -func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (DownstreamSendResult, error) { - session := findReceiptSession(event.MessageID, event.Account) - if session == nil && allowRecovery { - session = recoverReceiptSession(event) - } - if session == nil { - if event.SubmitSequenceID == 0 { - return DownstreamSendResult{ - Retryable: false, - ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID", - ErrorMessage: "历史回执缺少原 Submit Sequence_Id,无法重建 Msg_Id,系统已终止重投", - }, nil - } - if strings.TrimSpace(event.MessageID) == "" || strings.TrimSpace(event.Account) == "" { - return DownstreamSendResult{ - Retryable: false, - ReasonCode: "INVALID_RECEIPT_PAYLOAD", - ErrorMessage: "状态回执缺少平台消息 ID 或客户账号,系统已终止重投", - }, nil - } - return DownstreamSendResult{ - Retryable: true, - ReasonCode: "CLIENT_DISCONNECTED", - ErrorMessage: "下游客户端当前未连接,等待自动重试", - }, nil - } - if downstreamSubmitResponsePending(session.conn) { - return DownstreamSendResult{ - Retryable: true, - ReasonCode: "SUBMIT_RESPONSE_PENDING", - ErrorMessage: "客户 SubmitResp 尚未完成写出,回执已保留并等待响应后投递", - }, nil - } - stat := strings.TrimSpace(event.RawStatus) - if stat == "" { - stat = cmppReceiptStatus(event.ReceiptStatus) - } - when := time.Now() - if event.DeliveredAt != "" { - if parsed, err := time.Parse(time.RFC3339Nano, event.DeliveredAt); err == nil { - when = parsed - } - } - receipt := &cmpp.CmppReceiptPkt{ - MsgId: session.gatewayMsgID, - Stat: stat, - SubmitTime: when.Format("0601021504"), - DoneTime: when.Format("0601021504"), - DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber), - SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff), - } - receiptBytes, err := receipt.Pack() - if err != nil { - return DownstreamSendResult{}, err - } - deliver := downstreamDeliverPacket(session, session.gatewayMsgID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes)) - return sendDownstream(session, deliver, event.DeliveryID) -} - -func beginDownstreamSubmitBarrier(conn *cmpp.Conn) func() { - if conn == nil { - return func() {} - } - downstreamSubmitBarrier.Lock() - downstreamSubmitBarrier.byConn[conn]++ - downstreamSubmitBarrier.Unlock() - var once sync.Once - return func() { - once.Do(func() { - downstreamSubmitBarrier.Lock() - if downstreamSubmitBarrier.byConn[conn] <= 1 { - delete(downstreamSubmitBarrier.byConn, conn) - } else { - downstreamSubmitBarrier.byConn[conn]-- - } - downstreamSubmitBarrier.Unlock() - }) - } -} - -func downstreamSubmitResponsePending(conn *cmpp.Conn) bool { - if conn == nil { - return false - } - downstreamSubmitBarrier.RLock() - defer downstreamSubmitBarrier.RUnlock() - return downstreamSubmitBarrier.byConn[conn] > 0 -} - -func findReceiptSession(messageID string, account string) *downstreamSession { - downstreamRegistry.RLock() - defer downstreamRegistry.RUnlock() - if messageID != "" { - return downstreamRegistry.byMessageID[messageID] - } - if account != "" { - return downstreamRegistry.byAccount[account] - } - return nil -} - -func recoverReceiptSession(event DownstreamReceipt) *downstreamSession { - if event.MessageID == "" || event.SubmitSequenceID == 0 || event.Account == "" { - return nil - } - downstreamRegistry.RLock() - accountSession := downstreamRegistry.byAccount[event.Account] - downstreamRegistry.RUnlock() - if accountSession == nil || accountSession.conn == nil { - return nil - } - recovered := *accountSession - recovered.messageID = event.MessageID - recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) - return &recovered -} - -func PushUplink(event DownstreamUplink) (bool, error) { - result, err := PushUplinkWithResult(event) - return result.Sent, err -} - -func PushUplinkWithResult(event DownstreamUplink) (DownstreamSendResult, error) { - session := findSession(event.MessageID, event.Account) - if session == nil { - return DownstreamSendResult{ - Retryable: true, - ReasonCode: "CLIENT_DISCONNECTED", - ErrorMessage: "下游客户端当前未连接,等待自动重试", - }, nil - } - content, err := cmpputils.Utf8ToUcs2(event.Content) - if err != nil { - return DownstreamSendResult{}, err - } - deliver := downstreamDeliverPacket( - session, - messageIDFrom(defaultString(event.MessageID, event.Account), uint32(time.Now().UnixNano())), - defaultString(event.DestID, session.srcID), - event.PhoneNumber, - 8, - 0, - content, - ) - return sendDownstream(session, deliver, event.DeliveryID) -} - -func errorMessageWithCode(message string, code string) string { - message = strings.TrimSpace(message) - code = strings.TrimSpace(code) - if message == "" { - message = "gateway did not complete downstream delivery" - } - if code == "" { - return message - } - return fmt.Sprintf("%s (%s)", message, code) -} - -func downstreamDeliverPacket(session *downstreamSession, messageID uint64, destID string, sourceTerminalID string, msgFmt uint8, registerDelivery uint8, content string) cmpp.Packer { - if session != nil && (session.protocol == "cmpp20" || session.protocol == "cmpp21") { - return &cmpp.Cmpp2DeliverReqPkt{ - MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt, - SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery, - MsgLength: uint8(len(content)), MsgContent: content, - } - } - return &cmpp.Cmpp3DeliverReqPkt{ - MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt, - SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery, - MsgLength: uint8(len(content)), MsgContent: content, - } -} - -func findSession(messageID string, account string) *downstreamSession { - downstreamRegistry.RLock() - defer downstreamRegistry.RUnlock() - if messageID != "" { - if session := downstreamRegistry.byMessageID[messageID]; session != nil { - return session - } - } - if account != "" { - return downstreamRegistry.byAccount[account] - } - return nil -} - -func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID string) (DownstreamSendResult, error) { - session.mu.Lock() - defer session.mu.Unlock() - messageID := downstreamDeliverMessageID(deliver) - if messageID == 0 { - return DownstreamSendResult{}, errors.New("refusing downstream CMPP_DELIVER with Msg_Id=0") - } - sequenceID := <-session.conn.SeqId - sentAt := time.Now().UTC() - ackDeadlineAt := sentAt.Add(downstreamAckTimeout()) - result := DownstreamSendResult{ - ConnectionID: session.connectionID, - SequenceID: strconv.FormatUint(uint64(sequenceID), 10), - MessageID: strconv.FormatUint(messageID, 10), - SentAt: formatRFC3339Nano(sentAt), - AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt), - } - tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt) - if err := session.conn.SendPkt(deliver, sequenceID); err != nil { - removeDownstreamAck(tracker) - session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err) - if session.report != nil { - go session.report(session, "disconnected", err.Error()) - } - forgetDownstream(session) - result.Retryable = true - result.ReasonCode = "SEND_FAILED" - result.ErrorMessage = err.Error() - return result, nil - } - session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "success", "", nil) - result.Sent = true - if deliveryID != "" && session.deliveryReport != nil { - go session.deliveryReport(downstreamDeliveryLifecycleEvent{ - Kind: "sent", DeliveryID: deliveryID, ConnectionID: session.connectionID, - SequenceID: sequenceID, MessageID: messageID, ObservedAt: sentAt, AckDeadlineAt: ackDeadlineAt, - }) - } - session.touchPresence("connected", false, true) - if session.report != nil { - go session.report(session, "deliver", "") - } - return result, nil -} - -func (session *downstreamSession) recordDownstreamProtocol( - deliver cmpp.Packer, - deliveryID string, - sequenceID uint32, - messageID uint64, - status string, - resultCode string, - sendErr error, -) { - if session == nil || session.protocolLog == nil { - return - } - eventType, phone := downstreamDeliverMetadata(deliver) - detail := map[string]any{"sequenceId": sequenceID, "deliveryId": deliveryID} - if sendErr != nil { - detail["error"] = sendErr.Error() - } - session.protocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "platform_to_client", - EventType: eventType, - Status: status, - TenantID: session.tenantID, - ApplicationID: session.applicationID, - Account: session.account, - MessageID: session.messageID, - GatewayMessageID: strconv.FormatUint(messageID, 10), - Phone: defaultString(phone, session.phoneNumber), - ResultCode: resultCode, - Detail: detail, - }) -} - -func downstreamDeliverMetadata(deliver cmpp.Packer) (string, string) { - switch packet := deliver.(type) { - case *cmpp.Cmpp2DeliverReqPkt: - if packet.RegisterDelivery == 1 { - return "deliver_receipt", packet.SrcTerminalId - } - return "deliver_uplink", packet.SrcTerminalId - case *cmpp.Cmpp3DeliverReqPkt: - if packet.RegisterDelivery == 1 { - return "deliver_receipt", packet.SrcTerminalId - } - return "deliver_uplink", packet.SrcTerminalId - default: - return "deliver", "" - } -} - -func downstreamDeliverMessageID(deliver cmpp.Packer) uint64 { - switch packet := deliver.(type) { - case *cmpp.Cmpp2DeliverReqPkt: - return packet.MsgId - case *cmpp.Cmpp3DeliverReqPkt: - return packet.MsgId - default: - return 0 - } -} - -func downstreamAckKey(conn *cmpp.Conn, sequenceID uint32) string { - return fmt.Sprintf("%p:%d", conn, sequenceID) -} - -func registerDownstreamAck(session *downstreamSession, deliveryID string, sequenceID uint32, messageID uint64, deadline time.Time) *downstreamAckTracker { - if session == nil || session.conn == nil || strings.TrimSpace(deliveryID) == "" { - return nil - } - tracker := &downstreamAckTracker{ - deliveryID: deliveryID, connectionID: session.connectionID, - sequenceID: sequenceID, messageID: messageID, session: session, - } - key := downstreamAckKey(session.conn, sequenceID) - downstreamAckRegistry.Lock() - downstreamAckRegistry.items[key] = tracker - downstreamAckRegistry.Unlock() - tracker.timer = time.AfterFunc(time.Until(deadline), func() { - timedOut := takeDownstreamAck(session.conn, sequenceID) - if timedOut == nil || timedOut.session == nil || timedOut.session.deliveryReport == nil { - return - } - timedOut.session.deliveryReport(downstreamDeliveryLifecycleEvent{ - Kind: "failed", DeliveryID: timedOut.deliveryID, ConnectionID: timedOut.connectionID, - SequenceID: timedOut.sequenceID, MessageID: timedOut.messageID, ObservedAt: time.Now().UTC(), - FailureType: "ack_timeout", ErrorMessage: "CMPP_DELIVER_RESP timeout", - }) - }) - return tracker -} - -func takeDownstreamAck(conn *cmpp.Conn, sequenceID uint32) *downstreamAckTracker { - key := downstreamAckKey(conn, sequenceID) - downstreamAckRegistry.Lock() - tracker := downstreamAckRegistry.items[key] - delete(downstreamAckRegistry.items, key) - downstreamAckRegistry.Unlock() - if tracker != nil && tracker.timer != nil { - tracker.timer.Stop() - } - return tracker -} - -func removeDownstreamAck(tracker *downstreamAckTracker) { - if tracker == nil || tracker.session == nil { - return - } - _ = takeDownstreamAck(tracker.session.conn, tracker.sequenceID) -} - -func handleDownstreamAcknowledgement(conn *cmpp.Conn, sequenceID uint32, messageID uint64, result uint32, logger *log.Logger) { - tracker := takeDownstreamAck(conn, sequenceID) - if tracker == nil { - if logger != nil { - logger.Printf("cmpp inbound event=deliver_ack_unmatched seq=%d message_id=%d result=%d", sequenceID, messageID, result) - } - if session := findSessionByConn(conn); session != nil && session.protocolLog != nil { - session.protocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "client_to_platform", - EventType: "deliver_resp", - Status: "failed", - TenantID: session.tenantID, - ApplicationID: session.applicationID, - Account: session.account, - MessageID: session.messageID, - GatewayMessageID: strconv.FormatUint(messageID, 10), - Phone: session.phoneNumber, - ResultCode: strconv.FormatUint(uint64(result), 10), - Detail: map[string]any{"sequenceId": sequenceID, "unmatched": true}, - }) - } - return - } - if tracker.messageID != messageID { - if logger != nil { - logger.Printf("cmpp inbound event=deliver_ack_message_mismatch delivery_id=%s seq=%d expected_message_id=%d actual_message_id=%d", tracker.deliveryID, sequenceID, tracker.messageID, messageID) - } - result = 1 - } - if logger != nil { - logger.Printf("cmpp inbound event=deliver_acknowledged delivery_id=%s connection_id=%s seq=%d message_id=%d result=%d", tracker.deliveryID, tracker.connectionID, sequenceID, messageID, result) - } - if tracker.session != nil && tracker.session.deliveryReport != nil { - go tracker.session.deliveryReport(downstreamDeliveryLifecycleEvent{ - Kind: "acknowledged", DeliveryID: tracker.deliveryID, ConnectionID: tracker.connectionID, - SequenceID: sequenceID, MessageID: messageID, Result: result, ObservedAt: time.Now().UTC(), - }) - } - if tracker.session != nil && tracker.session.protocolLog != nil { - status := "success" - if result != 0 { - status = "failed" - } - tracker.session.protocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "client_to_platform", - EventType: "deliver_resp", - Status: status, - TenantID: tracker.session.tenantID, - ApplicationID: tracker.session.applicationID, - Account: tracker.session.account, - MessageID: tracker.session.messageID, - GatewayMessageID: strconv.FormatUint(messageID, 10), - Phone: tracker.session.phoneNumber, - ResultCode: strconv.FormatUint(uint64(result), 10), - Detail: map[string]any{"sequenceId": sequenceID, "deliveryId": tracker.deliveryID}, - }) - } -} - -func downstreamAckTimeout() time.Duration { - configured, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS"))) - if err != nil || configured <= 0 { - return defaultDownstreamAckTimeout - } - if configured < 5 { - configured = 5 - } - return time.Duration(configured) * time.Second -} - -func (s Server) pendingFlushInterval() time.Duration { - if s.PendingFlushInterval > 0 { - return s.PendingFlushInterval - } - return time.Minute -} - -func (s Server) logRecoveryCandidates(logger *log.Logger) { - candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore) - if err != nil { - logger.Printf("cmpp inbound recovery candidates load failed err=%v", err) - return - } - if len(candidates) == 0 { - logger.Printf("cmpp inbound recovery candidates loaded count=0") - return - } - accounts := make([]string, 0, len(candidates)) - for _, item := range candidates { - if strings.TrimSpace(item.Account) != "" { - accounts = append(accounts, item.Account) - } - } - logger.Printf("cmpp inbound recovery candidates loaded count=%d accounts=%s", len(candidates), strings.Join(accounts, ",")) -} - -func cmppReceiptStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case "delivered": - return "DELIVRD" - case "unknown": - return "UNKNOWN" - default: - return "UNDELIV" - } -} - -func defaultString(value string, fallback string) string { - if value == "" { - return fallback - } - return value -} - -func formatRFC3339Nano(value time.Time) string { - if value.IsZero() { - return "" - } - return value.UTC().Format(time.RFC3339Nano) -} - -func (s Server) gatewayInstanceID() string { - if strings.TrimSpace(s.GatewayInstanceID) != "" { - return strings.TrimSpace(s.GatewayInstanceID) - } - return "gateway-1" -} - -func (session downstreamSession) touchPresence(state string, includeSubmit bool, includeDeliver bool) { - if session.presence == nil || strings.TrimSpace(session.account) == "" { - return - } - now := time.Now().UTC() - snapshot := DownstreamPresence{ - Account: strings.TrimSpace(session.account), - SrcID: strings.TrimSpace(session.srcID), - RemoteIP: strings.TrimSpace(session.remoteIP), - GatewayInstanceID: strings.TrimSpace(session.instanceID), - State: defaultString(strings.TrimSpace(state), "connected"), - ConnectedAt: session.connectedAt, - UpdatedAt: now, - } - if includeSubmit { - snapshot.LastSubmitAt = now - } - if includeDeliver { - snapshot.LastDeliverAt = now - } - _ = session.presence.TouchAccount(context.Background(), snapshot) -} - -func (session downstreamSession) removePresence() error { - if session.presence == nil || strings.TrimSpace(session.account) == "" { - return nil - } - return session.presence.RemoveAccount(context.Background(), strings.TrimSpace(session.account)) -} diff --git a/gateway/internal/inbound/sessions.go b/gateway/internal/inbound/sessions.go new file mode 100644 index 0000000..0f93d7d --- /dev/null +++ b/gateway/internal/inbound/sessions.go @@ -0,0 +1,262 @@ +package inbound + +import ( + "context" + cmpp "github.com/bigwhite/gocmpp" + "log" + "strings" + "sync" + "time" +) + +// This registry is the single in-process owner of authenticated downstream +// sessions. Delivery and recovery code must resolve sessions through it. + +type downstreamConnectionEvent struct { + Account string `json:"account"` + ConnectionID string `json:"connectionId"` + Status string `json:"status"` + RemoteIP string `json:"remoteIp,omitempty"` + Protocol string `json:"protocol,omitempty"` + ConnectedAt string `json:"connectedAt,omitempty"` + ObservedAt string `json:"observedAt,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type downstreamSession struct { + messageID string + account string + tenantID string + applicationID string + enterpriseCode string + protocol string + srcID string + phoneNumber string + gatewayMsgID uint64 + remoteIP string + connectedAt time.Time + connectionID string + conn *cmpp.Conn + mu *sync.Mutex + presence PresenceStore + instanceID string + report func(*downstreamSession, string, string) + deliveryReport func(downstreamDeliveryLifecycleEvent) + protocolLog func(protocolLogEvent) +} + +var downstreamRegistry = struct { + sync.RWMutex + byMessageID map[string]*downstreamSession + byAccount map[string]*downstreamSession + byConn map[*cmpp.Conn]*downstreamSession +}{ + byMessageID: make(map[string]*downstreamSession), + byAccount: make(map[string]*downstreamSession), + byConn: make(map[*cmpp.Conn]*downstreamSession), +} + +func (s Server) handleActivity(_ *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) { + session := findSessionByConn(packet.Conn) + if session == nil { + return true, nil + } + switch response := packet.Packer.(type) { + case *cmpp.CmppActiveTestReqPkt, *cmpp.CmppActiveTestRspPkt: + if session.report != nil { + go s.reportConnectionOrDisconnect(session, "heartbeat", "") + } + case *cmpp.Cmpp2DeliverRspPkt: + handleDownstreamAcknowledgement(packet.Conn, response.SeqId, response.MsgId, uint32(response.Result), logger) + case *cmpp.Cmpp3DeliverRspPkt: + handleDownstreamAcknowledgement(packet.Conn, response.SeqId, response.MsgId, response.Result, logger) + } + return true, nil +} + +func (s Server) reportConnection(session *downstreamSession, status string, errorMessage string) { + _ = s.reportConnectionChecked(session, status, errorMessage) +} + +func (s Server) reportConnectionChecked(session *downstreamSession, status string, errorMessage string) error { + if session == nil || strings.TrimSpace(session.account) == "" || strings.TrimSpace(session.connectionID) == "" { + return nil + } + event := downstreamConnectionEvent{ + Account: session.account, ConnectionID: session.connectionID, Status: status, + RemoteIP: session.remoteIP, Protocol: session.protocol, + ConnectedAt: formatRFC3339Nano(session.connectedAt), ObservedAt: formatRFC3339Nano(time.Now()), + ErrorMessage: errorMessage, + } + if err := s.post(context.Background(), "/gateway/events/inbound/connection", event, nil); err != nil { + log.Printf("cmpp inbound connection state callback failed account=%s connection_id=%s status=%s err=%v", session.account, session.connectionID, status, err) + return err + } + return nil +} + +func (s Server) reportConnectionOrDisconnect(session *downstreamSession, status string, errorMessage string) { + if err := s.reportConnectionChecked(session, status, errorMessage); err != nil && status != "disconnected" { + _ = s.reportConnectionChecked(session, "disconnected", err.Error()) + forgetDownstream(session) + if session.conn != nil { + session.conn.Close() + } + } +} + +func (s Server) handleConnectionClosed(conn *cmpp.Conn) { + downstreamSubmitBarrier.Lock() + delete(downstreamSubmitBarrier.byConn, conn) + downstreamSubmitBarrier.Unlock() + session := findSessionByConn(conn) + if session == nil { + return + } + forgetDownstream(session) + _ = s.reportConnectionChecked(session, "disconnected", "CMPP client connection closed") +} + +func findSessionByConn(conn *cmpp.Conn) *downstreamSession { + downstreamRegistry.RLock() + defer downstreamRegistry.RUnlock() + return downstreamRegistry.byConn[conn] +} + +func rememberDownstream(session downstreamSession) { + if session.messageID == "" || session.conn == nil { + return + } + session.touchPresence("connected", true, false) + downstreamRegistry.Lock() + if existing := downstreamRegistry.byMessageID[session.messageID]; existing != nil && existing.conn == session.conn { + // A downstream long message returns one SUBMIT_RESP per fragment but is + // persisted as one platform message. Keep the first fragment Msg_Id so + // online delivery and restart recovery (which persists the first + // Sequence_Id) address the same client-side message. + session.gatewayMsgID = existing.gatewayMsgID + } + downstreamRegistry.byMessageID[session.messageID] = &session + downstreamRegistry.byConn[session.conn] = &session + if session.account != "" { + downstreamRegistry.byAccount[session.account] = &session + } + downstreamRegistry.Unlock() +} + +func rememberAccount(session *downstreamSession, maxConnections int) bool { + if session == nil || session.account == "" || session.conn == nil { + return false + } + if maxConnections <= 0 { + maxConnections = 1 + } + session.touchPresence("connected", false, false) + downstreamRegistry.Lock() + defer downstreamRegistry.Unlock() + active := 0 + for _, current := range downstreamRegistry.byConn { + if current != nil && current.account == session.account { + active++ + } + } + if active >= maxConnections { + return false + } + downstreamRegistry.byAccount[session.account] = session + downstreamRegistry.byConn[session.conn] = session + return true +} + +func forgetDownstream(session *downstreamSession) { + if session == nil { + return + } + downstreamRegistry.Lock() + for messageID, current := range downstreamRegistry.byMessageID { + if current != nil && current.conn == session.conn { + delete(downstreamRegistry.byMessageID, messageID) + } + } + if session.account != "" { + if current := downstreamRegistry.byAccount[session.account]; current == session { + delete(downstreamRegistry.byAccount, session.account) + } + } + if current := downstreamRegistry.byConn[session.conn]; current == session { + delete(downstreamRegistry.byConn, session.conn) + } + downstreamRegistry.Unlock() + _ = session.removePresence() +} + +func onlineAccounts() []string { + downstreamRegistry.RLock() + defer downstreamRegistry.RUnlock() + accounts := make([]string, 0, len(downstreamRegistry.byAccount)) + for account := range downstreamRegistry.byAccount { + if strings.TrimSpace(account) != "" { + accounts = append(accounts, account) + } + } + return accounts +} + +// DisconnectAccount closes every live downstream CMPP session for an +// application account. The normal connection-close callback removes registry +// and presence state and reports the disconnect to the API. +func DisconnectAccount(account string) int { + account = strings.TrimSpace(account) + if account == "" { + return 0 + } + downstreamRegistry.RLock() + sessions := make([]*downstreamSession, 0) + for _, session := range downstreamRegistry.byConn { + if session != nil && session.account == account && session.conn != nil { + sessions = append(sessions, session) + } + } + downstreamRegistry.RUnlock() + for _, session := range sessions { + session.conn.Close() + } + return len(sessions) +} + +func (s Server) gatewayInstanceID() string { + if strings.TrimSpace(s.GatewayInstanceID) != "" { + return strings.TrimSpace(s.GatewayInstanceID) + } + return "gateway-1" +} + +func (session downstreamSession) touchPresence(state string, includeSubmit bool, includeDeliver bool) { + if session.presence == nil || strings.TrimSpace(session.account) == "" { + return + } + now := time.Now().UTC() + snapshot := DownstreamPresence{ + Account: strings.TrimSpace(session.account), + SrcID: strings.TrimSpace(session.srcID), + RemoteIP: strings.TrimSpace(session.remoteIP), + GatewayInstanceID: strings.TrimSpace(session.instanceID), + State: defaultString(strings.TrimSpace(state), "connected"), + ConnectedAt: session.connectedAt, + UpdatedAt: now, + } + if includeSubmit { + snapshot.LastSubmitAt = now + } + if includeDeliver { + snapshot.LastDeliverAt = now + } + _ = session.presence.TouchAccount(context.Background(), snapshot) +} + +func (session downstreamSession) removePresence() error { + if session.presence == nil || strings.TrimSpace(session.account) == "" { + return nil + } + return session.presence.RemoveAccount(context.Background(), strings.TrimSpace(session.account)) +} diff --git a/gateway/internal/inbound/submit.go b/gateway/internal/inbound/submit.go new file mode 100644 index 0000000..b1545f0 --- /dev/null +++ b/gateway/internal/inbound/submit.go @@ -0,0 +1,333 @@ +package inbound + +import ( + "context" + "crypto/md5" + "errors" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + cmpputils "github.com/bigwhite/gocmpp/utils" + "log" + "net" + "strings" + "sync" + "time" +) + +// One client Submit may contain multiple destinations. The API result retains +// one internal message mapping per destination while CMPP receives one response. + +type submitRequest struct { + Account string `json:"account"` + PhoneNumber string `json:"phoneNumber,omitempty"` + PhoneNumbers []string `json:"phoneNumbers,omitempty"` + Content string `json:"content"` + SrcID string `json:"srcId,omitempty"` + DestID string `json:"destId,omitempty"` + SequenceID uint32 `json:"sequenceId,omitempty"` + RemoteIP string `json:"remoteIp,omitempty"` + LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"` +} + +type inboundLongMessageFragment struct { + Reference int `json:"reference"` + Total int `json:"total"` + Index int `json:"index"` + Format int `json:"format"` +} + +type submitResponseMessage struct { + PhoneNumber string `json:"phoneNumber"` + MessageID string `json:"messageId"` +} + +type submitResponse struct { + Accepted bool `json:"accepted"` + Result uint32 `json:"result,omitempty"` + TenantID string `json:"tenantId,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + MessageID string `json:"messageId"` + Messages []submitResponseMessage `json:"messages,omitempty"` +} + +func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) { + req, ok := normalizeInboundSubmit(packet.Packer) + if !ok { + return true, nil + } + session := findSessionByConn(packet.Conn) + if session == nil || strings.TrimSpace(session.account) == "" { + logger.Printf( + "cmpp inbound event=submit_rejected protocol=%s packet_type=%s remote=%s seq=%d result=9 stage=session reason=%q", + req.protocol, req.protocol, packet.Conn.Conn.RemoteAddr(), req.sequenceID, "authenticated connection session not found", + ) + setInboundSubmitResponse(response.Packer, 0, 9) + response.AfterSend = s.submitResponseProtocolLogger("", req.protocol, req.sequenceID, "", "", 0, 9) + return false, nil + } + account := session.account + enterpriseCode := strings.TrimRight(req.msgSrc, "\x00") + if session.enterpriseCode != "" && enterpriseCode != session.enterpriseCode { + logger.Printf( + "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d result=9 stage=protocol reason=%q", + defaultString(session.protocol, req.protocol), req.protocol, account, enterpriseCode, packet.Conn.Conn.RemoteAddr(), req.sequenceID, + fmt.Sprintf("enterprise code mismatch: expected %s", session.enterpriseCode), + ) + setInboundSubmitResponse(response.Packer, 0, 9) + response.AfterSend = s.submitResponseProtocolLogger(account, defaultString(session.protocol, req.protocol), req.sequenceID, "", "", 0, 9) + return false, nil + } + phones := make([]string, len(req.destTerminalIDs)) + for index, destination := range req.destTerminalIDs { + phones[index] = strings.TrimSpace(strings.TrimRight(destination, "\x00")) + } + phone := "" + if len(phones) > 0 { + phone = phones[0] + } + remote := packet.Conn.Conn.RemoteAddr() + clientProtocol := defaultString(session.protocol, req.protocol) + logger.Printf( + "cmpp inbound event=submit_received protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d phone=%s src_id=%s msg_fmt=%d pk=%d/%d dest_count=%d content_bytes=%d", + clientProtocol, req.protocol, account, enterpriseCode, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt, + req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent), + ) + content, longMessage, err := decodeInboundSubmitContent(req) + if err != nil { + logger.Printf( + "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=decode reason=%q", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, err, + ) + setInboundSubmitResponse(response.Packer, 0, 9) + response.AfterSend = s.submitResponseProtocolLogger(account, clientProtocol, req.sequenceID, phone, "", 0, 9) + return false, nil + } + contentHash := fmt.Sprintf("%x", md5.Sum([]byte(content))) + startedAt := time.Now() + releaseSubmitBarrier := beginDownstreamSubmitBarrier(packet.Conn) + result, err := s.submit(remote, submitRequest{ + Account: account, + PhoneNumber: phone, + PhoneNumbers: phones, + Content: content, + SrcID: req.srcID, + DestID: phone, + SequenceID: req.sequenceID, + RemoteIP: remoteIP(remote), + LongMessage: longMessage, + }) + if err != nil || !result.Accepted { + reason := "api returned accepted=false" + if err != nil { + reason = err.Error() + } + responseResult := result.Result + if responseResult == 0 { + responseResult = 9 + } + logger.Printf( + "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=%d stage=business duration_ms=%d content_chars=%d content_hash=%s reason=%q", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, responseResult, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, reason, + ) + setInboundSubmitResponse(response.Packer, 0, responseResult) + protocolLogger := s.submitResponseProtocolLogger(account, clientProtocol, req.sequenceID, phone, result.MessageID, 0, responseResult) + response.AfterSend = func(sendErr error) { + releaseSubmitBarrier() + protocolLogger(sendErr) + } + return false, nil + } + gatewayMsgID := messageIDFrom(result.MessageID, req.sequenceID) + setInboundSubmitResponse(response.Packer, gatewayMsgID, 0) + responseMessages := result.Messages + if len(responseMessages) == 0 { + responseMessages = []submitResponseMessage{{PhoneNumber: phone, MessageID: result.MessageID}} + } + for index, acceptedMessage := range responseMessages { + acceptedPhone := strings.TrimSpace(acceptedMessage.PhoneNumber) + if acceptedPhone == "" && index < len(phones) { + acceptedPhone = phones[index] + } + rememberDownstream(downstreamSession{ + messageID: acceptedMessage.MessageID, + account: account, + tenantID: result.TenantID, + applicationID: result.ApplicationID, + enterpriseCode: session.enterpriseCode, + protocol: clientProtocol, + srcID: strings.TrimSpace(req.srcID), + phoneNumber: acceptedPhone, + gatewayMsgID: gatewayMsgID, + remoteIP: remoteIP(remote), + connectedAt: time.Now().UTC(), + connectionID: session.connectionID, + conn: packet.Conn, + mu: &sync.Mutex{}, + presence: s.PresenceStore, + instanceID: s.gatewayInstanceID(), + report: session.report, + deliveryReport: session.deliveryReport, + protocolLog: session.protocolLog, + }) + } + if current := findSessionByConn(packet.Conn); current != nil && current.report != nil { + go current.report(current, "submit", "") + } + response.AfterSend = func(sendErr error) { + releaseSubmitBarrier() + s.emitProtocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "platform_to_client", + EventType: "submit_resp", + Status: protocolSendStatus(sendErr), + TenantID: result.TenantID, + ApplicationID: result.ApplicationID, + Account: account, + MessageID: result.MessageID, + GatewayMessageID: fmt.Sprint(gatewayMsgID), + Phone: phone, + ResultCode: protocolSendResultCode(sendErr, 0), + Detail: protocolSubmitResponseDetail(req.sequenceID, sendErr), + }) + if sendErr != nil { + return + } + go func() { + if _, err := s.flushPending(account, logger); err != nil { + logger.Printf("cmpp inbound event=post_submit_pending_flush_failed account=%s message_id=%s error=%q", account, result.MessageID, err.Error()) + } + }() + } + logger.Printf( + "cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s dest_count=%d accepted_count=%d result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, len(phones), len(responseMessages), result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, + ) + return false, nil +} + +type inboundSubmitPacket struct { + protocol string + pkTotal uint8 + pkNumber uint8 + tpUdhi uint8 + msgFmt uint8 + msgSrc string + srcID string + destTerminalIDs []string + msgContent string + sequenceID uint32 +} + +func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) { + switch req := packet.(type) { + case *cmpp.Cmpp2SubmitReqPkt: + return inboundSubmitPacket{ + protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt, + msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, + msgContent: req.MsgContent, sequenceID: req.SeqId, + }, true + case *cmpp.Cmpp3SubmitReqPkt: + return inboundSubmitPacket{ + protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt, + msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, + msgContent: req.MsgContent, sequenceID: req.SeqId, + }, true + default: + return inboundSubmitPacket{}, false + } +} + +func setInboundSubmitResponse(packet any, messageID uint64, result uint32) { + switch resp := packet.(type) { + case *cmpp.Cmpp2SubmitRspPkt: + resp.MsgId = messageID + resp.Result = uint8(result) + case *cmpp.Cmpp3SubmitRspPkt: + resp.MsgId = messageID + resp.Result = result + } +} + +func (s Server) submit(remote net.Addr, payload submitRequest) (submitResponse, error) { + payload.RemoteIP = remoteIP(remote) + var result submitResponse + err := s.post(context.Background(), "/gateway/events/inbound/submit", payload, &result) + return result, err +} + +func decodeContent(format uint8, content string) (string, error) { + switch format { + case 8: + return cmpputils.Ucs2ToUtf8(content) + case 15: + return cmpputils.GB18030ToUtf8(content) + default: + return content, nil + } +} + +func decodeInboundSubmitContent(req inboundSubmitPacket) (string, *inboundLongMessageFragment, error) { + raw := []byte(req.msgContent) + if req.tpUdhi == 0 && req.pkTotal <= 1 { + content, err := decodeContent(req.msgFmt, req.msgContent) + return content, nil, err + } + if len(raw) == 0 { + return "", nil, errors.New("UDH message content is empty") + } + + headerLength := int(raw[0]) + 1 + if headerLength > len(raw) { + return "", nil, fmt.Errorf("UDH length %d exceeds message content length %d", headerLength, len(raw)) + } + + var reference, total, index int + switch { + case len(raw) >= 6 && raw[0] == 0x05 && raw[1] == 0x00 && raw[2] == 0x03: + reference = int(raw[3]) + total = int(raw[4]) + index = int(raw[5]) + case len(raw) >= 7 && raw[0] == 0x06 && raw[1] == 0x08 && raw[2] == 0x04: + reference = int(raw[3])<<8 | int(raw[4]) + total = int(raw[5]) + index = int(raw[6]) + default: + if req.pkTotal > 1 { + return "", nil, errors.New("concatenated CMPP submit is missing a supported 8-bit or 16-bit UDH") + } + content, err := decodeContent(req.msgFmt, string(raw[headerLength:])) + return content, nil, err + } + if total < 2 || index < 1 || index > total { + return "", nil, fmt.Errorf("invalid concatenated UDH total/index %d/%d", index, total) + } + if req.pkTotal > 0 && int(req.pkTotal) != total { + return "", nil, fmt.Errorf("PkTotal %d does not match UDH total %d", req.pkTotal, total) + } + if req.pkNumber > 0 && int(req.pkNumber) != index { + return "", nil, fmt.Errorf("PkNumber %d does not match UDH index %d", req.pkNumber, index) + } + + content, err := decodeContent(req.msgFmt, string(raw[headerLength:])) + if err != nil { + return "", nil, err + } + return content, &inboundLongMessageFragment{ + Reference: reference, + Total: total, + Index: index, + Format: int(req.msgFmt), + }, nil +} + +func messageIDFrom(value string, seq uint32) uint64 { + hash := md5.Sum([]byte(value)) + result := uint64(seq) + for _, item := range hash[:6] { + result = (result << 8) + uint64(item) + } + if result == 0 { + return uint64(time.Now().UnixNano()) + } + return result +} diff --git a/gateway/internal/inbound/transport.go b/gateway/internal/inbound/transport.go new file mode 100644 index 0000000..1c3d786 --- /dev/null +++ b/gateway/internal/inbound/transport.go @@ -0,0 +1,84 @@ +package inbound + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +func (s Server) post(ctx context.Context, path string, payload any, result any) error { + client := s.HTTPClient + if client == nil { + client = &http.Client{Timeout: defaultHTTPTimeout} + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(apiBaseURL(s.APIBaseURL), "/")+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return fmt.Errorf("read api response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + detail := strings.TrimSpace(string(responseBody)) + if detail == "" { + return fmt.Errorf("api returned %s", resp.Status) + } + return fmt.Errorf("api returned %s: %s", resp.Status, detail) + } + if result != nil { + if len(responseBody) == 0 { + return io.EOF + } + return json.Unmarshal(responseBody, result) + } + return nil +} + +func apiBaseURL(value string) string { + if value == "" { + return "http://127.0.0.1:3000/api" + } + return value +} + +func remoteIP(addr net.Addr) string { + if tcp, ok := addr.(*net.TCPAddr); ok { + return tcp.IP.String() + } + host, _, err := net.SplitHostPort(addr.String()) + if err == nil { + return host + } + return addr.String() +} + +func defaultString(value string, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func formatRFC3339Nano(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} diff --git a/gateway/internal/upstream/connection.go b/gateway/internal/upstream/connection.go new file mode 100644 index 0000000..8e21bf1 --- /dev/null +++ b/gateway/internal/upstream/connection.go @@ -0,0 +1,202 @@ +package upstream + +import ( + "cmpp-platform/gateway/internal/queue" + "context" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + "log" + "net/http" + "strings" + "sync" + "time" +) + +// The reader and heartbeat goroutines share one connection lifecycle. Closing +// the connection must wake pending submitters before scheduling pool recovery. + +type connection struct { + channelID string + config queue.UpstreamConfig + index int + pool *connectionPool + apiBaseURL string + httpClient *http.Client + + mu sync.Mutex + sendMu sync.Mutex + client *cmpp.Client + window chan struct{} + pending map[uint32]chan submitPartResponse + tracker map[uint64]queue.SubmitCommand + longUplink map[string]*longUplinkAssembly + readOnce sync.Once + closed bool + heartbeatCancel context.CancelFunc + heartbeatPending map[uint32]time.Time +} + +type submitPartResponse struct { + seqID uint32 + msgID uint64 + result uint32 + err error +} + +func (c *connection) matches(config queue.UpstreamConfig) bool { + return c.config == normalizeUpstreamConfig(config) +} + +func (c *connection) ensureConnected() (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil && !c.closed { + return false, nil + } + client := cmpp.NewClient(protocolVersion(c.config.CMPPVersion)) + addr := fmt.Sprintf("%s:%d", c.config.GatewayHost, c.config.GatewayPort) + if err := client.Connect(addr, c.config.Account, c.config.PasswordCipher, defaultConnectTimeout); err != nil { + client.Disconnect() + return false, err + } + c.client = client + c.closed = false + c.heartbeatPending = make(map[uint32]time.Time) + heartbeatCtx, cancelHeartbeat := context.WithCancel(context.Background()) + c.heartbeatCancel = cancelHeartbeat + go c.readLoop() + go c.heartbeatLoop(heartbeatCtx) + return true, nil +} + +func (c *connection) readLoop() { + for { + c.mu.Lock() + client := c.client + closed := c.closed + c.mu.Unlock() + if closed || client == nil { + return + } + pkt, err := client.RecvAndUnpackPkt(time.Second) + if err != nil { + c.mu.Lock() + closed = c.closed + c.mu.Unlock() + if closed { + return + } + if isTemporaryReadTimeout(err) { + continue + } + c.handleConnectionLoss(err) + return + } + switch p := pkt.(type) { + case *cmpp.Cmpp2SubmitRspPkt: + c.mu.Lock() + ch := c.pending[p.SeqId] + c.mu.Unlock() + if ch != nil { + ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: uint32(p.Result)} + } + case *cmpp.Cmpp3SubmitRspPkt: + c.mu.Lock() + ch := c.pending[p.SeqId] + c.mu.Unlock() + if ch != nil { + ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: p.Result} + } + case *cmpp.Cmpp2DeliverReqPkt: + deliver := deliverPacketFromCMPP2(p) + if deliver.registerDelivery == 1 { + if err := c.handleDeliver(deliver); err != nil { + log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=intake_failed channel_id=%s sequence_id=%d error=%q", c.channelID, deliver.seqID, err) + c.handleConnectionLoss(fmt.Errorf("persist upstream receipt before DELIVER_RESP: %w", err)) + return + } + responseErr := c.sendResponse(client, &cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) + c.emitDeliverResponse(deliver, responseErr) + } else { + responseErr := c.sendResponse(client, &cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) + c.emitDeliverResponse(deliver, responseErr) + _ = c.handleDeliver(deliver) + } + case *cmpp.Cmpp3DeliverReqPkt: + deliver := deliverPacketFromCMPP3(p) + if deliver.registerDelivery == 1 { + if err := c.handleDeliver(deliver); err != nil { + log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=intake_failed channel_id=%s sequence_id=%d error=%q", c.channelID, deliver.seqID, err) + c.handleConnectionLoss(fmt.Errorf("persist upstream receipt before DELIVER_RESP: %w", err)) + return + } + responseErr := c.sendResponse(client, &cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) + c.emitDeliverResponse(deliver, responseErr) + } else { + responseErr := c.sendResponse(client, &cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) + c.emitDeliverResponse(deliver, responseErr) + _ = c.handleDeliver(deliver) + } + case *cmpp.CmppActiveTestReqPkt: + _ = c.sendResponse(client, &cmpp.CmppActiveTestRspPkt{}, p.SeqId) + _ = c.pool.reportState(context.Background(), "heartbeat", nil) + case *cmpp.CmppActiveTestRspPkt: + c.handleHeartbeatResponse(p.SeqId) + _ = c.pool.reportState(context.Background(), "heartbeat", nil) + } + } +} + +func (c *connection) sendResponse(client *cmpp.Client, packet cmpp.Packer, sequenceID uint32) error { + c.sendMu.Lock() + defer c.sendMu.Unlock() + return client.SendRspPkt(packet, sequenceID) +} + +func (c *connection) identity() string { + if c.pool != nil && strings.TrimSpace(c.pool.connectionID) != "" { + return fmt.Sprintf("%s-%d", c.pool.connectionID, c.index) + } + return fmt.Sprintf("%s-%d", c.channelID, c.index) +} + +func (c *connection) close() { + c.handleConnectionLoss(fmt.Errorf("connection closed")) +} + +func (c *connection) handleConnectionLoss(err error) { + c.mu.Lock() + if c.closed && c.client == nil { + c.mu.Unlock() + return + } + c.closed = true + if c.heartbeatCancel != nil { + c.heartbeatCancel() + c.heartbeatCancel = nil + } + pending := c.pending + c.pending = make(map[uint32]chan submitPartResponse) + c.heartbeatPending = make(map[uint32]time.Time) + if c.client != nil { + c.client.Disconnect() + c.client = nil + } + c.mu.Unlock() + + for _, ch := range pending { + select { + case ch <- submitPartResponse{err: err}: + default: + } + } + if c.pool != nil { + status := "disconnected" + if c.pool.countActiveConnections() > 0 { + status = "reconnecting" + } + c.pool.scheduleReconnect(err) + _ = c.pool.reportState(context.Background(), status, err) + } +} diff --git a/gateway/internal/upstream/deliver.go b/gateway/internal/upstream/deliver.go new file mode 100644 index 0000000..9fbd573 --- /dev/null +++ b/gateway/internal/upstream/deliver.go @@ -0,0 +1,180 @@ +package upstream + +import ( + "cmpp-platform/gateway/internal/queue" + "context" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + cmpputils "github.com/bigwhite/gocmpp/utils" + "log" + "strings" + "time" +) + +// Receipt packets are correlated through the per-connection Submit tracker; +// mobile-originated content uses a separate long-message assembly path. + +type deliverPacket struct { + seqID uint32 + msgID uint64 + destID string + tpUdhi uint8 + msgFmt uint8 + srcTerminalID string + registerDelivery uint8 + msgContent string +} + +func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket { + return deliverPacket{ + seqID: pkt.SeqId, + msgID: pkt.MsgId, + destID: pkt.DestId, + tpUdhi: pkt.TpUdhi, + msgFmt: pkt.MsgFmt, + srcTerminalID: pkt.SrcTerminalId, + registerDelivery: pkt.RegisterDelivery, + msgContent: pkt.MsgContent, + } +} + +func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket { + return deliverPacket{ + seqID: pkt.SeqId, + msgID: pkt.MsgId, + destID: pkt.DestId, + tpUdhi: pkt.TpUdhi, + msgFmt: pkt.MsgFmt, + srcTerminalID: pkt.SrcTerminalId, + registerDelivery: pkt.RegisterDelivery, + msgContent: pkt.MsgContent, + } +} + +func (c *connection) handleDeliver(pkt deliverPacket) error { + if pkt.registerDelivery == 1 { + var receipt cmpp.CmppReceiptPkt + if err := receipt.Unpack([]byte(pkt.msgContent)); err != nil { + log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=parse_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err) + return err + } + log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=received channel_id=%s sequence_id=%d gateway_message_id=%d raw_status=%s", c.channelID, pkt.seqID, receipt.MsgId, strings.TrimSpace(receipt.Stat)) + cmd, ok := c.commandFor(receipt.MsgId) + if !ok { + cmd, ok = c.commandFor(pkt.msgID) + } + traceID := fmt.Sprintf("receipt-%d", receipt.MsgId) + messageID := fmt.Sprintf("receipt-%d", receipt.MsgId) + channelID := c.channelID + if ok { + traceID = cmd.TraceID + messageID = cmd.MessageID + channelID = cmd.ChannelID + } + event := queue.ReceiptEvent{ + Envelope: queue.Envelope{ + SchemaVersion: queue.SchemaVersion, + MessageType: queue.MessageTypeReceiptEvent, + TraceID: traceID, + MessageID: messageID, + ChannelID: channelID, + CreatedAt: time.Now().UTC(), + }, + SequenceID: pkt.seqID, + GatewayMessageID: fmt.Sprint(receipt.MsgId), + PhoneNumber: strings.TrimSpace(receipt.DestTerminalId), + ReceiptStatus: receiptStatus(receipt.Stat), + RawStatus: strings.TrimSpace(receipt.Stat), + DeliveredAt: time.Now().UTC(), + ConnectionID: c.identity(), + } + if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/receipt/intake", event); err != nil { + log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=forward_failed channel_id=%s sequence_id=%d gateway_message_id=%d error=%q", c.channelID, pkt.seqID, receipt.MsgId, err) + return err + } else { + log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=forwarded channel_id=%s sequence_id=%d gateway_message_id=%d", c.channelID, pkt.seqID, receipt.MsgId) + } + return nil + } + + content, complete, err := c.decodeUplinkContent(pkt) + if err != nil { + log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_uplink status=decode_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err) + return err + } + if !complete { + return nil + } + cmd, _ := c.commandFor(pkt.msgID) + event := queue.UplinkEvent{ + Envelope: queue.Envelope{ + SchemaVersion: queue.SchemaVersion, + MessageType: queue.MessageTypeUplinkEvent, + TraceID: cmd.TraceID, + MessageID: cmd.MessageID, + ChannelID: c.channelID, + CreatedAt: time.Now().UTC(), + }, + SequenceID: pkt.seqID, + PhoneNumber: strings.TrimSpace(pkt.srcTerminalID), + DestID: strings.TrimSpace(pkt.destID), + Content: content, + ReceivedAt: time.Now().UTC(), + } + if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/uplink", event); err != nil { + log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forward_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err) + } else { + log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forwarded channel_id=%s sequence_id=%d packet_msg_id=%d", c.channelID, pkt.seqID, pkt.msgID) + } + return nil +} + +func (c *connection) decodeUplinkContent(pkt deliverPacket) (string, bool, error) { + if pkt.tpUdhi != 1 { + content, err := decodeContent(pkt.msgFmt, pkt.msgContent) + return content, true, err + } + ref, total, number, payload, ok := parseConcatSegment(pkt.msgContent) + if !ok { + content, err := decodeContent(pkt.msgFmt, pkt.msgContent) + return content, true, err + } + key := fmt.Sprintf("%s:%s:%s:%d:%d", c.channelID, strings.TrimSpace(pkt.srcTerminalID), strings.TrimSpace(pkt.destID), ref, total) + c.mu.Lock() + if c.longUplink == nil { + c.longUplink = make(map[string]*longUplinkAssembly) + } + pruneLongUplinkAssemblies(c.longUplink, time.Now(), 10*time.Minute) + content, complete, err := assembleLongUplink(c.longUplink, key, pkt.msgFmt, total, number, payload) + c.mu.Unlock() + return content, complete, err +} + +func (c *connection) commandFor(gatewayMsgID uint64) (queue.SubmitCommand, bool) { + c.mu.Lock() + defer c.mu.Unlock() + cmd, ok := c.tracker[gatewayMsgID] + return cmd, ok +} + +func decodeContent(format uint8, content string) (string, error) { + switch format { + case 8: + return cmpputils.Ucs2ToUtf8(content) + case 15: + return cmpputils.GB18030ToUtf8(content) + default: + return content, nil + } +} + +func receiptStatus(stat string) string { + switch strings.ToUpper(strings.TrimSpace(stat)) { + case "DELIVRD": + return "delivered" + case "": + return "unknown" + default: + return "undelivered" + } +} diff --git a/gateway/internal/upstream/flow_control.go b/gateway/internal/upstream/flow_control.go new file mode 100644 index 0000000..e4a40bf --- /dev/null +++ b/gateway/internal/upstream/flow_control.go @@ -0,0 +1,138 @@ +package upstream + +import ( + "context" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + "time" +) + +// A window token belongs to one physical connection and must be released only +// after its Submit attempt completes, preserving per-connection CMPP flow control. + +func (p *connectionPool) acquireConnection(ctx context.Context) (*connection, func(), error) { + waitCtx, cancel := context.WithTimeout(ctx, defaultSubmitTimeout) + defer cancel() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + for { + if conn, release := p.tryAcquireConnection(); conn != nil { + if connected, err := conn.ensureConnected(); err != nil { + release() + select { + case <-waitCtx.Done(): + return nil, nil, waitCtx.Err() + case <-ticker.C: + continue + } + } else if connected { + _ = p.reportState(context.Background(), "connected", nil) + } + return conn, release, nil + } + select { + case <-waitCtx.Done(): + return nil, nil, waitCtx.Err() + case <-ticker.C: + } + } +} + +func (p *connectionPool) tryAcquireConnection() (*connection, func()) { + p.mu.Lock() + defer p.mu.Unlock() + if len(p.conns) == 0 { + return nil, nil + } + for i := 0; i < len(p.conns); i++ { + index := (p.next + i) % len(p.conns) + conn := p.conns[index] + if conn.tryAcquireWindow() { + p.next = (index + 1) % len(p.conns) + return conn, conn.releaseWindow + } + } + return nil, nil +} + +func (c *connection) tryAcquireWindow() bool { + if c.window == nil { + c.window = make(chan struct{}, defaultWindowSize) + } + select { + case c.window <- struct{}{}: + return true + default: + return false + } +} + +func (c *connection) releaseWindow() { + if c.window == nil { + return + } + select { + case <-c.window: + default: + } +} + +func (c *connection) heartbeatLoop(ctx context.Context) { + interval := time.Duration(c.config.HeartbeatIntervalSeconds) * time.Second + if interval <= 0 { + interval = defaultHeartbeatInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !c.sendHeartbeat() { + return + } + } + } +} + +func (c *connection) sendHeartbeat() bool { + threshold := c.config.HeartbeatMissThreshold + if threshold <= 0 { + threshold = defaultHeartbeatMissThreshold + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return false + } + if len(c.heartbeatPending) >= threshold { + c.mu.Unlock() + c.handleConnectionLoss(fmt.Errorf("heartbeat timeout after %d unanswered ACTIVE_TEST requests", threshold)) + return false + } + if c.client == nil { + c.mu.Unlock() + return false + } + c.sendMu.Lock() + seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{}) + c.sendMu.Unlock() + if err != nil { + c.mu.Unlock() + c.handleConnectionLoss(fmt.Errorf("send ACTIVE_TEST: %w", err)) + return false + } + if !c.closed { + c.heartbeatPending[seq] = time.Now().UTC() + } + c.mu.Unlock() + return true +} + +func (c *connection) handleHeartbeatResponse(sequenceID uint32) { + c.mu.Lock() + delete(c.heartbeatPending, sequenceID) + c.mu.Unlock() +} diff --git a/gateway/internal/upstream/manager.go b/gateway/internal/upstream/manager.go index 9f79dc3..3420aae 100644 --- a/gateway/internal/upstream/manager.go +++ b/gateway/internal/upstream/manager.go @@ -1,22 +1,12 @@ package upstream import ( - "bytes" + "cmpp-platform/gateway/internal/queue" "context" - "encoding/json" - "errors" "fmt" - "log" - "net" "net/http" - "strings" "sync" "time" - - "cmpp-platform/gateway/internal/queue" - - cmpp "github.com/bigwhite/gocmpp" - cmpputils "github.com/bigwhite/gocmpp/utils" ) const ( @@ -55,56 +45,6 @@ type ConnectionState struct { LastError string `json:"lastError,omitempty"` } -func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) { - if err := validateSubmitCommand(cmd); err != nil { - result := submitResult(cmd, 0, "", "rejected", "INVALID_COMMAND", err.Error()) - if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil { - return result, postErr - } - return result, err - } - - pool, err := m.connectionFor(cmd) - if err != nil { - result := submitResult(cmd, 0, "", "rejected", "CONNECT_FAILED", err.Error()) - if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil { - return result, postErr - } - return result, err - } - - result, err := pool.submit(ctx, cmd, func(segment queue.SubmitSegmentResult) { - payload := struct { - queue.Envelope - SubmitID string `json:"submitId,omitempty"` - queue.SubmitSegmentResult - }{ - Envelope: cmd.Envelope, - SubmitID: cmd.SubmitID, - SubmitSegmentResult: segment, - } - callbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - postErr := m.post(callbackCtx, "/gateway/events/submit-segment-result", payload) - cancel() - if postErr != nil { - log.Printf( - "protocol_event protocol=cmpp direction=gateway_to_api event=submit_segment_result status=forward_failed channel_id=%s message_id=%s segment=%d/%d error=%q", - cmd.ChannelID, cmd.MessageID, segment.SegmentIndex, segment.SegmentTotal, postErr, - ) - } - }) - if err != nil { - if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil { - return result, postErr - } - return result, err - } - if err := m.post(ctx, "/gateway/events/submit-result", result); err != nil { - return result, err - } - return result, nil -} - func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChannelCommand) (ConnectionState, error) { if err := validateConnectChannelCommand(command); err != nil { return ConnectionState{}, err @@ -197,14 +137,6 @@ func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error return pool, nil } -func (m *Manager) post(ctx context.Context, path string, payload any) error { - client := m.HTTPClient - if client == nil { - client = &http.Client{Timeout: defaultHTTPTimeout} - } - return postJSON(ctx, client, m.APIBaseURL, path, payload) -} - func (m *Manager) ensureDefaultsLocked() { if m.HTTPClient == nil { m.HTTPClient = &http.Client{Timeout: defaultHTTPTimeout} @@ -229,1106 +161,6 @@ func (m *Manager) newConnectionPool(channelID string, connectionID string, confi } } -type connectionPool struct { - channelID string - connectionID string - config queue.UpstreamConfig - apiBaseURL string - httpClient *http.Client - reporter func(context.Context, ConnectionState) error - - mu sync.Mutex - connectMu sync.Mutex - conns []*connection - next int - reconnectSignal chan struct{} - stopCh chan struct{} - stopOnce sync.Once - supervisorOnce sync.Once - reconnectCount int - lastReconnectAttemptAt time.Time - nextReconnectAt time.Time - lastErrorCategory string -} - -func (p *connectionPool) matches(config queue.UpstreamConfig) bool { - return p.config == normalizeUpstreamConfig(config) -} - -func (p *connectionPool) ensureConnected() error { - p.connectMu.Lock() - defer p.connectMu.Unlock() - desired := p.config.DesiredConnections - if desired <= 0 { - desired = 1 - } - connectedAny := false - for { - p.mu.Lock() - active := p.conns[:0] - for _, existing := range p.conns { - existing.mu.Lock() - usable := existing.client != nil && !existing.closed - existing.mu.Unlock() - if usable { - active = append(active, existing) - } - } - p.conns = active - if len(p.conns) >= desired { - p.mu.Unlock() - break - } - index := len(p.conns) - conn := &connection{ - channelID: p.channelID, - config: p.config, - index: index, - pool: p, - apiBaseURL: p.apiBaseURL, - httpClient: p.httpClient, - window: make(chan struct{}, p.config.WindowSize), - pending: make(map[uint32]chan submitPartResponse), - tracker: make(map[uint64]queue.SubmitCommand), - longUplink: make(map[string]*longUplinkAssembly), - heartbeatPending: make(map[uint32]time.Time), - } - p.mu.Unlock() - - connected, err := conn.ensureConnected() - if err != nil { - return err - } - - p.mu.Lock() - p.conns = append(p.conns, conn) - p.mu.Unlock() - connectedAny = connectedAny || connected - } - if connectedAny { - _ = p.reportState(context.Background(), "connected", nil) - } - return nil -} - -func (p *connectionPool) startSupervisor() { - p.supervisorOnce.Do(func() { - go p.superviseReconnects() - }) -} - -func (p *connectionPool) stopped() bool { - select { - case <-p.stopCh: - return true - default: - return false - } -} - -func (p *connectionPool) signalReconnect() { - select { - case <-p.stopCh: - return - default: - } - select { - case p.reconnectSignal <- struct{}{}: - default: - } -} - -func (p *connectionPool) scheduleReconnect(stateErr error) { - select { - case <-p.stopCh: - return - default: - } - p.mu.Lock() - now := time.Now().UTC() - if !p.nextReconnectAt.IsZero() && p.nextReconnectAt.After(now) { - p.mu.Unlock() - return - } - p.reconnectCount++ - p.lastReconnectAttemptAt = now - p.lastErrorCategory = connectionErrorCategory(stateErr) - delay := reconnectDelay(p.reconnectCount, p.lastErrorCategory) - p.nextReconnectAt = p.lastReconnectAttemptAt.Add(delay) - p.mu.Unlock() - p.signalReconnect() -} - -func (p *connectionPool) resetReconnectState() { - p.mu.Lock() - p.reconnectCount = 0 - p.lastReconnectAttemptAt = time.Time{} - p.nextReconnectAt = time.Time{} - p.lastErrorCategory = "" - p.mu.Unlock() - p.signalReconnect() -} - -func (p *connectionPool) superviseReconnects() { - for { - select { - case <-p.stopCh: - return - case <-p.reconnectSignal: - } - for { - p.mu.Lock() - next := p.nextReconnectAt - p.mu.Unlock() - if next.IsZero() { - break - } - timer := time.NewTimer(time.Until(next)) - select { - case <-p.stopCh: - if !timer.Stop() { - <-timer.C - } - return - case <-p.reconnectSignal: - if !timer.Stop() { - <-timer.C - } - continue - case <-timer.C: - } - _ = p.reportState(context.Background(), "reconnecting", nil) - p.mu.Lock() - p.lastReconnectAttemptAt = time.Now().UTC() - p.mu.Unlock() - if err := p.ensureConnected(); err != nil { - select { - case <-p.stopCh: - return - default: - } - p.scheduleReconnect(err) - _ = p.reportState(context.Background(), "failed", err) - continue - } - select { - case <-p.stopCh: - return - default: - } - p.resetReconnectState() - _ = p.reportState(context.Background(), "connected", nil) - break - } - } -} - -func (p *connectionPool) submit( - ctx context.Context, - cmd queue.SubmitCommand, - onSegment func(queue.SubmitSegmentResult), -) (queue.SubmitResult, error) { - parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content) - if err != nil { - result := submitResult(cmd, 0, "", "rejected", "ENCODE_FAILED", err.Error()) - return result, err - } - - var firstSequence uint32 - var firstGatewayMessageID string - segments := make([]queue.SubmitSegmentResult, 0, len(parts)) - for _, part := range parts { - conn, release, err := p.acquireConnection(ctx) - if err != nil { - result := submitResult(cmd, 0, "", "timeout", "WINDOW_TIMEOUT", err.Error()) - result.Segments = segments - return result, err - } - seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part) - release() - segment := submitSegmentResult(part, seq, gatewayMessageID, result) - segments = append(segments, segment) - if onSegment != nil { - onSegment(segment) - } - if firstSequence == 0 { - firstSequence = seq - } - if firstGatewayMessageID == "" { - firstGatewayMessageID = gatewayMessageID - } - if err != nil { - result.Segments = segments - return result, err - } - if result.SubmitStatus != "accepted" { - result.Segments = segments - return result, nil - } - } - - result := submitResult(cmd, firstSequence, firstGatewayMessageID, "accepted", "", "") - result.Segments = segments - return result, nil -} - -func (p *connectionPool) acquireConnection(ctx context.Context) (*connection, func(), error) { - waitCtx, cancel := context.WithTimeout(ctx, defaultSubmitTimeout) - defer cancel() - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() - - for { - if conn, release := p.tryAcquireConnection(); conn != nil { - if connected, err := conn.ensureConnected(); err != nil { - release() - select { - case <-waitCtx.Done(): - return nil, nil, waitCtx.Err() - case <-ticker.C: - continue - } - } else if connected { - _ = p.reportState(context.Background(), "connected", nil) - } - return conn, release, nil - } - select { - case <-waitCtx.Done(): - return nil, nil, waitCtx.Err() - case <-ticker.C: - } - } -} - -func (p *connectionPool) tryAcquireConnection() (*connection, func()) { - p.mu.Lock() - defer p.mu.Unlock() - if len(p.conns) == 0 { - return nil, nil - } - for i := 0; i < len(p.conns); i++ { - index := (p.next + i) % len(p.conns) - conn := p.conns[index] - if conn.tryAcquireWindow() { - p.next = (index + 1) % len(p.conns) - return conn, conn.releaseWindow - } - } - return nil, nil -} - -func (p *connectionPool) close() { - p.connectMu.Lock() - defer p.connectMu.Unlock() - p.stopOnce.Do(func() { - close(p.stopCh) - }) - p.mu.Lock() - conns := p.conns - p.conns = nil - p.mu.Unlock() - for _, conn := range conns { - conn.close() - } -} - -func (p *connectionPool) reportState(ctx context.Context, status string, stateErr error) error { - if p.reporter == nil { - return nil - } - return p.reporter(ctx, p.snapshotState(status, stateErr)) -} - -func (p *connectionPool) snapshotState(status string, stateErr error) ConnectionState { - now := time.Now().UTC().Format(time.RFC3339Nano) - state := ConnectionState{ - ChannelID: p.channelID, - ConnectionID: p.connectionID, - Status: status, - DesiredConnections: p.config.DesiredConnections, - CurrentConnections: p.countActiveConnections(), - } - p.mu.Lock() - state.ReconnectCount = p.reconnectCount - state.LastErrorCategory = p.lastErrorCategory - if !p.lastReconnectAttemptAt.IsZero() { - state.LastReconnectAttemptAt = p.lastReconnectAttemptAt.Format(time.RFC3339Nano) - } - if !p.nextReconnectAt.IsZero() { - state.NextReconnectAt = p.nextReconnectAt.Format(time.RFC3339Nano) - } - p.mu.Unlock() - if state.DesiredConnections <= 0 { - state.DesiredConnections = 1 - } - switch status { - case "connected": - state.LastConnectedAt = now - state.LastHeartbeatAt = now - case "heartbeat": - state.LastHeartbeatAt = now - case "disconnected", "failed": - state.LastDisconnectedAt = now - } - if stateErr != nil { - state.LastError = stateErr.Error() - } - return state -} - -func (p *connectionPool) countActiveConnections() int { - p.mu.Lock() - defer p.mu.Unlock() - count := 0 - for _, conn := range p.conns { - conn.mu.Lock() - active := conn.client != nil && !conn.closed - conn.mu.Unlock() - if active { - count += 1 - } - } - return count -} - -type connection struct { - channelID string - config queue.UpstreamConfig - index int - pool *connectionPool - apiBaseURL string - httpClient *http.Client - - mu sync.Mutex - sendMu sync.Mutex - client *cmpp.Client - window chan struct{} - pending map[uint32]chan submitPartResponse - tracker map[uint64]queue.SubmitCommand - longUplink map[string]*longUplinkAssembly - readOnce sync.Once - closed bool - heartbeatCancel context.CancelFunc - heartbeatPending map[uint32]time.Time -} - -type submitPartResponse struct { - seqID uint32 - msgID uint64 - result uint32 - err error -} - -func (c *connection) matches(config queue.UpstreamConfig) bool { - return c.config == normalizeUpstreamConfig(config) -} - -func (c *connection) ensureConnected() (bool, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.client != nil && !c.closed { - return false, nil - } - client := cmpp.NewClient(protocolVersion(c.config.CMPPVersion)) - addr := fmt.Sprintf("%s:%d", c.config.GatewayHost, c.config.GatewayPort) - if err := client.Connect(addr, c.config.Account, c.config.PasswordCipher, defaultConnectTimeout); err != nil { - client.Disconnect() - return false, err - } - c.client = client - c.closed = false - c.heartbeatPending = make(map[uint32]time.Time) - heartbeatCtx, cancelHeartbeat := context.WithCancel(context.Background()) - c.heartbeatCancel = cancelHeartbeat - go c.readLoop() - go c.heartbeatLoop(heartbeatCtx) - return true, nil -} - -func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, part submitPart) (uint32, string, queue.SubmitResult, error) { - rspCh := make(chan submitPartResponse, 1) - pkt := c.submitRequestPacket(cmd, part) - - c.mu.Lock() - client := c.client - closed := c.closed - c.mu.Unlock() - if closed || client == nil { - err := fmt.Errorf("supplier connection is not available") - result := submitResult(cmd, 0, "", "timeout", "CONNECTION_LOST", err.Error()) - return 0, "", result, err - } - c.sendMu.Lock() - seq, err := client.SendReqPkt(pkt) - c.sendMu.Unlock() - if err != nil { - c.emitProtocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "platform_to_channel", - EventType: "submit", - Status: "failed", - TenantID: cmd.TenantID, - ApplicationID: cmd.ApplicationID, - ChannelID: cmd.ChannelID, - Account: c.config.Account, - MessageID: cmd.MessageID, - Phone: cmd.PhoneNumber, - ResultCode: "SEND_FAILED", - PayloadBytes: len(part.MsgContent), - Detail: map[string]any{ - "segmentTotal": part.PkTotal, - "segmentIndex": part.PkNumber, - }, - }) - c.close() - result := submitResult(cmd, 0, "", "timeout", "SEND_FAILED", err.Error()) - return 0, "", result, err - } - c.emitProtocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "platform_to_channel", - EventType: "submit", - Status: "success", - TenantID: cmd.TenantID, - ApplicationID: cmd.ApplicationID, - ChannelID: cmd.ChannelID, - Account: c.config.Account, - MessageID: cmd.MessageID, - Phone: cmd.PhoneNumber, - PayloadBytes: len(part.MsgContent), - Detail: map[string]any{ - "sequenceId": seq, - "segmentTotal": part.PkTotal, - "segmentIndex": part.PkNumber, - }, - }) - - c.mu.Lock() - c.pending[seq] = rspCh - c.mu.Unlock() - defer func() { - c.mu.Lock() - delete(c.pending, seq) - c.mu.Unlock() - }() - - waitCtx, cancel := context.WithTimeout(ctx, defaultSubmitTimeout) - defer cancel() - select { - case <-waitCtx.Done(): - result := submitResult(cmd, seq, "", "timeout", "SUBMIT_TIMEOUT", waitCtx.Err().Error()) - return seq, "", result, waitCtx.Err() - case rsp := <-rspCh: - if rsp.err != nil { - result := submitResult(cmd, seq, "", "timeout", "CONNECTION_LOST", rsp.err.Error()) - return seq, "", result, rsp.err - } - gatewayMessageID := fmt.Sprint(rsp.msgID) - status := "accepted" - errorCode := "" - errorMessage := "" - if rsp.result != 0 { - status = "rejected" - errorCode = fmt.Sprint(rsp.result) - errorMessage = fmt.Sprintf("upstream submit rejected with result %d", rsp.result) - } - c.emitProtocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "channel_to_platform", - EventType: "submit_resp", - Status: "success", - TenantID: cmd.TenantID, - ApplicationID: cmd.ApplicationID, - ChannelID: cmd.ChannelID, - Account: c.config.Account, - MessageID: cmd.MessageID, - GatewayMessageID: gatewayMessageID, - Phone: cmd.PhoneNumber, - ResultCode: fmt.Sprint(rsp.result), - Detail: map[string]any{ - "sequenceId": rsp.seqID, - "segmentTotal": part.PkTotal, - "segmentIndex": part.PkNumber, - }, - }) - if rsp.result == 0 { - c.mu.Lock() - c.tracker[rsp.msgID] = cmd - c.mu.Unlock() - } - return seq, gatewayMessageID, submitResult(cmd, seq, gatewayMessageID, status, errorCode, errorMessage), nil - } -} - -func (c *connection) submitRequestPacket(cmd queue.SubmitCommand, part submitPart) cmpp.Packer { - base := submitRequestFields{ - PkTotal: part.PkTotal, - PkNumber: part.PkNumber, - TpUdhi: part.TpUdhi, - RegisteredDelivery: uint8(cmd.CMPP.RegisteredDelivery), - MsgLevel: 1, - ServiceId: cmd.CMPP.ServiceID, - FeeUserType: uint8(defaultInt(cmd.CMPP.FeeUserType, 2)), - FeeTerminalId: cmd.PhoneNumber, - MsgFmt: uint8(cmd.CMPP.MsgFmt), - MsgSrc: c.config.Account, - FeeType: defaultString(cmd.CMPP.FeeType, "02"), - FeeCode: defaultString(cmd.CMPP.FeeCode, "0"), - SrcId: cmd.CMPP.SrcID, - DestUsrTl: 1, - DestTerminalId: []string{cmd.PhoneNumber}, - MsgLength: uint8(len(part.MsgContent)), - MsgContent: part.MsgContent, - } - if protocolVersion(c.config.CMPPVersion) == cmpp.V20 { - return &cmpp.Cmpp2SubmitReqPkt{ - PkTotal: base.PkTotal, - PkNumber: base.PkNumber, - RegisteredDelivery: base.RegisteredDelivery, - MsgLevel: base.MsgLevel, - ServiceId: base.ServiceId, - FeeUserType: base.FeeUserType, - FeeTerminalId: base.FeeTerminalId, - TpUdhi: base.TpUdhi, - MsgFmt: base.MsgFmt, - MsgSrc: base.MsgSrc, - FeeType: base.FeeType, - FeeCode: base.FeeCode, - SrcId: base.SrcId, - DestUsrTl: base.DestUsrTl, - DestTerminalId: base.DestTerminalId, - MsgLength: base.MsgLength, - MsgContent: base.MsgContent, - } - } - return &cmpp.Cmpp3SubmitReqPkt{ - PkTotal: base.PkTotal, - PkNumber: base.PkNumber, - RegisteredDelivery: base.RegisteredDelivery, - MsgLevel: base.MsgLevel, - ServiceId: base.ServiceId, - FeeUserType: base.FeeUserType, - FeeTerminalId: base.FeeTerminalId, - TpUdhi: base.TpUdhi, - MsgFmt: base.MsgFmt, - MsgSrc: base.MsgSrc, - FeeType: base.FeeType, - FeeCode: base.FeeCode, - SrcId: base.SrcId, - DestUsrTl: base.DestUsrTl, - DestTerminalId: base.DestTerminalId, - MsgLength: base.MsgLength, - MsgContent: base.MsgContent, - } -} - -type submitRequestFields struct { - PkTotal uint8 - PkNumber uint8 - RegisteredDelivery uint8 - MsgLevel uint8 - ServiceId string - FeeUserType uint8 - FeeTerminalId string - TpUdhi uint8 - MsgFmt uint8 - MsgSrc string - FeeType string - FeeCode string - SrcId string - DestUsrTl uint8 - DestTerminalId []string - MsgLength uint8 - MsgContent string -} - -func (c *connection) tryAcquireWindow() bool { - if c.window == nil { - c.window = make(chan struct{}, defaultWindowSize) - } - select { - case c.window <- struct{}{}: - return true - default: - return false - } -} - -func (c *connection) releaseWindow() { - if c.window == nil { - return - } - select { - case <-c.window: - default: - } -} - -func (c *connection) readLoop() { - for { - c.mu.Lock() - client := c.client - closed := c.closed - c.mu.Unlock() - if closed || client == nil { - return - } - pkt, err := client.RecvAndUnpackPkt(time.Second) - if err != nil { - c.mu.Lock() - closed = c.closed - c.mu.Unlock() - if closed { - return - } - if isTemporaryReadTimeout(err) { - continue - } - c.handleConnectionLoss(err) - return - } - switch p := pkt.(type) { - case *cmpp.Cmpp2SubmitRspPkt: - c.mu.Lock() - ch := c.pending[p.SeqId] - c.mu.Unlock() - if ch != nil { - ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: uint32(p.Result)} - } - case *cmpp.Cmpp3SubmitRspPkt: - c.mu.Lock() - ch := c.pending[p.SeqId] - c.mu.Unlock() - if ch != nil { - ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: p.Result} - } - case *cmpp.Cmpp2DeliverReqPkt: - deliver := deliverPacketFromCMPP2(p) - if deliver.registerDelivery == 1 { - if err := c.handleDeliver(deliver); err != nil { - log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=intake_failed channel_id=%s sequence_id=%d error=%q", c.channelID, deliver.seqID, err) - c.handleConnectionLoss(fmt.Errorf("persist upstream receipt before DELIVER_RESP: %w", err)) - return - } - responseErr := c.sendResponse(client, &cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) - c.emitDeliverResponse(deliver, responseErr) - } else { - responseErr := c.sendResponse(client, &cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) - c.emitDeliverResponse(deliver, responseErr) - _ = c.handleDeliver(deliver) - } - case *cmpp.Cmpp3DeliverReqPkt: - deliver := deliverPacketFromCMPP3(p) - if deliver.registerDelivery == 1 { - if err := c.handleDeliver(deliver); err != nil { - log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=intake_failed channel_id=%s sequence_id=%d error=%q", c.channelID, deliver.seqID, err) - c.handleConnectionLoss(fmt.Errorf("persist upstream receipt before DELIVER_RESP: %w", err)) - return - } - responseErr := c.sendResponse(client, &cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) - c.emitDeliverResponse(deliver, responseErr) - } else { - responseErr := c.sendResponse(client, &cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) - c.emitDeliverResponse(deliver, responseErr) - _ = c.handleDeliver(deliver) - } - case *cmpp.CmppActiveTestReqPkt: - _ = c.sendResponse(client, &cmpp.CmppActiveTestRspPkt{}, p.SeqId) - _ = c.pool.reportState(context.Background(), "heartbeat", nil) - case *cmpp.CmppActiveTestRspPkt: - c.handleHeartbeatResponse(p.SeqId) - _ = c.pool.reportState(context.Background(), "heartbeat", nil) - } - } -} - -func (c *connection) sendResponse(client *cmpp.Client, packet cmpp.Packer, sequenceID uint32) error { - c.sendMu.Lock() - defer c.sendMu.Unlock() - return client.SendRspPkt(packet, sequenceID) -} - -func (c *connection) heartbeatLoop(ctx context.Context) { - interval := time.Duration(c.config.HeartbeatIntervalSeconds) * time.Second - if interval <= 0 { - interval = defaultHeartbeatInterval - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if !c.sendHeartbeat() { - return - } - } - } -} - -func (c *connection) sendHeartbeat() bool { - threshold := c.config.HeartbeatMissThreshold - if threshold <= 0 { - threshold = defaultHeartbeatMissThreshold - } - c.mu.Lock() - if c.closed { - c.mu.Unlock() - return false - } - if len(c.heartbeatPending) >= threshold { - c.mu.Unlock() - c.handleConnectionLoss(fmt.Errorf("heartbeat timeout after %d unanswered ACTIVE_TEST requests", threshold)) - return false - } - if c.client == nil { - c.mu.Unlock() - return false - } - c.sendMu.Lock() - seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{}) - c.sendMu.Unlock() - if err != nil { - c.mu.Unlock() - c.handleConnectionLoss(fmt.Errorf("send ACTIVE_TEST: %w", err)) - return false - } - if !c.closed { - c.heartbeatPending[seq] = time.Now().UTC() - } - c.mu.Unlock() - return true -} - -func (c *connection) handleHeartbeatResponse(sequenceID uint32) { - c.mu.Lock() - delete(c.heartbeatPending, sequenceID) - c.mu.Unlock() -} - -type deliverPacket struct { - seqID uint32 - msgID uint64 - destID string - tpUdhi uint8 - msgFmt uint8 - srcTerminalID string - registerDelivery uint8 - msgContent string -} - -type protocolLogEvent struct { - Protocol string `json:"protocol"` - Direction string `json:"direction"` - EventType string `json:"eventType"` - Status string `json:"status"` - TenantID string `json:"tenantId,omitempty"` - ApplicationID string `json:"applicationId,omitempty"` - ChannelID string `json:"channelId,omitempty"` - Account string `json:"account,omitempty"` - MessageID string `json:"messageId,omitempty"` - GatewayMessageID string `json:"gatewayMessageId,omitempty"` - Phone string `json:"phone,omitempty"` - ResultCode string `json:"resultCode,omitempty"` - PayloadBytes int `json:"payloadBytes,omitempty"` - Detail map[string]any `json:"detail,omitempty"` -} - -func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) { - status := "success" - resultCode := "0" - detail := map[string]any{"sequenceId": pkt.seqID} - gatewayMessageID := fmt.Sprint(pkt.msgID) - messageID := "" - phone := "" - tenantID := "" - applicationID := "" - channelID := c.channelID - if pkt.registerDelivery == 1 { - var receipt cmpp.CmppReceiptPkt - if err := receipt.Unpack([]byte(pkt.msgContent)); err == nil { - gatewayMessageID = fmt.Sprint(receipt.MsgId) - phone = strings.TrimSpace(receipt.DestTerminalId) - if cmd, ok := c.commandFor(receipt.MsgId); ok { - messageID = cmd.MessageID - tenantID = cmd.TenantID - applicationID = cmd.ApplicationID - channelID = cmd.ChannelID - } - } - } - if responseErr != nil { - status = "failed" - resultCode = "SEND_FAILED" - detail["error"] = responseErr.Error() - } - c.emitProtocolLog(protocolLogEvent{ - Protocol: "cmpp", - Direction: "platform_to_channel", - EventType: "deliver_resp", - Status: status, - TenantID: tenantID, - ApplicationID: applicationID, - ChannelID: channelID, - Account: c.config.Account, - MessageID: messageID, - GatewayMessageID: gatewayMessageID, - Phone: phone, - ResultCode: resultCode, - Detail: detail, - }) -} - -func (c *connection) emitProtocolLog(event protocolLogEvent) { - go func() { - ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) - defer cancel() - if err := postJSON(ctx, c.httpClient, c.apiBaseURL, "/gateway/events/protocol-log", event); err != nil { - log.Printf("protocol_event protocol=%s direction=%s event=%s status=telemetry_failed channel_id=%s message_id=%s error=%q", event.Protocol, event.Direction, event.EventType, event.ChannelID, event.MessageID, err) - } - }() -} - -func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket { - return deliverPacket{ - seqID: pkt.SeqId, - msgID: pkt.MsgId, - destID: pkt.DestId, - tpUdhi: pkt.TpUdhi, - msgFmt: pkt.MsgFmt, - srcTerminalID: pkt.SrcTerminalId, - registerDelivery: pkt.RegisterDelivery, - msgContent: pkt.MsgContent, - } -} - -func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket { - return deliverPacket{ - seqID: pkt.SeqId, - msgID: pkt.MsgId, - destID: pkt.DestId, - tpUdhi: pkt.TpUdhi, - msgFmt: pkt.MsgFmt, - srcTerminalID: pkt.SrcTerminalId, - registerDelivery: pkt.RegisterDelivery, - msgContent: pkt.MsgContent, - } -} - -func (c *connection) handleDeliver(pkt deliverPacket) error { - if pkt.registerDelivery == 1 { - var receipt cmpp.CmppReceiptPkt - if err := receipt.Unpack([]byte(pkt.msgContent)); err != nil { - log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=parse_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err) - return err - } - log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=received channel_id=%s sequence_id=%d gateway_message_id=%d raw_status=%s", c.channelID, pkt.seqID, receipt.MsgId, strings.TrimSpace(receipt.Stat)) - cmd, ok := c.commandFor(receipt.MsgId) - if !ok { - cmd, ok = c.commandFor(pkt.msgID) - } - traceID := fmt.Sprintf("receipt-%d", receipt.MsgId) - messageID := fmt.Sprintf("receipt-%d", receipt.MsgId) - channelID := c.channelID - if ok { - traceID = cmd.TraceID - messageID = cmd.MessageID - channelID = cmd.ChannelID - } - event := queue.ReceiptEvent{ - Envelope: queue.Envelope{ - SchemaVersion: queue.SchemaVersion, - MessageType: queue.MessageTypeReceiptEvent, - TraceID: traceID, - MessageID: messageID, - ChannelID: channelID, - CreatedAt: time.Now().UTC(), - }, - SequenceID: pkt.seqID, - GatewayMessageID: fmt.Sprint(receipt.MsgId), - PhoneNumber: strings.TrimSpace(receipt.DestTerminalId), - ReceiptStatus: receiptStatus(receipt.Stat), - RawStatus: strings.TrimSpace(receipt.Stat), - DeliveredAt: time.Now().UTC(), - ConnectionID: c.identity(), - } - if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/receipt/intake", event); err != nil { - log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=forward_failed channel_id=%s sequence_id=%d gateway_message_id=%d error=%q", c.channelID, pkt.seqID, receipt.MsgId, err) - return err - } else { - log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=forwarded channel_id=%s sequence_id=%d gateway_message_id=%d", c.channelID, pkt.seqID, receipt.MsgId) - } - return nil - } - - content, complete, err := c.decodeUplinkContent(pkt) - if err != nil { - log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_uplink status=decode_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err) - return err - } - if !complete { - return nil - } - cmd, _ := c.commandFor(pkt.msgID) - event := queue.UplinkEvent{ - Envelope: queue.Envelope{ - SchemaVersion: queue.SchemaVersion, - MessageType: queue.MessageTypeUplinkEvent, - TraceID: cmd.TraceID, - MessageID: cmd.MessageID, - ChannelID: c.channelID, - CreatedAt: time.Now().UTC(), - }, - SequenceID: pkt.seqID, - PhoneNumber: strings.TrimSpace(pkt.srcTerminalID), - DestID: strings.TrimSpace(pkt.destID), - Content: content, - ReceivedAt: time.Now().UTC(), - } - if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/uplink", event); err != nil { - log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forward_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err) - } else { - log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forwarded channel_id=%s sequence_id=%d packet_msg_id=%d", c.channelID, pkt.seqID, pkt.msgID) - } - return nil -} - -func (c *connection) identity() string { - if c.pool != nil && strings.TrimSpace(c.pool.connectionID) != "" { - return fmt.Sprintf("%s-%d", c.pool.connectionID, c.index) - } - return fmt.Sprintf("%s-%d", c.channelID, c.index) -} - -func (c *connection) decodeUplinkContent(pkt deliverPacket) (string, bool, error) { - if pkt.tpUdhi != 1 { - content, err := decodeContent(pkt.msgFmt, pkt.msgContent) - return content, true, err - } - ref, total, number, payload, ok := parseConcatSegment(pkt.msgContent) - if !ok { - content, err := decodeContent(pkt.msgFmt, pkt.msgContent) - return content, true, err - } - key := fmt.Sprintf("%s:%s:%s:%d:%d", c.channelID, strings.TrimSpace(pkt.srcTerminalID), strings.TrimSpace(pkt.destID), ref, total) - c.mu.Lock() - if c.longUplink == nil { - c.longUplink = make(map[string]*longUplinkAssembly) - } - pruneLongUplinkAssemblies(c.longUplink, time.Now(), 10*time.Minute) - content, complete, err := assembleLongUplink(c.longUplink, key, pkt.msgFmt, total, number, payload) - c.mu.Unlock() - return content, complete, err -} - -func (c *connection) commandFor(gatewayMsgID uint64) (queue.SubmitCommand, bool) { - c.mu.Lock() - defer c.mu.Unlock() - cmd, ok := c.tracker[gatewayMsgID] - return cmd, ok -} - -func (c *connection) close() { - c.handleConnectionLoss(fmt.Errorf("connection closed")) -} - -func (c *connection) handleConnectionLoss(err error) { - c.mu.Lock() - if c.closed && c.client == nil { - c.mu.Unlock() - return - } - c.closed = true - if c.heartbeatCancel != nil { - c.heartbeatCancel() - c.heartbeatCancel = nil - } - pending := c.pending - c.pending = make(map[uint32]chan submitPartResponse) - c.heartbeatPending = make(map[uint32]time.Time) - if c.client != nil { - c.client.Disconnect() - c.client = nil - } - c.mu.Unlock() - - for _, ch := range pending { - select { - case ch <- submitPartResponse{err: err}: - default: - } - } - if c.pool != nil { - status := "disconnected" - if c.pool.countActiveConnections() > 0 { - status = "reconnecting" - } - c.pool.scheduleReconnect(err) - _ = c.pool.reportState(context.Background(), status, err) - } -} - -func submitResult(cmd queue.SubmitCommand, sequenceID uint32, gatewayMessageID string, status string, code string, message string) queue.SubmitResult { - if gatewayMessageID == "" { - gatewayMessageID = fmt.Sprintf("GW-%s-%d", cmd.SubmitID, time.Now().UnixNano()) - } - return queue.SubmitResult{ - Envelope: queue.Envelope{ - SchemaVersion: queue.SchemaVersion, - MessageType: queue.MessageTypeSubmitResult, - TraceID: cmd.TraceID, - MessageID: cmd.MessageID, - ChannelID: cmd.ChannelID, - CreatedAt: time.Now().UTC(), - }, - SubmitID: cmd.SubmitID, - SequenceID: sequenceID, - GatewayMessageID: gatewayMessageID, - SubmitStatus: status, - ErrorCode: code, - ErrorMessage: message, - SubmittedAt: time.Now().UTC(), - } -} - -func submitSegmentResult(part submitPart, sequenceID uint32, gatewayMessageID string, result queue.SubmitResult) queue.SubmitSegmentResult { - if gatewayMessageID == "" { - gatewayMessageID = result.GatewayMessageID - } - return queue.SubmitSegmentResult{ - SegmentTotal: int(part.PkTotal), - SegmentIndex: int(part.PkNumber), - SequenceID: sequenceID, - GatewayMessageID: gatewayMessageID, - SubmitStatus: result.SubmitStatus, - ErrorCode: result.ErrorCode, - ErrorMessage: result.ErrorMessage, - SubmittedAt: result.SubmittedAt, - } -} - -func validateSubmitCommand(cmd queue.SubmitCommand) error { - if cmd.MessageType != queue.MessageTypeSubmitCommand { - return fmt.Errorf("unsupported messageType %q", cmd.MessageType) - } - if cmd.MessageID == "" || cmd.ChannelID == "" || cmd.SubmitID == "" { - return fmt.Errorf("messageId, channelId and submitId are required") - } - if cmd.Upstream.GatewayHost == "" || cmd.Upstream.GatewayPort <= 0 { - return fmt.Errorf("upstream gatewayHost and gatewayPort are required") - } - if cmd.Upstream.Account == "" || cmd.Upstream.PasswordCipher == "" { - return fmt.Errorf("upstream account and passwordCipher are required") - } - if len(cmd.PhoneNumber) == 0 || len(cmd.Content) == 0 { - return fmt.Errorf("phoneNumber and content are required") - } - return nil -} - func validateConnectChannelCommand(command queue.ConnectChannelCommand) error { if command.MessageType != queue.MessageTypeConnectChannel { return fmt.Errorf("unsupported messageType %q", command.MessageType) @@ -1364,132 +196,3 @@ func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig { } return config } - -func connectionErrorCategory(err error) string { - if err == nil { - return "" - } - message := strings.ToLower(err.Error()) - switch { - case strings.Contains(message, "auth"), strings.Contains(message, "password"), strings.Contains(message, "credential"): - return "authentication" - case strings.Contains(message, "heartbeat"): - return "heartbeat_timeout" - case strings.Contains(message, "timeout"): - return "timeout" - default: - return "network" - } -} - -func reconnectDelay(attempt int, category string) time.Duration { - if category == "authentication" { - return defaultAuthReconnectDelay - } - if attempt < 1 { - attempt = 1 - } - delays := []time.Duration{ - defaultReconnectInitialDelay, - 15 * time.Second, - 30 * time.Second, - time.Minute, - 2 * time.Minute, - defaultReconnectMaximumDelay, - } - delay := delays[min(attempt-1, len(delays)-1)] - // Deterministic ±10% jitter prevents a large set of channels from retrying together. - offsetPercent := (attempt*37)%21 - 10 - delay += time.Duration(int64(delay) * int64(offsetPercent) / 100) - if delay < time.Second { - return time.Second - } - return delay -} - -func isTemporaryReadTimeout(err error) bool { - var netErr net.Error - return errors.As(err, &netErr) && netErr.Timeout() -} - -func postJSON(ctx context.Context, client *http.Client, apiBaseURL string, path string, payload any) error { - if client == nil { - client = &http.Client{Timeout: defaultHTTPTimeout} - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - base := strings.TrimRight(apiBaseURL, "/") - if base == "" { - base = "http://127.0.0.1:3000/api" - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("api returned %s", resp.Status) - } - return nil -} - -func encodeContent(format int, content string) (string, error) { - switch format { - case 8: - return cmpputils.Utf8ToUcs2(content) - case 15: - return cmpputils.Utf8ToGB18030(content) - default: - return content, nil - } -} - -func decodeContent(format uint8, content string) (string, error) { - switch format { - case 8: - return cmpputils.Ucs2ToUtf8(content) - case 15: - return cmpputils.GB18030ToUtf8(content) - default: - return content, nil - } -} - -func protocolVersion(version string) cmpp.Type { - if strings.HasPrefix(version, "2") { - return cmpp.V20 - } - return cmpp.V30 -} - -func receiptStatus(stat string) string { - switch strings.ToUpper(strings.TrimSpace(stat)) { - case "DELIVRD": - return "delivered" - case "": - return "unknown" - default: - return "undelivered" - } -} - -func defaultString(value string, fallback string) string { - if value == "" { - return fallback - } - return value -} - -func defaultInt(value int, fallback int) int { - if value == 0 { - return fallback - } - return value -} diff --git a/gateway/internal/upstream/pool.go b/gateway/internal/upstream/pool.go new file mode 100644 index 0000000..75f869b --- /dev/null +++ b/gateway/internal/upstream/pool.go @@ -0,0 +1,121 @@ +package upstream + +import ( + "cmpp-platform/gateway/internal/queue" + "context" + "net/http" + "sync" + "time" +) + +type connectionPool struct { + channelID string + connectionID string + config queue.UpstreamConfig + apiBaseURL string + httpClient *http.Client + reporter func(context.Context, ConnectionState) error + + mu sync.Mutex + connectMu sync.Mutex + conns []*connection + next int + reconnectSignal chan struct{} + stopCh chan struct{} + stopOnce sync.Once + supervisorOnce sync.Once + reconnectCount int + lastReconnectAttemptAt time.Time + nextReconnectAt time.Time + lastErrorCategory string +} + +func (p *connectionPool) matches(config queue.UpstreamConfig) bool { + return p.config == normalizeUpstreamConfig(config) +} + +func (p *connectionPool) ensureConnected() error { + p.connectMu.Lock() + defer p.connectMu.Unlock() + desired := p.config.DesiredConnections + if desired <= 0 { + desired = 1 + } + connectedAny := false + for { + p.mu.Lock() + active := p.conns[:0] + for _, existing := range p.conns { + existing.mu.Lock() + usable := existing.client != nil && !existing.closed + existing.mu.Unlock() + if usable { + active = append(active, existing) + } + } + p.conns = active + if len(p.conns) >= desired { + p.mu.Unlock() + break + } + index := len(p.conns) + conn := &connection{ + channelID: p.channelID, + config: p.config, + index: index, + pool: p, + apiBaseURL: p.apiBaseURL, + httpClient: p.httpClient, + window: make(chan struct{}, p.config.WindowSize), + pending: make(map[uint32]chan submitPartResponse), + tracker: make(map[uint64]queue.SubmitCommand), + longUplink: make(map[string]*longUplinkAssembly), + heartbeatPending: make(map[uint32]time.Time), + } + p.mu.Unlock() + + connected, err := conn.ensureConnected() + if err != nil { + return err + } + + p.mu.Lock() + p.conns = append(p.conns, conn) + p.mu.Unlock() + connectedAny = connectedAny || connected + } + if connectedAny { + _ = p.reportState(context.Background(), "connected", nil) + } + return nil +} + +func (p *connectionPool) close() { + p.connectMu.Lock() + defer p.connectMu.Unlock() + p.stopOnce.Do(func() { + close(p.stopCh) + }) + p.mu.Lock() + conns := p.conns + p.conns = nil + p.mu.Unlock() + for _, conn := range conns { + conn.close() + } +} + +func (p *connectionPool) countActiveConnections() int { + p.mu.Lock() + defer p.mu.Unlock() + count := 0 + for _, conn := range p.conns { + conn.mu.Lock() + active := conn.client != nil && !conn.closed + conn.mu.Unlock() + if active { + count += 1 + } + } + return count +} diff --git a/gateway/internal/upstream/protocol_log.go b/gateway/internal/upstream/protocol_log.go new file mode 100644 index 0000000..6f1b78e --- /dev/null +++ b/gateway/internal/upstream/protocol_log.go @@ -0,0 +1,81 @@ +package upstream + +import ( + "context" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + "log" + "strings" +) + +type protocolLogEvent struct { + Protocol string `json:"protocol"` + Direction string `json:"direction"` + EventType string `json:"eventType"` + Status string `json:"status"` + TenantID string `json:"tenantId,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + ChannelID string `json:"channelId,omitempty"` + Account string `json:"account,omitempty"` + MessageID string `json:"messageId,omitempty"` + GatewayMessageID string `json:"gatewayMessageId,omitempty"` + Phone string `json:"phone,omitempty"` + ResultCode string `json:"resultCode,omitempty"` + PayloadBytes int `json:"payloadBytes,omitempty"` + Detail map[string]any `json:"detail,omitempty"` +} + +func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) { + status := "success" + resultCode := "0" + detail := map[string]any{"sequenceId": pkt.seqID} + gatewayMessageID := fmt.Sprint(pkt.msgID) + messageID := "" + phone := "" + tenantID := "" + applicationID := "" + channelID := c.channelID + if pkt.registerDelivery == 1 { + var receipt cmpp.CmppReceiptPkt + if err := receipt.Unpack([]byte(pkt.msgContent)); err == nil { + gatewayMessageID = fmt.Sprint(receipt.MsgId) + phone = strings.TrimSpace(receipt.DestTerminalId) + if cmd, ok := c.commandFor(receipt.MsgId); ok { + messageID = cmd.MessageID + tenantID = cmd.TenantID + applicationID = cmd.ApplicationID + channelID = cmd.ChannelID + } + } + } + if responseErr != nil { + status = "failed" + resultCode = "SEND_FAILED" + detail["error"] = responseErr.Error() + } + c.emitProtocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "platform_to_channel", + EventType: "deliver_resp", + Status: status, + TenantID: tenantID, + ApplicationID: applicationID, + ChannelID: channelID, + Account: c.config.Account, + MessageID: messageID, + GatewayMessageID: gatewayMessageID, + Phone: phone, + ResultCode: resultCode, + Detail: detail, + }) +} + +func (c *connection) emitProtocolLog(event protocolLogEvent) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + if err := postJSON(ctx, c.httpClient, c.apiBaseURL, "/gateway/events/protocol-log", event); err != nil { + log.Printf("protocol_event protocol=%s direction=%s event=%s status=telemetry_failed channel_id=%s message_id=%s error=%q", event.Protocol, event.Direction, event.EventType, event.ChannelID, event.MessageID, err) + } + }() +} diff --git a/gateway/internal/upstream/reconnect.go b/gateway/internal/upstream/reconnect.go new file mode 100644 index 0000000..4252c6e --- /dev/null +++ b/gateway/internal/upstream/reconnect.go @@ -0,0 +1,171 @@ +package upstream + +import ( + "context" + "errors" + "net" + "strings" + "time" +) + +// Authentication failures intentionally use the slow retry class while +// transient network failures use capped backoff; manual disconnect closes stopCh. + +func (p *connectionPool) startSupervisor() { + p.supervisorOnce.Do(func() { + go p.superviseReconnects() + }) +} + +func (p *connectionPool) stopped() bool { + select { + case <-p.stopCh: + return true + default: + return false + } +} + +func (p *connectionPool) signalReconnect() { + select { + case <-p.stopCh: + return + default: + } + select { + case p.reconnectSignal <- struct{}{}: + default: + } +} + +func (p *connectionPool) scheduleReconnect(stateErr error) { + select { + case <-p.stopCh: + return + default: + } + p.mu.Lock() + now := time.Now().UTC() + if !p.nextReconnectAt.IsZero() && p.nextReconnectAt.After(now) { + p.mu.Unlock() + return + } + p.reconnectCount++ + p.lastReconnectAttemptAt = now + p.lastErrorCategory = connectionErrorCategory(stateErr) + delay := reconnectDelay(p.reconnectCount, p.lastErrorCategory) + p.nextReconnectAt = p.lastReconnectAttemptAt.Add(delay) + p.mu.Unlock() + p.signalReconnect() +} + +func (p *connectionPool) resetReconnectState() { + p.mu.Lock() + p.reconnectCount = 0 + p.lastReconnectAttemptAt = time.Time{} + p.nextReconnectAt = time.Time{} + p.lastErrorCategory = "" + p.mu.Unlock() + p.signalReconnect() +} + +func (p *connectionPool) superviseReconnects() { + for { + select { + case <-p.stopCh: + return + case <-p.reconnectSignal: + } + for { + p.mu.Lock() + next := p.nextReconnectAt + p.mu.Unlock() + if next.IsZero() { + break + } + timer := time.NewTimer(time.Until(next)) + select { + case <-p.stopCh: + if !timer.Stop() { + <-timer.C + } + return + case <-p.reconnectSignal: + if !timer.Stop() { + <-timer.C + } + continue + case <-timer.C: + } + _ = p.reportState(context.Background(), "reconnecting", nil) + p.mu.Lock() + p.lastReconnectAttemptAt = time.Now().UTC() + p.mu.Unlock() + if err := p.ensureConnected(); err != nil { + select { + case <-p.stopCh: + return + default: + } + p.scheduleReconnect(err) + _ = p.reportState(context.Background(), "failed", err) + continue + } + select { + case <-p.stopCh: + return + default: + } + p.resetReconnectState() + _ = p.reportState(context.Background(), "connected", nil) + break + } + } +} + +func connectionErrorCategory(err error) string { + if err == nil { + return "" + } + message := strings.ToLower(err.Error()) + switch { + case strings.Contains(message, "auth"), strings.Contains(message, "password"), strings.Contains(message, "credential"): + return "authentication" + case strings.Contains(message, "heartbeat"): + return "heartbeat_timeout" + case strings.Contains(message, "timeout"): + return "timeout" + default: + return "network" + } +} + +func reconnectDelay(attempt int, category string) time.Duration { + if category == "authentication" { + return defaultAuthReconnectDelay + } + if attempt < 1 { + attempt = 1 + } + delays := []time.Duration{ + defaultReconnectInitialDelay, + 15 * time.Second, + 30 * time.Second, + time.Minute, + 2 * time.Minute, + defaultReconnectMaximumDelay, + } + delay := delays[min(attempt-1, len(delays)-1)] + // Deterministic ±10% jitter prevents a large set of channels from retrying together. + offsetPercent := (attempt*37)%21 - 10 + delay += time.Duration(int64(delay) * int64(offsetPercent) / 100) + if delay < time.Second { + return time.Second + } + return delay +} + +func isTemporaryReadTimeout(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/gateway/internal/upstream/submit.go b/gateway/internal/upstream/submit.go new file mode 100644 index 0000000..5677b7c --- /dev/null +++ b/gateway/internal/upstream/submit.go @@ -0,0 +1,387 @@ +package upstream + +import ( + "cmpp-platform/gateway/internal/queue" + "context" + "fmt" + cmpp "github.com/bigwhite/gocmpp" + cmpputils "github.com/bigwhite/gocmpp/utils" + "log" + "strings" + "time" +) + +// Segment callbacks are emitted in submission order. The aggregate result keeps +// the first sequence and Msg_Id while retaining every segment result for billing. + +func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) { + if err := validateSubmitCommand(cmd); err != nil { + result := submitResult(cmd, 0, "", "rejected", "INVALID_COMMAND", err.Error()) + if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil { + return result, postErr + } + return result, err + } + + pool, err := m.connectionFor(cmd) + if err != nil { + result := submitResult(cmd, 0, "", "rejected", "CONNECT_FAILED", err.Error()) + if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil { + return result, postErr + } + return result, err + } + + result, err := pool.submit(ctx, cmd, func(segment queue.SubmitSegmentResult) { + payload := struct { + queue.Envelope + SubmitID string `json:"submitId,omitempty"` + queue.SubmitSegmentResult + }{ + Envelope: cmd.Envelope, + SubmitID: cmd.SubmitID, + SubmitSegmentResult: segment, + } + callbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + postErr := m.post(callbackCtx, "/gateway/events/submit-segment-result", payload) + cancel() + if postErr != nil { + log.Printf( + "protocol_event protocol=cmpp direction=gateway_to_api event=submit_segment_result status=forward_failed channel_id=%s message_id=%s segment=%d/%d error=%q", + cmd.ChannelID, cmd.MessageID, segment.SegmentIndex, segment.SegmentTotal, postErr, + ) + } + }) + if err != nil { + if postErr := m.post(ctx, "/gateway/events/submit-result", result); postErr != nil { + return result, postErr + } + return result, err + } + if err := m.post(ctx, "/gateway/events/submit-result", result); err != nil { + return result, err + } + return result, nil +} + +func (p *connectionPool) submit( + ctx context.Context, + cmd queue.SubmitCommand, + onSegment func(queue.SubmitSegmentResult), +) (queue.SubmitResult, error) { + parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content) + if err != nil { + result := submitResult(cmd, 0, "", "rejected", "ENCODE_FAILED", err.Error()) + return result, err + } + + var firstSequence uint32 + var firstGatewayMessageID string + segments := make([]queue.SubmitSegmentResult, 0, len(parts)) + for _, part := range parts { + conn, release, err := p.acquireConnection(ctx) + if err != nil { + result := submitResult(cmd, 0, "", "timeout", "WINDOW_TIMEOUT", err.Error()) + result.Segments = segments + return result, err + } + seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part) + release() + segment := submitSegmentResult(part, seq, gatewayMessageID, result) + segments = append(segments, segment) + if onSegment != nil { + onSegment(segment) + } + if firstSequence == 0 { + firstSequence = seq + } + if firstGatewayMessageID == "" { + firstGatewayMessageID = gatewayMessageID + } + if err != nil { + result.Segments = segments + return result, err + } + if result.SubmitStatus != "accepted" { + result.Segments = segments + return result, nil + } + } + + result := submitResult(cmd, firstSequence, firstGatewayMessageID, "accepted", "", "") + result.Segments = segments + return result, nil +} + +func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, part submitPart) (uint32, string, queue.SubmitResult, error) { + rspCh := make(chan submitPartResponse, 1) + pkt := c.submitRequestPacket(cmd, part) + + c.mu.Lock() + client := c.client + closed := c.closed + c.mu.Unlock() + if closed || client == nil { + err := fmt.Errorf("supplier connection is not available") + result := submitResult(cmd, 0, "", "timeout", "CONNECTION_LOST", err.Error()) + return 0, "", result, err + } + c.sendMu.Lock() + seq, err := client.SendReqPkt(pkt) + c.sendMu.Unlock() + if err != nil { + c.emitProtocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "platform_to_channel", + EventType: "submit", + Status: "failed", + TenantID: cmd.TenantID, + ApplicationID: cmd.ApplicationID, + ChannelID: cmd.ChannelID, + Account: c.config.Account, + MessageID: cmd.MessageID, + Phone: cmd.PhoneNumber, + ResultCode: "SEND_FAILED", + PayloadBytes: len(part.MsgContent), + Detail: map[string]any{ + "segmentTotal": part.PkTotal, + "segmentIndex": part.PkNumber, + }, + }) + c.close() + result := submitResult(cmd, 0, "", "timeout", "SEND_FAILED", err.Error()) + return 0, "", result, err + } + c.emitProtocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "platform_to_channel", + EventType: "submit", + Status: "success", + TenantID: cmd.TenantID, + ApplicationID: cmd.ApplicationID, + ChannelID: cmd.ChannelID, + Account: c.config.Account, + MessageID: cmd.MessageID, + Phone: cmd.PhoneNumber, + PayloadBytes: len(part.MsgContent), + Detail: map[string]any{ + "sequenceId": seq, + "segmentTotal": part.PkTotal, + "segmentIndex": part.PkNumber, + }, + }) + + c.mu.Lock() + c.pending[seq] = rspCh + c.mu.Unlock() + defer func() { + c.mu.Lock() + delete(c.pending, seq) + c.mu.Unlock() + }() + + waitCtx, cancel := context.WithTimeout(ctx, defaultSubmitTimeout) + defer cancel() + select { + case <-waitCtx.Done(): + result := submitResult(cmd, seq, "", "timeout", "SUBMIT_TIMEOUT", waitCtx.Err().Error()) + return seq, "", result, waitCtx.Err() + case rsp := <-rspCh: + if rsp.err != nil { + result := submitResult(cmd, seq, "", "timeout", "CONNECTION_LOST", rsp.err.Error()) + return seq, "", result, rsp.err + } + gatewayMessageID := fmt.Sprint(rsp.msgID) + status := "accepted" + errorCode := "" + errorMessage := "" + if rsp.result != 0 { + status = "rejected" + errorCode = fmt.Sprint(rsp.result) + errorMessage = fmt.Sprintf("upstream submit rejected with result %d", rsp.result) + } + c.emitProtocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "channel_to_platform", + EventType: "submit_resp", + Status: "success", + TenantID: cmd.TenantID, + ApplicationID: cmd.ApplicationID, + ChannelID: cmd.ChannelID, + Account: c.config.Account, + MessageID: cmd.MessageID, + GatewayMessageID: gatewayMessageID, + Phone: cmd.PhoneNumber, + ResultCode: fmt.Sprint(rsp.result), + Detail: map[string]any{ + "sequenceId": rsp.seqID, + "segmentTotal": part.PkTotal, + "segmentIndex": part.PkNumber, + }, + }) + if rsp.result == 0 { + c.mu.Lock() + c.tracker[rsp.msgID] = cmd + c.mu.Unlock() + } + return seq, gatewayMessageID, submitResult(cmd, seq, gatewayMessageID, status, errorCode, errorMessage), nil + } +} + +func (c *connection) submitRequestPacket(cmd queue.SubmitCommand, part submitPart) cmpp.Packer { + base := submitRequestFields{ + PkTotal: part.PkTotal, + PkNumber: part.PkNumber, + TpUdhi: part.TpUdhi, + RegisteredDelivery: uint8(cmd.CMPP.RegisteredDelivery), + MsgLevel: 1, + ServiceId: cmd.CMPP.ServiceID, + FeeUserType: uint8(defaultInt(cmd.CMPP.FeeUserType, 2)), + FeeTerminalId: cmd.PhoneNumber, + MsgFmt: uint8(cmd.CMPP.MsgFmt), + MsgSrc: c.config.Account, + FeeType: defaultString(cmd.CMPP.FeeType, "02"), + FeeCode: defaultString(cmd.CMPP.FeeCode, "0"), + SrcId: cmd.CMPP.SrcID, + DestUsrTl: 1, + DestTerminalId: []string{cmd.PhoneNumber}, + MsgLength: uint8(len(part.MsgContent)), + MsgContent: part.MsgContent, + } + if protocolVersion(c.config.CMPPVersion) == cmpp.V20 { + return &cmpp.Cmpp2SubmitReqPkt{ + PkTotal: base.PkTotal, + PkNumber: base.PkNumber, + RegisteredDelivery: base.RegisteredDelivery, + MsgLevel: base.MsgLevel, + ServiceId: base.ServiceId, + FeeUserType: base.FeeUserType, + FeeTerminalId: base.FeeTerminalId, + TpUdhi: base.TpUdhi, + MsgFmt: base.MsgFmt, + MsgSrc: base.MsgSrc, + FeeType: base.FeeType, + FeeCode: base.FeeCode, + SrcId: base.SrcId, + DestUsrTl: base.DestUsrTl, + DestTerminalId: base.DestTerminalId, + MsgLength: base.MsgLength, + MsgContent: base.MsgContent, + } + } + return &cmpp.Cmpp3SubmitReqPkt{ + PkTotal: base.PkTotal, + PkNumber: base.PkNumber, + RegisteredDelivery: base.RegisteredDelivery, + MsgLevel: base.MsgLevel, + ServiceId: base.ServiceId, + FeeUserType: base.FeeUserType, + FeeTerminalId: base.FeeTerminalId, + TpUdhi: base.TpUdhi, + MsgFmt: base.MsgFmt, + MsgSrc: base.MsgSrc, + FeeType: base.FeeType, + FeeCode: base.FeeCode, + SrcId: base.SrcId, + DestUsrTl: base.DestUsrTl, + DestTerminalId: base.DestTerminalId, + MsgLength: base.MsgLength, + MsgContent: base.MsgContent, + } +} + +type submitRequestFields struct { + PkTotal uint8 + PkNumber uint8 + RegisteredDelivery uint8 + MsgLevel uint8 + ServiceId string + FeeUserType uint8 + FeeTerminalId string + TpUdhi uint8 + MsgFmt uint8 + MsgSrc string + FeeType string + FeeCode string + SrcId string + DestUsrTl uint8 + DestTerminalId []string + MsgLength uint8 + MsgContent string +} + +func submitResult(cmd queue.SubmitCommand, sequenceID uint32, gatewayMessageID string, status string, code string, message string) queue.SubmitResult { + if gatewayMessageID == "" { + gatewayMessageID = fmt.Sprintf("GW-%s-%d", cmd.SubmitID, time.Now().UnixNano()) + } + return queue.SubmitResult{ + Envelope: queue.Envelope{ + SchemaVersion: queue.SchemaVersion, + MessageType: queue.MessageTypeSubmitResult, + TraceID: cmd.TraceID, + MessageID: cmd.MessageID, + ChannelID: cmd.ChannelID, + CreatedAt: time.Now().UTC(), + }, + SubmitID: cmd.SubmitID, + SequenceID: sequenceID, + GatewayMessageID: gatewayMessageID, + SubmitStatus: status, + ErrorCode: code, + ErrorMessage: message, + SubmittedAt: time.Now().UTC(), + } +} + +func submitSegmentResult(part submitPart, sequenceID uint32, gatewayMessageID string, result queue.SubmitResult) queue.SubmitSegmentResult { + if gatewayMessageID == "" { + gatewayMessageID = result.GatewayMessageID + } + return queue.SubmitSegmentResult{ + SegmentTotal: int(part.PkTotal), + SegmentIndex: int(part.PkNumber), + SequenceID: sequenceID, + GatewayMessageID: gatewayMessageID, + SubmitStatus: result.SubmitStatus, + ErrorCode: result.ErrorCode, + ErrorMessage: result.ErrorMessage, + SubmittedAt: result.SubmittedAt, + } +} + +func validateSubmitCommand(cmd queue.SubmitCommand) error { + if cmd.MessageType != queue.MessageTypeSubmitCommand { + return fmt.Errorf("unsupported messageType %q", cmd.MessageType) + } + if cmd.MessageID == "" || cmd.ChannelID == "" || cmd.SubmitID == "" { + return fmt.Errorf("messageId, channelId and submitId are required") + } + if cmd.Upstream.GatewayHost == "" || cmd.Upstream.GatewayPort <= 0 { + return fmt.Errorf("upstream gatewayHost and gatewayPort are required") + } + if cmd.Upstream.Account == "" || cmd.Upstream.PasswordCipher == "" { + return fmt.Errorf("upstream account and passwordCipher are required") + } + if len(cmd.PhoneNumber) == 0 || len(cmd.Content) == 0 { + return fmt.Errorf("phoneNumber and content are required") + } + return nil +} + +func encodeContent(format int, content string) (string, error) { + switch format { + case 8: + return cmpputils.Utf8ToUcs2(content) + case 15: + return cmpputils.Utf8ToGB18030(content) + default: + return content, nil + } +} + +func protocolVersion(version string) cmpp.Type { + if strings.HasPrefix(version, "2") { + return cmpp.V20 + } + return cmpp.V30 +} diff --git a/gateway/internal/upstream/transport.go b/gateway/internal/upstream/transport.go new file mode 100644 index 0000000..d23cebe --- /dev/null +++ b/gateway/internal/upstream/transport.go @@ -0,0 +1,105 @@ +package upstream + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func (m *Manager) post(ctx context.Context, path string, payload any) error { + client := m.HTTPClient + if client == nil { + client = &http.Client{Timeout: defaultHTTPTimeout} + } + return postJSON(ctx, client, m.APIBaseURL, path, payload) +} + +func (p *connectionPool) reportState(ctx context.Context, status string, stateErr error) error { + if p.reporter == nil { + return nil + } + return p.reporter(ctx, p.snapshotState(status, stateErr)) +} + +func (p *connectionPool) snapshotState(status string, stateErr error) ConnectionState { + now := time.Now().UTC().Format(time.RFC3339Nano) + state := ConnectionState{ + ChannelID: p.channelID, + ConnectionID: p.connectionID, + Status: status, + DesiredConnections: p.config.DesiredConnections, + CurrentConnections: p.countActiveConnections(), + } + p.mu.Lock() + state.ReconnectCount = p.reconnectCount + state.LastErrorCategory = p.lastErrorCategory + if !p.lastReconnectAttemptAt.IsZero() { + state.LastReconnectAttemptAt = p.lastReconnectAttemptAt.Format(time.RFC3339Nano) + } + if !p.nextReconnectAt.IsZero() { + state.NextReconnectAt = p.nextReconnectAt.Format(time.RFC3339Nano) + } + p.mu.Unlock() + if state.DesiredConnections <= 0 { + state.DesiredConnections = 1 + } + switch status { + case "connected": + state.LastConnectedAt = now + state.LastHeartbeatAt = now + case "heartbeat": + state.LastHeartbeatAt = now + case "disconnected", "failed": + state.LastDisconnectedAt = now + } + if stateErr != nil { + state.LastError = stateErr.Error() + } + return state +} + +func postJSON(ctx context.Context, client *http.Client, apiBaseURL string, path string, payload any) error { + if client == nil { + client = &http.Client{Timeout: defaultHTTPTimeout} + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + base := strings.TrimRight(apiBaseURL, "/") + if base == "" { + base = "http://127.0.0.1:3000/api" + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("api returned %s", resp.Status) + } + return nil +} + +func defaultString(value string, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func defaultInt(value int, fallback int) int { + if value == 0 { + return fallback + } + return value +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ed07e5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + msgpackr-extract: set this to true or false diff --git a/src/api/admin/channels-reports.api.ts b/src/api/admin/channels-reports.api.ts new file mode 100644 index 0000000..28e028f --- /dev/null +++ b/src/api/admin/channels-reports.api.ts @@ -0,0 +1,93 @@ +import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; +import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types'; +import { assertUploadFileSize } from '@/utils/fileUpload'; + +// Report generation consumes channel report fields, so these endpoints keep one +// explicit integration boundary while the facade remains unchanged. +export const adminChannelsReportsApi = { + listChannels: () => request('/admin/channels'), + listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/channels', query)), + createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => + request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), + updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => + request(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { + method: 'POST', + body: JSON.stringify(body), + }), + testChannel: (id: string, body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string }) => + request(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }), + changeChannelStatus: (id: string, status: string, reason?: string) => request(`/admin/channels/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason }), + }), + deleteChannel: (id: string, reason?: string) => request(`/admin/channels/${id}`, { + method: 'DELETE', + body: JSON.stringify({ reason }), + }), + getDeletionPreflight: (type: DeletionTargetType, id: string) => + request(`/admin/deletions/${type}/${id}/preflight`), + deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) => + request(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }), + listChannelConnectionLogs: (id: string) => request(`/admin/channels/${id}/connection-logs`), + listChannelGroups: () => request('/admin/channel-groups'), + createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) => + request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), + updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array> }) => + request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteChannelGroup: (id: string) => + request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), + addChannelGroupItem: (body: Record) => + request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), + listChannelRouteRules: () => request('/admin/channel-route-rules'), + createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => + request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), + listChannelConnections: (id: string) => request(`/admin/channels/${id}/connections`), + replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => + request(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), + listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), + createChannelReportField: (body: Record) => + request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), + replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array>) => + request(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }), + listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/report-materials/pending', query)), + listReportImportProfiles: (reportType?: 'signature' | 'drainage') => + request(withQuery('/admin/report-materials/import-profiles', { reportType })), + saveReportImportProfile: (body: Omit & { id?: string }) => + request('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }), + analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => { + assertUploadFileSize(file); + const form = new FormData(); + form.set('file', file); + Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); }); + return requestForm & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form); + }, + commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }) => + request>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }), + listReportImportReviewBatches: (query: { reportType?: 'signature' | 'drainage'; status?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/report-materials/imports/review-batches', query)), + reviewReportImportItems: (id: string, body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }) => + request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(`/admin/report-materials/imports/${id}/review`, { method: 'POST', body: JSON.stringify(body) }), + listReportMaterialBatches: (query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/report-materials/batches', query)), + preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) => + request('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }), + createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => + request('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), + listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), + listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/report-tasks', query)), + createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => + request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), + changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) => + request }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), + createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => + request>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), + importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => + request>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), + listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), + listReportRecordsPage: (query: { taskId?: string; channelId?: string; keyword?: string; reportType?: 'signature' | 'drainage'; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/report-records', query)), +}; diff --git a/src/api/admin/files.api.ts b/src/api/admin/files.api.ts new file mode 100644 index 0000000..d047374 --- /dev/null +++ b/src/api/admin/files.api.ts @@ -0,0 +1,38 @@ +import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; +import type { FileObject } from '../types'; +import { assertUploadFileSize } from '@/utils/fileUpload'; +import { clearSession, dispatchSessionEvent, hasRecentUserActivity, readSession, redirectToPortalLogin } from '../session'; +import { readErrorBody } from '../core/httpClient'; + +export const adminFilesApi = { + uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { + assertUploadFileSize(file); + const form = new FormData(); + form.set('file', file); + form.set('purpose', body.purpose); + if (body.prefix) { + form.set('prefix', body.prefix); + } + const headers = new Headers(); + const session = readSession('admin'); + if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); + if (tenantId) { + headers.set('x-tenant-id', tenantId); + } + const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); + if (response.status === 401 && session) { + const error = await readErrorBody(response.clone()); + if (error.code === 'SESSION_LOCKED') { + dispatchSessionEvent('admin', 'locked', { message: error.message }); + } else { + clearSession('admin'); + dispatchSessionEvent('admin', 'logout', { code: error.code, message: error.message }); + redirectToPortalLogin('admin'); + } + } + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; + }, +}; diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts new file mode 100644 index 0000000..55a7a2c --- /dev/null +++ b/src/api/admin/governance.api.ts @@ -0,0 +1,194 @@ +import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; +import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types'; + +// Review, risk and billing mutations keep their original URLs, payloads and +// response types behind one governance boundary. +export const adminGovernanceApi = { + listAccounts: () => request('/admin/billing/accounts'), + updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) => + request(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), + listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), + listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/billing/manual-recharges', query)), + preflightManualRecharge: (body: { tenantId: string; amountCents: number }) => + request('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), + createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => + request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), + listTemplateAudits: (query: { keyword?: string; status?: string }) => { + const params = new URLSearchParams(); + if (query.keyword) params.set('keyword', query.keyword); + if (query.status && query.status !== 'all') params.set('status', query.status); + const suffix = params.toString() ? `?${params}` : ''; + return request(`/admin/enterprise-templates${suffix}`); + }, + approveTemplate: (id: string) => request(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectTemplate: (id: string, reason = '运营审核驳回') => request(`/admin/templates/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + approveSignature: (id: string) => request(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectSignature: (id: string, reason = '运营审核驳回') => request(`/admin/signatures/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + getReviewPreflight: (type: 'signature' | 'template', id: string) => + request(`/admin/reviews/${type}/${id}/preflight`), + submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) => + request(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), + listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) => + request(withQuery('/admin/enterprise-signatures', query)), + listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/enterprise-signatures', query)), + listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => + request(withQuery('/admin/enterprise-signature-options', query)), + createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => + request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record }) => + request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => + request(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), + listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) => + request(withQuery('/admin/drainage-infos', query)), + listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) => + request(withQuery('/admin/audit-records', query)), + createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }) => + request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), + updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }) => + request(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + approveDrainageInfo: (id: string) => + request(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectDrainageInfo: (id: string, reason: string) => + request(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), + changeDrainageInfoStatus: (id: string, status: string, reason?: string) => + request(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), + listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) => + request(withQuery('/admin/enterprise-templates', query)), + listEnterpriseTemplatesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/enterprise-templates', query)), + createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => + request('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => + request(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) => + request(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), + listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => { + const params = new URLSearchParams(); + if (query.keyword) params.set('keyword', query.keyword); + if (query.status && query.status !== 'all') params.set('status', query.status); + const suffix = params.toString() ? `?${params}` : ''; + return request(`/admin/enterprise-certifications${suffix}`); + }, + getEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}`), + approveEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}/approve`, { + method: 'POST', + body: JSON.stringify({}), + }), + rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request(`/admin/enterprise-certifications/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), + listRiskRules: (applicationId?: string) => + request(withQuery('/admin/risk-review/rules', { applicationId })), + createRiskRule: (body: { + applicationId?: string; + code: RiskRuleItem['code']; + thresholdValue: number; + action: RiskRuleItem['action']; + status: RiskRuleItem['status']; + priority?: number; + config?: RiskRuleItem['config']; + }) => request('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }), + updateRiskRule: (id: string, body: { + thresholdValue?: number; + action?: RiskRuleItem['action']; + status?: RiskRuleItem['status']; + priority?: number; + config?: RiskRuleItem['config']; + }) => request(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + listPhoneFrequencyHits: (query: { + tenantId?: string; + applicationId?: string; + phoneNumber?: string; + status?: 'active' | 'expired' | 'released'; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + } = {}) => request>(withQuery('/admin/risk-review/phone-frequency-hits', query)), + releasePhoneFrequencyHit: (id: string, reason: string) => + request(`/admin/risk-review/phone-frequency-hits/${id}/release`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + listPhoneFrequencyWhitelist: (query: { + phoneNumber?: string; + keyword?: string; + status?: 'active' | 'inactive' | 'deleted'; + updatedAtFrom?: string; + updatedAtTo?: string; + page?: number; + pageSize?: number; + } = {}) => request>(withQuery('/admin/risk-review/phone-frequency-whitelist', query)), + createPhoneFrequencyWhitelist: (body: { + phoneNumber: string; + reason: string; + remark?: string; + status?: 'active' | 'inactive'; + }) => request('/admin/risk-review/phone-frequency-whitelist', { + method: 'POST', + body: JSON.stringify(body), + }), + updatePhoneFrequencyWhitelist: (id: string, body: { + phoneNumber?: string; + reason?: string; + remark?: string; + status?: 'active' | 'inactive'; + }) => request(`/admin/risk-review/phone-frequency-whitelist/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }), + deletePhoneFrequencyWhitelist: (id: string, reason: string) => + request(`/admin/risk-review/phone-frequency-whitelist/${id}`, { + method: 'DELETE', + body: JSON.stringify({ reason }), + }), + listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => + request(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)), + approveRiskReviewTask: (id: string, reason?: string) => + request(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }), + rejectRiskReviewTask: (id: string, reason?: string) => + request(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), + rejectRiskReviewTasks: (ids: string[], reason: string) => + request('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }), + listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/sensitive-words', query)), + createSensitiveWord: (body: { word: string; level?: string; status?: string }) => + request('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }), + deleteSensitiveWord: (id: string) => request(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }), + listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/global', query)), + createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => + request('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), + deleteGlobalBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), + listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), + createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => + request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), + deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), + listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => + request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)), + createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => + request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), + deletePhoneSegment: (id: string) => request(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }), + listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => + request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), + createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => + request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), + deletePhoneCarrierRule: (id: string) => request(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }), + listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), + createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => + request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), + deleteDrainageField: (id: string) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), + listCommonReportFields: () => request('/admin/dictionaries/common-report-fields'), + createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => + request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), + deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), +}; diff --git a/src/api/admin/identity.api.ts b/src/api/admin/identity.api.ts new file mode 100644 index 0000000..6000893 --- /dev/null +++ b/src/api/admin/identity.api.ts @@ -0,0 +1,76 @@ +import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; +import type { ApplicationCmppParams, ApplicationConnectionsResponse, ApplicationDeactivationPreview, ApplicationReportField, CaptchaResponse, EnterpriseApplication, HttpApiConfig, HttpApiConfigResponse, HttpWebhookEndpoint, ManagedUser, PagedResult, TenantManagementRow, TenantOption, UserPayload } from '../types'; +import type { LoginSession } from '../session'; +import { portalSessionApi } from './session.api'; + +// R1 domain fragment. src/api/adminApi.ts remains the public compatibility facade. +export const adminIdentityApi = { + getCaptcha: () => request('/admin/auth/captcha'), + login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => + request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), + touchSession: () => portalSessionApi.touch('admin'), + lockSession: () => portalSessionApi.lock('admin'), + unlockSession: (password: string) => portalSessionApi.unlock('admin', password), + reauthenticate: (password: string) => portalSessionApi.reauthenticate('admin', password), + logout: () => portalSessionApi.logout('admin'), + changeOwnPassword: (body: { currentPassword: string; password: string }) => + portalSessionApi.changeOwnPassword('admin', body), + listTenants: () => request('/admin/tenants'), + listTenantManagementRows: () => request('/admin/tenants/management-list'), + getTenant: (id: string) => request(`/admin/tenants/${id}`), + createTenant: (body: { name: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => + request('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), + updateTenant: (id: string, body: { name?: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => + request(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeTenantStatus: (id: string, status: string) => + request(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), + deleteTenant: (id: string) => request(`/admin/tenants/${id}`, { method: 'DELETE' }), + listUsers: (query: { tenantId?: string; roleCode?: string; displayName?: string; login?: string; status?: string } = {}) => + request(withQuery('/admin/users', query)), + createUser: (body: UserPayload) => request('/admin/users', { method: 'POST', body: JSON.stringify(body) }), + updateUser: (id: string, body: Omit) => request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeUserStatus: (id: string, status: string, operatorId?: string) => + request(`/admin/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status, operatorId }) }), + deleteUser: (id: string, operatorId?: string) => request(`/admin/users/${id}`, { method: 'DELETE', body: JSON.stringify({ operatorId }) }), + changeUserPassword: (id: string, password: string, operatorId?: string) => + request(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }), + listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) => + request(withQuery('/admin/enterprise-applications', query)), + listEnterpriseApplicationsPage: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/enterprise-applications', query)), + listEnterpriseApplicationOptions: (query: { tenantId?: string } = {}) => + request(withQuery('/admin/enterprise-application-options', query)), + getEnterpriseApplication: (id: string) => + request(`/admin/enterprise-applications/${id}`), + createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => + request('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => + request(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + getApplicationDeactivationPreview: (id: string) => + request(`/admin/enterprise-applications/${id}/deactivation-preview`), + changeApplicationStatus: (id: string, status: string, reason?: string, force = false) => + request(`/admin/enterprise-applications/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason, force }), + }), + listApplicationConnections: (applicationId: string) => + request(`/admin/enterprise-applications/${applicationId}/connections`), + listApplicationReportFields: (applicationId: string, reportType?: 'signature' | 'drainage') => + request(withQuery(`/admin/enterprise-applications/${applicationId}/report-fields`, { reportType })), + listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') => + 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) }), + listApplicationHttpWebhooks: (applicationId: string) => + request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks`), + saveApplicationHttpWebhook: ( + applicationId: string, + eventType: 'receipt' | 'uplink', + body: { url: string; rotateSecret?: boolean; status?: string }, + ) => request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks/${eventType}`, { + method: 'PUT', + body: JSON.stringify(body), + }), +}; diff --git a/src/api/admin/operations.api.ts b/src/api/admin/operations.api.ts new file mode 100644 index 0000000..d2df273 --- /dev/null +++ b/src/api/admin/operations.api.ts @@ -0,0 +1,70 @@ +import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; +import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, ProtocolInteractionLogResponse, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types'; + +// Read-heavy operations endpoints are isolated from configuration mutations. +export const adminOperationsApi = { + getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), + getSendQuality: (date?: string) => request(withQuery('/admin/operations/send-quality', { date })), + getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => + request(withQuery('/admin/operations/signature-quality', query)), + listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => + request(withQuery('/admin/system-logs', query)), + listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) => + request(withQuery('/admin/system-logs/protocol-interactions', query)), + exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) => + request('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), + listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/reports/reconciliation', query)), + exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) => + requestBlob(withQuery('/admin/reports/reconciliation/export', query)), + listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => + request & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)), + exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => + requestBlob(withQuery('/admin/reports/profit/export', query)), + listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => + request & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), + exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => + requestBlob(withQuery('/admin/reports/quality/export', query)), + listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) => + request(withQuery('/admin/send/batch-tasks', query)), + listAdminBatchTasksPage: (query: { tenantId?: string; status?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/send/batch-tasks', query)), + listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => + request(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)), + terminateAdminBatchTask: (id: string) => + request(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }), + listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) => + request(withQuery('/admin/send/messages', query)), + listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) => + request(withQuery('/admin/operations/message-segment-audits', query)), + listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/operations/messages', query)), + exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => + requestBlob(withQuery('/admin/operations/messages/export', query)), + listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => + request(withQuery('/admin/operations/uplink-messages', query)), + listAdminUplinkMessagesPage: (query: { tenantId?: string; channelId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/operations/uplink-messages', query)), + claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) => + request(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }), + listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), + listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) => + request(withQuery('/admin/operations/gateway-submit-dead-letters', query)), + requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) => + request(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }), + listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), + getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) => + request(withQuery('/admin/operations/downstream-deliveries/dashboard', query)), + listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; updatedAtFrom?: string; updatedAtTo?: string; page?: number; pageSize?: number } = {}) => + request(withQuery('/admin/operations/downstream-recovery-statuses', query)), + getDownstreamRecoveryStatus: (id: string) => + request(`/admin/operations/downstream-recovery-statuses/${id}`), + exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) => + requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)), + listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string } = {}) => + request>(withQuery('/admin/operations/downstream-deliveries', query)), + requeueDownstreamDelivery: (id: string) => + request(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }), + batchRequeueDownstreamDeliveries: (ids: string[]) => + request('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }), +}; diff --git a/src/api/admin/session.api.ts b/src/api/admin/session.api.ts new file mode 100644 index 0000000..22ed2ea --- /dev/null +++ b/src/api/admin/session.api.ts @@ -0,0 +1,14 @@ +import { request, type SessionTiming } from '../core/httpClient'; +import type { ManagedUser } from '../types'; +import type { LoginSession, Portal } from '../session'; + +export const portalSessionApi = { + current: (portal: Portal) => request(`/${portal}/auth/session`, { suppressSessionRedirect: true }), + touch: (portal: Portal) => request(`/${portal}/auth/session/touch`, { method: 'POST', body: '{}' }), + lock: (portal: Portal) => request<{ locked: boolean }>(`/${portal}/auth/session/lock`, { method: 'POST', body: '{}' }), + unlock: (portal: Portal, password: string) => request(`/${portal}/auth/session/unlock`, { method: 'POST', body: JSON.stringify({ password }) }), + reauthenticate: (portal: Portal, password: string) => request>(`/${portal}/auth/reauthenticate`, { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }), + logout: (portal: Portal) => request<{ success: boolean }>(`/${portal}/auth/logout`, { method: 'POST', body: '{}' }), + changeOwnPassword: (portal: Portal, body: { currentPassword: string; password: string }) => + request(`/${portal}/auth/password`, { method: 'POST', body: JSON.stringify(body) }), +}; diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 4e05b31..d1e4f6f 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -1,2207 +1,19 @@ -import { - clearSession, - currentRouteForPortal, - dispatchSessionEvent, - getSessionTenantId, - hasRecentUserActivity, - portalFromPath, - readSession, - redirectToPortalLogin, - requestReauthentication, - saveSessionRecovery, - type LoginSession, - type Portal, -} from './session'; -import { assertUploadFileSize } from '@/utils/fileUpload'; - -type RequestOptions = RequestInit & { - tenantId?: string; - reauthenticationAttempted?: boolean; - suppressSessionRedirect?: boolean; -}; - -export type DeletionTargetType = 'channel' | 'signature' | 'template'; -export type DeletionDependency = { kind: string; label: string; count: number; items: string[] }; -export type DeletionPreflight = { - type: DeletionTargetType; - id: string; - expectedUpdatedAt: string; - identity: Record; - dependencies: DeletionDependency[]; - impacts: string[]; - blockedReasons: string[]; - allowedActions: Array<'delete'>; - recoverability: { mode: 'soft_delete'; description: string }; -}; -export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string }; -export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean }; - -type ApiErrorBody = { message?: string | string[]; error?: string; code?: string }; - -async function readErrorBody(response: Response): Promise { - const text = await response.text(); - if (!text) return {}; - try { - return JSON.parse(text) as ApiErrorBody; - } catch { - return { message: text }; - } -} - -export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a'; - -type SessionTiming = Pick; - -function requestPortal(path: string): Portal | undefined { - return portalFromPath(path); -} - -async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) { - if (!portal) return false; - const session = readSession(portal); - const body = await readErrorBody(response.clone()); - if (body.code === 'SESSION_LOCKED' && session) { - dispatchSessionEvent(portal, 'locked', { message: body.message }); - throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定'); - } - if (suppressRedirect) return false; - - if (session) { - saveSessionRecovery(portal, { - returnUrl: currentRouteForPortal(portal), - code: body.code, - message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录', - }); - } - clearSession(portal); - dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message }); - redirectToPortalLogin(portal); - throw new Error('登录会话已失效,请重新登录'); -} - -async function readErrorMessage(response: Response) { - const fallback = `请求失败(${response.status})`; - const text = await response.text(); - if (!text) return fallback; - - try { - const parsed = JSON.parse(text) as { message?: string | string[]; error?: string }; - if (Array.isArray(parsed.message)) return parsed.message.join(';'); - if (parsed.message) return parsed.message; - if (parsed.error) return parsed.error; - } catch { - return text; - } - - return text; -} - -async function request(path: string, options: RequestOptions = {}): Promise { - const headers = new Headers(options.headers); - headers.set('Content-Type', 'application/json'); - const portal = requestPortal(path); - const session = portal ? readSession(portal) : null; - if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); - const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); - if (tenantId) { - headers.set('x-tenant-id', tenantId); - } - const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); - const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login'; - if (response.status === 401 && !isLoginAttempt) { - await handleSessionFailure(response, portal, options.suppressSessionRedirect); - } - if (response.status === 403 && session && !options.reauthenticationAttempted) { - const body = await readErrorBody(response.clone()); - if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { - await requestReauthentication(); - return request(path, { ...options, reauthenticationAttempted: true }); - } - } - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } - return response.json() as Promise; -} - -async function requestBlob(path: string, options: RequestOptions = {}): Promise { - const headers = new Headers(options.headers); - const portal = requestPortal(path); - const session = portal ? readSession(portal) : null; - if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); - const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); - if (tenantId) { - headers.set('x-tenant-id', tenantId); - } - const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); - if (response.status === 401) { - await handleSessionFailure(response, portal, options.suppressSessionRedirect); - } - if (response.status === 403 && session && !options.reauthenticationAttempted) { - const body = await readErrorBody(response.clone()); - if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { - await requestReauthentication(); - return requestBlob(path, { ...options, reauthenticationAttempted: true }); - } - } - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } - return response.blob(); -} - -async function requestForm(path: string, form: FormData, reauthenticationAttempted = false): Promise { - const headers = new Headers(); - const portal = requestPortal(path); - const session = portal ? readSession(portal) : null; - if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); - const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' }); - if (response.status === 401) { - await handleSessionFailure(response, portal); - throw new Error('登录会话已失效,请重新登录'); - } - if (response.status === 403 && session && !reauthenticationAttempted) { - const body = await readErrorBody(response.clone()); - if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { - await requestReauthentication(); - return requestForm(path, form, true); - } - } - if (!response.ok) throw new Error(await readErrorMessage(response)); - return response.json() as Promise; -} - -export type AdminChannel = { - id: string; - code: string; - name: string; - carrier?: string | null; - sendRegion?: string | null; - gatewayHost: string; - gatewayPort: number; - enterpriseCode?: string | null; - account: string; - srcId: string; - cmppVersion?: '2.0' | '3.0' | string | null; - rateLimitPerSecond: number; - unitPrice: number; - status: string; - config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null; - connectionStates?: CmppConnectionState[]; -}; - -export type ChannelConnectionLogResponse = { - channelId: string; - connectionStates: CmppConnectionState[]; - logs: Array<{ - id: string; - time: string; - event: string; - action: string; - resourceId?: string; - detail?: unknown; - }>; -}; - -export type ChannelTestResponse = { - channelId: string; - status: string; - testNo: string; - submitted: number; - messages: Array<{ - phoneNumber: string; - messageRecordId: string; - submitId: string; - streamMessageId?: string; - }>; - queuedAt: string; -}; - -export type EnterpriseCertification = { - id: string; - tenantId: string; - companyName: string; - licenseNo?: string | null; - contactName?: string | null; - contactPhone?: string | null; - materials?: Record | null; - status: string; - rejectReason?: string | null; - submittedAt: string; - reviewedAt?: string | null; - reviewer?: { id: string; username: string; displayName: string } | null; - tenant?: { id: string; name: string; code: string }; -}; - -export type AuditRecord = { - id: string; - tenantId?: string | null; - targetType: string; - targetId: string; - action: string; - statusBefore?: string | null; - statusAfter: string; - reason?: string | null; - reviewerId?: string | null; - reviewer?: { id: string; username: string; displayName: string } | null; - createdAt: string; -}; - -export type SmsTemplateAudit = { - id: string; - tenantId: string; - applicationId: string; - name: string; - content: string; - category?: string | null; - auditStatus: string; - rejectReason?: string | null; - createdAt: string; - updatedAt: string; - application?: { name: string }; - tenant?: { name: string }; -}; - -export type ReviewPreflight = { - type: 'signature' | 'template'; - id: string; - tenantId: string; - status: string; - expectedUpdatedAt: string; - identity: Record; - impacts: string[]; - materialSummary: Record; - blockedReasons: string[]; - allowedActions: Array<'approve' | 'reject'>; -}; - -export type ReviewDecisionResult = { - operationId: string; - replayed: boolean; - decision: 'approve' | 'reject'; - status: string; - item: ClientSmsSignature | SmsTemplateAudit; -}; - -export type TenantOption = { - id: string; - name: string; - code: string; - status: string; - enterpriseProfile?: { - creditCode?: string; - province?: string; - city?: string; - address?: string; - contactName?: string; - contactIdCard?: string; - contactPhone?: string; - contactEmail?: string; - photoFileObjectId?: string; - } | null; -}; - -export type TenantManagementRow = TenantOption & { - account?: TenantAccount | null; - todaySpendCents: number; - todayRefundCents: number; -}; - -export type CaptchaResponse = { - captchaId: string; - challenge: string; - expiresInSeconds: number; -}; - -export type ManagedUser = { - id: string; - tenantId?: string | null; - username: string; - email?: string | null; - phone?: string | null; - displayName: string; - status: string; - failedLoginCount: number; - lockedUntil?: string | null; - lastLoginAt?: string | null; - createdAt: string; - tenant?: TenantOption | null; - roles: Array<{ role: { code: string; name: string; scope: string } }>; -}; - -export type UserPayload = { - tenantId?: string | null; - username?: string; - email?: string | null; - phone?: string | null; - displayName: string; - password?: string; - status?: string; - roleCode: 'platform_admin' | 'enterprise_admin'; - operatorId?: string; -}; - -export type DashboardResponse = { - taskCount: number; - messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; - today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; returnedCents: number; billingUnits: number }; - uplinkCount: number; - billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; - transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } }; - gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; - pendingAuditCount: number; - pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number }; - hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>; - auditProcessingSpeed: Array<{ category: string; label: string; count: number; averageProcessingMs: number | null }>; - downstreamDeliverySummary?: { - pending: number; - failed: number; - delivered: number; - stalledPending: number; - stalledAck: number; - recentFailed: number; - alertCount: number; - }; - accounts: Array<{ id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; tenant?: TenantOption }>; - enterpriseSpendRanks: Array<{ tenantId: string; tenantName: string; todaySpendCents: number; balanceCents: number; creditCents: number }>; - recentTasks: Array>; - recentRecharges: Array; -}; - -export type ChannelQualityStat = { - channelId: string; - channelName: string; - total: number; - acceptedCount: number; - submitFailureCount: number; - submitFailureRate: number; - successCount: number; - unknownCount: number; - failureCount: number; - successRate: number; - unknownRate: number; - failureRate: number; - averageArrivalMs?: number | null; -}; - -export type SignatureQualityStat = { - id: string; - signatureId: string; - signatureName: string; - tenantId: string; - tenantName: string; - hasDrainage: boolean; - total: number; - acceptedCount: number; - submitFailureCount: number; - successCount: number; - unknownCount: number; - failureCount: number; - successRate: number; - averageArrivalMs?: number | null; -}; - -export type SignatureChannelCarrierQualityStat = { - signatureId: string; - channelId: string; - channelName: string; - carrier: string; - total: number; - acceptedCount: number; - submitFailureCount: number; - successCount: number; - unknownCount: number; - failureCount: number; - successRate: number; - averageArrivalMs?: number | null; -}; - -export type SignatureCarrierBusinessQualityStat = { - signatureId: string; - carrier: string; - businessMessageCount: number; - finalSuccessCount: number; - finalSuccessRate: number; - averageArrivalMs?: number | null; -}; - -export type SignatureChannelQualityItem = { - signatureId: string; - signatureName: string; - tenantId: string; - tenantName: string; - applicationNames?: string | null; - total: number; - acceptedCount: number; - submitFailureCount: number; - successCount: number; - unknownCount: number; - failureCount: number; - successRate: number; - averageArrivalMs?: number | null; - channelSubmitTotal: number; - carrierOverview: SignatureCarrierBusinessQualityStat[]; - breakdowns: SignatureChannelCarrierQualityStat[]; -}; - -export type SignatureChannelQualityResponse = { - date: string; - items: SignatureChannelQualityItem[]; - total: number; - page: number; - pageSize: number; -}; - -export type DailySendSummary = { - total: number; - successCount: number; - unknownCount: number; - failureCount: number; - successRate: number; -}; - -export type ApplicationQualityStat = DailySendSummary & { - applicationId: string; - applicationName: string; - tenantId: string; - tenantName: string; -}; - -export type SendQualityResponse = { - date: string; - summary: DailySendSummary; - channels: ChannelQualityStat[]; - signatures: SignatureQualityStat[]; - applications: ApplicationQualityStat[]; -}; - -export type RechargeOrder = { - id: string; - tenantId: string; - orderNo: string; - amountCents: number; - status: string; - payMethod?: string | null; - paidAt?: string | null; - operatorId?: string | null; - remark?: string | null; - balanceAfterCents?: number | null; - createdAt: string; - tenant?: TenantOption; -}; - -export type ManualRechargePreflight = { - tenant: Pick; - accountId: string; - expectedAccountUpdatedAt: string; - balanceCents: number; - creditCents: number; - amountCents: number; - balanceAfterCents: number; - direction: 'topup' | 'correction'; - allowedActions: Array<'confirm'>; - blockedReasons: string[]; -}; - -export type ManualRechargeResult = RechargeOrder & { - balanceAfterCents: number; - operationId: string; - replayed: boolean; -}; - -export type ClientSmsApplication = { - id: string; - tenantId: string; - name: string; - scene?: string | null; - customerUnitPrice?: number | null; - queuePriority?: 'normal' | 'priority' | string | null; - status: string; - dailyLimit?: number | null; - createdAt?: string; - updatedAt?: string; - sentToday?: number; - deliveryRate?: number; - cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; - interfaceEnabled?: boolean | null; - cmppConnections?: CmppDownstreamConnection[]; - httpConfig?: HttpApiConfig | null; -}; - -export type ClientSmsSignature = { - id: string; - tenantId: string; - applicationId?: string | null; - name: string; - purpose?: string | null; - drainageInfo?: Record | null; - auditStatus: string; - rejectReason?: string | null; - createdAt: string; - updatedAt: string; - materials?: Array>; - tenant?: TenantOption; - application?: ClientSmsApplication | null; - reportStatus?: string; - reportTasks?: Array; - reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>; - carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; - drainageReportTargets?: Record>; - drainageCarrierReportSummary?: Record>; -}; - -export type ClientSmsSignatureView = Pick & { - pendingReport?: boolean; - reportChangedAt?: string; - 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 }; - total: number; - page: number; - pageSize: number; -}; - -export type SmsDrainageInfo = { - id: string; - tenantId: string; - signatureId: string; - applicationId?: string | null; - siteName: string; - url: string; - remark?: string | null; - reportValues?: Record | null; - auditStatus: string; - rejectReason?: string | null; - submittedAt: string; - reviewedAt?: string | null; - createdAt: string; - updatedAt: string; - tenant?: TenantOption; - signature?: ClientSmsSignature; - application?: ClientSmsApplication | null; - reportTasks?: ReportTask[]; -}; - -export type ClientSmsTemplate = { - id: string; - tenantId: string; - applicationId: string; - signatureId?: string | null; - name: string; - content: string; - category?: string | null; - auditStatus: string; - rejectReason?: string | null; - createdAt: string; - updatedAt: string; - variables?: Array<{ name: string; example?: string | null; required?: boolean }>; - application?: { id: string; name: string }; - signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record | null }; - tenant?: TenantOption; -}; - -export type SmsBatchTask = { - id: string; - tenantId: string; - taskNo: string; - sourceType?: string; - contentHash?: string | null; - windowStartedAt?: string | null; - windowEndsAt?: string | null; - applicationId?: string | null; - templateId?: string | null; - content: string; - category?: string | null; - phoneTotal: number; - status: string; - auditStatus?: string | null; - reviewReason?: string | null; - rejectReason?: string | null; - progressTotal: number; - progressSent?: number; - progressDelivered?: number; - progressFailed?: number; - submittedTotal?: number; - successTotal?: number; - failedTotal?: number; - unknownTotal?: number; - timeoutTotal?: number; - scheduledAt?: string | null; - canceledAt?: string | null; - createdAt: string; - tenant?: TenantOption; - application?: { id: string; name: string }; - template?: { id: string; name: string; content: string; billingUnits?: number }; - messages?: SmsMessageRecord[]; - messageStats?: Array<{ - batchTaskId?: string | null; - carrier?: string | null; - province?: string | null; - status: string; - _count: { _all: number }; - _sum: { billingUnits?: number | null }; - }>; -}; - -export type ImportPreviewResponse = { - fileName?: string; - encoding: string; - totalRows: number; - validCount: number; - errorCount: number; - phones: string[]; - errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }>; -}; - -export type SmsMessageRecord = { - id: string; - tenantId?: string | null; - batchTaskId?: string | null; - applicationId?: string | null; - channelId?: string | null; - messageId: string; - phoneNumber: string; - carrier?: string | null; - province?: string | null; - content: string; - clientSrcId?: string | null; - applicationExtension?: string | null; - billingUnits: number; - amountCents: number; - status: string; - errorMessage?: string | null; - errorCode?: string | null; - queuedAt: string; - submittedAt?: string | null; - deliveredAt?: string | null; - receiptStatus?: string | null; - submitStatus?: string | null; - channel?: AdminChannel | null; - tenant?: TenantOption | null; - application?: { id: string; name: string }; - submitRecords?: SmsSubmitRecord[]; - receiptRecords?: SmsReceiptRecord[]; - downstreamDeliveries?: Array<{ - id: string; - deliveryType: string; - status: string; - deliveredAt?: string | null; - lastError?: string | null; - }>; -}; - -export type SmsSubmitRecord = { - id: string; - channelId: string; - channelGroupName?: string | null; - submitId: string; - sequenceId?: number | null; - gatewayMessageId?: string | null; - submitStatus: string; - errorCode?: string | null; - errorMessage?: string | null; - submittedAt?: string | null; - createdAt: string; - channel?: AdminChannel | null; - channelGroup?: { id: string; name: string } | null; -}; - -export type SmsReceiptRecord = { - id: string; - channelId?: string | null; - messageId: string; - gatewayMessageId: string; - sequenceId?: number | null; - receiptStatus: string; - rawStatus: string; - errorCode?: string | null; - deliveredAt: string; - createdAt: string; - channel?: AdminChannel | null; -}; - -export type SmsMessageSegmentAudit = { - id: string; - tenantId: string; - batchTaskId?: string | null; - messageRecordId: string; - submitRecordId?: string | null; - channelId?: string | null; - submitId: string; - attempt: number; - segmentTotal: number; - segmentIndex: number; - sequenceId?: number | null; - gatewayMessageId?: string | null; - submitStatus: string; - receiptStatus?: string | null; - rawStatus?: string | null; - compensationType?: string | null; - errorCode?: string | null; - errorMessage?: string | null; - submittedAt?: string | null; - deliveredAt?: string | null; - createdAt: string; - updatedAt: string; - channel?: AdminChannel | null; -}; - -export type SmsUplinkMessage = { - id: string; - tenantId?: string | null; - channelId: string; - applicationId?: string | null; - messageRecordId?: string | null; - messageId?: string | null; - sequenceId?: number | null; - phoneNumber: string; - destId: string; - content: string; - matchStatus?: string; - matchReason?: string | null; - receivedAt: string; - createdAt: string; - tenant?: TenantOption | null; - application?: { id: string; name: string } | null; - messageRecord?: SmsMessageRecord | null; - channel?: AdminChannel | null; - matchCandidates?: SmsUplinkMatchCandidate[]; -}; - -export type SmsUplinkMatchCandidate = { - id: string; - uplinkMessageId: string; - tenantId: string; - applicationId: string; - messageRecordId?: string | null; - matchSource: string; - confidence: number; - reason?: string | null; - status: string; - claimedAt?: string | null; - claimedById?: string | null; - createdAt: string; - updatedAt: string; - tenant?: TenantOption | null; - application?: { id: string; name: string } | null; - 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; - createdAt?: string; - updatedAt?: string; -}; - -export type ChannelGroup = DictionaryItem & { - code: string; - name: string; - carrier: 'mobile' | 'unicom' | 'telecom'; - description?: string | null; - retryEnabled?: boolean; - retryTimeLimitHours?: number; - retryTimeLimitMinutes?: number; - items?: ChannelGroupItem[]; -}; - -export type ChannelGroupItem = DictionaryItem & { - groupId: string; - channelId: string; - carrier?: 'mobile' | 'unicom' | 'telecom' | null; - province?: string | null; - priority: number; - weight?: number; - isBackup?: boolean; - channel?: AdminChannel; -}; - -export type ChannelReportField = DictionaryItem & { - channelId: string; - drainageFieldId?: string | null; - reportType?: 'signature' | 'drainage' | 'both'; - code: string; - name: string; - fieldType: string; - required: boolean; - description?: string | null; - sortOrder?: number; - exportName?: string | null; - columnWidth?: number; - imageWidth?: number; - imageHeight?: number; - defaultValue?: string | null; - transform?: string | null; - drainageField?: DictionaryItem | null; -}; - -export type ReportMaterialPendingItem = { - id: string; - reportType: 'signature' | 'drainage'; - signatureId: string; - drainageItemId?: string | null; - materialVersion: number; - changedAt: string; - name: string; - detail?: string | null; - signatureName?: string; - tenant?: TenantOption; - application?: ClientSmsApplication | null; -}; - -export type PagedResult = { - items: T[]; - total: number; - page: number; - pageSize: number; -}; - -export type ReportMaterialBatch = { - id: string; - batchNo: string; - status: string; - selectedCount: number; - channelCount: number; - fileCount: number; - reportTotal: number; - successCount: number; - successRate: number; - createdAt: string; - completedAt?: string | null; - exportFiles: Array<{ id: string; fileObjectId?: string | null; fileName: string; rowCount: number; channelId?: string | null }>; -}; - -export type ReportImportReviewItem = { - id: string; - rowNumber: number; - reportType: 'signature' | 'drainage'; - operation: 'create' | 'update' | 'invalid'; - targetId?: string | null; - status: string; - payload: Record; - originalSnapshot?: Record | null; - errorMessage?: string | null; - reviewReason?: string | null; - reviewedAt?: string | null; - reviewer?: { id: string; username: string; displayName: string } | null; -}; - -export type ReportImportReviewBatch = { - id: string; - tenantId: string; - applicationId?: string | null; - fileName: string; - reportType: 'signature' | 'drainage'; - status: string; - rowCount: number; - successCount: number; - failedCount: number; - createdAt: string; - reviewedAt?: string | null; - tenant?: { id: string; name: string } | null; - application?: { id: string; name: string } | null; - reviewer?: { id: string; username: string; displayName: string } | null; - items: ReportImportReviewItem[]; -}; - -export type ReportMaterialPreflightTarget = { - id: string; - name: string; - carrier: string; - businessKey: string; - eligible: boolean; - blockedReasons: string[]; - duplicateBatchId?: string; -}; - -export type ReportMaterialPreflightItem = { - id: string; - reportType: 'signature' | 'drainage'; - signatureId: string; - drainageItemId?: string; - materialVersion: number; - name: string; - tenantName: string; - applicationId?: string; - applicationName: string; - eligible: boolean; - blockedReasons: string[]; - targets: ReportMaterialPreflightTarget[]; -}; - -export type ReportMaterialBatchPreflight = { - checkedAt: string; - eligible: boolean; - eligibleItemCount: number; - blockedItemCount: number; - eligibleTargetCount: number; - skippedTargetCount: number; - items: ReportMaterialPreflightItem[]; -}; - -export type ReportMaterialBatchResult = Record & { - id: string; - batchNo: string; - status: string; - operationId: string; - replayed: boolean; - result: { successCount: number; skippedCount: number; failedCount: number; items: ReportMaterialPreflightItem[] }; -}; - -export type ReportImportMapping = { - sourceHeader: string; - sourceHeaderPath?: string; - sourceColumnIndex: number; - targetFieldCode: string; - targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic'; - fieldType: 'string' | 'image' | 'file'; - required?: boolean; - transform?: string; - sortOrder?: number; -}; - -export type ReportImportProfile = { - id: string; - name: string; - reportType: 'signature' | 'drainage'; - tenantId?: string | null; - applicationId?: string | null; - sheetName?: string | null; - headerRowCount: number; - dataStartRow: number; - columns: ReportImportMapping[]; -}; - -export type ApplicationReportField = { - id: string; - code: string; - name: string; - fieldType: string; - required: boolean; - description?: string | null; - reportTypes: string[]; - commonReportTypes?: Array<'signature' | 'drainage'>; - 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'; - required: boolean; - sortOrder: number; - drainageField: DictionaryItem & { code?: string; name?: string; fieldType?: string; description?: string | null }; -}; - -export type ReportTask = DictionaryItem & { - tenantId: string; - signatureId: string; - channelId: string; - reportType?: 'signature' | 'drainage'; - drainageItemId?: string | null; - status: string; - signature?: { - id: string; - name: string; - purpose?: string | null; - drainageInfo?: Record | null; - tenant?: { id: string; name: string }; - application?: { id: string; name: string } | null; - }; - drainageInfo?: SmsDrainageInfo | null; - channel?: { id: string; name: string; code: string }; - reason?: string | null; - createdAt?: string; - updatedAt?: string; - exportItems?: Array<{ - id: string; - rowNumber: number; - exportFile: { id: string; fileObjectId?: string | null; fileName: string; rowCount: number; batchId?: string | null }; - batchItem: { id: string; materialVersion: number; batch: { id: string; batchNo: string; createdAt: string } }; - }>; - records?: Array<{ - id: string; - action: string; - statusBefore?: string | null; - statusAfter: string; - reason?: string | null; - createdAt: string; - }>; - deliveryStats?: { - total: number; - acceptedCount: number; - submitFailureCount: number; - submitFailureRate: number; - successCount: number; - successRate: number; - unknownCount: number; - unknownRate: number; - failureCount: number; - failureRate: number; - }; - lastSuccessfulSentAt?: string | null; -}; - -export type ReportRecord = DictionaryItem & { - taskId: string; - channelId: string; - action: string; - statusBefore?: string | null; - statusAfter?: string | null; - reason?: string | null; - sourceEntry?: 'system' | 'legacy' | 'enterprise_signature' | 'report_task' | 'channel_report'; - channel?: AdminChannel; - task?: ReportTask; -}; - -export type FileObject = { - id: string; - tenantId?: string | null; - bucket: string; - objectKey: string; - fileName: string; - contentType: string; - sizeBytes: string | number; - purpose: string; - createdAt: string; -}; - -export type FileRef = { - fileObjectId: string; - fileName: string; - contentType?: string; -}; - -export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') { - return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`; -} - -export type RiskReviewTask = { - id: string; - tenantId: string; - applicationId?: string | null; - templateId?: string | null; - taskNo: string; - sourceType?: string; - contentHash?: string | null; - windowStartedAt?: string | null; - windowEndsAt?: string | null; - content: string; - category?: string | null; - phoneTotal: number; - uniquePhoneTotal: number; - duplicateRatio: number; - illegalRatio: number; - blacklistHitRatio: number; - variableIssues?: unknown; - status: string; - riskDecision: string; - reviewReason?: string | null; - rejectReason?: string | null; - createdAt: string; - reviewedAt?: string | null; - tenant?: { id: string; name: string } | null; - application?: { id: string; name: string } | null; - reviewedBy?: { id: string; username: string; displayName: string } | null; - riskHits?: Array<{ id: string; ruleName: string; reason: string }>; - _count?: { messageRecords: number }; -}; - -export type RiskRuleItem = { - id: string; - tenantId?: string | null; - applicationId?: string | null; - code: 'MAX_PHONES_PER_TASK' | 'NON_WORKING_MARKETING_BULK' | 'TASK_CREATE_FREQUENCY'; - name: string; - description?: string | null; - metric: string; - thresholdValue: number; - action: 'block' | 'manual_review'; - status: 'active' | 'inactive'; - priority: number; - config?: { startTime?: string; endTime?: string; timeZone?: string } | null; - application?: { id: string; name: string; tenantId: string; tenant?: { id: string; name: string } } | null; - updatedAt: string; -}; - -export type RiskTaskMessagePage = { - items: Array<{ - id: string; - phoneNumber: string; - province?: string | null; - carrier?: string | null; - status: string; - }>; - total: number; - page: number; - pageSize: number; -}; - -export type BatchTaskMessagePage = RiskTaskMessagePage; - -export type TenantAccount = { - id: string; - tenantId: string; - balanceCents: number; - creditCents: number; - status: string; - updatedAt?: string; - tenant?: TenantOption; -}; - -export type OperationLogItem = { - id: string; - time: string; - level: 'info' | 'success' | 'warning' | 'error'; - tenant: string; - module: string; - operator: string; - action: string; - resourceId: string; - detail: Record; - ip: string; - userAgent: string; -}; - -export type OperationLogResponse = { - items: OperationLogItem[]; - total: number; - page: number; - pageSize: number; - modules: string[]; -}; - -export type ProtocolInteractionLogItem = { - id: string; - protocol: 'cmpp' | 'http'; - direction: 'client_to_platform' | 'platform_to_channel' | 'channel_to_platform' | 'platform_to_client'; - eventType: string; - status: 'received' | 'accepted' | 'success' | 'failed' | 'retrying'; - tenantId?: string | null; - applicationId?: string | null; - channelId?: string | null; - account?: string | null; - messageId?: string | null; - gatewayMessageId?: string | null; - traceId?: string | null; - requestId?: string | null; - phoneMasked?: string | null; - resultCode?: string | null; - durationMs?: number | null; - payloadBytes?: number | null; - retryCount?: number | null; - detail?: Record | null; - createdAt: string; -}; - -export type ProtocolInteractionLogResponse = { - items: ProtocolInteractionLogItem[]; - total: number; - page: number; - pageSize: number; - eventTypes: string[]; -}; - -export type SystemLogExportResult = { - operationId: string; - status: 'completed'; - fileName: string; - recordCount: number; - truncated: boolean; - content: string; - filters: { keyword?: string; level?: string; module?: string; range?: string }; -}; - -export type PagedResponse = { - items: T[]; - total: number; - page: number; - pageSize: number; -}; - -export type DailyReconciliationReport = { - id: string; - reportDate: string; - tenantId: string; - tenantName: string; - applicationId: string; - applicationName: string; - submittedUnits: number; - sentUnits: number; - unknownUnits: number; - successUnits: number; - failedUnits: number; - generatedAt: string; - updatedAt: string; -}; - -export type DailyProfitReport = { - id: string; - reportDate: string; - dimensionType: 'application' | 'channel'; - dimensionId: string; - dimensionName: string; - tenantId?: string | null; - tenantName?: string | null; - applicationId?: string | null; - channelId?: string | null; - submittedUnits: number; - sentUnits: number; - unknownUnits: number; - successUnits: number; - failedUnits: number; - revenueCents: number; - refundCents: number; - costCents: number; - profitCents: number; - profitRateBps: number; - generatedAt: string; - updatedAt: string; -}; - -export type DailyQualityReport = { - id: string; - reportDate: string; - dimensionType: 'application' | 'channel' | 'signature' | 'drainage'; - dimensionId: string; - dimensionName: string; - tenantId?: string | null; - tenantName?: string | null; - applicationId?: string | null; - channelId?: string | null; - signatureId?: string | null; - drainageInfoId?: string | null; - submittedUnits: number; - sentUnits: number; - unknownUnits: number; - successUnits: number; - failedUnits: number; - successRateBps: number; - avgArrivalMs?: number | null; - generatedAt: string; - updatedAt: string; -}; - -export type CursorPage = { - items: T[]; - pageSize: number; - hasMore: boolean; - nextCursor: string | null; -}; - -export type EnterpriseApplication = { - id: string; - tenantId: string; - name: string; - scene?: string | null; - status: string; - disablingAt?: string | null; - autoDisableAt?: string | null; - disableReason?: string | null; - deactivation?: ApplicationDeactivationPreview | null; - dailyLimit?: number | null; - customerUnitPrice?: number | null; - queuePriority?: 'normal' | 'priority' | string | null; - templateMismatchMode?: string | null; - downstreamReceiptRetryEnabled?: boolean | null; - downstreamUplinkRetryEnabled?: boolean | null; - cmppAccount?: string | null; - cmppEnterpriseCode?: string | null; - cmppApplicationExtension?: string | null; - cmppAccessNumberFillEnabled?: boolean | null; - cmppAccessNumberFillPrefix?: string | null; - cmppClientSrcId?: string | null; - interfaceEnabled?: boolean | null; - interfaceType?: 'cmpp20' | string | null; - cmppMaxConnections?: number | null; - cmppWindowSize?: number | null; - ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; - httpConfig?: HttpApiConfig | null; - tenant?: TenantOption; - sentToday?: number; - deliveryRate?: number; - cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; - cmppConnections?: CmppDownstreamConnection[]; -}; - -export type CmppDownstreamConnection = { - id: string; - tenantId: string; - applicationId: string; - account: string; - enterpriseCode: string; - connectionId: string; - remoteIp?: string | null; - protocol?: string | null; - status: string; - connectedAt: string; - lastHeartbeatAt?: string | null; - lastSubmitAt?: string | null; - lastDeliverAt?: string | null; - disconnectedAt?: string | null; - lastError?: string | null; - updatedAt: string; -}; - -export type CmppConnectionState = { - id: string; - tenantId?: string | null; - applicationId?: string | null; - channelId: string; - connectionId: string; - status: string; - desiredConnections: number; - currentConnections: number; - lastConnectedAt?: string | null; - lastDisconnectedAt?: string | null; - lastHeartbeatAt?: string | null; - reconnectCount: number; - lastReconnectAttemptAt?: string | null; - nextReconnectAt?: string | null; - lastErrorCategory?: string | null; - lastError?: string | null; - updatedAt: string; - channel?: AdminChannel; -}; - -export type ApplicationConnectionsResponse = { - application: EnterpriseApplication; - connections: CmppDownstreamConnection[]; - summary: { desiredConnections: number; currentConnections: number; status: string }; -}; - -export type ApplicationCmppParams = { - applicationId: string; - applicationName: string; - tenantName: string; - appCode: string; - gatewayHost: string; - gatewayPort: number; - enterpriseCode: string; - account: string; - passwordCipher: string; - srcId: string; - applicationExtension?: string | null; - accessNumberFillEnabled?: boolean; - accessNumberFillPrefix?: string | null; - interfaceEnabled?: boolean; - interfaceType?: string; - maxConnections: number; - heartbeatSeconds: number; - windowSize: number; - protocolVersion: string; -}; - -export type DownstreamDeliveryRecord = { - id: string; - tenantId: string; - applicationId: string; - messageRecordId?: string | null; - messageId?: string | null; - deliveryType: string; - status: string; - payload: Record; - retryCount: number; - manualRetryCount: number; - lastRetriedAt?: string | null; - retryEnabled: boolean; - nextRetryAt?: string | null; - sentAt?: string | null; - acknowledgedAt?: string | null; - ackDeadlineAt?: string | null; - ackResult?: number | null; - ackSequenceId?: string | null; - ackMessageId?: string | null; - connectionId?: string | null; - deliveredAt?: string | null; - lastError?: string | null; - createdAt: string; - updatedAt: string; - tenant?: TenantOption | null; - application?: EnterpriseApplication | null; - messageRecord?: SmsMessageRecord | null; - attempts?: Array<{ - id: string; - attemptNo: number; - connectionId?: string | null; - sequenceId?: string | null; - messageId?: string | null; - status: string; - sentAt?: string | null; - ackDeadlineAt?: string | null; - acknowledgedAt?: string | null; - ackResult?: number | null; - failureType?: string | null; - errorMessage?: string | null; - createdAt: string; - updatedAt: string; - }>; -}; - -export type ApplicationDeactivationPreview = { - status: string; - reason?: string | null; - disablingAt?: string | null; - autoDisableAt?: string | null; - awaitingSupplierReceipt: number; - waitingToSend: number; - awaitingClientAck: number; - retryableFailures: number; - pendingUplinks: number; - activeConnections: number; - totalOutstanding: number; -}; - -export type BatchRequeueResponse = { - total: number; - successCount: number; - failedCount: number; - results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }>; -}; - -export type DownstreamDeliveryDashboard = { - summary: { - total: number; - pending: number; - awaitingAck: number; - delivered: number; - failed: number; - unconfirmed: number; - rejected: number; - stalledPending: number; - stalledAck: number; - recentFailed: number; - alertCount: number; - }; - typeBreakdown: Array<{ - deliveryType: string; - total: number; - pending: number; - awaitingAck: number; - delivered: number; - failed: number; - unconfirmed: number; - rejected: number; - }>; - retryBuckets: Array<{ - label: string; - count: number; - }>; - topApplications: Array<{ - applicationId: string; - name: string; - pending: number; - awaitingAck: number; - failed: number; - unconfirmed: number; - rejected: number; - delivered: number; - alertCount: number; - }>; -}; - -export type GatewayDownstreamRecoveryStatus = { - id: string; - account: string; - tenantId?: string | null; - applicationId?: string | null; - gatewayInstanceId?: string | null; - state: string; - lockOwner?: string | null; - lockExpiresAt?: string | null; - lastAttemptAt?: string | null; - lastSuccessAt?: string | null; - lastFailureAt?: string | null; - nextRetryAt?: string | null; - attemptCount: number; - failureCategory?: string | null; - lastError?: string | null; - lastSkipReason?: string | null; - createdAt: string; - updatedAt: string; - tenant?: TenantOption | null; - application?: EnterpriseApplication | null; -}; - -export type GatewaySubmitException = { - id: string; - streamMessageId: string; - tenantId?: string | null; - applicationId?: string | null; - channelId?: string | null; - traceId?: string | null; - messageId?: string | null; - submitId?: string | null; - status: 'pending' | 'requeueing' | 'requeued' | 'resolved' | string; - failureCode: string; - failureMessage: string; - attempts: number; - maxAttempts: number; - commandPayload?: Record | null; - rawPayloadAvailable?: boolean; - messageState?: { - status: string; - submitStatus?: string | null; - receiptStatus?: string | null; - phoneNumber: string; - content: string; - } | null; - manualRetryCount: number; - lastRetryStreamId?: string | null; - lastRetriedAt?: string | null; - resolvedAt?: string | null; - resolvedStatus?: string | null; - createdAt: string; - updatedAt: string; - tenant?: Pick | null; - application?: Pick | null; - channel?: Pick | null; -}; - -export type GatewaySubmitExceptionResponse = PagedResponse & { - summary: { - pending: number; - requeueing: number; - requeued: number; - resolved: number; - oldestPendingAt?: string | null; - }; -}; - -export type DownstreamRecoveryStatusResponse = PagedResponse & { - summary: { - total: number; - running: number; - success: number; - failed: number; - waitingConnection: number; - backoff: number; - failureCategories: Array<{ category: string; count: number }>; - }; -}; - -export type DownstreamRecoveryStatusExportQuery = { - tenantId?: string; - applicationId?: string; - state?: string; - failureCategory?: string; - keyword?: string; - updatedAtFrom?: string; - updatedAtTo?: string; -}; - -function withQuery(path: string, query: Record) { - const params = new URLSearchParams(); - Object.entries(query).forEach(([key, value]) => { - if (value !== undefined && value !== '' && value !== 'all') { - params.set(key, String(value)); - } - }); - const suffix = params.toString() ? `?${params}` : ''; - return `${path}${suffix}`; -} - -export const portalSessionApi = { - current: (portal: Portal) => request(`/${portal}/auth/session`, { suppressSessionRedirect: true }), - touch: (portal: Portal) => request(`/${portal}/auth/session/touch`, { method: 'POST', body: '{}' }), - lock: (portal: Portal) => request<{ locked: boolean }>(`/${portal}/auth/session/lock`, { method: 'POST', body: '{}' }), - unlock: (portal: Portal, password: string) => request(`/${portal}/auth/session/unlock`, { method: 'POST', body: JSON.stringify({ password }) }), - reauthenticate: (portal: Portal, password: string) => request>(`/${portal}/auth/reauthenticate`, { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }), - logout: (portal: Portal) => request<{ success: boolean }>(`/${portal}/auth/logout`, { method: 'POST', body: '{}' }), - changeOwnPassword: (portal: Portal, body: { currentPassword: string; password: string }) => - request(`/${portal}/auth/password`, { method: 'POST', body: JSON.stringify(body) }), -}; +// Stable R1 compatibility facade. Existing pages continue importing from this file. +export * from './types'; +export { fileDownloadUrl } from './core/httpClient'; +export { portalSessionApi } from './admin/session.api'; +export { clientApi } from './client/client.api'; + +import { adminIdentityApi } from './admin/identity.api'; +import { adminChannelsReportsApi } from './admin/channels-reports.api'; +import { adminOperationsApi } from './admin/operations.api'; +import { adminGovernanceApi } from './admin/governance.api'; +import { adminFilesApi } from './admin/files.api'; export const adminApi = { - getCaptcha: () => request('/admin/auth/captcha'), - login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => - request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), - touchSession: () => portalSessionApi.touch('admin'), - lockSession: () => portalSessionApi.lock('admin'), - unlockSession: (password: string) => portalSessionApi.unlock('admin', password), - reauthenticate: (password: string) => portalSessionApi.reauthenticate('admin', password), - logout: () => portalSessionApi.logout('admin'), - changeOwnPassword: (body: { currentPassword: string; password: string }) => - portalSessionApi.changeOwnPassword('admin', body), - listTenants: () => request('/admin/tenants'), - listTenantManagementRows: () => request('/admin/tenants/management-list'), - getTenant: (id: string) => request(`/admin/tenants/${id}`), - createTenant: (body: { name: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => - request('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), - updateTenant: (id: string, body: { name?: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => - request(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - changeTenantStatus: (id: string, status: string) => - request(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), - deleteTenant: (id: string) => request(`/admin/tenants/${id}`, { method: 'DELETE' }), - listUsers: (query: { tenantId?: string; roleCode?: string; displayName?: string; login?: string; status?: string } = {}) => - request(withQuery('/admin/users', query)), - createUser: (body: UserPayload) => request('/admin/users', { method: 'POST', body: JSON.stringify(body) }), - updateUser: (id: string, body: Omit) => request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - changeUserStatus: (id: string, status: string, operatorId?: string) => - request(`/admin/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status, operatorId }) }), - deleteUser: (id: string, operatorId?: string) => request(`/admin/users/${id}`, { method: 'DELETE', body: JSON.stringify({ operatorId }) }), - changeUserPassword: (id: string, password: string, operatorId?: string) => - request(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }), - getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), - getSendQuality: (date?: string) => request(withQuery('/admin/operations/send-quality', { date })), - getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => - request(withQuery('/admin/operations/signature-quality', query)), - listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => - request(withQuery('/admin/system-logs', query)), - listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) => - request(withQuery('/admin/system-logs/protocol-interactions', query)), - exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) => - request('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), - listAccounts: () => request('/admin/billing/accounts'), - updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) => - request(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), - listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), - listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/billing/manual-recharges', query)), - preflightManualRecharge: (body: { tenantId: string; amountCents: number }) => - request('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), - createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => - request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), - listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) => - request(withQuery('/admin/enterprise-applications', query)), - listEnterpriseApplicationsPage: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/enterprise-applications', query)), - listEnterpriseApplicationOptions: (query: { tenantId?: string } = {}) => - request(withQuery('/admin/enterprise-application-options', query)), - getEnterpriseApplication: (id: string) => - request(`/admin/enterprise-applications/${id}`), - createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => - request('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), - updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => - request(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - getApplicationDeactivationPreview: (id: string) => - request(`/admin/enterprise-applications/${id}/deactivation-preview`), - changeApplicationStatus: (id: string, status: string, reason?: string, force = false) => - request(`/admin/enterprise-applications/${id}/status`, { - method: 'POST', - body: JSON.stringify({ status, reason, force }), - }), - listApplicationConnections: (applicationId: string) => - request(`/admin/enterprise-applications/${applicationId}/connections`), - listApplicationReportFields: (applicationId: string, reportType?: 'signature' | 'drainage') => - request(withQuery(`/admin/enterprise-applications/${applicationId}/report-fields`, { reportType })), - listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') => - 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) }), - listApplicationHttpWebhooks: (applicationId: string) => - request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks`), - saveApplicationHttpWebhook: ( - applicationId: string, - eventType: 'receipt' | 'uplink', - body: { url: string; rotateSecret?: boolean; status?: string }, - ) => request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks/${eventType}`, { - method: 'PUT', - body: JSON.stringify(body), - }), - listChannels: () => request('/admin/channels'), - listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/channels', query)), - listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/reports/reconciliation', query)), - exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) => - requestBlob(withQuery('/admin/reports/reconciliation/export', query)), - listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => - request & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)), - exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => - requestBlob(withQuery('/admin/reports/profit/export', query)), - listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => - request & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), - exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => - requestBlob(withQuery('/admin/reports/quality/export', query)), - createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => - request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), - updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => - request(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { - method: 'POST', - body: JSON.stringify(body), - }), - testChannel: (id: string, body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string }) => - request(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }), - changeChannelStatus: (id: string, status: string, reason?: string) => request(`/admin/channels/${id}/status`, { - method: 'POST', - body: JSON.stringify({ status, reason }), - }), - deleteChannel: (id: string, reason?: string) => request(`/admin/channels/${id}`, { - method: 'DELETE', - body: JSON.stringify({ reason }), - }), - getDeletionPreflight: (type: DeletionTargetType, id: string) => - request(`/admin/deletions/${type}/${id}/preflight`), - deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) => - request(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }), - listChannelConnectionLogs: (id: string) => request(`/admin/channels/${id}/connection-logs`), - listTemplateAudits: (query: { keyword?: string; status?: string }) => { - const params = new URLSearchParams(); - if (query.keyword) params.set('keyword', query.keyword); - if (query.status && query.status !== 'all') params.set('status', query.status); - const suffix = params.toString() ? `?${params}` : ''; - return request(`/admin/enterprise-templates${suffix}`); - }, - approveTemplate: (id: string) => request(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), - rejectTemplate: (id: string, reason = '运营审核驳回') => request(`/admin/templates/${id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason }), - }), - approveSignature: (id: string) => request(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), - rejectSignature: (id: string, reason = '运营审核驳回') => request(`/admin/signatures/${id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason }), - }), - getReviewPreflight: (type: 'signature' | 'template', id: string) => - request(`/admin/reviews/${type}/${id}/preflight`), - submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) => - request(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), - listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) => - request(withQuery('/admin/enterprise-signatures', query)), - listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/enterprise-signatures', query)), - listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => - request(withQuery('/admin/enterprise-signature-options', query)), - createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => - request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), - updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record }) => - request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => - request(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) => - request(withQuery('/admin/drainage-infos', query)), - listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) => - request(withQuery('/admin/audit-records', query)), - createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }) => - request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), - updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }) => - request(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - approveDrainageInfo: (id: string) => - request(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), - rejectDrainageInfo: (id: string, reason: string) => - request(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), - changeDrainageInfoStatus: (id: string, status: string, reason?: string) => - request(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) => - request(withQuery('/admin/enterprise-templates', query)), - listEnterpriseTemplatesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/enterprise-templates', query)), - createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => - request('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), - updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => - request(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) => - request(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => { - const params = new URLSearchParams(); - if (query.keyword) params.set('keyword', query.keyword); - if (query.status && query.status !== 'all') params.set('status', query.status); - const suffix = params.toString() ? `?${params}` : ''; - return request(`/admin/enterprise-certifications${suffix}`); - }, - getEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}`), - approveEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}/approve`, { - method: 'POST', - body: JSON.stringify({}), - }), - rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request(`/admin/enterprise-certifications/${id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason }), - }), - listChannelGroups: () => request('/admin/channel-groups'), - createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) => - request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), - updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array> }) => - request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - deleteChannelGroup: (id: string) => - request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), - addChannelGroupItem: (body: Record) => - request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), - listChannelRouteRules: () => request('/admin/channel-route-rules'), - createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => - request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), - listChannelConnections: (id: string) => request(`/admin/channels/${id}/connections`), - replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => - request(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), - listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), - createChannelReportField: (body: Record) => - request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), - replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array>) => - request(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }), - listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/report-materials/pending', query)), - listReportImportProfiles: (reportType?: 'signature' | 'drainage') => - request(withQuery('/admin/report-materials/import-profiles', { reportType })), - saveReportImportProfile: (body: Omit & { id?: string }) => - request('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }), - analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => { - assertUploadFileSize(file); - const form = new FormData(); - form.set('file', file); - Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); }); - return requestForm & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form); - }, - commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }) => - request>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }), - listReportImportReviewBatches: (query: { reportType?: 'signature' | 'drainage'; status?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/report-materials/imports/review-batches', query)), - reviewReportImportItems: (id: string, body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }) => - request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(`/admin/report-materials/imports/${id}/review`, { method: 'POST', body: JSON.stringify(body) }), - listReportMaterialBatches: (query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/report-materials/batches', query)), - preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) => - request('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }), - createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => - request('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), - listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), - listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/report-tasks', query)), - createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => - request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), - changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) => - request }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), - createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => - request>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), - importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => - request>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), - listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), - listReportRecordsPage: (query: { taskId?: string; channelId?: string; keyword?: string; reportType?: 'signature' | 'drainage'; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/report-records', query)), - listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) => - request(withQuery('/admin/send/batch-tasks', query)), - listAdminBatchTasksPage: (query: { tenantId?: string; status?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/send/batch-tasks', query)), - listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => - request(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)), - terminateAdminBatchTask: (id: string) => - request(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }), - listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) => - request(withQuery('/admin/send/messages', query)), - listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) => - request(withQuery('/admin/operations/message-segment-audits', query)), - listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/operations/messages', query)), - exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => - requestBlob(withQuery('/admin/operations/messages/export', query)), - listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => - request(withQuery('/admin/operations/uplink-messages', query)), - listAdminUplinkMessagesPage: (query: { tenantId?: string; channelId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/operations/uplink-messages', query)), - claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) => - request(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }), - listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), - listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) => - request(withQuery('/admin/operations/gateway-submit-dead-letters', query)), - requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) => - request(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }), - listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), - getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) => - request(withQuery('/admin/operations/downstream-deliveries/dashboard', query)), - listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; updatedAtFrom?: string; updatedAtTo?: string; page?: number; pageSize?: number } = {}) => - request(withQuery('/admin/operations/downstream-recovery-statuses', query)), - getDownstreamRecoveryStatus: (id: string) => - request(`/admin/operations/downstream-recovery-statuses/${id}`), - exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) => - requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)), - listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string } = {}) => - request>(withQuery('/admin/operations/downstream-deliveries', query)), - requeueDownstreamDelivery: (id: string) => - request(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }), - batchRequeueDownstreamDeliveries: (ids: string[]) => - request('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }), - listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), - listRiskRules: (applicationId?: string) => - request(withQuery('/admin/risk-review/rules', { applicationId })), - createRiskRule: (body: { - applicationId?: string; - code: RiskRuleItem['code']; - thresholdValue: number; - action: RiskRuleItem['action']; - status: RiskRuleItem['status']; - priority?: number; - config?: RiskRuleItem['config']; - }) => request('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }), - updateRiskRule: (id: string, body: { - thresholdValue?: number; - action?: RiskRuleItem['action']; - status?: RiskRuleItem['status']; - priority?: number; - config?: RiskRuleItem['config']; - }) => request(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => - request(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)), - approveRiskReviewTask: (id: string, reason?: string) => - request(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }), - rejectRiskReviewTask: (id: string, reason?: string) => - request(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), - rejectRiskReviewTasks: (ids: string[], reason: string) => - request('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }), - listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/sensitive-words', query)), - createSensitiveWord: (body: { word: string; level?: string; status?: string }) => - request('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }), - deleteSensitiveWord: (id: string) => request(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }), - listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/global', query)), - createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => - request('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), - deleteGlobalBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), - listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), - createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => - request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), - deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), - listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => - request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)), - createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => - request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), - deletePhoneSegment: (id: string) => request(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }), - listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => - request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), - createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => - request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), - deletePhoneCarrierRule: (id: string) => request(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }), - listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), - createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => - request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), - deleteDrainageField: (id: string) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), - listCommonReportFields: () => request('/admin/dictionaries/common-report-fields'), - createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => - request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), - deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), - uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { - assertUploadFileSize(file); - const form = new FormData(); - form.set('file', file); - form.set('purpose', body.purpose); - if (body.prefix) { - form.set('prefix', body.prefix); - } - const headers = new Headers(); - const session = readSession('admin'); - if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); - if (tenantId) { - headers.set('x-tenant-id', tenantId); - } - const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); - if (response.status === 401 && session) { - const error = await readErrorBody(response.clone()); - if (error.code === 'SESSION_LOCKED') { - dispatchSessionEvent('admin', 'locked', { message: error.message }); - } else { - clearSession('admin'); - dispatchSessionEvent('admin', 'logout', { code: error.code, message: error.message }); - redirectToPortalLogin('admin'); - } - } - if (!response.ok) { - throw new Error(await response.text()); - } - return response.json() as Promise; - }, -}; - -export const clientApi = { - getCaptcha: () => request('/client/auth/captcha'), - login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => - request('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }), - listUsers: ( - query: { displayName?: string; login?: string; status?: string } = {}, - tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID, - ) => request(withQuery('/client/users', query), { tenantId }), - createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }), - updateUser: (id: string, body: Omit, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), - changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }), - deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }), - changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }), - getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/operations/dashboard', { tenantId }), - listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/enterprise-certification', { tenantId }), - submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/enterprise-certification', { - method: 'POST', - tenantId, - body: JSON.stringify({ ...body, tenantId }), - }), - listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/operations/system-logs', query), { tenantId }), - exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string }) => - request('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), - listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/billing/orders', { tenantId }), - listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request>(withQuery('/client/billing/orders', query), { tenantId }), - listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/applications', { tenantId }), - listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request>(withQuery('/client/applications', query), { tenantId }), - listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/application-options', { 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 }), - listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/report-fields/common', { reportType }), { tenantId }), - listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/signatures', { tenantId }), - listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/signature-options', { tenantId }), - getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/signatures-workspace', query), { 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 }) }), - 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({}) }), - changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - 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) => - request(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }), - updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), - changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), - listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/templates', { - status: query.status, - keyword: query.keyword, - includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), - }), { tenantId }), - listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request>(withQuery('/client/templates', { - keyword: query.keyword, - includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), - page: query.page, - pageSize: query.pageSize, - }), { tenantId }), - createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), - updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), - submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), - changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), - getDeletionPreflight: (type: Exclude, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/deletions/${type}/${id}/preflight`, { tenantId }), - deleteGovernedTarget: (type: Exclude, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }), - listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/send/batch-tasks', query), { tenantId }), - listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request>(withQuery('/client/send/batch-tasks', query), { tenantId }), - cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), - createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), - previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), - confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), - listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/send/batch-tasks/${id}/messages`, { tenantId }), - listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request>(withQuery('/client/operations/messages', query), { tenantId }), - 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 }), - listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request>(withQuery('/client/operations/uplink-messages', query), { tenantId }), - uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => { - assertUploadFileSize(file); - const form = new FormData(); - form.set('file', file); - form.set('purpose', body.purpose); - if (body.prefix) { - form.set('prefix', body.prefix); - } - const headers = new Headers(); - const session = readSession('client'); - if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); - const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); - if (response.status === 401 && session) { - const error = await readErrorBody(response.clone()); - if (error.code === 'SESSION_LOCKED') { - dispatchSessionEvent('client', 'locked', { message: error.message }); - } else { - clearSession('client'); - dispatchSessionEvent('client', 'logout', { code: error.code, message: error.message }); - redirectToPortalLogin('client'); - } - } - if (!response.ok) { - throw new Error(await response.text()); - } - return response.json() as Promise; - }, + ...adminIdentityApi, + ...adminChannelsReportsApi, + ...adminOperationsApi, + ...adminGovernanceApi, + ...adminFilesApi, }; diff --git a/src/api/client/client.api.ts b/src/api/client/client.api.ts new file mode 100644 index 0000000..2def77c --- /dev/null +++ b/src/api/client/client.api.ts @@ -0,0 +1,162 @@ +import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; +import type { ApplicationCmppParams, CaptchaResponse, ClientApplicationReportField, ClientSignatureWorkspace, ClientSmsApplication, ClientSmsSignatureView, ClientSmsTemplate, DashboardResponse, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, EnterpriseCertification, FileObject, HttpApiConfigResponse, HttpApiCredential, HttpApiRequestLog, HttpWebhookDelivery, HttpWebhookEndpoint, ImportPreviewResponse, ManagedUser, OperationLogResponse, PagedResult, RechargeOrder, SmsBatchTask, SmsDrainageInfo, SmsMessageRecord, SmsUplinkMessage, SystemLogExportResult, UserPayload } from '../types'; +import { assertUploadFileSize } from '@/utils/fileUpload'; +import type { LoginSession } from '../session'; +import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, redirectToPortalLogin } from '../session'; +import { readErrorBody } from '../core/httpClient'; +import { DEFAULT_CLIENT_TENANT_ID } from '../types'; + +// Client methods moved intact during R1; tenant and session behavior still flows +// through the shared HTTP client and the existing upload path below. +export const clientApi = { + getCaptcha: () => request('/client/auth/captcha'), + login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => + request('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }), + listUsers: ( + query: { displayName?: string; login?: string; status?: string } = {}, + tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID, + ) => request(withQuery('/client/users', query), { tenantId }), + createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }), + updateUser: (id: string, body: Omit, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), + changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }), + deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }), + changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }), + getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/operations/dashboard', { tenantId }), + listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/enterprise-certification', { tenantId }), + submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/enterprise-certification', { + method: 'POST', + tenantId, + body: JSON.stringify({ ...body, tenantId }), + }), + listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/operations/system-logs', query), { tenantId }), + exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string }) => + request('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), + listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/billing/orders', { tenantId }), + listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/billing/orders', query), { tenantId }), + listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/applications', { tenantId }), + listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/applications', query), { tenantId }), + listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/application-options', { 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 }), + listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/report-fields/common', { reportType }), { tenantId }), + listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/signatures', { tenantId }), + listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/signature-options', { tenantId }), + getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/signatures-workspace', query), { 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 }) }), + 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({}) }), + changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + 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) => + request(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }), + updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), + changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), + listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/templates', { + status: query.status, + keyword: query.keyword, + includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), + }), { tenantId }), + listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/templates', { + keyword: query.keyword, + includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), + page: query.page, + pageSize: query.pageSize, + }), { tenantId }), + createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), + submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), + changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), + getDeletionPreflight: (type: Exclude, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/deletions/${type}/${id}/preflight`, { tenantId }), + deleteGovernedTarget: (type: Exclude, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }), + listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/send/batch-tasks', query), { tenantId }), + listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/send/batch-tasks', query), { tenantId }), + cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), + createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/send/batch-tasks/${id}/messages`, { tenantId }), + listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/operations/messages', query), { tenantId }), + 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 }), + listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/operations/uplink-messages', query), { tenantId }), + uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => { + assertUploadFileSize(file); + const form = new FormData(); + form.set('file', file); + form.set('purpose', body.purpose); + if (body.prefix) { + form.set('prefix', body.prefix); + } + const headers = new Headers(); + const session = readSession('client'); + if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); + const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); + if (response.status === 401 && session) { + const error = await readErrorBody(response.clone()); + if (error.code === 'SESSION_LOCKED') { + dispatchSessionEvent('client', 'locked', { message: error.message }); + } else { + clearSession('client'); + dispatchSessionEvent('client', 'logout', { code: error.code, message: error.message }); + redirectToPortalLogin('client'); + } + } + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; + }, +}; diff --git a/src/api/core/httpClient.ts b/src/api/core/httpClient.ts new file mode 100644 index 0000000..6e5e772 --- /dev/null +++ b/src/api/core/httpClient.ts @@ -0,0 +1,174 @@ +import { + clearSession, + currentRouteForPortal, + dispatchSessionEvent, + getSessionTenantId, + hasRecentUserActivity, + portalFromPath, + readSession, + redirectToPortalLogin, + requestReauthentication, + saveSessionRecovery, + type LoginSession, + type Portal, +} from '../session'; + +type RequestOptions = RequestInit & { + tenantId?: string; + reauthenticationAttempted?: boolean; + suppressSessionRedirect?: boolean; +}; + + +type ApiErrorBody = { message?: string | string[]; error?: string; code?: string }; + +export async function readErrorBody(response: Response): Promise { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text) as ApiErrorBody; + } catch { + return { message: text }; + } +} + + +export type SessionTiming = Pick; + +// Authentication failures are handled centrally so every domain API keeps the +// same lock, recovery and redirect behavior as the original adminApi facade. +function requestPortal(path: string): Portal | undefined { + return portalFromPath(path); +} + +async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) { + if (!portal) return false; + const session = readSession(portal); + const body = await readErrorBody(response.clone()); + if (body.code === 'SESSION_LOCKED' && session) { + dispatchSessionEvent(portal, 'locked', { message: body.message }); + throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定'); + } + if (suppressRedirect) return false; + + if (session) { + saveSessionRecovery(portal, { + returnUrl: currentRouteForPortal(portal), + code: body.code, + message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录', + }); + } + clearSession(portal); + dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message }); + redirectToPortalLogin(portal); + throw new Error('登录会话已失效,请重新登录'); +} + +async function readErrorMessage(response: Response) { + const fallback = `请求失败(${response.status})`; + const text = await response.text(); + if (!text) return fallback; + + try { + const parsed = JSON.parse(text) as { message?: string | string[]; error?: string }; + if (Array.isArray(parsed.message)) return parsed.message.join(';'); + if (parsed.message) return parsed.message; + if (parsed.error) return parsed.error; + } catch { + return text; + } + + return text; +} + +export async function request(path: string, options: RequestOptions = {}): Promise { + const headers = new Headers(options.headers); + headers.set('Content-Type', 'application/json'); + const portal = requestPortal(path); + const session = portal ? readSession(portal) : null; + if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); + const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); + if (tenantId) { + headers.set('x-tenant-id', tenantId); + } + const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); + const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login'; + if (response.status === 401 && !isLoginAttempt) { + await handleSessionFailure(response, portal, options.suppressSessionRedirect); + } + if (response.status === 403 && session && !options.reauthenticationAttempted) { + const body = await readErrorBody(response.clone()); + if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { + await requestReauthentication(); + return request(path, { ...options, reauthenticationAttempted: true }); + } + } + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + return response.json() as Promise; +} + +export async function requestBlob(path: string, options: RequestOptions = {}): Promise { + const headers = new Headers(options.headers); + const portal = requestPortal(path); + const session = portal ? readSession(portal) : null; + if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); + const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); + if (tenantId) { + headers.set('x-tenant-id', tenantId); + } + const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); + if (response.status === 401) { + await handleSessionFailure(response, portal, options.suppressSessionRedirect); + } + if (response.status === 403 && session && !options.reauthenticationAttempted) { + const body = await readErrorBody(response.clone()); + if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { + await requestReauthentication(); + return requestBlob(path, { ...options, reauthenticationAttempted: true }); + } + } + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + return response.blob(); +} + +export async function requestForm(path: string, form: FormData, reauthenticationAttempted = false): Promise { + const headers = new Headers(); + const portal = requestPortal(path); + const session = portal ? readSession(portal) : null; + if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); + const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' }); + if (response.status === 401) { + await handleSessionFailure(response, portal); + throw new Error('登录会话已失效,请重新登录'); + } + if (response.status === 403 && session && !reauthenticationAttempted) { + const body = await readErrorBody(response.clone()); + if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { + await requestReauthentication(); + return requestForm(path, form, true); + } + } + if (!response.ok) throw new Error(await readErrorMessage(response)); + return response.json() as Promise; +} + + +export function withQuery(path: string, query: Record) { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== '' && value !== 'all') { + params.set(key, String(value)); + } + }); + const suffix = params.toString() ? `?${params}` : ''; + return `${path}${suffix}`; +} + + +export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') { + return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`; +} diff --git a/src/api/types/channels-reports.ts b/src/api/types/channels-reports.ts new file mode 100644 index 0000000..a01f112 --- /dev/null +++ b/src/api/types/channels-reports.ts @@ -0,0 +1,310 @@ +// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts. + +import type { ClientSmsApplication, CmppConnectionState, SmsDrainageInfo, TenantOption } from './identity-config'; + +export type AdminChannel = { + id: string; + code: string; + name: string; + carrier?: string | null; + sendRegion?: string | null; + gatewayHost: string; + gatewayPort: number; + enterpriseCode?: string | null; + account: string; + srcId: string; + cmppVersion?: '2.0' | '3.0' | string | null; + rateLimitPerSecond: number; + unitPrice: number; + status: string; + config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null; + connectionStates?: CmppConnectionState[]; +}; + +export type ChannelConnectionLogResponse = { + channelId: string; + connectionStates: CmppConnectionState[]; + logs: Array<{ + id: string; + time: string; + event: string; + action: string; + resourceId?: string; + detail?: unknown; + }>; +}; + +export type ChannelTestResponse = { + channelId: string; + status: string; + testNo: string; + submitted: number; + messages: Array<{ + phoneNumber: string; + messageRecordId: string; + submitId: string; + streamMessageId?: string; + }>; + queuedAt: string; +}; + +export type DictionaryItem = Record & { + id: string; + status?: string; + createdAt?: string; + updatedAt?: string; +}; + +export type ChannelGroup = DictionaryItem & { + code: string; + name: string; + carrier: 'mobile' | 'unicom' | 'telecom'; + description?: string | null; + retryEnabled?: boolean; + retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; + items?: ChannelGroupItem[]; +}; + +export type ChannelGroupItem = DictionaryItem & { + groupId: string; + channelId: string; + carrier?: 'mobile' | 'unicom' | 'telecom' | null; + province?: string | null; + priority: number; + weight?: number; + isBackup?: boolean; + channel?: AdminChannel; +}; + +export type ChannelReportField = DictionaryItem & { + channelId: string; + drainageFieldId?: string | null; + reportType?: 'signature' | 'drainage' | 'both'; + code: string; + name: string; + fieldType: string; + required: boolean; + description?: string | null; + sortOrder?: number; + exportName?: string | null; + columnWidth?: number; + imageWidth?: number; + imageHeight?: number; + defaultValue?: string | null; + transform?: string | null; + drainageField?: DictionaryItem | null; +}; + +export type ReportMaterialPendingItem = { + id: string; + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string | null; + materialVersion: number; + changedAt: string; + name: string; + detail?: string | null; + signatureName?: string; + tenant?: TenantOption; + application?: ClientSmsApplication | null; +}; + +export type ReportMaterialBatch = { + id: string; + batchNo: string; + status: string; + selectedCount: number; + channelCount: number; + fileCount: number; + reportTotal: number; + successCount: number; + successRate: number; + createdAt: string; + completedAt?: string | null; + exportFiles: Array<{ id: string; fileObjectId?: string | null; fileName: string; rowCount: number; channelId?: string | null }>; +}; + +export type ReportImportReviewItem = { + id: string; + rowNumber: number; + reportType: 'signature' | 'drainage'; + operation: 'create' | 'update' | 'invalid'; + targetId?: string | null; + status: string; + payload: Record; + originalSnapshot?: Record | null; + errorMessage?: string | null; + reviewReason?: string | null; + reviewedAt?: string | null; + reviewer?: { id: string; username: string; displayName: string } | null; +}; + +export type ReportImportReviewBatch = { + id: string; + tenantId: string; + applicationId?: string | null; + fileName: string; + reportType: 'signature' | 'drainage'; + status: string; + rowCount: number; + successCount: number; + failedCount: number; + createdAt: string; + reviewedAt?: string | null; + tenant?: { id: string; name: string } | null; + application?: { id: string; name: string } | null; + reviewer?: { id: string; username: string; displayName: string } | null; + items: ReportImportReviewItem[]; +}; + +export type ReportMaterialPreflightTarget = { + id: string; + name: string; + carrier: string; + businessKey: string; + eligible: boolean; + blockedReasons: string[]; + duplicateBatchId?: string; +}; + +export type ReportMaterialPreflightItem = { + id: string; + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string; + materialVersion: number; + name: string; + tenantName: string; + applicationId?: string; + applicationName: string; + eligible: boolean; + blockedReasons: string[]; + targets: ReportMaterialPreflightTarget[]; +}; + +export type ReportMaterialBatchPreflight = { + checkedAt: string; + eligible: boolean; + eligibleItemCount: number; + blockedItemCount: number; + eligibleTargetCount: number; + skippedTargetCount: number; + items: ReportMaterialPreflightItem[]; +}; + +export type ReportMaterialBatchResult = Record & { + id: string; + batchNo: string; + status: string; + operationId: string; + replayed: boolean; + result: { successCount: number; skippedCount: number; failedCount: number; items: ReportMaterialPreflightItem[] }; +}; + +export type ReportImportMapping = { + sourceHeader: string; + sourceHeaderPath?: string; + sourceColumnIndex: number; + targetFieldCode: string; + targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic'; + fieldType: 'string' | 'image' | 'file'; + required?: boolean; + transform?: string; + sortOrder?: number; +}; + +export type ReportImportProfile = { + id: string; + name: string; + reportType: 'signature' | 'drainage'; + tenantId?: string | null; + applicationId?: string | null; + sheetName?: string | null; + headerRowCount: number; + dataStartRow: number; + columns: ReportImportMapping[]; +}; + +export type ApplicationReportField = { + id: string; + code: string; + name: string; + fieldType: string; + required: boolean; + description?: string | null; + reportTypes: string[]; + commonReportTypes?: Array<'signature' | 'drainage'>; + 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'; + required: boolean; + sortOrder: number; + drainageField: DictionaryItem & { code?: string; name?: string; fieldType?: string; description?: string | null }; +}; + +export type ReportTask = DictionaryItem & { + tenantId: string; + signatureId: string; + channelId: string; + reportType?: 'signature' | 'drainage'; + drainageItemId?: string | null; + status: string; + signature?: { + id: string; + name: string; + purpose?: string | null; + drainageInfo?: Record | null; + tenant?: { id: string; name: string }; + application?: { id: string; name: string } | null; + }; + drainageInfo?: SmsDrainageInfo | null; + channel?: { id: string; name: string; code: string }; + reason?: string | null; + createdAt?: string; + updatedAt?: string; + exportItems?: Array<{ + id: string; + rowNumber: number; + exportFile: { id: string; fileObjectId?: string | null; fileName: string; rowCount: number; batchId?: string | null }; + batchItem: { id: string; materialVersion: number; batch: { id: string; batchNo: string; createdAt: string } }; + }>; + records?: Array<{ + id: string; + action: string; + statusBefore?: string | null; + statusAfter: string; + reason?: string | null; + createdAt: string; + }>; + deliveryStats?: { + total: number; + acceptedCount: number; + submitFailureCount: number; + submitFailureRate: number; + successCount: number; + successRate: number; + unknownCount: number; + unknownRate: number; + failureCount: number; + failureRate: number; + }; + lastSuccessfulSentAt?: string | null; +}; + +export type ReportRecord = DictionaryItem & { + taskId: string; + channelId: string; + action: string; + statusBefore?: string | null; + statusAfter?: string | null; + reason?: string | null; + sourceEntry?: 'system' | 'legacy' | 'enterprise_signature' | 'report_task' | 'channel_report'; + channel?: AdminChannel; + task?: ReportTask; +}; diff --git a/src/api/types/common.ts b/src/api/types/common.ts new file mode 100644 index 0000000..80522de --- /dev/null +++ b/src/api/types/common.ts @@ -0,0 +1,64 @@ +// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts. + + +export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a'; + + +export type DeletionTargetType = 'channel' | 'signature' | 'template'; + +export type DeletionDependency = { kind: string; label: string; count: number; items: string[] }; + +export type DeletionPreflight = { + type: DeletionTargetType; + id: string; + expectedUpdatedAt: string; + identity: Record; + dependencies: DeletionDependency[]; + impacts: string[]; + blockedReasons: string[]; + allowedActions: Array<'delete'>; + recoverability: { mode: 'soft_delete'; description: string }; +}; + +export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string }; + +export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean }; + +export type PagedResult = { + items: T[]; + total: number; + page: number; + pageSize: number; +}; + +export type FileObject = { + id: string; + tenantId?: string | null; + bucket: string; + objectKey: string; + fileName: string; + contentType: string; + sizeBytes: string | number; + purpose: string; + createdAt: string; +}; + +export type FileRef = { + fileObjectId: string; + fileName: string; + contentType?: string; +}; + +export type PagedResponse = { + items: T[]; + total: number; + page: number; + pageSize: number; +}; + +export type CursorPage = { + items: T[]; + pageSize: number; + hasMore: boolean; + nextCursor: string | null; +}; diff --git a/src/api/types/governance.ts b/src/api/types/governance.ts new file mode 100644 index 0000000..18a4bb4 --- /dev/null +++ b/src/api/types/governance.ts @@ -0,0 +1,96 @@ +// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts. + +export type RiskReviewTask = { + id: string; + tenantId: string; + applicationId?: string | null; + templateId?: string | null; + taskNo: string; + sourceType?: string; + contentHash?: string | null; + windowStartedAt?: string | null; + windowEndsAt?: string | null; + content: string; + category?: string | null; + phoneTotal: number; + uniquePhoneTotal: number; + duplicateRatio: number; + illegalRatio: number; + blacklistHitRatio: number; + variableIssues?: unknown; + status: string; + riskDecision: string; + reviewReason?: string | null; + rejectReason?: string | null; + createdAt: string; + reviewedAt?: string | null; + tenant?: { id: string; name: string } | null; + application?: { id: string; name: string } | null; + reviewedBy?: { id: string; username: string; displayName: string } | null; + riskHits?: Array<{ id: string; ruleName: string; reason: string }>; + _count?: { messageRecords: number }; +}; + +export type RiskRuleItem = { + id: string; + tenantId?: string | null; + applicationId?: string | null; + code: 'MAX_PHONES_PER_TASK' | 'NON_WORKING_MARKETING_BULK' | 'TASK_CREATE_FREQUENCY' | 'PHONE_FREQUENCY_24H' | 'PHONE_FREQUENCY_5M'; + name: string; + description?: string | null; + metric: string; + thresholdValue: number; + action: 'block' | 'manual_review'; + status: 'active' | 'inactive'; + priority: number; + config?: { startTime?: string; endTime?: string; timeZone?: string; periodSeconds?: number; alignment?: string } | null; + application?: { id: string; name: string; tenantId: string; tenant?: { id: string; name: string } } | null; + updatedAt: string; +}; + +export type PhoneFrequencyHit = { + id: string; + tenantId: string; + applicationId: string; + ruleCode: 'PHONE_FREQUENCY_24H' | 'PHONE_FREQUENCY_5M'; + ruleName: string; + phoneNumber: string; + thresholdValue: number; + actualValue: number; + windowStartedAt: string; + windowEndsAt: string; + releasedAt?: string | null; + releaseReason?: string | null; + createdAt: string; + tenant: { id: string; name: string }; + application: { id: string; name: string }; + releasedBy?: { id: string; username: string; displayName: string } | null; +}; + +export type PhoneFrequencyWhitelistItem = { + id: string; + phoneNumber: string; + status: 'active' | 'inactive' | 'deleted'; + reason: string; + remark?: string | null; + deletedAt?: string | null; + createdAt: string; + updatedAt: string; + createdBy: { id: string; username: string; displayName: string }; + updatedBy: { id: string; username: string; displayName: string }; +}; + +export type RiskTaskMessagePage = { + items: Array<{ + id: string; + phoneNumber: string; + province?: string | null; + carrier?: string | null; + status: string; + }>; + total: number; + page: number; + pageSize: number; +}; + +export type BatchTaskMessagePage = RiskTaskMessagePage; diff --git a/src/api/types/identity-config.ts b/src/api/types/identity-config.ts new file mode 100644 index 0000000..ed13c9f --- /dev/null +++ b/src/api/types/identity-config.ts @@ -0,0 +1,496 @@ +// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts. + +import type { AdminChannel, ReportTask } from './channels-reports'; +import type { ApplicationDeactivationPreview, SmsMessageRecord, TenantAccount } from './operations'; + +export type EnterpriseCertification = { + id: string; + tenantId: string; + companyName: string; + licenseNo?: string | null; + contactName?: string | null; + contactPhone?: string | null; + materials?: Record | null; + status: string; + rejectReason?: string | null; + submittedAt: string; + reviewedAt?: string | null; + reviewer?: { id: string; username: string; displayName: string } | null; + tenant?: { id: string; name: string; code: string }; +}; + +export type AuditRecord = { + id: string; + tenantId?: string | null; + targetType: string; + targetId: string; + action: string; + statusBefore?: string | null; + statusAfter: string; + reason?: string | null; + reviewerId?: string | null; + reviewer?: { id: string; username: string; displayName: string } | null; + createdAt: string; +}; + +export type SmsTemplateAudit = { + id: string; + tenantId: string; + applicationId: string; + name: string; + content: string; + category?: string | null; + auditStatus: string; + rejectReason?: string | null; + createdAt: string; + updatedAt: string; + application?: { name: string }; + tenant?: { name: string }; +}; + +export type ReviewPreflight = { + type: 'signature' | 'template'; + id: string; + tenantId: string; + status: string; + expectedUpdatedAt: string; + identity: Record; + impacts: string[]; + materialSummary: Record; + blockedReasons: string[]; + allowedActions: Array<'approve' | 'reject'>; +}; + +export type ReviewDecisionResult = { + operationId: string; + replayed: boolean; + decision: 'approve' | 'reject'; + status: string; + item: ClientSmsSignature | SmsTemplateAudit; +}; + +export type TenantOption = { + id: string; + name: string; + code: string; + status: string; + enterpriseProfile?: { + creditCode?: string; + province?: string; + city?: string; + address?: string; + contactName?: string; + contactIdCard?: string; + contactPhone?: string; + contactEmail?: string; + photoFileObjectId?: string; + } | null; +}; + +export type TenantManagementRow = TenantOption & { + account?: TenantAccount | null; + todaySpendCents: number; + todayRefundCents: number; +}; + +export type CaptchaResponse = { + captchaId: string; + challenge: string; + expiresInSeconds: number; +}; + +export type ManagedUser = { + id: string; + tenantId?: string | null; + username: string; + email?: string | null; + phone?: string | null; + displayName: string; + status: string; + failedLoginCount: number; + lockedUntil?: string | null; + lastLoginAt?: string | null; + createdAt: string; + tenant?: TenantOption | null; + roles: Array<{ role: { code: string; name: string; scope: string } }>; +}; + +export type UserPayload = { + tenantId?: string | null; + username?: string; + email?: string | null; + phone?: string | null; + displayName: string; + password?: string; + status?: string; + roleCode: 'platform_admin' | 'enterprise_admin'; + operatorId?: string; +}; + +export type DashboardResponse = { + taskCount: number; + messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; + today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; returnedCents: number; billingUnits: number }; + uplinkCount: number; + billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; + transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } }; + gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; + pendingAuditCount: number; + pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number }; + hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>; + auditProcessingSpeed: Array<{ category: string; label: string; count: number; averageProcessingMs: number | null }>; + downstreamDeliverySummary?: { + pending: number; + failed: number; + delivered: number; + stalledPending: number; + stalledAck: number; + recentFailed: number; + alertCount: number; + }; + accounts: Array<{ id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; tenant?: TenantOption }>; + enterpriseSpendRanks: Array<{ tenantId: string; tenantName: string; todaySpendCents: number; balanceCents: number; creditCents: number }>; + recentTasks: Array>; + recentRecharges: Array; + clientOverview?: { + enterpriseName: string | null; + certificationStatus: 'certified' | 'uncertified'; + signatureCount: number; + pendingBatchTaskCount: number; + }; +}; + +export type RechargeOrder = { + id: string; + tenantId: string; + orderNo: string; + amountCents: number; + status: string; + payMethod?: string | null; + paidAt?: string | null; + operatorId?: string | null; + remark?: string | null; + balanceAfterCents?: number | null; + createdAt: string; + tenant?: TenantOption; +}; + +export type ManualRechargePreflight = { + tenant: Pick; + accountId: string; + expectedAccountUpdatedAt: string; + balanceCents: number; + creditCents: number; + amountCents: number; + balanceAfterCents: number; + direction: 'topup' | 'correction'; + allowedActions: Array<'confirm'>; + blockedReasons: string[]; +}; + +export type ManualRechargeResult = RechargeOrder & { + balanceAfterCents: number; + operationId: string; + replayed: boolean; +}; + +export type ClientSmsApplication = { + id: string; + tenantId: string; + name: string; + scene?: string | null; + customerUnitPrice?: number | null; + queuePriority?: 'normal' | 'priority' | string | null; + status: string; + dailyLimit?: number | null; + createdAt?: string; + updatedAt?: string; + sentToday?: number; + deliveryRate?: number; + cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; + interfaceEnabled?: boolean | null; + cmppConnections?: CmppDownstreamConnection[]; + httpConfig?: HttpApiConfig | null; +}; + +export type ClientSmsSignature = { + id: string; + tenantId: string; + applicationId?: string | null; + name: string; + purpose?: string | null; + drainageInfo?: Record | null; + auditStatus: string; + rejectReason?: string | null; + createdAt: string; + updatedAt: string; + materials?: Array>; + tenant?: TenantOption; + application?: ClientSmsApplication | null; + reportStatus?: string; + reportTasks?: Array; + reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>; + carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; + drainageReportTargets?: Record>; + drainageCarrierReportSummary?: Record>; +}; + +export type ClientSmsSignatureView = Pick & { + pendingReport?: boolean; + reportChangedAt?: string; + 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 }; + total: number; + page: number; + pageSize: number; +}; + +export type SmsDrainageInfo = { + id: string; + tenantId: string; + signatureId: string; + applicationId?: string | null; + siteName: string; + url: string; + remark?: string | null; + reportValues?: Record | null; + auditStatus: string; + rejectReason?: string | null; + submittedAt: string; + reviewedAt?: string | null; + createdAt: string; + updatedAt: string; + tenant?: TenantOption; + signature?: ClientSmsSignature; + application?: ClientSmsApplication | null; + reportTasks?: ReportTask[]; +}; + +export type ClientSmsTemplate = { + id: string; + tenantId: string; + applicationId: string; + signatureId?: string | null; + name: string; + content: string; + category?: string | null; + auditStatus: string; + rejectReason?: string | null; + createdAt: string; + updatedAt: string; + variables?: Array<{ name: string; example?: string | null; required?: boolean }>; + application?: { id: string; name: string }; + signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record | null }; + tenant?: TenantOption; +}; + +export type SmsBatchTask = { + id: string; + tenantId: string; + taskNo: string; + sourceType?: string; + contentHash?: string | null; + windowStartedAt?: string | null; + windowEndsAt?: string | null; + applicationId?: string | null; + templateId?: string | null; + content: string; + category?: string | null; + phoneTotal: number; + status: string; + auditStatus?: string | null; + reviewReason?: string | null; + rejectReason?: string | null; + progressTotal: number; + progressSent?: number; + progressDelivered?: number; + progressFailed?: number; + submittedTotal?: number; + successTotal?: number; + failedTotal?: number; + unknownTotal?: number; + timeoutTotal?: number; + scheduledAt?: string | null; + canceledAt?: string | null; + createdAt: string; + tenant?: TenantOption; + application?: { id: string; name: string }; + template?: { id: string; name: string; content: string; billingUnits?: number }; + messages?: SmsMessageRecord[]; + messageStats?: Array<{ + batchTaskId?: string | null; + carrier?: string | null; + province?: string | null; + status: string; + _count: { _all: number }; + _sum: { billingUnits?: number | null }; + }>; +}; + +export type ImportPreviewResponse = { + fileName?: string; + encoding: string; + totalRows: number; + validCount: number; + errorCount: number; + phones: string[]; + errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }>; +}; + +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 EnterpriseApplication = { + id: string; + tenantId: string; + name: string; + scene?: string | null; + status: string; + disablingAt?: string | null; + autoDisableAt?: string | null; + disableReason?: string | null; + deactivation?: ApplicationDeactivationPreview | null; + dailyLimit?: number | null; + customerUnitPrice?: number | null; + queuePriority?: 'normal' | 'priority' | string | null; + templateMismatchMode?: string | null; + downstreamReceiptRetryEnabled?: boolean | null; + downstreamUplinkRetryEnabled?: boolean | null; + cmppAccount?: string | null; + cmppEnterpriseCode?: string | null; + cmppApplicationExtension?: string | null; + cmppAccessNumberFillEnabled?: boolean | null; + cmppAccessNumberFillPrefix?: string | null; + cmppClientSrcId?: string | null; + interfaceEnabled?: boolean | null; + interfaceType?: 'cmpp20' | string | null; + cmppMaxConnections?: number | null; + cmppWindowSize?: number | null; + ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; + httpConfig?: HttpApiConfig | null; + tenant?: TenantOption; + sentToday?: number; + deliveryRate?: number; + cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; + cmppConnections?: CmppDownstreamConnection[]; +}; + +export type CmppDownstreamConnection = { + id: string; + tenantId: string; + applicationId: string; + account: string; + enterpriseCode: string; + connectionId: string; + remoteIp?: string | null; + protocol?: string | null; + status: string; + connectedAt: string; + lastHeartbeatAt?: string | null; + lastSubmitAt?: string | null; + lastDeliverAt?: string | null; + disconnectedAt?: string | null; + lastError?: string | null; + updatedAt: string; +}; + +export type CmppConnectionState = { + id: string; + tenantId?: string | null; + applicationId?: string | null; + channelId: string; + connectionId: string; + status: string; + desiredConnections: number; + currentConnections: number; + lastConnectedAt?: string | null; + lastDisconnectedAt?: string | null; + lastHeartbeatAt?: string | null; + reconnectCount: number; + lastReconnectAttemptAt?: string | null; + nextReconnectAt?: string | null; + lastErrorCategory?: string | null; + lastError?: string | null; + updatedAt: string; + channel?: AdminChannel; +}; + +export type ApplicationConnectionsResponse = { + application: EnterpriseApplication; + connections: CmppDownstreamConnection[]; + summary: { desiredConnections: number; currentConnections: number; status: string }; +}; + +export type ApplicationCmppParams = { + applicationId: string; + applicationName: string; + tenantName: string; + appCode: string; + gatewayHost: string; + gatewayPort: number; + enterpriseCode: string; + account: string; + passwordCipher: string; + srcId: string; + applicationExtension?: string | null; + accessNumberFillEnabled?: boolean; + accessNumberFillPrefix?: string | null; + interfaceEnabled?: boolean; + interfaceType?: string; + maxConnections: number; + heartbeatSeconds: number; + windowSize: number; + protocolVersion: string; +}; diff --git a/src/api/types/index.ts b/src/api/types/index.ts new file mode 100644 index 0000000..cd332eb --- /dev/null +++ b/src/api/types/index.ts @@ -0,0 +1,5 @@ +export * from './common'; +export * from './identity-config'; +export * from './channels-reports'; +export * from './operations'; +export * from './governance'; diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts new file mode 100644 index 0000000..319816e --- /dev/null +++ b/src/api/types/operations.ts @@ -0,0 +1,580 @@ +// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts. + +import type { AdminChannel } from './channels-reports'; +import type { PagedResponse } from './common'; +import type { EnterpriseApplication, TenantOption } from './identity-config'; + +export type ChannelQualityStat = { + channelId: string; + channelName: string; + total: number; + acceptedCount: number; + submitFailureCount: number; + submitFailureRate: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + unknownRate: number; + failureRate: number; + averageArrivalMs?: number | null; +}; + +export type SignatureQualityStat = { + id: string; + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + hasDrainage: boolean; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs?: number | null; +}; + +export type SignatureChannelCarrierQualityStat = { + signatureId: string; + channelId: string; + channelName: string; + carrier: string; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs?: number | null; +}; + +export type SignatureCarrierBusinessQualityStat = { + signatureId: string; + carrier: string; + businessMessageCount: number; + finalSuccessCount: number; + finalSuccessRate: number; + averageArrivalMs?: number | null; +}; + +export type SignatureChannelQualityItem = { + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + applicationNames?: string | null; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs?: number | null; + channelSubmitTotal: number; + carrierOverview: SignatureCarrierBusinessQualityStat[]; + breakdowns: SignatureChannelCarrierQualityStat[]; +}; + +export type SignatureChannelQualityResponse = { + date: string; + items: SignatureChannelQualityItem[]; + total: number; + page: number; + pageSize: number; +}; + +export type DailySendSummary = { + total: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; +}; + +export type ApplicationQualityStat = DailySendSummary & { + applicationId: string; + applicationName: string; + tenantId: string; + tenantName: string; +}; + +export type SendQualityResponse = { + date: string; + summary: DailySendSummary; + channels: ChannelQualityStat[]; + signatures: SignatureQualityStat[]; + applications: ApplicationQualityStat[]; +}; + +export type SmsMessageRecord = { + id: string; + tenantId?: string | null; + batchTaskId?: string | null; + applicationId?: string | null; + channelId?: string | null; + messageId: string; + phoneNumber: string; + carrier?: string | null; + province?: string | null; + content: string; + clientSrcId?: string | null; + applicationExtension?: string | null; + billingUnits: number; + amountCents: number; + status: string; + errorMessage?: string | null; + errorCode?: string | null; + queuedAt: string; + submittedAt?: string | null; + deliveredAt?: string | null; + receiptStatus?: string | null; + submitStatus?: string | null; + channel?: AdminChannel | null; + tenant?: TenantOption | null; + application?: { id: string; name: string }; + submitRecords?: SmsSubmitRecord[]; + receiptRecords?: SmsReceiptRecord[]; + downstreamDeliveries?: Array<{ + id: string; + deliveryType: string; + status: string; + deliveredAt?: string | null; + lastError?: string | null; + }>; +}; + +export type SmsSubmitRecord = { + id: string; + channelId: string; + channelGroupName?: string | null; + submitId: string; + sequenceId?: number | null; + gatewayMessageId?: string | null; + submitStatus: string; + errorCode?: string | null; + errorMessage?: string | null; + submittedAt?: string | null; + createdAt: string; + channel?: AdminChannel | null; + channelGroup?: { id: string; name: string } | null; +}; + +export type SmsReceiptRecord = { + id: string; + channelId?: string | null; + messageId: string; + gatewayMessageId: string; + sequenceId?: number | null; + receiptStatus: string; + rawStatus: string; + errorCode?: string | null; + deliveredAt: string; + createdAt: string; + channel?: AdminChannel | null; +}; + +export type SmsMessageSegmentAudit = { + id: string; + tenantId: string; + batchTaskId?: string | null; + messageRecordId: string; + submitRecordId?: string | null; + channelId?: string | null; + submitId: string; + attempt: number; + segmentTotal: number; + segmentIndex: number; + sequenceId?: number | null; + gatewayMessageId?: string | null; + submitStatus: string; + receiptStatus?: string | null; + rawStatus?: string | null; + compensationType?: string | null; + errorCode?: string | null; + errorMessage?: string | null; + submittedAt?: string | null; + deliveredAt?: string | null; + createdAt: string; + updatedAt: string; + channel?: AdminChannel | null; +}; + +export type SmsUplinkMessage = { + id: string; + tenantId?: string | null; + channelId: string; + applicationId?: string | null; + messageRecordId?: string | null; + messageId?: string | null; + sequenceId?: number | null; + phoneNumber: string; + destId: string; + content: string; + matchStatus?: string; + matchReason?: string | null; + receivedAt: string; + createdAt: string; + tenant?: TenantOption | null; + application?: { id: string; name: string } | null; + messageRecord?: SmsMessageRecord | null; + channel?: AdminChannel | null; + matchCandidates?: SmsUplinkMatchCandidate[]; +}; + +export type SmsUplinkMatchCandidate = { + id: string; + uplinkMessageId: string; + tenantId: string; + applicationId: string; + messageRecordId?: string | null; + matchSource: string; + confidence: number; + reason?: string | null; + status: string; + claimedAt?: string | null; + claimedById?: string | null; + createdAt: string; + updatedAt: string; + tenant?: TenantOption | null; + application?: { id: string; name: string } | null; + messageRecord?: SmsMessageRecord | null; +}; + +export type TenantAccount = { + id: string; + tenantId: string; + balanceCents: number; + creditCents: number; + status: string; + updatedAt?: string; + tenant?: TenantOption; +}; + +export type OperationLogItem = { + id: string; + time: string; + level: 'info' | 'success' | 'warning' | 'error'; + tenant: string; + module: string; + operator: string; + action: string; + resourceId: string; + detail: Record; + ip: string; + userAgent: string; +}; + +export type OperationLogResponse = { + items: OperationLogItem[]; + total: number; + page: number; + pageSize: number; + modules: string[]; +}; + +export type ProtocolInteractionLogItem = { + id: string; + protocol: 'cmpp' | 'http'; + direction: 'client_to_platform' | 'platform_to_channel' | 'channel_to_platform' | 'platform_to_client'; + eventType: string; + status: 'received' | 'accepted' | 'success' | 'failed' | 'retrying'; + tenantId?: string | null; + applicationId?: string | null; + channelId?: string | null; + account?: string | null; + messageId?: string | null; + gatewayMessageId?: string | null; + traceId?: string | null; + requestId?: string | null; + phoneMasked?: string | null; + resultCode?: string | null; + durationMs?: number | null; + payloadBytes?: number | null; + retryCount?: number | null; + detail?: Record | null; + createdAt: string; +}; + +export type ProtocolInteractionLogResponse = { + items: ProtocolInteractionLogItem[]; + total: number; + page: number; + pageSize: number; + eventTypes: string[]; +}; + +export type SystemLogExportResult = { + operationId: string; + status: 'completed'; + fileName: string; + recordCount: number; + truncated: boolean; + content: string; + filters: { keyword?: string; level?: string; module?: string; range?: string }; +}; + +export type DailyReconciliationReport = { + id: string; + reportDate: string; + tenantId: string; + tenantName: string; + applicationId: string; + applicationName: string; + submittedUnits: number; + sentUnits: number; + unknownUnits: number; + successUnits: number; + failedUnits: number; + generatedAt: string; + updatedAt: string; +}; + +export type DailyProfitReport = { + id: string; + reportDate: string; + dimensionType: 'application' | 'channel'; + dimensionId: string; + dimensionName: string; + tenantId?: string | null; + tenantName?: string | null; + applicationId?: string | null; + channelId?: string | null; + submittedUnits: number; + sentUnits: number; + unknownUnits: number; + successUnits: number; + failedUnits: number; + revenueCents: number; + refundCents: number; + costCents: number; + profitCents: number; + profitRateBps: number; + generatedAt: string; + updatedAt: string; +}; + +export type DailyQualityReport = { + id: string; + reportDate: string; + dimensionType: 'application' | 'channel' | 'signature' | 'drainage'; + dimensionId: string; + dimensionName: string; + tenantId?: string | null; + tenantName?: string | null; + applicationId?: string | null; + channelId?: string | null; + signatureId?: string | null; + drainageInfoId?: string | null; + submittedUnits: number; + sentUnits: number; + unknownUnits: number; + successUnits: number; + failedUnits: number; + successRateBps: number; + avgArrivalMs?: number | null; + generatedAt: string; + updatedAt: string; +}; + +export type DownstreamDeliveryRecord = { + id: string; + tenantId: string; + applicationId: string; + messageRecordId?: string | null; + messageId?: string | null; + deliveryType: string; + status: string; + payload: Record; + retryCount: number; + manualRetryCount: number; + lastRetriedAt?: string | null; + retryEnabled: boolean; + nextRetryAt?: string | null; + sentAt?: string | null; + acknowledgedAt?: string | null; + ackDeadlineAt?: string | null; + ackResult?: number | null; + ackSequenceId?: string | null; + ackMessageId?: string | null; + connectionId?: string | null; + deliveredAt?: string | null; + lastError?: string | null; + createdAt: string; + updatedAt: string; + tenant?: TenantOption | null; + application?: EnterpriseApplication | null; + messageRecord?: SmsMessageRecord | null; + attempts?: Array<{ + id: string; + attemptNo: number; + connectionId?: string | null; + sequenceId?: string | null; + messageId?: string | null; + status: string; + sentAt?: string | null; + ackDeadlineAt?: string | null; + acknowledgedAt?: string | null; + ackResult?: number | null; + failureType?: string | null; + errorMessage?: string | null; + createdAt: string; + updatedAt: string; + }>; +}; + +export type ApplicationDeactivationPreview = { + status: string; + reason?: string | null; + disablingAt?: string | null; + autoDisableAt?: string | null; + awaitingSupplierReceipt: number; + waitingToSend: number; + awaitingClientAck: number; + retryableFailures: number; + pendingUplinks: number; + activeConnections: number; + totalOutstanding: number; +}; + +export type BatchRequeueResponse = { + total: number; + successCount: number; + failedCount: number; + results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }>; +}; + +export type DownstreamDeliveryDashboard = { + summary: { + total: number; + pending: number; + awaitingAck: number; + delivered: number; + failed: number; + unconfirmed: number; + rejected: number; + stalledPending: number; + stalledAck: number; + recentFailed: number; + alertCount: number; + }; + typeBreakdown: Array<{ + deliveryType: string; + total: number; + pending: number; + awaitingAck: number; + delivered: number; + failed: number; + unconfirmed: number; + rejected: number; + }>; + retryBuckets: Array<{ + label: string; + count: number; + }>; + topApplications: Array<{ + applicationId: string; + name: string; + pending: number; + awaitingAck: number; + failed: number; + unconfirmed: number; + rejected: number; + delivered: number; + alertCount: number; + }>; +}; + +export type GatewayDownstreamRecoveryStatus = { + id: string; + account: string; + tenantId?: string | null; + applicationId?: string | null; + gatewayInstanceId?: string | null; + state: string; + lockOwner?: string | null; + lockExpiresAt?: string | null; + lastAttemptAt?: string | null; + lastSuccessAt?: string | null; + lastFailureAt?: string | null; + nextRetryAt?: string | null; + attemptCount: number; + failureCategory?: string | null; + lastError?: string | null; + lastSkipReason?: string | null; + createdAt: string; + updatedAt: string; + tenant?: TenantOption | null; + application?: EnterpriseApplication | null; +}; + +export type GatewaySubmitException = { + id: string; + streamMessageId: string; + tenantId?: string | null; + applicationId?: string | null; + channelId?: string | null; + traceId?: string | null; + messageId?: string | null; + submitId?: string | null; + status: 'pending' | 'requeueing' | 'requeued' | 'resolved' | string; + failureCode: string; + failureMessage: string; + attempts: number; + maxAttempts: number; + commandPayload?: Record | null; + rawPayloadAvailable?: boolean; + messageState?: { + status: string; + submitStatus?: string | null; + receiptStatus?: string | null; + phoneNumber: string; + content: string; + } | null; + manualRetryCount: number; + lastRetryStreamId?: string | null; + lastRetriedAt?: string | null; + resolvedAt?: string | null; + resolvedStatus?: string | null; + createdAt: string; + updatedAt: string; + tenant?: Pick | null; + application?: Pick | null; + channel?: Pick | null; +}; + +export type GatewaySubmitExceptionResponse = PagedResponse & { + summary: { + pending: number; + requeueing: number; + requeued: number; + resolved: number; + oldestPendingAt?: string | null; + }; +}; + +export type DownstreamRecoveryStatusResponse = PagedResponse & { + summary: { + total: number; + running: number; + success: number; + failed: number; + waitingConnection: number; + backoff: number; + failureCategories: Array<{ category: string; count: number }>; + }; +}; + +export type DownstreamRecoveryStatusExportQuery = { + tenantId?: string; + applicationId?: string; + state?: string; + failureCategory?: string; + keyword?: string; + updatedAtFrom?: string; + updatedAtTo?: string; +}; diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index d11607b..9d1c68c 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -1,488 +1,15 @@ import { useEffect, useState } from 'react'; -import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; +import { Plus, Search } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelQualityStat, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi'; -import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; -import { formatDateTime } from '@/utils/dateTime'; -import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; - -type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; -type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed'; - -type SmsChannel = { - id: string; - name: string; - carrier: Carrier; - sendRegion: string; - unitPrice: number; - status: ChannelStatus; - total: number; - submitFailureRate: number; - submitFailureCount: number; - successRate: number; - successCount: number; - unknownRate: number; - unknownCount: number; - failureRate: number; - failureCount: number; - gatewayHost: string; - gatewayPort: string; - businessCode: string; - corpCode: string; - account: string; - accessNo: string; - cmppVersion: '2.0' | '3.0'; - desiredConnections: number; - windowSize: number; - heartbeatIntervalSeconds: number; - heartbeatMissThreshold: number; - extensionDigits: number; - rateLimitPerSecond: number; - passwordCipher?: string; -}; - -type ChannelModalState = { - mode: 'create' | 'edit'; - channel?: SmsChannel; -}; - -type ChannelConfirmAction = { - type: 'toggle' | 'copy'; - channel: SmsChannel; -}; - -type ChannelLogState = { - channel: SmsChannel; - data?: ChannelConnectionLogResponse; -}; - -const connectionStatusLabelMap: Record = { - connected: '已连接', - connecting: '连接中', - reconnecting: '重连中', - disconnected: '已断开', - failed: '连接失败', - auth_failed: '鉴权失败', - heartbeat_timeout: '心跳超时', -}; - -function formatLogDetail(detail?: unknown) { - if (!detail) return '无附加信息'; - if (typeof detail === 'string') return detail; - return JSON.stringify(detail, null, 2); -} - -const carrierOptions = [ - { label: '全部运营商', value: 'all' }, - { label: '移动', value: 'mobile' }, - { label: '联通', value: 'unicom' }, - { label: '电信', value: 'telecom' }, - { label: '三网', value: 'all' }, -]; - -const statusOptions = [ - { label: '全部状态', value: 'all' }, - { label: '连接正常', value: 'normal' }, - { label: '已停用', value: 'stopped' }, - { label: '连接中', value: 'connecting' }, - { label: '连接失败', value: 'failed' }, -]; - -const cmppVersionOptions = [ - { label: 'CMPP 2.0', value: '2.0' }, - { label: 'CMPP 3.0', value: '3.0' }, -]; - -const regionOptions = [ - { label: '全国', value: '全国' }, - ...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })), -]; - -const carrierLabelMap: Record = { - mobile: '移动', - unicom: '联通', - telecom: '电信', - all: '三网', -}; - -const carrierToneMap: Record = { - mobile: 'info', - unicom: 'danger', - telecom: 'success', - all: 'neutral', -}; - -const statusLabelMap: Record = { - normal: '连接正常', - stopped: '已停用', - connecting: '连接中', - failed: '连接失败', -}; - -const statusToneMap: Record = { - normal: 'success', - stopped: 'neutral', - connecting: 'info', - failed: 'danger', -}; - -function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus { - if (channel.status !== 'active') { - return 'stopped'; - } - if (connections.some((connection) => - connection.status === 'connected' - && connection.currentConnections > 0 - && connection.desiredConnections > 0, - )) { - return 'normal'; - } - if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(connection.status) || connection.lastError)) { - return 'failed'; - } - return 'connecting'; -} - -function mapApiChannel( - channel: AdminChannel, - connections: CmppConnectionState[] = channel.connectionStates ?? [], - quality?: ChannelQualityStat, -): SmsChannel { - return { - id: channel.id, - name: channel.name, - carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile', - sendRegion: channel.sendRegion ?? '全国', - unitPrice: channel.unitPrice, - status: resolveChannelStatus(channel, connections), - total: quality?.total ?? 0, - submitFailureRate: quality?.submitFailureRate ?? 0, - submitFailureCount: quality?.submitFailureCount ?? 0, - successRate: quality?.successRate ?? 0, - successCount: quality?.successCount ?? 0, - unknownRate: quality?.unknownRate ?? 0, - unknownCount: quality?.unknownCount ?? 0, - failureRate: quality?.failureRate ?? 0, - failureCount: quality?.failureCount ?? 0, - gatewayHost: channel.gatewayHost, - gatewayPort: String(channel.gatewayPort), - businessCode: String(channel.config?.serviceId ?? 'SMS'), - corpCode: channel.enterpriseCode ?? channel.code, - account: channel.account, - accessNo: channel.srcId, - cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0', - desiredConnections: Number(channel.config?.desiredConnections ?? 1), - windowSize: Number(channel.config?.windowSize ?? 16), - heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30), - heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3), - extensionDigits: Number(channel.config?.extensionDigits ?? 0), - rateLimitPerSecond: channel.rateLimitPerSecond, - }; -} - -function mapUiStatusToApi(channel: SmsChannel) { - return channel.status === 'stopped' ? 'active' : 'disabled'; -} - -function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) { - return { - name: channel.name, - carrier: channel.carrier, - sendRegion: channel.sendRegion, - gatewayHost: channel.gatewayHost, - gatewayPort: Number(channel.gatewayPort), - protocol: 'CMPP', - enterpriseCode: channel.corpCode, - account: channel.account, - passwordCipher: passwordCipher || undefined, - srcId: channel.accessNo, - cmppVersion: channel.cmppVersion, - rateLimitPerSecond: channel.rateLimitPerSecond, - unitPrice: Math.round(channel.unitPrice), - desiredConnections: channel.desiredConnections, - windowSize: channel.windowSize, - heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds, - heartbeatMissThreshold: channel.heartbeatMissThreshold, - config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode }, - }; -} - -function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) { - return ( -
- {label} - {rate}% - {count.toLocaleString('zh-CN')} -
- ); -} - -function ChannelFormModal({ - modal, - onClose, - onSubmit, -}: { - modal: ChannelModalState; - onClose: () => void; - onSubmit: (channel: SmsChannel) => void; -}) { - const channel = modal.channel; - const [name, setName] = useState(channel?.name ?? ''); - const [carrier, setCarrier] = useState(channel?.carrier ?? 'mobile'); - const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300'); - const [unitPriceError, setUnitPriceError] = useState(''); - const [region, setRegion] = useState(channel?.sendRegion ?? '全国'); - const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? ''); - const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890'); - const [businessCode, setBusinessCode] = useState(channel?.businessCode ?? 'SMS'); - const [corpCode, setCorpCode] = useState(channel?.corpCode ?? ''); - const [account, setAccount] = useState(channel?.account ?? ''); - const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0'); - const [password, setPassword] = useState(''); - const [accessNo, setAccessNo] = useState(channel?.accessNo ?? ''); - const [extensionDigits, setExtensionDigits] = useState(String(channel?.extensionDigits ?? 0)); - const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100)); - const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1)); - const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); - const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30)); - const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3)); - - function submit() { - if (!isValidMoneyInput(unitPrice)) { - setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位'); - return; - } - onSubmit({ - id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)), - name: name || '新建短信通道', - carrier, - sendRegion: region, - unitPrice: yuanToMoneyUnits(unitPrice), - status: channel?.status ?? 'connecting', - total: channel?.total ?? 0, - submitFailureRate: channel?.submitFailureRate ?? 0, - submitFailureCount: channel?.submitFailureCount ?? 0, - successRate: channel?.successRate ?? 0, - successCount: channel?.successCount ?? 0, - unknownRate: channel?.unknownRate ?? 0, - unknownCount: channel?.unknownCount ?? 0, - failureRate: channel?.failureRate ?? 0, - failureCount: channel?.failureCount ?? 0, - gatewayHost, - gatewayPort, - businessCode: businessCode.trim() || 'SMS', - corpCode, - account, - accessNo, - cmppVersion, - desiredConnections: Number(desiredConnections) || 1, - windowSize: Number(windowSize) || 16, - heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30, - heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3, - extensionDigits: Number(extensionDigits), - rateLimitPerSecond: Number(flowLimit), - passwordCipher: password || undefined, - }); - } - - return ( - - - - - )} - onClose={onClose} - open - size="xl" - title={

{modal.mode === 'edit' ? '编辑通道' : '创建通道'}

} - > -
-
-

业务信息

-
- setName(event.target.value)} placeholder="请输入通道名称" value={name} /> -
- * 运营商 - {(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => ( - - ))} -
- { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} /> - -
- setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} /> - setGatewayPort(event.target.value)} value={gatewayPort} /> -
- setBusinessCode(event.target.value.toUpperCase())} value={businessCode} /> - setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} /> - setAccount(event.target.value)} placeholder="请输入网关账号" value={account} /> - setPassword(event.target.value)} - placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'} - required={modal.mode === 'create'} - type="password" - value={password} - /> -
- setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> - setExtensionDigits(event.target.value)} type="number" value={extensionDigits} /> -
- setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> - setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} /> - setWindowSize(event.target.value)} placeholder="16" value={windowSize} /> - setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} /> - setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} /> -
-
-
-
- ); -} - -function SmsTestModal({ - channel, - onClose, - onOpenRecords, -}: { - channel: SmsChannel; - onClose: () => void; - onOpenRecords: () => void; -}) { - const [phones, setPhones] = useState(''); - const [content, setContent] = useState(''); - const [accessNo, setAccessNo] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(''); - const [result, setResult] = useState(null); - const billingCount = Math.max(1, Math.ceil(content.length / 67)); - - async function submitTestSms() { - if (!phones.trim()) { - setError('请输入测试手机号'); - return; - } - if (!content.trim()) { - setError('请输入测试短信内容'); - return; - } - setSubmitting(true); - setError(''); - setResult(null); - try { - const response = await adminApi.testChannel(channel.id, { - phones, - content, - accessNo: accessNo.trim() || undefined, - }); - setResult(response); - } catch (failure) { - setError(failure instanceof Error ? failure.message : '测试短信发送失败'); - } finally { - setSubmitting(false); - } - } - - return ( - - - {result ? : null} - - - )} - onClose={onClose} - open - size="xl" - title={( -
- -
-

短信测试

-

向指定手机号发送测试短信

-
-
- )} - > -
-
- 测试通道 - {channel.name} -
-