4 Commits
84 changed files with 5920 additions and 1291 deletions
@@ -0,0 +1,24 @@
BEGIN;
-- Preserve every historical record. A conflicting installation must be reviewed before release.
LOCK TABLE "SmsSignature" IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "SmsSignature"
WHERE "auditStatus" NOT IN ('deleted', 'disabled')
GROUP BY "tenantId", "applicationId", "name" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'Cannot enforce signature uniqueness: duplicate active signatures exist; review tenantId/applicationId/name groups without deleting or merging automatically';
END IF;
END $$;
CREATE UNIQUE INDEX "SmsSignature_active_application_name_key"
ON "SmsSignature" ("tenantId", "applicationId", "name")
WHERE "applicationId" IS NOT NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
CREATE UNIQUE INDEX "SmsSignature_active_unbound_name_key"
ON "SmsSignature" ("tenantId", "name")
WHERE "applicationId" IS NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
COMMIT;
@@ -0,0 +1,4 @@
ALTER TABLE "SmsTemplate" ADD COLUMN "optOutRules" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "SmsTemplate" ADD CONSTRAINT "SmsTemplate_optOutRules_array" CHECK (jsonb_typeof("optOutRules") = 'array');
ALTER TABLE "SmsMessageRecord" ADD COLUMN "originalContent" TEXT;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "sentContent" TEXT, ADD COLUMN "contentPolicy" JSONB;
@@ -0,0 +1,19 @@
-- Widen only; historical invalid values abort the entire transaction. Never narrow on rollback.
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '5min';
ALTER TABLE "UpstreamReceiptInbox" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "UpstreamReceiptInbox_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsReceiptRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsReceiptRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsUplinkMessage" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsUplinkMessage_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsSubmitRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsSubmitRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsMessageSegmentAudit" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsMessageSegmentAudit_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDelivery" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDelivery_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDeliveryAttempt" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDeliveryAttempt_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
COMMIT;
+13 -7
View File
@@ -776,6 +776,8 @@ model ReportNotificationRead {
} }
model SmsSignature { model SmsSignature {
// Active name uniqueness (including null applicationId) is enforced by two partial SQL indexes.
// Owned by migration 20260917120000_signature_active_name_unique; do not replace with @@unique.
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
applicationId String? applicationId String?
@@ -851,6 +853,7 @@ model SignatureMaterial {
} }
model SmsTemplate { model SmsTemplate {
optOutRules Json @default("[]")
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
applicationId String applicationId String
@@ -1829,6 +1832,7 @@ model SmsDrainageDecision {
} }
model SmsMessageRecord { model SmsMessageRecord {
originalContent String?
channelWordDecisions SmsChannelSensitiveDecision[] channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false) channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[] monitorFacts SendingMonitorFact[]
@@ -1922,6 +1926,8 @@ model CmppSubmitSession {
} }
model SmsSubmitRecord { model SmsSubmitRecord {
sentContent String?
contentPolicy Json?
drainageGate Json? drainageGate Json?
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String? tenantId String?
@@ -1933,7 +1939,7 @@ model SmsSubmitRecord {
sessionId String? sessionId String?
retryOfSubmitRecordId String? @unique retryOfSubmitRecordId String? @unique
submitId String @unique submitId String @unique
sequenceId Int? sequenceId BigInt?
gatewayMessageId String? gatewayMessageId String?
submitStatus String @default("queued") submitStatus String @default("queued")
resultEventId String? @unique resultEventId String? @unique
@@ -2084,7 +2090,7 @@ model SmsMessageSegmentAudit {
attempt Int @default(0) attempt Int @default(0)
segmentTotal Int @default(1) segmentTotal Int @default(1)
segmentIndex Int @default(1) segmentIndex Int @default(1)
sequenceId Int? sequenceId BigInt?
gatewayMessageId String? gatewayMessageId String?
submitStatus String @default("queued") submitStatus String @default("queued")
receiptStatus String? receiptStatus String?
@@ -2197,7 +2203,7 @@ model SmsReceiptRecord {
messageId String messageId String
gatewayMessageId String gatewayMessageId String
phoneNumber String? phoneNumber String?
sequenceId Int? sequenceId BigInt?
receiptStatus String receiptStatus String
rawStatus String rawStatus String
errorCode String? errorCode String?
@@ -2268,7 +2274,7 @@ model SmsUplinkMessage {
messageRecordId String? messageRecordId String?
messageId String? messageId String?
gatewayMessageId String? gatewayMessageId String?
sequenceId Int? sequenceId BigInt?
phoneNumber String phoneNumber String
destId String destId String
content String content String
@@ -2337,7 +2343,7 @@ model CmppDownstreamDelivery {
sentAt DateTime? sentAt DateTime?
acknowledgedAt DateTime? acknowledgedAt DateTime?
ackDeadlineAt DateTime? ackDeadlineAt DateTime?
ackResult Int? ackResult BigInt?
ackSequenceId String? ackSequenceId String?
ackMessageId String? ackMessageId String?
connectionId String? connectionId String?
@@ -2445,7 +2451,7 @@ model CmppDownstreamDeliveryAttempt {
sentAt DateTime? sentAt DateTime?
ackDeadlineAt DateTime? ackDeadlineAt DateTime?
acknowledgedAt DateTime? acknowledgedAt DateTime?
ackResult Int? ackResult BigInt?
failureType String? failureType String?
errorMessage String? errorMessage String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -2469,7 +2475,7 @@ model UpstreamReceiptInbox {
protocol String protocol String
protocolVersion String protocolVersion String
provisionalMessageId String? provisionalMessageId String?
sequenceId Int? sequenceId BigInt?
gatewayMessageId String gatewayMessageId String
phoneNumber String? phoneNumber String?
receiptStatus String receiptStatus String
+8 -1
View File
@@ -1,3 +1,5 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module'; import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module';
import { HomeModule } from './home-dashboard/home.module'; import { HomeModule } from './home-dashboard/home.module';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
@@ -67,7 +69,12 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
SendingMonitorModule, SendingMonitorModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware], providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
RequestContextMiddleware,
SessionValidationMiddleware,
ManualOperationAuditMiddleware,
],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
@@ -0,0 +1,51 @@
import { ChannelConfigurationService } from './channel-configuration.service';
import { selectChannelCandidate } from '../send-chain/send-chain.helpers';
describe('carrier capability reduction', () => {
it('preserves group references and avoids reconnecting for a capability-only change', async () => {
const channel = {
id: 'c',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
status: 'active',
config: {},
};
const prisma = {
smsChannel: {
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => ({ ...channel, ...data })),
},
operationLog: { create: jest.fn() },
smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([{ group: { name: 'existing' } }]),
deleteMany: jest.fn(),
},
};
const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() };
await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', {
carriers: ['mobile', 'unicom'],
});
expect(prisma.smsChannel.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }),
);
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
expect(connection.requestChannelConnection).not.toHaveBeenCalled();
const candidate = {
channelId: 'c',
carrier: 'telecom',
channel: {
...channel,
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
},
};
expect(
selectChannelCandidate([candidate], {
carrier: 'telecom',
excludedChannelIds: new Set(),
approvedChannelIds: new Set(['c']),
}),
).toBeUndefined();
});
});
@@ -1,17 +1,26 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; 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, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service'; import { ChannelConnectionService } from './channel-connection.service';
import type { ChangeChannelStatusDto, CreateChannelDto, UpdateChannelDto } from './channels.contracts';
import {
channelConnectionSettingsChanged,
currentShanghaiDayRange,
legacyCarrierFromCapabilities,
normalizeBusinessCarrier,
normalizeChannelCarriers,
normalizeChannelRateLimit,
normalizeChannelRuntimeConfig,
normalizeCmppVersion,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */ /** R5 channel domain service composed behind ChannelsService. */
export class ChannelConfigurationService { export class ChannelConfigurationService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
listChannels() { listChannels() {
return this.prisma.smsChannel.findMany({ return this.prisma.smsChannel.findMany({
@@ -20,7 +29,13 @@ export class ChannelConfigurationService {
}); });
} }
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) { 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 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 pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsChannelWhereInput = { const where: Prisma.SmsChannelWhereInput = {
@@ -44,12 +59,18 @@ export class ChannelConfigurationService {
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)])); const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。 // 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
const pageIds = candidates const pageIds = candidates
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) .sort(
|| left.name.localeCompare(right.name, 'zh-CN') (left, right) =>
|| left.id.localeCompare(right.id)) (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) ||
left.name.localeCompare(right.name, 'zh-CN') ||
left.id.localeCompare(right.id),
)
.slice((page - 1) * pageSize, page * pageSize) .slice((page - 1) * pageSize, page * pageSize)
.map((channel) => channel.id); .map((channel) => channel.id);
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } }); const pageItems = await this.prisma.smsChannel.findMany({
where: { id: { in: pageIds } },
include: { connectionStates: true },
});
const itemById = new Map(pageItems.map((item) => [item.id, item])); const itemById = new Map(pageItems.map((item) => [item.id, item]));
const items = pageIds.flatMap((id) => { const items = pageIds.flatMap((id) => {
const item = itemById.get(id); const item = itemById.get(id);
@@ -122,39 +143,28 @@ export class ChannelConfigurationService {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
} }
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined const config =
|| data.desiredConnections !== undefined data.config !== undefined ||
|| data.windowSize !== undefined data.desiredConnections !== undefined ||
|| data.heartbeatIntervalSeconds !== undefined data.windowSize !== undefined ||
|| data.heartbeatMissThreshold !== undefined data.heartbeatIntervalSeconds !== undefined ||
? normalizeChannelRuntimeConfig( data.heartbeatMissThreshold !== undefined
channel.config, ? normalizeChannelRuntimeConfig(
data.config, channel.config,
data.desiredConnections, data.config,
data.windowSize, data.desiredConnections,
data.heartbeatIntervalSeconds, data.windowSize,
data.heartbeatMissThreshold, data.heartbeatIntervalSeconds,
) data.heartbeatMissThreshold,
: undefined; )
const rateLimitPerSecond = data.rateLimitPerSecond === undefined : undefined;
? undefined const rateLimitPerSecond =
: normalizeChannelRateLimit(data.rateLimitPerSecond); data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond);
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier); const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
const carriers = data.carriers !== undefined || data.carrier !== undefined const carriers =
? normalizeChannelCarriers(data.carriers, data.carrier) data.carriers !== undefined || data.carrier !== undefined
: existingCarriers; ? normalizeChannelCarriers(data.carriers, data.carrier)
if (data.carriers !== undefined || data.carrier !== undefined) { : existingCarriers;
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
if (removed.length) {
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
include: { group: true },
});
if (blockingGroups.length) {
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
}
}
}
const connectionConfigChanged = channelConnectionSettingsChanged(channel, { const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
gatewayHost: data.gatewayHost ?? channel.gatewayHost, gatewayHost: data.gatewayHost ?? channel.gatewayHost,
gatewayPort: gatewayPort ?? channel.gatewayPort, gatewayPort: gatewayPort ?? channel.gatewayPort,
@@ -168,7 +178,10 @@ export class ChannelConfigurationService {
data: { data: {
code: data.code, code: data.code,
name: data.name, name: data.name,
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined, carrier:
data.carriers !== undefined || data.carrier !== undefined
? legacyCarrierFromCapabilities(carriers)
: undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined, carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion, sendRegion: data.sendRegion,
protocol: 'CMPP', protocol: 'CMPP',
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map } from 'rxjs/operators';
import { protocolFieldsToJson } from './protocol-uint32';
@Injectable()
export class ProtocolFieldsInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(map(protocolFieldsToJson));
}
}
+40
View File
@@ -0,0 +1,40 @@
import {
protocolFieldsToJson,
protocolUint32,
protocolUint32FromDb,
protocolUint32ToDb,
parseProtocolSequence,
} from './protocol-uint32';
describe('CMPP unsigned protocol fields', () => {
it.each([0, 2147483647, 2147483648, 4294967295])(
'round trips %s without changing the JSON number contract',
(value) => {
expect(protocolUint32FromDb(protocolUint32ToDb(value))).toBe(value);
expect(
JSON.parse(
JSON.stringify(protocolFieldsToJson({ rows: [{ sequenceId: BigInt(value), ackResult: BigInt(value) }] })),
),
).toEqual({ rows: [{ sequenceId: value, ackResult: value }] });
},
);
it.each([-1, 4294967296, 1.5, NaN, Infinity, '', '0', ' ', {}, true])('rejects invalid wire value %s', (value) => {
expect(() => protocolUint32(value)).toThrow();
expect(() => protocolUint32ToDb(value)).toThrow();
});
it('preserves optional historical nulls and unrelated serializers', () => {
expect(protocolUint32ToDb(null)).toBeUndefined();
expect(protocolUint32FromDb(null)).toBeUndefined();
const date = new Date();
expect(
protocolFieldsToJson({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' }),
).toEqual({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' });
expect(() => protocolUint32FromDb(4294967296n)).toThrow();
});
it('distinguishes text zero from missing or malformed historical sequences', () => {
for (const value of [null, undefined, '', ' ', '-1', '1.5', '1e2', '4294967296'])
expect(parseProtocolSequence(value)).toBeUndefined();
expect(parseProtocolSequence('0')).toBe(0);
expect(parseProtocolSequence('4294967295')).toBe(4294967295);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
/** Protocol integers are exact JS numbers on the wire and bigint in PostgreSQL. */
export function protocolUint32(value: unknown, field = 'sequenceId'): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
throw new BadRequestException(`${field} must be an unsigned 32-bit integer`);
}
return value;
}
export function protocolUint32ToDb(value: unknown, field = 'sequenceId'): bigint | undefined {
return value == null ? undefined : BigInt(protocolUint32(value, field));
}
export function protocolUint32FromDb(value: bigint | number | null | undefined): number | undefined {
if (value == null) return undefined;
return protocolUint32(typeof value === 'bigint' ? Number(value) : value);
}
/** Historical Submit sequence columns are text; blanks must never become zero. */
export function parseProtocolSequence(value: string | null | undefined): number | undefined {
if (value == null || !/^\d+$/.test(value)) return undefined;
const number = Number(value);
return Number.isInteger(number) && number <= 0xffffffff ? number : undefined;
}
/** Only protocol fields are converted, leaving money and dates to their existing serializers. */
export function protocolFieldsToJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(protocolFieldsToJson);
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
(key === 'sequenceId' || key === 'ackResult') && typeof item === 'bigint'
? protocolUint32FromDb(item)
: protocolFieldsToJson(item),
]),
);
}
+3
View File
@@ -1,3 +1,5 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller'; import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@@ -21,6 +23,7 @@ import { SendChainService } from './send-chain/send-chain.service';
], ],
controllers: [DrainageSubmitGuardController, GatewayCallbackController], controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [ providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
BillingService, BillingService,
RiskReviewService, RiskReviewService,
PhoneFrequencyService, PhoneFrequencyService,
+1
View File
@@ -268,6 +268,7 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null, carrier: message.carrier ?? null,
province: message.province ?? null, province: message.province ?? null,
content: message.content, content: message.content,
originalContent: message.originalContent ?? null,
drainageGate: message.drainageGate drainageGate: message.drainageGate
? { ? {
version: message.drainageGate.version, version: message.drainageGate.version,
@@ -84,6 +84,7 @@ export class OperationsMessageQueries {
carrier: true, carrier: true,
province: true, province: true,
content: true, content: true,
originalContent: true,
hasDrainageContent: true, hasDrainageContent: true,
drainageDetection: true, drainageDetection: true,
billingUnits: true, billingUnits: true,
@@ -115,8 +116,11 @@ export class OperationsMessageQueries {
application: { select: { id: true, name: true } }, application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } }, channel: { select: { id: true, name: true, srcId: true } },
submitRecords: { submitRecords: {
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
select: { select: {
id: true, id: true,
sentContent: true,
contentPolicy: true,
submitId: true, submitId: true,
channelId: true, channelId: true,
channelGroupId: true, channelGroupId: true,
+11 -21
View File
@@ -376,6 +376,12 @@ export class OperationsQualityQueries {
message.status, message.status,
message."submitStatus" AS submit_status, message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status, message."receiptStatus" AS receipt_status,
CASE
WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN 'success'
WHEN message.status = 'submit_failed' OR message."submitStatus" IN ('rejected', 'timeout') THEN 'submit_failed'
WHEN message."receiptStatus" = 'undelivered' OR (message.status = 'failed' AND message."receiptStatus" IS NOT NULL AND message."receiptStatus" <> 'unknown') THEN 'failure'
ELSE 'unknown'
END AS quality_status,
CASE CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL AND message."submittedAt" IS NOT NULL
@@ -403,27 +409,11 @@ export class OperationsQualityQueries {
tenant.name AS "tenantName", tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(base.signature_id)::integer AS total, COUNT(base.signature_id)::integer AS total,
COUNT(base.signature_id) FILTER ( COUNT(base.signature_id) FILTER (WHERE base.quality_status <> 'submit_failed')::integer AS "acceptedCount",
WHERE COALESCE(base.status, '') <> 'submit_failed' COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'submit_failed')::integer AS "submitFailureCount",
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'success')::integer AS "successCount",
)::integer AS "acceptedCount", COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'unknown')::integer AS "unknownCount",
COUNT(base.signature_id) FILTER ( COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'failure')::integer AS "failureCount",
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(base.signature_id) 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(base.signature_id) 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 CASE
WHEN COUNT(base.signature_id) FILTER ( WHEN COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service'; import { SmsConfigService } from '../sms-config/sms-config.service';
import { SignatureNameConflict } from '../sms-config/signature-uniqueness';
import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts'; import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
import { import {
normalizePage, normalizePage,
@@ -310,6 +311,19 @@ export class ReportImportReviewService {
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
} }
private async findImportSignature(tenantId: string, applicationId: string | undefined, name: string) {
const where = { tenantId, applicationId: applicationId ?? null, name };
return (
(await this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: { notIn: ['deleted', 'disabled'] } },
})) ??
this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: 'disabled' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
);
}
async stageSignatureRow( async stageSignatureRow(
tenantId: string, tenantId: string,
applicationId: string | undefined, applicationId: string | undefined,
@@ -320,9 +334,7 @@ export class ReportImportReviewService {
if (!name) throw new Error('缺少短信签名'); if (!name) throw new Error('缺少短信签名');
const purpose = mappedCorePatchValue(mappings, values, 'purpose'); const purpose = mappedCorePatchValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values); const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({ const existing = await this.findImportSignature(tenantId, applicationId, name);
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
return { return {
operation: existing ? 'update' : 'create', operation: existing ? 'update' : 'create',
targetId: existing?.id, targetId: existing?.id,
@@ -443,20 +455,22 @@ export class ReportImportReviewService {
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
} else { } else {
const duplicate = await this.prisma.smsSignature.findFirst({ const duplicate = await this.findImportSignature(batch.tenantId, applicationId, name);
where: {
tenantId: batch.tenantId,
applicationId: applicationId ?? null,
name,
auditStatus: { not: 'deleted' },
},
});
if (duplicate) { if (duplicate) {
targetId = duplicate.id; targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
} else { } else {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() }); try {
targetId = created.id; const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id;
} catch (error) {
if (!(error instanceof SignatureNameConflict)) throw error;
// A concurrent create won. Imports continue to supplement the existing materials.
const current = await this.findImportSignature(batch.tenantId, applicationId, name);
if (!current) throw error;
targetId = current.id;
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
}
} }
} }
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` }); await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
@@ -11,6 +11,14 @@ const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobil
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) }; const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 }; const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
describe('channel sensitive routing snapshot', () => { describe('channel sensitive routing snapshot', () => {
it('checks rewritten content separately for each candidate', () => {
const snapshot = new ChannelWordSnapshot([{ ...rule, word: '拒收请回复R' }]);
expect(
snapshot.select('m', '正文拒收请回复R', items, options, (id) => (id === 'a' ? '正文' : '正文拒收请回复R'))
.selected?.channelId,
).toBe('a');
expect(snapshot.select('n', '正文', items, options, () => '正文拒收请回复R').selected?.channelId).toBe('b');
});
it('removes only matching eligible channels before original priority selection', () => { it('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]); const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b'); expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
@@ -40,13 +40,16 @@ export class ChannelWordSnapshot {
content: string, content: string,
items: T[], items: T[],
options: Parameters<typeof selectChannelCandidate>[1], options: Parameters<typeof selectChannelCandidate>[1],
contentForChannel?: (channelId: string) => string,
) { ) {
const candidates = items.filter((item) => selectChannelCandidate([item], options)); const candidates = items.filter((item) => selectChannelCandidate([item], options));
const candidateIds = new Set(candidates.map((item) => item.channelId)); const candidateIds = new Set(candidates.map((item) => item.channelId));
const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name])); const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
const hits = this.hits(content) const hits = (
.filter((hit) => candidateIds.has(hit.channelId)) contentForChannel
.map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId })); ? [...candidateIds].flatMap((id) => this.hits(contentForChannel(id)).filter((hit) => hit.channelId === id))
: this.hits(content).filter((hit) => candidateIds.has(hit.channelId))
).map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]); const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded }); const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
const rejected = !selected && candidates.length > 0 && hits.length > 0; const rejected = !selected && candidates.length > 0 && hits.length > 0;
@@ -59,6 +62,13 @@ export class ChannelWordSnapshot {
readAt: this.readAt, readAt: this.readAt,
stage: 'route', stage: 'route',
contentHash: createHash('sha256').update(content).digest('hex'), contentHash: createHash('sha256').update(content).digest('hex'),
...(contentForChannel
? {
candidateContentHashes: Object.fromEntries(
[...candidateIds].map((id) => [id, createHash('sha256').update(contentForChannel(id)).digest('hex')]),
),
}
: {}),
candidateChannelIds: [...candidateIds], candidateChannelIds: [...candidateIds],
excludedChannelIds: hits.map((hit) => hit.channelId), excludedChannelIds: hits.map((hit) => hit.channelId),
hits, hits,
@@ -1,3 +1,4 @@
import { parseProtocolSequence } from '../common/protocol-uint32';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
export type FinalReceiptMessage = { export type FinalReceiptMessage = {
@@ -44,29 +45,33 @@ async function resolveClientReceiptTargets(
}); });
if (group?.segments.length) { if (group?.segments.length) {
return group.segments.flatMap((segment) => { return group.segments.flatMap((segment) => {
const submitSequenceId = Number(segment.sequenceId); const submitSequenceId = parseProtocolSequence(segment.sequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return []; if (submitSequenceId === undefined) return [];
return [{ return [
segmentIndex: segment.segmentIndex, {
segmentTotal: group.segmentTotal, segmentIndex: segment.segmentIndex,
submitSequenceId, segmentTotal: group.segmentTotal,
submitGroupMessageId: group.messageId, submitSequenceId,
registeredDelivery: segment.registeredDelivery, submitGroupMessageId: group.messageId,
}]; registeredDelivery: segment.registeredDelivery,
},
];
}); });
} }
} }
const submitSequenceId = Number(message.cmppSubmitSequenceId); const submitSequenceId = parseProtocolSequence(message.cmppSubmitSequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return []; if (submitSequenceId === undefined) return [];
return [{ return [
segmentIndex: 1, {
segmentTotal: 1, segmentIndex: 1,
submitSequenceId, segmentTotal: 1,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId, submitSequenceId,
// Null means a historical CMPP record created before this field existed. submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
registeredDelivery: message.cmppRegisteredDelivery !== false, // Null means a historical CMPP record created before this field existed.
}]; registeredDelivery: message.cmppRegisteredDelivery !== false,
},
];
} }
/** /**
@@ -104,8 +109,7 @@ export async function queueFinalReceiptDeliveries(
propagateHttpQueueError: data.propagateHttpQueueError, propagateHttpQueueError: data.propagateHttpQueueError,
}); });
const targets = (await resolveClientReceiptTargets(prisma, message)) const targets = (await resolveClientReceiptTargets(prisma, message)).filter((target) => target.registeredDelivery);
.filter((target) => target.registeredDelivery);
for (const target of targets) { for (const target of targets) {
const isSingleFragment = target.segmentTotal === 1; const isSingleFragment = target.segmentTotal === 1;
await queue({ await queue({
@@ -41,10 +41,12 @@ export class DrainageSubmitGuardController {
if ( if (
!submit || !submit ||
submit.channelId !== body.channelId || submit.channelId !== body.channelId ||
createHash('sha256').update(submit.messageRecord.content).digest('hex') !== body.contentHash createHash('sha256')
.update(submit.sentContent ?? submit.messageRecord.content)
.digest('hex') !== body.contentHash
) )
return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' }; return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' };
let message = submit.messageRecord; let message = { ...submit.messageRecord, content: submit.sentContent ?? submit.messageRecord.content };
if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) { if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) {
const template = await tx.smsTemplate.findFirst({ const template = await tx.smsTemplate.findFirst({
where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId }, where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId },
@@ -0,0 +1,56 @@
import { Prisma } from '@prisma/client';
import { SendReceiptService } from './send-receipt.service';
describe('concurrent protocol receipt intake', () => {
function fixture() {
const duplicate = new Prisma.PrismaClientKnownRequestError('duplicate receiptKey', {
code: 'P2002',
clientVersion: 'test',
});
const prisma = {
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel' }) },
upstreamReceiptInbox: {
upsert: jest.fn().mockRejectedValue(duplicate),
findUnique: jest.fn().mockResolvedValue({ id: 'durable', status: 'matched' }),
},
};
return {
prisma,
duplicate,
service: new SendReceiptService(prisma as never, {} as never, undefined, {} as never, {}),
};
}
const event = {
channelId: 'channel',
sequenceId: 4294967295,
gatewayMessageId: '18446744073709551615',
receiptStatus: 'delivered' as const,
rawStatus: 'DELIVRD',
};
it('acknowledges only the same durable receipt after a unique-key race', async () => {
const { prisma, service } = fixture();
await expect(service.intakeReceipt(event)).resolves.toEqual({
accepted: true,
inboxId: 'durable',
status: 'matched',
});
expect(prisma.upstreamReceiptInbox.findUnique.mock.calls[0][0].where).toEqual(
prisma.upstreamReceiptInbox.upsert.mock.calls[0][0].where,
);
});
it('does not hide a missing conflicting row or an unrelated database outage', async () => {
const { prisma, duplicate, service } = fixture();
prisma.upstreamReceiptInbox.findUnique.mockResolvedValue(null);
await expect(service.intakeReceipt(event)).rejects.toBe(duplicate);
const outage = new Error('database unavailable');
prisma.upstreamReceiptInbox.upsert.mockRejectedValue(outage);
await expect(service.intakeReceipt(event)).rejects.toBe(outage);
});
it('rejects invalid sequence before looking up or writing any business records', async () => {
const { prisma, service } = fixture();
await expect(service.intakeReceipt({ ...event, sequenceId: -1 })).rejects.toThrow();
expect(prisma.smsChannel.findUnique).not.toHaveBeenCalled();
expect(prisma.upstreamReceiptInbox.upsert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,109 @@
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { PrismaService } from '../prisma/prisma.service';
const channel = {
account: 'supplier',
gatewayHost: 'localhost',
gatewayPort: 7890,
protocol: 'CMPP',
cmppVersion: '3.0',
};
const message = { id: 'm', messageId: 'MSG', phoneNumber: '13800138000', tenantId: 'tenant' };
const source = (id = 's', channelId = 'c') => ({
id,
submitId: id,
channelId,
tenantId: 'tenant',
messageRecordId: 'm',
messageRecord: message,
channel,
});
const fragment = (submit = source()) => ({
messageRecordId: 'm',
messageRecord: message,
channelId: submit.channelId,
channel,
submitId: submit.submitId,
submitRecord: submit,
});
const event = {
messageId: 'MSG',
channelId: 'c',
gatewayMessageId: 'GW',
phoneNumber: message.phoneNumber,
receiptStatus: 'delivered' as const,
rawStatus: 'DELIVRD',
};
function fixture(submits: unknown[] = [], fragments: unknown[] = []) {
return {
smsMessageRecord: { findUnique: jest.fn().mockResolvedValue(message) },
smsSubmitRecord: {
findMany: jest.fn().mockResolvedValue(submits),
findUnique: jest.fn().mockResolvedValue(source()),
},
smsMessageSegmentAudit: { findMany: jest.fn().mockResolvedValue(fragments) },
smsChannel: { findUnique: jest.fn().mockResolvedValue(channel) },
};
}
const resolve = (db: ReturnType<typeof fixture>, data = event) =>
resolveReceiptAttempt(db as unknown as PrismaService, data);
describe('receipt attempt identity', () => {
it('deduplicates primary and fragment evidence for one attempt', async () => {
expect((await resolve(fixture([source()], [fragment()]))).submitRecordId).toBe('s');
});
it('rejects a primary ID colliding with another attempt fragment on the same channel', async () => {
await expect(resolve(fixture([source()], [fragment(source('other'))]))).rejects.toThrow('提交尝试关联');
});
it('selects the incoming channel, irrespective of newest candidate order', async () => {
expect((await resolve(fixture([source('new', 'other'), source()]))).submitRecordId).toBe('s');
});
it('rejects two same-channel submit candidates', async () => {
await expect(resolve(fixture([source(), source('other')]))).rejects.toThrow('提交尝试关联');
});
it('accepts one other connection of the same supplier', async () => {
expect((await resolve(fixture([], [fragment(source('s', 'other'))]))).channelId).toBe('other');
});
it('rejects ambiguous connections of the same supplier', async () => {
await expect(resolve(fixture([], [fragment(source('a', 'a')), fragment(source('b', 'b'))]))).rejects.toThrow(
'提交尝试关联',
);
});
it('does not silently accept changed supplier credentials captured by Inbox', async () => {
const db = fixture([source()]);
await expect(
resolveReceiptAttempt(db as unknown as PrismaService, event, { ...channel, account: 'old-supplier' }),
).rejects.toThrow('提交尝试关联');
});
it('rejects an exact business ID with a different destination', async () => {
await expect(resolve(fixture([source()]), { ...event, phoneNumber: '13900139000' })).rejects.toThrow(
'提交尝试关联',
);
});
it('rejects malformed cross-tenant fragment relations', async () => {
await expect(resolve(fixture([], [fragment({ ...source(), tenantId: 'other' })]))).rejects.toThrow('提交尝试关联');
});
it('recovers a legacy fragment relation from its globally unique submit ID', async () => {
const db = fixture([], [{ ...fragment(), submitRecord: null }]);
expect((await resolve(db)).submitRecordId).toBe('s');
expect(db.smsSubmitRecord.findUnique).toHaveBeenCalledWith({ where: { submitId: 's' } });
});
it('rejects a truncated candidate set instead of pretending it is unique', async () => {
await expect(resolve(fixture(Array.from({ length: 101 }, () => source())))).rejects.toThrow('提交尝试关联');
});
it('limits submit-response-loss recovery to one timed-out submit in 72 hours', async () => {
const db = fixture();
db.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([source()]);
expect((await resolve(db)).submitRecordId).toBe('s');
expect(db.smsSubmitRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({
submitStatus: 'timeout',
gatewayMessageId: null,
channelId: 'c',
messageRecordId: 'm',
}),
take: 2,
}),
);
});
});
@@ -0,0 +1,125 @@
import { NotFoundException } from '@nestjs/common';
import { SmsMessageRecord } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { GatewayReceiptEventDto } from './send-chain.contracts';
import { isSameUpstreamEndpointIdentity } from './send-chain.helpers';
type UpstreamIdentity = {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
};
type Candidate = {
message: SmsMessageRecord;
messageId: string;
submitRecordId: string;
submitId: string;
channelId: string;
channel: UpstreamIdentity | null;
};
const unmatched = () => new NotFoundException('回执缺少唯一且可信的提交尝试关联');
/** A supplier Msg_Id is not globally unique. Combine submit and fragment evidence
* before accepting a candidate; a fragment can collide with another attempt's
* primary Msg_Id, including on the same logical channel. */
export async function resolveReceiptAttempt(
db: PrismaService,
data: GatewayReceiptEventDto,
identity?: UpstreamIdentity,
) {
if (!data.gatewayMessageId) throw unmatched();
const exact = data.messageId ? await db.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }) : null;
const phone = data.phoneNumber?.trim();
if (exact && phone && exact.phoneNumber !== phone) throw unmatched();
const scope = exact
? { messageRecordId: exact.id }
: phone
? { messageRecord: { phoneNumber: phone } }
: { channelId: data.channelId };
const [submits, segments] = await Promise.all([
db.smsSubmitRecord.findMany({
where: { ...scope, gatewayMessageId: data.gatewayMessageId },
include: { messageRecord: true, channel: true },
take: 101,
}),
db.smsMessageSegmentAudit.findMany({
where: { ...scope, gatewayMessageId: data.gatewayMessageId },
include: { messageRecord: true, submitRecord: true, channel: true },
take: 101,
}),
]);
// A truncated set must never look unique after filtering.
if (submits.length > 100 || segments.length > 100) throw unmatched();
const candidates = new Map<string, Candidate>();
for (const submit of submits)
candidates.set(submit.id, {
message: submit.messageRecord,
messageId: submit.messageRecord.messageId,
submitRecordId: submit.id,
submitId: submit.submitId,
channelId: submit.channelId,
channel: submit.channel,
});
for (const segment of segments) {
const submit =
segment.submitRecord ?? (await db.smsSubmitRecord.findUnique({ where: { submitId: segment.submitId } }));
if (
!submit ||
submit.messageRecordId !== segment.messageRecordId ||
submit.channelId !== segment.channelId ||
submit.tenantId !== segment.messageRecord.tenantId
)
throw unmatched();
candidates.set(submit.id, {
message: segment.messageRecord,
messageId: segment.messageRecord.messageId,
submitRecordId: submit.id,
submitId: submit.submitId,
channelId: submit.channelId,
channel: segment.channel,
});
}
const all = [...candidates.values()];
const direct = all.filter((c) => c.channelId === data.channelId);
if (direct.length > 1) throw unmatched();
if (direct.length === 1) {
// Inbox supplies the identity captured at intake; changed channel credentials
// cannot silently reassign an older supplier's receipt.
if (identity && (!direct[0].channel || !isSameUpstreamEndpointIdentity(identity, direct[0].channel)))
throw unmatched();
return direct[0];
}
const incoming = identity ?? (await db.smsChannel.findUnique({ where: { id: data.channelId } }));
if (!incoming) throw unmatched();
const shared = all.filter((c) => c.channel && isSameUpstreamEndpointIdentity(incoming, c.channel));
if (shared.length > 1) throw unmatched();
if (shared.length === 1 && (exact || phone)) return shared[0];
if (!phone) throw unmatched();
// Preserve the existing narrowly bounded recovery of one timed-out submission
// whose provider identity was not recorded before its first receipt arrived.
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const legacy = await db.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: null,
submitStatus: 'timeout',
...(exact ? { messageRecordId: exact.id } : {}),
submittedAt: { gte: new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000), lte: deliveredAt },
messageRecord: { phoneNumber: phone },
},
include: { messageRecord: true, channel: true },
take: 2,
});
if (legacy.length !== 1 || (identity && !isSameUpstreamEndpointIdentity(identity, legacy[0].channel)))
throw unmatched();
const source = legacy[0];
return {
message: source.messageRecord,
messageId: source.messageRecord.messageId,
submitRecordId: source.id,
submitId: source.submitId,
channelId: source.channelId,
};
}
@@ -258,6 +258,7 @@ export interface SendJob {
export type QueuePriority = 'normal' | 'priority'; export type QueuePriority = 'normal' | 'priority';
export type RoutedChannel = { export type RoutedChannel = {
contentPolicy?: import('./template-optout-policy').ContentPolicyDecision;
channel: { channel: {
id: string; id: string;
code: string; code: string;
+87 -52
View File
@@ -1,6 +1,16 @@
import { parseProtocolSequence } from '../common/protocol-uint32';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { CreateBatchTaskDto, GatewayControlDeliveryResult, GatewayDownstreamRecoveryStatusDto, GatewayDownstreamSentDto, GatewayInboundAuthDto, GatewayReceiptEventDto, GatewaySubmitResultDto, QueuePriority } from './send-chain.contracts'; 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. // R8 pure policies and deterministic key/status helpers. No database, queue or network access.
@@ -70,6 +80,7 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */ /** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) { export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
void _drainage; // Kept in the signature for existing callers.
return undefined; return undefined;
} }
@@ -171,7 +182,7 @@ export function parseImportRows(content: string, delimiter?: ',' | '\t') {
return dataLines.map((line, index) => { return dataLines.map((line, index) => {
const cells = splitImportLine(line, firstDelimiter); const cells = splitImportLine(line, firstDelimiter);
const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = { const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = {
rowNumber: (hasHeader ? index + 2 : index + 1), rowNumber: hasHeader ? index + 2 : index + 1,
phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0], phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0],
variables: {}, variables: {},
}; };
@@ -194,7 +205,9 @@ export function cellByHeader(headers: string[], cells: string[], candidates: str
} }
export function normalizeCarrier(carrier?: string | null) { export function normalizeCarrier(carrier?: string | null) {
const value = String(carrier ?? '').trim().toLowerCase(); const value = String(carrier ?? '')
.trim()
.toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
@@ -226,14 +239,20 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
return fallback; return fallback;
} }
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string, channelCarriers?: string[] | null) { export function isCarrierCompatible(
channelCarrier: string | null | undefined,
targetCarrier: string,
channelCarriers?: string[] | null,
) {
if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier)); if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier));
const normalized = normalizeCarrier(channelCarrier); const normalized = normalizeCarrier(channelCarrier);
return normalized === 'all' || normalized === targetCarrier; return normalized === 'all' || normalized === targetCarrier;
} }
export function normalizeRegion(region?: string | null) { export function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); return String(region ?? '')
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
.trim();
} }
export function matchTemplateContent(templateContent: string, actualContent: string) { export function matchTemplateContent(templateContent: string, actualContent: string) {
@@ -281,7 +300,10 @@ export function isNationalChannel(item: { province?: string | null; channel: { s
return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国'; return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国';
} }
export function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) { export function isProvinceChannel(
item: { province?: string | null; channel: { sendRegion?: string | null } },
province?: string | null,
) {
if (!province) { if (!province) {
return false; return false;
} }
@@ -307,11 +329,13 @@ export function validateInboundApplicationSrcId(
} }
const fillPrefix = application.cmppAccessNumberFillEnabled const fillPrefix = application.cmppAccessNumberFillEnabled
? application.cmppAccessNumberFillPrefix?.trim() ?? '' ? (application.cmppAccessNumberFillPrefix?.trim() ?? '')
: ''; : '';
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`; const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
if (!submittedSrcId || submittedSrcId !== expectedSrcId) { if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`); throw new BadRequestException(
`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`,
);
} }
return submittedSrcId; return submittedSrcId;
} }
@@ -330,9 +354,7 @@ export function positiveInteger(value: string | undefined, fallback: number) {
} }
export function parseOptionalSequenceId(value: string | null | undefined) { export function parseOptionalSequenceId(value: string | null | undefined) {
if (!value) return undefined; return parseProtocolSequence(value);
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
} }
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] { export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
@@ -344,13 +366,17 @@ export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['r
} }
export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) { export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
return createHash('sha256').update([ return createHash('sha256')
data.id, .update(
data.connectionId ?? '', [
data.sequenceId ?? '', data.id,
data.messageId ?? '', data.connectionId ?? '',
data.sequenceId ? '' : data.sentAt ?? '', data.sequenceId ?? '',
].join('\u0000')).digest('hex'); data.messageId ?? '',
data.sequenceId ? '' : (data.sentAt ?? ''),
].join('\u0000'),
)
.digest('hex');
} }
export function shanghaiDateKey(now = new Date()) { export function shanghaiDateKey(now = new Date()) {
@@ -378,12 +404,14 @@ export function bullmqConnection() {
export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) { export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
if (data.authSource && data.timestamp !== undefined) { if (data.authSource && data.timestamp !== undefined) {
const expected = createHash('md5') const expected = createHash('md5')
.update(Buffer.concat([ .update(
Buffer.from(octetString(data.account, 6), 'binary'), Buffer.concat([
Buffer.alloc(9), Buffer.from(octetString(data.account, 6), 'binary'),
Buffer.from(secretHash), Buffer.alloc(9),
Buffer.from(String(data.timestamp).padStart(10, '0')), Buffer.from(secretHash),
])) Buffer.from(String(data.timestamp).padStart(10, '0')),
]),
)
.digest('base64'); .digest('base64');
return expected === data.authSource; return expected === data.authSource;
} }
@@ -410,8 +438,9 @@ export function hasRecoveryAuditStateChanged(
if (!previous) { if (!previous) {
return true; return true;
} }
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'] return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'].some(
.some((key) => (previous[key] ?? null) !== (current[key] ?? null)); (key) => (previous[key] ?? null) !== (current[key] ?? null),
);
} }
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) { export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
@@ -463,8 +492,9 @@ export function isChannelSendAvailable(channel: ChannelCandidate['channel']) {
if (channel.status !== 'active') { if (channel.status !== 'active') {
return false; return false;
} }
return (channel.connectionStates ?? []).some((connection) => return (channel.connectionStates ?? []).some(
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected', (connection) =>
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
); );
} }
@@ -483,11 +513,12 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
routingKey?: string; routingKey?: string;
}, },
) { ) {
const eligible = items.filter((item) => const eligible = items.filter(
!options.excludedChannelIds.has(item.channelId) (item) =>
&& options.approvedChannelIds.has(item.channelId) !options.excludedChannelIds.has(item.channelId) &&
&& normalizeCarrier(item.carrier) === options.carrier options.approvedChannelIds.has(item.channelId) &&
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers), normalizeCarrier(item.carrier) === options.carrier &&
isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
); );
const provinceCandidates = options.forceNational const provinceCandidates = options.forceNational
? [] ? []
@@ -537,11 +568,8 @@ export function aggregateReceiptSegmentState(
deliveredAt: Date, deliveredAt: Date,
) { ) {
if (audits.length === 0) { if (audits.length === 0) {
const status = data.receiptStatus === 'delivered' const status =
? 'delivered' data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
: data.receiptStatus === 'unknown'
? 'unknown'
: 'failed';
return { return {
terminal: true, terminal: true,
segmentTotal: 1, segmentTotal: 1,
@@ -576,7 +604,8 @@ export function aggregateReceiptSegmentState(
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered'); const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
if (delivered.length >= segmentTotal) { if (delivered.length >= segmentTotal) {
const latest = delivered.reduce((current, audit) => const latest = delivered.reduce((current, audit) =>
(audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current); (audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current,
);
return { return {
terminal: true, terminal: true,
segmentTotal, segmentTotal,
@@ -617,20 +646,26 @@ export function isSameUpstreamEndpointIdentity(
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
right: { 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() return (
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() left.account.trim() === right.account.trim() &&
&& left.gatewayPort === right.gatewayPort left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() &&
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() left.gatewayPort === right.gatewayPort &&
&& left.cmppVersion.trim() === right.cmppVersion.trim(); left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() &&
left.cmppVersion.trim() === right.cmppVersion.trim()
);
} }
export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) { export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
return createHash('sha256').update([ return createHash('sha256')
channelId, .update(
data.gatewayMessageId, [
data.phoneNumber?.trim() ?? '', channelId,
data.receiptStatus, data.gatewayMessageId,
data.rawStatus.trim(), data.phoneNumber?.trim() ?? '',
data.errorCode ?? '', data.receiptStatus,
].join('\u0000')).digest('hex'); data.rawStatus.trim(),
data.errorCode ?? '',
].join('\u0000'),
)
.digest('hex');
} }
+107 -45
View File
@@ -466,6 +466,34 @@ function createPrismaMock() {
return prisma; return prisma;
} }
// Supply relational receipt evidence separately from aggregate result fixtures.
async function receiptEvidence(
prisma: ReturnType<typeof createPrismaMock>,
overrides: Record<string, unknown> = {},
attempt: Record<string, unknown> = {},
) {
const message = { ...(await prisma.smsMessageRecord.findUnique()), ...overrides };
const prior = await prisma.smsSubmitRecord.findFirst();
const source = {
...prior,
messageRecordId: message.id,
tenantId: message.tenantId,
channelId: overrides.channelId ?? message.channelId,
submitId: message.submitId ?? prior.submitId,
messageRecord: message,
channel: await prisma.smsChannel.findUnique(),
...attempt,
};
prisma.smsSubmitRecord.findMany.mockResolvedValue([source]);
prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) =>
Promise.resolve(where.retryOfSubmitRecordId ? null : source),
);
const aggregate = prisma.smsMessageSegmentAudit.findMany;
prisma.smsMessageSegmentAudit.findMany = jest
.fn()
.mockImplementation((args) => (args.include?.messageRecord ? Promise.resolve([]) : aggregate(args)));
}
function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEvent: jest.Mock }) { function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEvent: jest.Mock }) {
const billing = { const billing = {
estimateSmsCost: jest.fn().mockReturnValue({ estimateSmsCost: jest.fn().mockReturnValue({
@@ -3259,7 +3287,7 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({ expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'submit-1' }, where: { id: 'submit-1' },
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }), data: expect.objectContaining({ sequenceId: 7n, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
}); });
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown', 'timeout'] } }, where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown', 'timeout'] } },
@@ -3547,6 +3575,7 @@ describe('SendChainService', () => {
expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }), expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }),
); );
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -3601,6 +3630,7 @@ describe('SendChainService', () => {
}, },
}); });
await receiptEvidence(prisma, { ...(await prisma.smsMessageRecord.findFirst()), submitId: 'SUB-1' });
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -3636,6 +3666,10 @@ describe('SendChainService', () => {
unitPrice: 3, unitPrice: 3,
}); });
await receiptEvidence(prisma, await prisma.smsMessageRecord.findFirst(), {
channelId: 'channel-old',
submitId: 'SUB-OLD',
});
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-old', channelId: 'channel-old',
@@ -3661,29 +3695,28 @@ describe('SendChainService', () => {
it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => { it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
.mockResolvedValueOnce([]) {
.mockResolvedValueOnce([]) id: 'submit-timeout-1',
.mockResolvedValueOnce([ submitId: 'SUB-1',
{ messageRecordId: 'record-1',
id: 'submit-timeout-1', channelId: 'channel-1',
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
messageRecord: {
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
channelId: 'channel-1', channelId: 'channel-1',
gatewayMessageId: null, gatewayMessageId: null,
submitStatus: 'timeout', status: 'timeout',
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
messageRecord: {
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
channelId: 'channel-1',
gatewayMessageId: null,
status: 'timeout',
},
}, },
]); },
]);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'receipt-123456789', messageId: 'receipt-123456789',
@@ -3703,7 +3736,7 @@ describe('SendChainService', () => {
}, },
data: { data: {
gatewayMessageId: 'GW-RECOVERED-1', gatewayMessageId: 'GW-RECOVERED-1',
sequenceId: 7, sequenceId: 7n,
}, },
}); });
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
@@ -3721,6 +3754,8 @@ describe('SendChainService', () => {
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([ prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([
{ {
id: 'submit-channel-b', id: 'submit-channel-b',
submitId: 'SUB-B',
messageRecordId: 'record-channel-b',
channelId: 'channel-b', channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID', gatewayMessageId: 'SHARED-UPSTREAM-ID',
messageRecord: { messageRecord: {
@@ -3737,6 +3772,12 @@ describe('SendChainService', () => {
}, },
]); ]);
prisma.smsSubmitRecord.findUnique.mockResolvedValue({
id: 'submit-channel-b',
submitId: 'SUB-B',
messageRecordId: 'record-channel-b',
channelId: 'channel-b',
});
await service.handleReceipt({ await service.handleReceipt({
messageId: 'receipt-SHARED-UPSTREAM-ID', messageId: 'receipt-SHARED-UPSTREAM-ID',
channelId: 'channel-b', channelId: 'channel-b',
@@ -3750,7 +3791,6 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith( expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID', gatewayMessageId: 'SHARED-UPSTREAM-ID',
messageRecord: { phoneNumber: '15601992925' }, messageRecord: { phoneNumber: '15601992925' },
}), }),
@@ -3788,7 +3828,14 @@ describe('SendChainService', () => {
submitRecordId: 'submit-original', submitRecordId: 'submit-original',
channelId: 'channel-original', channelId: 'channel-original',
gatewayMessageId: '736070230367350788', gatewayMessageId: '736070230367350788',
submitRecord: { id: 'submit-original', submitId: 'SUB-LONG-1' }, messageRecordId: 'record-long',
submitRecord: {
id: 'submit-original',
submitId: 'SUB-LONG-1',
messageRecordId: 'record-long',
channelId: 'channel-original',
tenantId: 'tenant-1',
},
channel: { channel: {
id: 'channel-original', id: 'channel-original',
account: 'C59748', account: 'C59748',
@@ -3814,6 +3861,12 @@ describe('SendChainService', () => {
]) ])
.mockResolvedValueOnce([]); .mockResolvedValueOnce([]);
prisma.smsSubmitRecord.findUnique.mockResolvedValue({
id: 'submit-original',
submitId: 'SUB-LONG-1',
messageRecordId: 'record-long',
channelId: 'channel-original',
});
await service.handleReceipt({ await service.handleReceipt({
messageId: 'receipt-736070230367350788', messageId: 'receipt-736070230367350788',
channelId: 'channel-copy', channelId: 'channel-copy',
@@ -3851,7 +3904,13 @@ describe('SendChainService', () => {
submitId: 'SUB-ORIGINAL', submitId: 'SUB-ORIGINAL',
channelId: 'channel-original', channelId: 'channel-original',
gatewayMessageId: 'SHARED-ID', gatewayMessageId: 'SHARED-ID',
submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' }, messageRecordId: 'record-original',
submitRecord: {
id: 'submit-original',
submitId: 'SUB-ORIGINAL',
messageRecordId: 'record-original',
channelId: 'channel-original',
},
channel: { channel: {
id: 'channel-original', id: 'channel-original',
account: 'C59748', account: 'C59748',
@@ -3877,7 +3936,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered', receiptStatus: 'delivered',
rawStatus: 'DELIVRD', rawStatus: 'DELIVRD',
}), }),
).rejects.toThrow('SMS message record not found'); ).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
}); });
@@ -3925,6 +3984,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
]); ]);
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-LONG-1', messageId: 'MSG-LONG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4014,6 +4074,7 @@ describe('SendChainService', () => {
}, },
]); ]);
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-MESSAGE-LEVEL', messageId: 'MSG-MESSAGE-LEVEL',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4078,6 +4139,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
]); ]);
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-CONFLICT', messageId: 'MSG-CONFLICT',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4238,6 +4300,7 @@ describe('SendChainService', () => {
}, },
}); });
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-LONG-FAIL', messageId: 'MSG-LONG-FAIL',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4279,6 +4342,7 @@ describe('SendChainService', () => {
rawStatus: 'DELIVRD', rawStatus: 'DELIVRD',
deliveredAt: '2026-07-01T10:01:00.000Z', deliveredAt: '2026-07-01T10:01:00.000Z',
}; };
await receiptEvidence(prisma);
await service.handleReceipt(receipt); await service.handleReceipt(receipt);
await service.handleReceipt(receipt); await service.handleReceipt(receipt);
@@ -4289,19 +4353,16 @@ describe('SendChainService', () => {
it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => { it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
.mockResolvedValueOnce([]) {
.mockResolvedValueOnce([]) id: 'submit-timeout-1',
.mockResolvedValueOnce([ messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
{ },
id: 'submit-timeout-1', {
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' }, id: 'submit-timeout-2',
}, messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
{ },
id: 'submit-timeout-2', ]);
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
},
]);
await expect( await expect(
service.handleReceipt({ service.handleReceipt({
@@ -4312,7 +4373,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered', receiptStatus: 'delivered',
rawStatus: 'DELIVRD', rawStatus: 'DELIVRD',
}), }),
).rejects.toThrow('SMS message record not found'); ).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
}); });
@@ -4424,6 +4485,7 @@ describe('SendChainService', () => {
it('records receipts and uplink messages from gateway events', async () => { it('records receipts and uplink messages from gateway events', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4791,7 +4853,7 @@ describe('SendChainService', () => {
}, },
create: expect.objectContaining({ create: expect.objectContaining({
segmentTotal: 3, segmentTotal: 3,
sequenceId: 71, sequenceId: 71n,
gatewayMessageId: 'GW-SEG-1', gatewayMessageId: 'GW-SEG-1',
submitStatus: 'accepted', submitStatus: 'accepted',
}), }),
@@ -4800,7 +4862,7 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith( expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { id: 'submit-1', gatewayMessageId: null }, where: { id: 'submit-1', gatewayMessageId: null },
data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }), data: expect.objectContaining({ sequenceId: 71n, gatewayMessageId: 'GW-SEG-1' }),
}), }),
); );
}); });
@@ -5198,7 +5260,7 @@ describe('SendChainService', () => {
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ data: expect.objectContaining({
status: 'delivered', status: 'delivered',
ackResult: 0, ackResult: 0n,
deliveredAt: new Date('2026-07-14T03:40:18.060Z'), deliveredAt: new Date('2026-07-14T03:40:18.060Z'),
}), }),
}), }),
@@ -5208,7 +5270,7 @@ describe('SendChainService', () => {
update: expect.objectContaining({ update: expect.objectContaining({
status: 'acknowledged', status: 'acknowledged',
acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'), acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'),
ackResult: 0, ackResult: 0n,
}), }),
}), }),
); );
@@ -5228,7 +5290,7 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }), data: expect.objectContaining({ ackResult: 0n, ackMessageId: '0' }),
}), }),
); );
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith( expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
@@ -1,3 +1,4 @@
import { protocolUint32ToDb } from '../common/protocol-uint32';
import { completionContext } from './completion-context'; import { completionContext } from './completion-context';
import { resolveUplinkMatch } from './uplink-matching'; import { resolveUplinkMatch } from './uplink-matching';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
@@ -33,6 +34,7 @@ export class SendDownstreamDeliveryService {
) {} ) {}
async handleUplink(data: GatewayUplinkEventDto) { async handleUplink(data: GatewayUplinkEventDto) {
protocolUint32ToDb(data.sequenceId);
if (!completionContext.getStore()) { if (!completionContext.getStore()) {
return this.prisma.$transaction( return this.prisma.$transaction(
(tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)), (tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)),
@@ -63,7 +65,7 @@ export class SendDownstreamDeliveryService {
channelId: data.channelId, channelId: data.channelId,
messageId: match.messageId, messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
destId: data.destId, destId: data.destId,
content: data.content, content: data.content,
@@ -33,7 +33,11 @@ function normalizedFilter(filter: DownstreamRequeueFilter): DownstreamRequeueFil
}; };
} }
function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayableByDefault = true): Prisma.CmppDownstreamDeliveryWhereInput { function taskWhere(
filter: DownstreamRequeueFilter,
snapshotAt: Date,
replayableByDefault = true,
): Prisma.CmppDownstreamDeliveryWhereInput {
const normalized = normalizedFilter(filter); const normalized = normalizedFilter(filter);
const from = parseDateBoundary(normalized.createdAtFrom, false); const from = parseDateBoundary(normalized.createdAtFrom, false);
const to = parseDateBoundary(normalized.createdAtTo, true); const to = parseDateBoundary(normalized.createdAtTo, true);
@@ -41,16 +45,19 @@ function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayable
tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined, tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined,
applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined, applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined,
deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined, deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined,
status: normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined, status:
normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined,
createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt }, createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt },
OR: normalized.keyword ? [ OR: normalized.keyword
{ messageId: { contains: normalized.keyword } }, ? [
{ payload: { path: ['account'], string_contains: normalized.keyword } }, { messageId: { contains: normalized.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } }, { payload: { path: ['account'], string_contains: normalized.keyword } },
{ lastError: { contains: normalized.keyword } }, { payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
{ tenant: { name: { contains: normalized.keyword } } }, { lastError: { contains: normalized.keyword } },
{ application: { name: { contains: normalized.keyword } } }, { tenant: { name: { contains: normalized.keyword } } },
] : undefined, { application: { name: { contains: normalized.keyword } } },
]
: undefined,
}; };
} }
@@ -71,9 +78,19 @@ function verifyPreview(token: string, operatorId?: string) {
if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检'); if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检');
const expected = createHmac('sha256', previewSecret()).update(encoded).digest(); const expected = createHmac('sha256', previewSecret()).update(encoded).digest();
let actual: Buffer; let actual: Buffer;
try { actual = Buffer.from(supplied, 'base64url'); } catch { throw new BadRequestException('预检凭证无效,请重新预检'); } try {
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new BadRequestException('预检凭证无效,请重新预检'); actual = Buffer.from(supplied, 'base64url');
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { filter: DownstreamRequeueFilter; snapshotAt: string; operatorId?: string; expiresAt: number }; } catch {
throw new BadRequestException('预检凭证无效,请重新预检');
}
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
throw new BadRequestException('预检凭证无效,请重新预检');
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as {
filter: DownstreamRequeueFilter;
snapshotAt: string;
operatorId?: string;
expiresAt: number;
};
if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检'); if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检');
if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致'); if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致');
return payload; return payload;
@@ -85,21 +102,35 @@ function jsonFailures(value: unknown): Record<string, number> {
} }
export class SendDownstreamRequeueTaskService { export class SendDownstreamRequeueTaskService {
constructor(private readonly prisma: PrismaService, private readonly facade: RequeueFacade) {} constructor(
private readonly prisma: PrismaService,
private readonly facade: RequeueFacade,
) {}
async preview(filter: DownstreamRequeueFilter, operatorId?: string) { async preview(filter: DownstreamRequeueFilter, operatorId?: string) {
const snapshotAt = new Date(); const snapshotAt = new Date();
const normalized = normalizedFilter(filter); const normalized = normalizedFilter(filter);
const base = taskWhere(normalized, snapshotAt, false); const base = taskWhere(normalized, snapshotAt, false);
const replayableWhere = { AND: [base, { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput; const replayableWhere = {
AND: [base, { status: { in: REPLAYABLE_STATUSES } }],
} as Prisma.CmppDownstreamDeliveryWhereInput;
const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([ const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([
this.prisma.cmppDownstreamDelivery.count({ where: base }), this.prisma.cmppDownstreamDelivery.count({ where: base }),
this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }), this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.findFirst({ where: base, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }), this.prisma.cmppDownstreamDelivery.findFirst({
where: base,
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
}),
]); ]);
const tokenPayload = { filter: normalized, snapshotAt: snapshotAt.toISOString(), operatorId: operatorId || '', expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS }; const tokenPayload = {
filter: normalized,
snapshotAt: snapshotAt.toISOString(),
operatorId: operatorId || '',
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
};
return { return {
snapshotAt, snapshotAt,
previewToken: signPreview(tokenPayload), previewToken: signPreview(tokenPayload),
@@ -113,36 +144,82 @@ export class SendDownstreamRequeueTaskService {
}; };
} }
async create(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, createdById?: string) { async create(
data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
createdById?: string,
) {
const reason = data.reason?.trim(); const reason = data.reason?.trim();
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字'); if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
const preview = verifyPreview(data.previewToken, createdById); const preview = verifyPreview(data.previewToken, createdById);
const filter = normalizedFilter(preview.filter); const filter = normalizedFilter(preview.filter);
const snapshotAt = new Date(preview.snapshotAt); const snapshotAt = new Date(preview.snapshotAt);
if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录'); if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录');
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: { const activeTask = await this.prisma.downstreamRequeueTask.findFirst({
status: { in: ACTIVE_TASK_STATUSES }, where: {
...(filter.applicationId !== 'all' ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] } : {}), status: { in: ACTIVE_TASK_STATUSES },
}, select: { taskNo: true } }); ...(filter.applicationId !== 'all'
? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] }
: {}),
},
select: { taskNo: true },
});
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`); if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
const where = { AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput; const where = {
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ where, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: 100001, select: { id: true, applicationId: true, status: true } }); AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }],
} as Prisma.CmppDownstreamDeliveryWhereInput;
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({
where,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: 100001,
select: { id: true, applicationId: true, status: true },
});
if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录'); if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录');
if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围'); if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10))); const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10)));
const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10))); const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10)));
const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`; const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000)
.toString()
.padStart(3, '0')}`;
const task = await this.prisma.$transaction(async (tx) => { const task = await this.prisma.$transaction(async (tx) => {
const created = await tx.downstreamRequeueTask.create({ data: { const created = await tx.downstreamRequeueTask.create({
taskNo, data: {
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null, taskNo,
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null, tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
filterSnapshot: filter as Prisma.InputJsonValue, applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
snapshotAt, reason, ratePerSecond, consecutiveFailureLimit: failureLimit, filterSnapshot: filter as Prisma.InputJsonValue,
totalCount: deliveries.length, createdById, snapshotAt,
} }); reason,
await tx.downstreamRequeueTaskItem.createMany({ data: deliveries.map((item) => ({ taskId: created.id, deliveryId: item.id, applicationId: item.applicationId, previousStatus: item.status })) }); ratePerSecond,
await tx.operationLog.create({ data: { userId: createdById, action: 'gateway.downstream_requeue_task_created', resource: 'downstream_requeue_task', resourceId: created.id, detail: { taskNo, reason, totalCount: deliveries.length, snapshotAt, filter, ratePerSecond, consecutiveFailureLimit: failureLimit } } }); consecutiveFailureLimit: failureLimit,
totalCount: deliveries.length,
createdById,
},
});
await tx.downstreamRequeueTaskItem.createMany({
data: deliveries.map((item) => ({
taskId: created.id,
deliveryId: item.id,
applicationId: item.applicationId,
previousStatus: item.status,
})),
});
await tx.operationLog.create({
data: {
userId: createdById,
action: 'gateway.downstream_requeue_task_created',
resource: 'downstream_requeue_task',
resourceId: created.id,
detail: {
taskNo,
reason,
totalCount: deliveries.length,
snapshotAt,
filter,
ratePerSecond,
consecutiveFailureLimit: failureLimit,
},
},
});
return created; return created;
}); });
return this.get(task.id); return this.get(task.id);
@@ -153,16 +230,37 @@ export class SendDownstreamRequeueTaskService {
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10))); const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
const where = { status: query.status && query.status !== 'all' ? query.status : undefined }; const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTask.findMany({ where, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), this.prisma.downstreamRequeueTask.findMany({
where,
include: {
tenant: true,
application: true,
createdBy: { select: { id: true, displayName: true, username: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.downstreamRequeueTask.count({ where }), this.prisma.downstreamRequeueTask.count({ where }),
]); ]);
return { items, total, page, pageSize }; return { items, total, page, pageSize };
} }
async get(id: string) { async get(id: string) {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } } }); const task = await this.prisma.downstreamRequeueTask.findUnique({
where: { id },
include: {
tenant: true,
application: true,
createdBy: { select: { id: true, displayName: true, username: true } },
},
});
if (!task) throw new NotFoundException('后台重投任务不存在'); if (!task) throw new NotFoundException('后台重投任务不存在');
const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId: id }, _count: { _all: true } }); const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({
by: ['status'],
where: { taskId: id },
_count: { _all: true },
});
return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) }; return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) };
} }
@@ -175,14 +273,22 @@ export class SendDownstreamRequeueTaskService {
const where: Prisma.DownstreamRequeueTaskItemWhereInput = { const where: Prisma.DownstreamRequeueTaskItemWhereInput = {
taskId: id, taskId: id,
status: query.status && query.status !== 'all' ? query.status : undefined, status: query.status && query.status !== 'all' ? query.status : undefined,
OR: keyword ? [ OR: keyword
{ delivery: { messageId: { contains: keyword } } }, ? [
{ skipReason: { contains: keyword } }, { delivery: { messageId: { contains: keyword } } },
{ errorMessage: { contains: keyword } }, { skipReason: { contains: keyword } },
] : undefined, { errorMessage: { contains: keyword } },
]
: undefined,
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTaskItem.findMany({ where, include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } }, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }), this.prisma.downstreamRequeueTaskItem.findMany({
where,
include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.downstreamRequeueTaskItem.count({ where }), this.prisma.downstreamRequeueTaskItem.count({ where }),
]); ]);
return { items, total, page, pageSize }; return { items, total, page, pageSize };
@@ -192,18 +298,47 @@ export class SendDownstreamRequeueTaskService {
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作'); if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } }); const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
if (!task) throw new NotFoundException('后台重投任务不存在'); if (!task) throw new NotFoundException('后台重投任务不存在');
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused']; const allowed =
action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作'); if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作');
const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated'; const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated';
const updated = await this.prisma.downstreamRequeueTask.update({ where: { id }, data: { status, pausedAt: status === 'paused' ? new Date() : null, finishedAt: status === 'terminated' ? new Date() : undefined, scanLeaseOwner: null, scanLeaseUntil: null } }); const updated = await this.prisma.downstreamRequeueTask.update({
if (status === 'terminated') await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } }, data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() } }); where: { id },
await this.prisma.operationLog.create({ data: { userId: operatorId, action: `gateway.downstream_requeue_task_${action}`, resource: 'downstream_requeue_task', resourceId: id, detail: { taskNo: task.taskNo, previousStatus: task.status, status } } }); data: {
status,
pausedAt: status === 'paused' ? new Date() : null,
finishedAt: status === 'terminated' ? new Date() : undefined,
scanLeaseOwner: null,
scanLeaseUntil: null,
},
});
if (status === 'terminated')
await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } },
data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() },
});
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: `gateway.downstream_requeue_task_${action}`,
resource: 'downstream_requeue_task',
resourceId: id,
detail: { taskNo: task.taskNo, previousStatus: task.status, status },
},
});
return updated; return updated;
} }
async runScan() { async runScan() {
await this.prisma.downstreamRequeueRateWindow.deleteMany({ where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } } }); await this.prisma.downstreamRequeueRateWindow.deleteMany({
const tasks = await this.prisma.downstreamRequeueTask.findMany({ where: { status: { in: ['queued', 'running'] } }, orderBy: { createdAt: 'asc' }, take: 3, select: { id: true } }); where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } },
});
const tasks = await this.prisma.downstreamRequeueTask.findMany({
where: { status: { in: ['queued', 'running'] } },
orderBy: { createdAt: 'asc' },
take: 3,
select: { id: true },
});
for (const task of tasks) await this.processTask(task.id); for (const task of tasks) await this.processTask(task.id);
} }
@@ -211,7 +346,11 @@ export class SendDownstreamRequeueTaskService {
const leaseOwner = randomUUID(); const leaseOwner = randomUUID();
const now = new Date(); const now = new Date();
const lease = await this.prisma.downstreamRequeueTask.updateMany({ const lease = await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, status: { in: ['queued', 'running'] }, OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }] }, where: {
id: taskId,
status: { in: ['queued', 'running'] },
OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }],
},
data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) }, data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) },
}); });
if (!lease.count) return; if (!lease.count) return;
@@ -220,7 +359,10 @@ export class SendDownstreamRequeueTaskService {
if (!task || !['queued', 'running'].includes(task.status)) return; if (!task || !['queued', 'running'].includes(task.status)) return;
// A process may die after the database claim but before the Gateway call. The lease makes that // A process may die after the database claim but before the Gateway call. The lease makes that
// ambiguous window visible and recoverable; every recovered item is revalidated before replay. // ambiguous window visible and recoverable; every recovered item is revalidated before replay.
await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } }, data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' } }); await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } },
data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' },
});
let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures)); let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures));
const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit); const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (existingFailureEntry) { if (existingFailureEntry) {
@@ -228,19 +370,36 @@ export class SendDownstreamRequeueTaskService {
await this.refreshTask(taskId); await this.refreshTask(taskId);
return; return;
} }
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status: 'running', startedAt: task.startedAt ?? new Date() } }); await this.prisma.downstreamRequeueTask.update({
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'queued' }, orderBy: { createdAt: 'asc' }, take: Math.min(200, task.ratePerSecond * 3), select: { id: true, deliveryId: true, applicationId: true, previousStatus: true } }); where: { id: taskId },
data: { status: 'running', startedAt: task.startedAt ?? new Date() },
});
const items = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: 'queued' },
orderBy: { createdAt: 'asc' },
take: Math.min(200, task.ratePerSecond * 3),
select: { id: true, deliveryId: true, applicationId: true, previousStatus: true },
});
for (const item of items) { for (const item of items) {
const latestTask = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } }); const latestTask = await this.prisma.downstreamRequeueTask.findUnique({
where: { id: taskId },
select: { status: true },
});
if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break; if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break;
if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue; if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue;
const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'queued' }, data: { status: 'processing', claimedAt: new Date() } }); const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { id: item.id, status: 'queued' },
data: { status: 'processing', claimedAt: new Date() },
});
if (!claimed.count) continue; if (!claimed.count) continue;
const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus); const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus);
if (outcome === 'success') failures[item.applicationId] = 0; if (outcome === 'success') failures[item.applicationId] = 0;
if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1; if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
const maxFailures = Math.max(0, ...Object.values(failures)); const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } }); await this.prisma.downstreamRequeueTask.update({
where: { id: taskId },
data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures },
});
if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) { if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) {
await this.autoPause(task, item.applicationId, failures[item.applicationId]); await this.autoPause(task, item.applicationId, failures[item.applicationId]);
break; break;
@@ -248,16 +407,26 @@ export class SendDownstreamRequeueTaskService {
} }
failures = await this.reconcileWaiting(taskId, failures); failures = await this.reconcileWaiting(taskId, failures);
const maxFailures = Math.max(0, ...Object.values(failures)); const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, status: { in: ['queued', 'running'] } }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } }); await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, status: { in: ['queued', 'running'] } },
data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures },
});
const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit); const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]); if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]);
await this.refreshTask(taskId); await this.refreshTask(taskId);
} finally { } finally {
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, scanLeaseOwner: leaseOwner }, data: { scanLeaseOwner: null, scanLeaseUntil: null } }); await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, scanLeaseOwner: leaseOwner },
data: { scanLeaseOwner: null, scanLeaseUntil: null },
});
} }
} }
private async processItem(itemId: string, deliveryId: string, previousStatus: string): Promise<'success' | 'failed' | 'waiting' | 'skipped'> { private async processItem(
itemId: string,
deliveryId: string,
previousStatus: string,
): Promise<'success' | 'failed' | 'waiting' | 'skipped'> {
try { try {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { id: deliveryId }, where: { id: deliveryId },
@@ -271,32 +440,88 @@ export class SendDownstreamRequeueTaskService {
return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认'); return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认');
} }
if (!REPLAYABLE_STATUSES.includes(delivery.status)) { if (!REPLAYABLE_STATUSES.includes(delivery.status)) {
if (delivery.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_external_ack', skipReason: null } }); return 'waiting'; } if (delivery.status === 'awaiting_ack') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'waiting_external_ack', skipReason: null },
});
return 'waiting';
}
return this.finishItem(itemId, 'skipped', '执行前状态已变化'); return this.finishItem(itemId, 'skipped', '执行前状态已变化');
} }
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) return this.finishItem(itemId, 'skipped', '应用或投递能力已停用'); if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled)
if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType)) return this.finishItem(itemId, 'skipped', '投递数据不完整'); return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({ where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } }, select: { id: true } }); if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType))
return this.finishItem(itemId, 'skipped', '投递数据不完整');
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({
where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } },
select: { id: true },
});
if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理'); if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理');
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: delivery.applicationId, status: 'connected' } }); const connected = await this.prisma.cmppDownstreamConnection.count({
if (connected === 0) { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' } }); return 'waiting'; } where: { applicationId: delivery.applicationId, status: 'connected' },
const result = await this.facade.requeueDownstreamDelivery(deliveryId) as { status?: string; lastError?: string | null }; });
if (result?.status === 'delivered') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'success', completedAt: new Date() } }); return 'success'; } if (connected === 0) {
if (result?.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_ack', completedAt: null } }); return 'waiting'; } await this.prisma.downstreamRequeueTaskItem.update({
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'failed', errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态', completedAt: new Date() } }); where: { id: itemId },
data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' },
});
return 'waiting';
}
const result = (await this.facade.requeueDownstreamDelivery(deliveryId)) as {
status?: string;
lastError?: string | null;
};
if (result?.status === 'delivered') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'success', completedAt: new Date() },
});
return 'success';
}
if (result?.status === 'awaiting_ack') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'waiting_ack', completedAt: null },
});
return 'waiting';
}
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: {
status: 'failed',
errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态',
completedAt: new Date(),
},
});
return 'failed'; return 'failed';
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '后台重投失败'; const message = error instanceof Error ? error.message : '后台重投失败';
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化' const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message)
: /payload|投递类型/.test(message) ? '投递数据不完整' ? '执行前状态已变化'
: /Submit|Msg_Id|Sequence/.test(message) ? '缺少原Submit映射,无法安全重投' : null; : /payload|投递类型/.test(message)
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: skipReason ? 'skipped' : 'failed', skipReason, errorMessage: skipReason ? null : message, completedAt: new Date() } }); ? '投递数据不完整'
: /Submit|Msg_Id|Sequence/.test(message)
? '缺少原Submit映射,无法安全重投'
: null;
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: {
status: skipReason ? 'skipped' : 'failed',
skipReason,
errorMessage: skipReason ? null : message,
completedAt: new Date(),
},
});
return skipReason ? 'skipped' : 'failed'; return skipReason ? 'skipped' : 'failed';
} }
} }
private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> { private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status, skipReason: reason, completedAt: new Date() } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status, skipReason: reason, completedAt: new Date() },
});
return 'skipped'; return 'skipped';
} }
@@ -314,24 +539,61 @@ export class SendDownstreamRequeueTaskService {
} }
private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) { private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) {
const task = currentFailures ? null : await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { applicationFailures: true } }); const task = currentFailures
? null
: await this.prisma.downstreamRequeueTask.findUnique({
where: { id: taskId },
select: { applicationFailures: true },
});
const failures = currentFailures ?? jsonFailures(task?.applicationFailures); const failures = currentFailures ?? jsonFailures(task?.applicationFailures);
const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'waiting_connection' }, select: { id: true, applicationId: true } }); const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: 'waiting_connection' },
select: { id: true, applicationId: true },
});
for (const item of connectionItems) { for (const item of connectionItems) {
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: item.applicationId, status: 'connected' } }); const connected = await this.prisma.cmppDownstreamConnection.count({
if (connected > 0) await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'waiting_connection' }, data: { status: 'queued', errorMessage: null, claimedAt: null } }); where: { applicationId: item.applicationId, status: 'connected' },
});
if (connected > 0)
await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { id: item.id, status: 'waiting_connection' },
data: { status: 'queued', errorMessage: null, claimedAt: null },
});
} }
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } }, include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } }, take: 500 }); const items = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } },
include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } },
take: 500,
});
const now = new Date(); const now = new Date();
for (const item of items) { for (const item of items) {
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) { if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0n) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: item.status === 'waiting_external_ack' ? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now } : { status: 'success', completedAt: now } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data:
item.status === 'waiting_external_ack'
? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now }
: { status: 'success', completedAt: now },
});
if (item.status === 'waiting_ack') failures[item.applicationId] = 0; if (item.status === 'waiting_ack') failures[item.applicationId] = 0;
} else if (['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) || (item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)) { } else if (
['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) ||
(item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)
) {
if (item.status === 'waiting_external_ack') { if (item.status === 'waiting_external_ack') {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null },
});
} else { } else {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'failed', errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时', completedAt: now } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data: {
status: 'failed',
errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时',
completedAt: now,
},
});
failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1; failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
} }
} }
@@ -339,21 +601,70 @@ export class SendDownstreamRequeueTaskService {
return failures; return failures;
} }
private async autoPause(task: { id: string; taskNo: string; consecutiveFailureLimit: number }, applicationId: string, count: number) { private async autoPause(
task: { id: string; taskNo: string; consecutiveFailureLimit: number },
applicationId: string,
count: number,
) {
const pausedAt = new Date(); const pausedAt = new Date();
const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`; const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`;
const updated = await this.prisma.downstreamRequeueTask.updateMany({ where: { id: task.id, status: { in: ['queued', 'running'] } }, data: { status: 'paused', pausedAt, lastError: message } }); const updated = await this.prisma.downstreamRequeueTask.updateMany({
if (updated.count) await this.prisma.operationLog.create({ data: { action: 'gateway.downstream_requeue_task_auto_paused', resource: 'downstream_requeue_task', resourceId: task.id, detail: { taskNo: task.taskNo, applicationId, consecutiveFailures: count, failureLimit: task.consecutiveFailureLimit, pausedAt } } }); where: { id: task.id, status: { in: ['queued', 'running'] } },
data: { status: 'paused', pausedAt, lastError: message },
});
if (updated.count)
await this.prisma.operationLog.create({
data: {
action: 'gateway.downstream_requeue_task_auto_paused',
resource: 'downstream_requeue_task',
resourceId: task.id,
detail: {
taskNo: task.taskNo,
applicationId,
consecutiveFailures: count,
failureLimit: task.consecutiveFailureLimit,
pausedAt,
},
},
});
} }
private async refreshTask(taskId: string) { private async refreshTask(taskId: string) {
const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId }, _count: { _all: true } }); const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({
by: ['status'],
where: { taskId },
_count: { _all: true },
});
const counts = new Map(groups.map((item) => [item.status, item._count._all])); const counts = new Map(groups.map((item) => [item.status, item._count._all]));
const queued = counts.get('queued') ?? 0; const queued = counts.get('queued') ?? 0;
const active = (counts.get('processing') ?? 0) + (counts.get('waiting_connection') ?? 0) + (counts.get('waiting_ack') ?? 0) + (counts.get('waiting_external_ack') ?? 0); const active =
(counts.get('processing') ?? 0) +
(counts.get('waiting_connection') ?? 0) +
(counts.get('waiting_ack') ?? 0) +
(counts.get('waiting_external_ack') ?? 0);
const failed = counts.get('failed') ?? 0; const failed = counts.get('failed') ?? 0;
const current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } }); const current = await this.prisma.downstreamRequeueTask.findUnique({
const status = current?.status === 'paused' || current?.status === 'terminated' ? current.status : queued + active === 0 ? (failed > 0 ? 'partial_completed' : 'completed') : 'running'; where: { id: taskId },
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status, successCount: counts.get('success') ?? 0, failedCount: failed, skippedCount: counts.get('skipped') ?? 0, waitingCount: active, ...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}) } }); select: { status: true },
});
const status =
current?.status === 'paused' || current?.status === 'terminated'
? current.status
: queued + active === 0
? failed > 0
? 'partial_completed'
: 'completed'
: 'running';
await this.prisma.downstreamRequeueTask.update({
where: { id: taskId },
data: {
status,
successCount: counts.get('success') ?? 0,
failedCount: failed,
skippedCount: counts.get('skipped') ?? 0,
waitingCount: active,
...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}),
},
});
} }
} }
@@ -1,15 +1,34 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { protocolUint32, protocolUint32ToDb } from '../common/protocol-uint32';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.service'; import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.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 type {
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'; GatewayPendingDeliveryQueryDto,
import type { SendSubmissionService } from './send-submission.service'; GatewayDownstreamSentDto,
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewayControlDeliveryResult,
GatewayDownstreamRecoveryStatusDto,
} from './send-chain.contracts';
import {
positiveInteger,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
isObjectRecord,
asDateOrNull,
downstreamRetryDelayMs,
downstreamAckTimeoutMs,
downstreamMaxRetries,
downstreamControlFailureMessage,
downstreamDeliveryAttemptKey,
hasRecoveryAuditStateChanged,
normalizeRecoveryFailureCategory,
} from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
/** /**
* R10 downstreamState implementation. * R10 downstreamState implementation.
@@ -37,7 +56,11 @@ export class SendDownstreamStateService {
take: 500, take: 500,
}); });
for (const expired of expiredAcknowledgements) { for (const expired of expiredAcknowledgements) {
await this.facade.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout'); await this.facade.markDownstreamDeliveryFailed(
expired.id,
'CMPP_DELIVER_RESP timeout recovered after Gateway restart',
'ack_timeout',
);
} }
const now = new Date(); const now = new Date();
await this.prisma.cmppDownstreamDelivery.updateMany({ await this.prisma.cmppDownstreamDelivery.updateMany({
@@ -150,6 +173,7 @@ export class SendDownstreamStateService {
} }
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) { async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
protocolUint32(data.result, 'result');
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date(); const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
const acknowledgedMessageId = String(data.messageId ?? '').trim(); const acknowledgedMessageId = String(data.messageId ?? '').trim();
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
@@ -166,7 +190,7 @@ export class SendDownstreamStateService {
sequenceId: data.sequenceId, sequenceId: data.sequenceId,
messageId: data.messageId, messageId: data.messageId,
acknowledgedAt, acknowledgedAt,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
ackDeadlineAt: null, ackDeadlineAt: null,
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted errorMessage: acknowledgementAccepted
@@ -184,7 +208,7 @@ export class SendDownstreamStateService {
messageId: data.messageId, messageId: data.messageId,
status: acknowledgementAccepted ? 'acknowledged' : 'rejected', status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
acknowledgedAt, acknowledgedAt,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted errorMessage: acknowledgementAccepted
? null ? null
@@ -201,7 +225,7 @@ export class SendDownstreamStateService {
acknowledgedAt, acknowledgedAt,
deliveredAt: acknowledgedAt, deliveredAt: acknowledgedAt,
ackDeadlineAt: null, ackDeadlineAt: null,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
ackSequenceId: data.sequenceId, ackSequenceId: data.sequenceId,
ackMessageId: data.messageId, ackMessageId: data.messageId,
connectionId: data.connectionId, connectionId: data.connectionId,
@@ -215,16 +239,24 @@ export class SendDownstreamStateService {
where: { id: data.id, status: { not: 'delivered' } }, where: { id: data.id, status: { not: 'delivered' } },
data: { data: {
acknowledgedAt, acknowledgedAt,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
ackSequenceId: data.sequenceId, ackSequenceId: data.sequenceId,
ackMessageId: data.messageId, ackMessageId: data.messageId,
connectionId: data.connectionId, connectionId: data.connectionId,
}, },
}); });
if (data.result === 0) { if (data.result === 0) {
return this.facade.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid'); 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'); return this.facade.markDownstreamDeliveryFailed(
data.id,
`downstream CMPP_DELIVER_RESP result=${data.result}`,
'ack_rejected',
);
} }
async markDownstreamDeliveryFailed( async markDownstreamDeliveryFailed(
@@ -261,7 +293,11 @@ export class SendDownstreamStateService {
return delivery; return delivery;
} }
const retryCount = (delivery.retryCount ?? 0) + 1; const retryCount = (delivery.retryCount ?? 0) + 1;
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost'; const acknowledgementFailure =
failureType === 'ack_timeout' ||
failureType === 'ack_rejected' ||
failureType === 'ack_invalid' ||
failureType === 'connection_lost';
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false; const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout'; const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout';
const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries(); const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries();
@@ -330,12 +366,14 @@ export class SendDownstreamStateService {
if (!account) { if (!account) {
throw new BadRequestException('account is required'); throw new BadRequestException('account is required');
} }
const recoveryStatuses = (this.prisma as PrismaService & { const recoveryStatuses = (
gatewayDownstreamRecoveryStatus: { this.prisma as PrismaService & {
findUnique: (args: Record<string, unknown>) => Promise<any>; gatewayDownstreamRecoveryStatus: {
upsert: (args: Record<string, unknown>) => Promise<any>; findUnique: (args: Record<string, unknown>) => Promise<any>;
}; upsert: (args: Record<string, unknown>) => Promise<any>;
}).gatewayDownstreamRecoveryStatus; };
}
).gatewayDownstreamRecoveryStatus;
const previous = await recoveryStatuses.findUnique({ const previous = await recoveryStatuses.findUnique({
where: { account }, where: { account },
select: { select: {
@@ -499,7 +537,7 @@ export class SendDownstreamStateService {
}, },
}); });
try { try {
const result = await this.facade.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult; const result = (await this.facade.postGatewayControl(path, requestPayload)) as GatewayControlDeliveryResult;
if (result.sent || result.delivered) { if (result.sent || result.delivered) {
return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result }); return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
} }
@@ -517,10 +555,13 @@ export class SendDownstreamStateService {
} }
async recoverStaleDownstreamManualRequeues(now = new Date()) { async recoverStaleDownstreamManualRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger( const staleCutoff = new Date(
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, now.getTime() -
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, positiveInteger(
)); process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
),
);
const stale = await this.prisma.cmppDownstreamDelivery.findMany({ const stale = await this.prisma.cmppDownstreamDelivery.findMany({
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } }, where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
select: { id: true, updatedAt: true }, select: { id: true, updatedAt: true },
@@ -1,3 +1,4 @@
import { protocolUint32ToDb } from '../common/protocol-uint32';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
@@ -43,6 +44,7 @@ export class SendGatewayResultService {
) {} ) {}
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
protocolUint32ToDb(data.sequenceId);
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data); const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
const effectiveSubmitId = submitRecord.submitId; const effectiveSubmitId = submitRecord.submitId;
@@ -87,7 +89,7 @@ export class SendGatewayResultService {
gatewayMessageId: null, gatewayMessageId: null,
}, },
data: { data: {
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
submittedAt, submittedAt,
}, },
@@ -155,6 +157,8 @@ export class SendGatewayResultService {
} }
async handleSubmitResult(data: GatewaySubmitResultDto) { async handleSubmitResult(data: GatewaySubmitResultDto) {
protocolUint32ToDb(data.sequenceId);
for (const segment of data.segments ?? []) protocolUint32ToDb(segment.sequenceId);
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data); const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
if (data.eventId && submitRecord.resultEventId) { if (data.eventId && submitRecord.resultEventId) {
@@ -171,7 +175,7 @@ export class SendGatewayResultService {
await this.prisma.smsSubmitRecord.updateMany({ await this.prisma.smsSubmitRecord.updateMany({
where: { id: submitRecord.id }, where: { id: submitRecord.id },
data: { data: {
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
submitStatus: data.submitStatus, submitStatus: data.submitStatus,
errorCode: data.errorCode, errorCode: data.errorCode,
@@ -190,7 +194,8 @@ export class SendGatewayResultService {
data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed'; data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
if ( if (
data.submitStatus !== 'accepted' && data.submitStatus !== 'accepted' &&
(message.status === 'delivered' || (message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT')) (['delivered', 'failed'].includes(message.status) ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT'))
) { ) {
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt); await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
return message; return message;
@@ -381,10 +386,8 @@ export class SendGatewayResultService {
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({ const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
where: { where: {
messageRecordId: message.id, messageRecordId: message.id,
OR: [ channelId: data.channelId,
data.submitId ? { submitId: data.submitId } : undefined, ...(data.submitId ? { submitId: data.submitId } : { gatewayMessageId: data.gatewayMessageId }),
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
].filter(Boolean) as Array<{ submitId?: string; gatewayMessageId?: string }>,
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
@@ -435,7 +438,7 @@ export class SendGatewayResultService {
channelId: data.channelId ?? message.channelId ?? null, channelId: data.channelId ?? message.channelId ?? null,
attempt, attempt,
segmentTotal, segmentTotal,
sequenceId: segment.sequenceId ?? data.sequenceId ?? null, sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
submitStatus: status, submitStatus: status,
errorCode: segment.errorCode ?? data.errorCode ?? null, errorCode: segment.errorCode ?? data.errorCode ?? null,
@@ -453,7 +456,7 @@ export class SendGatewayResultService {
attempt, attempt,
segmentTotal, segmentTotal,
segmentIndex, segmentIndex,
sequenceId: segment.sequenceId ?? data.sequenceId ?? null, sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
submitStatus: status, submitStatus: status,
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null, compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
@@ -1,3 +1,4 @@
import { applyOptOutRule, loadOptOutPolicies, policyAudit } from './template-optout-policy';
import { completionContext } from './completion-context'; import { completionContext } from './completion-context';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@@ -300,7 +301,9 @@ export class SendGatewaySubmitService {
sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId)); sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId));
}), }),
); );
const prepared = planned.map(({ message, routed }) => { const prepared = planned.map(({ message: input, routed }) => {
const decision = routed.contentPolicy ?? applyOptOutRule(input);
const message = { ...input, content: decision.content };
const submitId = `SUB-${randomUUID()}`; const submitId = `SUB-${randomUUID()}`;
const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension); const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension);
return { return {
@@ -308,6 +311,7 @@ export class SendGatewaySubmitService {
routed, routed,
submitId, submitId,
command: this.buildGatewaySubmitCommand(message, routed, 0, submitId, upstreamSrcId), command: this.buildGatewaySubmitCommand(message, routed, 0, submitId, upstreamSrcId),
decision,
sessionId: sessionByChannel.get(routed.channel.id), sessionId: sessionByChannel.get(routed.channel.id),
}; };
}); });
@@ -315,7 +319,9 @@ export class SendGatewaySubmitService {
await this.measureSendStage('submit_transaction', () => await this.measureSendStage('submit_transaction', () =>
this.prisma.$transaction(async (tx) => { this.prisma.$transaction(async (tx) => {
await tx.smsSubmitRecord.createMany({ await tx.smsSubmitRecord.createMany({
data: prepared.map(({ message, routed, submitId, sessionId }) => ({ data: prepared.map(({ message, routed, submitId, sessionId, decision }) => ({
sentContent: message.content,
contentPolicy: policyAudit(decision),
id: randomUUID(), id: randomUUID(),
tenantId: message.tenantId, tenantId: message.tenantId,
batchTaskId: message.batchTaskId, batchTaskId: message.batchTaskId,
@@ -332,15 +338,18 @@ export class SendGatewaySubmitService {
}); });
const updates = Prisma.join( const updates = Prisma.join(
prepared.map( prepared.map(
({ message, routed, submitId }) => Prisma.sql`( ({ message, routed, submitId, decision }) => Prisma.sql`(
${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text, ${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text,
${routed.province ?? null}::text, ${submitId}::text ${routed.province ?? null}::text, ${submitId}::text, ${message.content}::text,
${message.originalContent ?? (decision.content !== decision.originalContent ? decision.originalContent : null)}::text
)`, )`,
), ),
); );
await tx.$executeRaw(Prisma.sql` await tx.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message UPDATE "SmsMessageRecord" AS message
SET "channelId" = updates."channelId", SET content = updates.content,
"originalContent" = COALESCE(message."originalContent", updates."originalContent"),
"channelId" = updates."channelId",
carrier = updates.carrier, carrier = updates.carrier,
province = updates.province, province = updates.province,
"submitId" = updates."submitId", "submitId" = updates."submitId",
@@ -350,7 +359,7 @@ export class SendGatewaySubmitService {
"errorCode" = NULL, "errorCode" = NULL,
"errorMessage" = NULL, "errorMessage" = NULL,
"updatedAt" = (NOW() AT TIME ZONE 'UTC') "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId") FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId", content, "originalContent")
WHERE message.id = updates.id AND message.status = 'queued' WHERE message.id = updates.id AND message.status = 'queued'
`); `);
if (writeOutbox) { if (writeOutbox) {
@@ -393,6 +402,8 @@ export class SendGatewaySubmitService {
signatureId?: string | null; signatureId?: string | null;
phoneNumber: string; phoneNumber: string;
content?: string; content?: string;
originalContent?: string | null;
billingUnits?: number;
carrier?: string | null; carrier?: string | null;
province?: string | null; province?: string | null;
template?: { signature?: { id?: string | null } | null } | null; template?: { signature?: { id?: string | null } | null } | null;
@@ -467,6 +478,10 @@ export class SendGatewaySubmitService {
this.prisma, this.prisma,
routes.flatMap((route) => route.group.items.map((item) => item.channelId)), routes.flatMap((route) => route.group.items.map((item) => item.channelId)),
); );
const policies = await loadOptOutPolicies(
this.prisma,
messages.map((m) => ({ ...m, content: m.content ?? '' })),
);
const planned: Array<{ message: T; routed: RoutedChannel }> = []; const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string; code?: string }> = []; const failed: Array<{ message: T; reason: string; code?: string }> = [];
for (const input of routeInputs) { for (const input of routeInputs) {
@@ -485,10 +500,10 @@ export class SendGatewaySubmitService {
input.message.content === undefined input.message.content === undefined
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } }) ? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
: input.message; : input.message;
content = stored.content!; content = stored.originalContent ?? stored.content!;
gate = await evaluateMessageDrainage( gate = await evaluateMessageDrainage(
this.prisma, this.prisma,
{ ...input.message, content: stored.content!, signatureId: input.signatureId }, { ...input.message, content, signatureId: input.signatureId },
input.carrier, input.carrier,
drainageMaterials drainageMaterials
.filter( .filter(
@@ -530,13 +545,19 @@ export class SendGatewaySubmitService {
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')), (process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
), ),
); );
const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, { const { selected, rejected } = channelWords.select(
carrier: input.carrier, input.message.id,
province: input.province, content,
excludedChannelIds: new Set(), approvedItems,
approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)), {
routingKey: input.message.id, carrier: input.carrier,
}); province: input.province,
excludedChannelIds: new Set(),
approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)),
routingKey: input.message.id,
},
(id) => policies({ ...input.message, content }, id).content,
);
if (!selected) { if (!selected) {
failed.push({ failed.push({
message: input.message, message: input.message,
@@ -548,6 +569,7 @@ export class SendGatewaySubmitService {
planned.push({ planned.push({
message: input.message, message: input.message,
routed: { routed: {
contentPolicy: policies({ ...input.message, content }, selected.channelId),
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier: input.carrier, carrier: input.carrier,
province: input.province, province: input.province,
@@ -755,6 +777,8 @@ export class SendGatewaySubmitService {
attempt: number, attempt: number,
retryOfSubmitRecordId?: string, retryOfSubmitRecordId?: string,
) { ) {
const decision = routed.contentPolicy ?? applyOptOutRule(message);
const submittedMessage = { ...message, content: decision.content };
const channel = routed.channel; const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension); const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
await this.measureSendStage('rate_limit', () => await this.measureSendStage('rate_limit', () =>
@@ -762,7 +786,7 @@ export class SendGatewaySubmitService {
); );
const submitId = `SUB-${randomUUID()}`; const submitId = `SUB-${randomUUID()}`;
const sessionId = await this.getOpenSubmitSessionId(channel.id); const sessionId = await this.getOpenSubmitSessionId(channel.id);
const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId); const command = this.buildGatewaySubmitCommand(submittedMessage, routed, attempt, submitId, upstreamSrcId);
const writeOutbox = Boolean(completionContext.getStore()) || this.submitOutboxEnabled(); const writeOutbox = Boolean(completionContext.getStore()) || this.submitOutboxEnabled();
try { try {
await this.measureSendStage('submit_transaction', () => await this.measureSendStage('submit_transaction', () =>
@@ -777,6 +801,8 @@ export class SendGatewaySubmitService {
channelGroupName: routed.groupName, channelGroupName: routed.groupName,
sessionId, sessionId,
retryOfSubmitRecordId, retryOfSubmitRecordId,
sentContent: decision.content,
contentPolicy: policyAudit(decision),
submitId, submitId,
submitStatus: 'queued', submitStatus: 'queued',
costUnitPrice: channel.unitPrice ?? 0, costUnitPrice: channel.unitPrice ?? 0,
@@ -786,6 +812,8 @@ export class SendGatewaySubmitService {
await tx.smsMessageRecord.update({ await tx.smsMessageRecord.update({
where: { id: message.id }, where: { id: message.id },
data: { data: {
content: decision.content,
originalContent: decision.content !== decision.originalContent ? decision.originalContent : undefined,
channelId: channel.id, channelId: channel.id,
carrier: routed.carrier, carrier: routed.carrier,
province: routed.province, province: routed.province,
@@ -1118,7 +1146,9 @@ return streamId`;
); );
const excluded = new Set(options.excludeChannelIds ?? []); const excluded = new Set(options.excludeChannelIds ?? []);
const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } }); const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const gate = await evaluateMessageDrainage(this.prisma, { ...stored, signatureId }, carrier); const original = { ...stored, content: stored.originalContent ?? stored.content };
const policies = await loadOptOutPolicies(this.prisma, [original]);
const gate = await evaluateMessageDrainage(this.prisma, { ...original, signatureId }, carrier);
const approvedChannelIds = new Set( const approvedChannelIds = new Set(
route.group.items route.group.items
.map((item) => item.channelId) .map((item) => item.channelId)
@@ -1130,20 +1160,27 @@ return streamId`;
this.prisma, this.prisma,
route.group.items.map((item) => item.channelId), route.group.items.map((item) => item.channelId),
); );
const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, { const { selected, rejected } = channelWords.select(
carrier, message.id,
province, original.content,
forceNational: options.forceNational, route.group.items,
excludedChannelIds: excluded, {
approvedChannelIds, carrier,
routingKey: message.id, province,
}); forceNational: options.forceNational,
excludedChannelIds: excluded,
approvedChannelIds,
routingKey: message.id,
},
(id) => policies(original, id).content,
);
if (!options.previewOnly) await channelWords.persist(this.prisma); if (!options.previewOnly) await channelWords.persist(this.prisma);
if (rejected) throw new ChannelWordRejection(); if (rejected) throw new ChannelWordRejection();
if (!selected) { if (!selected) {
throw new NotFoundException('无已报备通过且在线的可用通道'); throw new NotFoundException('无已报备通过且在线的可用通道');
} }
return { return {
contentPolicy: policies(original, selected.channelId),
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier, carrier,
province, province,
+93 -199
View File
@@ -1,3 +1,5 @@
import { protocolUint32ToDb, protocolUint32FromDb } from '../common/protocol-uint32';
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { completionContext } from './completion-context'; import { completionContext } from './completion-context';
import { Logger, NotFoundException } from '@nestjs/common'; import { Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@@ -14,7 +16,6 @@ import {
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
aggregateReceiptSegmentState, aggregateReceiptSegmentState,
isSameUpstreamEndpointIdentity,
receiptEventKey, receiptEventKey,
longMessageReceiptMode, longMessageReceiptMode,
} from './send-chain.helpers'; } from './send-chain.helpers';
@@ -39,6 +40,7 @@ export class SendReceiptService {
) {} ) {}
async intakeReceipt(data: GatewayReceiptEventDto) { async intakeReceipt(data: GatewayReceiptEventDto) {
protocolUint32ToDb(data.sequenceId);
const channel = await this.prisma.smsChannel.findUnique({ const channel = await this.prisma.smsChannel.findUnique({
where: { id: data.channelId }, where: { id: data.channelId },
select: { select: {
@@ -55,34 +57,45 @@ export class SendReceiptService {
} }
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const receiptKey = receiptEventKey(data, data.channelId); const receiptKey = receiptEventKey(data, data.channelId);
const inbox = await this.prisma.upstreamReceiptInbox.upsert({ const inbox = await this.prisma.upstreamReceiptInbox
where: { receiptKey }, .upsert({
update: { where: { receiptKey },
incomingConnectionId: data.connectionId, update: {
}, incomingConnectionId: data.connectionId,
create: { },
receiptKey, create: {
incomingChannelId: data.channelId, receiptKey,
incomingConnectionId: data.connectionId, incomingChannelId: data.channelId,
upstreamAccount: channel.account, incomingConnectionId: data.connectionId,
upstreamHost: channel.gatewayHost, upstreamAccount: channel.account,
upstreamPort: channel.gatewayPort, upstreamHost: channel.gatewayHost,
protocol: channel.protocol, upstreamPort: channel.gatewayPort,
protocolVersion: channel.cmppVersion, protocol: channel.protocol,
provisionalMessageId: data.messageId, protocolVersion: channel.cmppVersion,
sequenceId: data.sequenceId, provisionalMessageId: data.messageId,
gatewayMessageId: data.gatewayMessageId, sequenceId: protocolUint32ToDb(data.sequenceId),
phoneNumber: data.phoneNumber?.trim() || null, gatewayMessageId: data.gatewayMessageId,
receiptStatus: data.receiptStatus, phoneNumber: data.phoneNumber?.trim() || null,
rawStatus: data.rawStatus, receiptStatus: data.receiptStatus,
errorCode: data.errorCode, rawStatus: data.rawStatus,
errorMessage: data.errorMessage, errorCode: data.errorCode,
deliveredAt, errorMessage: data.errorMessage,
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null, deliveredAt,
status: 'pending', gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
nextRetryAt: new Date(), status: 'pending',
}, nextRetryAt: new Date(),
}); },
})
.catch(async (error: unknown) => {
// Prisma can emulate an upsert when the optional update is empty. Another
// callback may win the unique receiptKey insert; acknowledge only the
// exact durable fact, never swallow unrelated persistence failures.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const existing = await this.prisma.upstreamReceiptInbox.findUnique({ where: { receiptKey } });
if (existing) return existing;
}
throw error;
});
if (['pending', 'retrying'].includes(inbox.status)) { if (['pending', 'retrying'].includes(inbox.status)) {
setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id)); setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id));
} }
@@ -143,7 +156,7 @@ export class SendReceiptService {
messageId: inbox.provisionalMessageId ?? undefined, messageId: inbox.provisionalMessageId ?? undefined,
channelId: inbox.incomingChannelId, channelId: inbox.incomingChannelId,
connectionId: inbox.incomingConnectionId ?? undefined, connectionId: inbox.incomingConnectionId ?? undefined,
sequenceId: inbox.sequenceId ?? undefined, sequenceId: protocolUint32FromDb(inbox.sequenceId),
gatewayMessageId: inbox.gatewayMessageId, gatewayMessageId: inbox.gatewayMessageId,
phoneNumber: inbox.phoneNumber ?? undefined, phoneNumber: inbox.phoneNumber ?? undefined,
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus), receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
@@ -223,6 +236,7 @@ export class SendReceiptService {
cmppVersion: string; cmppVersion: string;
}, },
) { ) {
protocolUint32ToDb(data.sequenceId);
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity); const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
const logicalChannelId = resolved.channelId ?? data.channelId; const logicalChannelId = resolved.channelId ?? data.channelId;
const receiptKey = receiptEventKey(data, logicalChannelId); const receiptKey = receiptEventKey(data, logicalChannelId);
@@ -245,7 +259,7 @@ export class SendReceiptService {
}, },
data: { data: {
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
}, },
}); });
} }
@@ -262,7 +276,7 @@ export class SendReceiptService {
messageId: resolved.messageId, messageId: resolved.messageId,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber, phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
receiptStatus: data.receiptStatus, receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus, rawStatus: data.rawStatus,
errorCode: data.errorCode, errorCode: data.errorCode,
@@ -282,6 +296,34 @@ export class SendReceiptService {
throw error; throw error;
} }
const logicalReceipt = { ...data, channelId: logicalChannelId }; const logicalReceipt = { ...data, channelId: logicalChannelId };
// The message row is locked by AttemptCompletion. A committed final decision
// also commits accounting and notifications; later evidence cannot undo it.
const sameAttempt =
(!message.submitId || message.submitId === resolved.submitId) &&
(!message.channelId || message.channelId === logicalChannelId);
const frozen =
['failed', 'delivered'].includes(message.status) ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT');
if (sameAttempt && frozen) {
const contradicts =
message.status === 'delivered'
? !['delivered', 'unknown'].includes(data.receiptStatus)
: data.receiptStatus === 'delivered';
if (contradicts) {
if (!existingReceipt)
await this.recordReceiptConflict({
message,
submitRecordId: resolved.submitRecordId,
submitId: resolved.submitId,
receiptRecordId,
receiptKey,
data: logicalReceipt,
});
} else if (data.receiptStatus !== 'unknown') {
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
}
return message;
}
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId); await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
const receiptMode = const receiptMode =
Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment'; Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment';
@@ -455,7 +497,10 @@ export class SendReceiptService {
receiptKey: input.receiptKey, receiptKey: input.receiptKey,
gatewayMessageId: input.data.gatewayMessageId, gatewayMessageId: input.data.gatewayMessageId,
phoneNumber: input.data.phoneNumber, phoneNumber: input.data.phoneNumber,
reason: 'message_level_success_followed_by_failure', reason:
input.message.status === 'delivered'
? 'message_level_success_followed_by_failure'
: 'final_failure_followed_by_success',
}; };
await this.prisma.smsReceiptAnomaly.upsert({ await this.prisma.smsReceiptAnomaly.upsert({
where: { anomalyKey }, where: { anomalyKey },
@@ -479,7 +524,8 @@ export class SendReceiptService {
messageRecordId: input.message.id, messageRecordId: input.message.id,
submitRecordId: input.submitRecordId, submitRecordId: input.submitRecordId,
receiptRecordId: input.receiptRecordId, receiptRecordId: input.receiptRecordId,
anomalyType: 'aggregate_success_then_failure', anomalyType:
input.message.status === 'delivered' ? 'aggregate_success_then_failure' : 'final_failure_then_success',
previousStatus: input.message.status, previousStatus: input.message.status,
incomingStatus: input.data.receiptStatus, incomingStatus: input.data.receiptStatus,
rawStatus: input.data.rawStatus, rawStatus: input.data.rawStatus,
@@ -505,12 +551,21 @@ export class SendReceiptService {
submitRecordId?: string, submitRecordId?: string,
) { ) {
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate(); const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
const source = submitRecordId
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
: null;
if (submitRecordId && (!source || source.messageRecordId !== message.id || source.channelId !== data.channelId))
throw new Error('completion_receipt_source_mismatch');
if (!source) throw new NotFoundException('回执缺少可确认的提交尝试关联');
const updated = await segmentAudits.updateMany({ const updated = await segmentAudits.updateMany({
where: { where: {
messageRecordId: message.id, messageRecordId: message.id,
channelId: source.channelId,
OR: [{ submitRecordId: source.id }, { submitRecordId: null, submitId: source.submitId }],
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
}, },
data: { data: {
submitRecordId: source.id,
receiptStatus: data.receiptStatus, receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus, rawStatus: data.rawStatus,
errorCode: data.errorCode ?? null, errorCode: data.errorCode ?? null,
@@ -520,12 +575,7 @@ export class SendReceiptService {
if (updated.count > 0) { if (updated.count > 0) {
return; return;
} }
const submitRecord = submitRecordId const submitRecord = source;
? 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({ await segmentAudits.upsert({
where: { where: {
messageRecordId_submitId_segmentIndex: { messageRecordId_submitId_segmentIndex: {
@@ -537,7 +587,7 @@ export class SendReceiptService {
update: { update: {
submitRecordId: submitRecord?.id ?? submitRecordId ?? null, submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
channelId: data.channelId ?? message.channelId ?? null, channelId: data.channelId ?? message.channelId ?? null,
sequenceId: data.sequenceId ?? null, sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
receiptStatus: data.receiptStatus, receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus, rawStatus: data.rawStatus,
@@ -554,7 +604,7 @@ export class SendReceiptService {
attempt: 0, attempt: 0,
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
segmentIndex: 1, segmentIndex: 1,
sequenceId: data.sequenceId ?? null, sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
submitStatus: submitRecord?.submitStatus ?? 'accepted', submitStatus: submitRecord?.submitStatus ?? 'accepted',
receiptStatus: data.receiptStatus, receiptStatus: data.receiptStatus,
@@ -600,162 +650,6 @@ export class SendReceiptService {
cmppVersion: string; cmppVersion: string;
}, },
) { ) {
const exactMessage = data.messageId return resolveReceiptAttempt(this.prisma, data, incomingIdentity);
? 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,
};
} }
} }
@@ -0,0 +1,57 @@
import { applyOptOutRule, gatewayFragmentCount, loadOptOutPolicies, OPT_OUT_SUFFIX } from './template-optout-policy';
import { PrismaService } from '../prisma/prisma.service';
const add = { channelId: 'a', action: 'add' as const };
const remove = { channelId: 'b', action: 'remove' as const };
describe('template opt-out fragment preservation', () => {
test.each([1, 64, 65, 69, 70, 71, 77, 128, 129, 134, 135, 195, 201])(
'preserves billing and wire parts at length %i',
(length) => {
const content = '文'.repeat(length);
const result = applyOptOutRule({ content }, add);
expect(gatewayFragmentCount(result.content)).toBe(gatewayFragmentCount(content));
expect(result.reason).toBe(
gatewayFragmentCount(content + OPT_OUT_SUFFIX) === gatewayFragmentCount(content)
? 'applied'
: 'fragment_count_changed',
);
},
);
it('adds 71 to 77 but skips 69 to 75', () => {
expect(applyOptOutRule({ content: '文'.repeat(69) }, add).content).toHaveLength(69);
expect(applyOptOutRule({ content: '文'.repeat(71) }, add).content).toHaveLength(77);
});
it('does not remove suffix across a fragment boundary or alter inline text', () => {
expect(applyOptOutRule({ content: '文'.repeat(69) + OPT_OUT_SUFFIX }, remove).reason).toBe(
'fragment_count_changed',
);
const content = `正文${OPT_OUT_SUFFIX}。后文`;
expect(applyOptOutRule({ content }, remove).content).toBe(content);
});
it('retains original on alternate-channel retry and never stacks additions', () => {
const originalContent = '文'.repeat(71);
const first = applyOptOutRule({ content: originalContent }, add);
expect(applyOptOutRule({ originalContent, content: first.content }, add).content).toBe(first.content);
expect(applyOptOutRule({ originalContent, content: first.content }, remove).content).toBe(originalContent);
expect(applyOptOutRule({ originalContent, content: first.content }).content).toBe(originalContent);
});
it('preserves UTF-16 parts and skips historical billing mismatch', () => {
const content = '😀'.repeat(34);
expect(applyOptOutRule({ content }, add).reason).toBe('fragment_count_changed');
expect(applyOptOutRule({ content: '文'.repeat(71), billingUnits: 1 }, add).reason).toBe('fragment_count_changed');
expect(gatewayFragmentCount('😀'.repeat(67))).toBe(3);
});
it('matches independently of template admission, scopes tenants/apps and prefers exact text', async () => {
const templates = [
{ id: 'v', tenantId: 't', applicationId: 'app', content: '【测】${name}', optOutRules: [remove] },
{ id: 'e', tenantId: 't', applicationId: 'app', content: '【测】正文', optOutRules: [add] },
];
const db = { smsTemplate: { findMany: jest.fn().mockResolvedValue(templates) } };
const message = { tenantId: 't', applicationId: 'app', content: '【测】正文' };
const policies = await loadOptOutPolicies(db as unknown as PrismaService, [message]);
expect(policies(message, 'a').reason).toBe('applied');
expect(policies({ ...message, tenantId: 'other' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, content: '其他短信' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, templateId: 'v' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, templateId: 'unconfigured-template' }, 'a').reason).toBe('no_policy');
});
});
@@ -0,0 +1,106 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { estimateBillingUnits } from '../sms-config/sms-config.helpers';
import { matchTemplateContent } from './send-chain.helpers';
export const OPT_OUT_SUFFIX = '拒收请回复R';
export type OptOutRule = { channelId: string; action: 'add' | 'remove' };
export type ContentPolicyDecision = {
originalContent: string;
content: string;
templateId: string | null;
action: 'add' | 'remove' | 'none';
reason: 'applied' | 'unchanged' | 'fragment_count_changed' | 'no_policy';
};
type PolicyMessage = {
tenantId?: string | null;
applicationId?: string | null;
templateId?: string | null;
content: string;
originalContent?: string | null;
billingUnits?: number;
};
// Gateway UCS2 uses UTF-16 and never splits a Unicode character across parts.
export function gatewayFragmentCount(content: string) {
if (content.length * 2 <= 140) return 1;
let count = 1,
units = 0;
for (const character of content) {
if (units + character.length > 67) {
count++;
units = 0;
}
units += character.length;
}
return count;
}
export function applyOptOutRule(message: PolicyMessage, rule?: OptOutRule, templateId?: string): ContentPolicyDecision {
const originalContent = message.originalContent ?? message.content;
const result: ContentPolicyDecision = {
originalContent,
content: originalContent,
templateId: templateId ?? null,
action: rule?.action ?? 'none',
reason: 'no_policy',
};
if (!rule) return result;
const content =
rule.action === 'add'
? originalContent.endsWith(OPT_OUT_SUFFIX)
? originalContent
: originalContent + OPT_OUT_SUFFIX
: originalContent.endsWith(OPT_OUT_SUFFIX)
? originalContent.slice(0, -OPT_OUT_SUFFIX.length)
: originalContent;
if (content === originalContent) return { ...result, reason: 'unchanged' };
const originalUnits = estimateBillingUnits(originalContent);
if (
estimateBillingUnits(content) !== originalUnits ||
(message.billingUnits !== undefined && originalUnits !== message.billingUnits) ||
gatewayFragmentCount(content) !== gatewayFragmentCount(originalContent)
) {
return { ...result, reason: 'fragment_count_changed' };
}
return { ...result, content, reason: 'applied' };
}
export async function loadOptOutPolicies(db: PrismaService, messages: PolicyMessage[]) {
const applicationIds = [...new Set(messages.map((m) => m.applicationId).filter((id): id is string => Boolean(id)))];
const templates = applicationIds.length
? await db.smsTemplate.findMany({
where: {
applicationId: { in: applicationIds },
auditStatus: 'approved',
signature: { auditStatus: 'approved' },
NOT: { optOutRules: { equals: [] } },
},
select: { id: true, tenantId: true, applicationId: true, content: true, optOutRules: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
})
: [];
return (message: PolicyMessage, channelId: string) => {
const content = message.originalContent ?? message.content;
const matches = templates.filter(
(t) =>
t.tenantId === message.tenantId &&
t.applicationId === message.applicationId &&
(t.content === content || matchTemplateContent(t.content, content) !== null),
);
const template = message.templateId
? matches.find((t) => t.id === message.templateId)
: (matches.find((t) => t.content === content) ?? matches[0]);
const rules = (template?.optOutRules ?? []) as unknown as OptOutRule[];
return applyOptOutRule(
message,
rules.find((r) => r.channelId === channelId),
template?.id,
);
};
}
export function policyAudit(decision?: ContentPolicyDecision): Prisma.InputJsonValue | undefined {
if (!decision || decision.action === 'none') return undefined;
return { templateId: decision.templateId, action: decision.action, reason: decision.reason, preserveFragments: true };
}
+149 -131
View File
@@ -1,177 +1,195 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; 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 { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode'; import type { ReviewDto, StatusChangeDto } from './sms-config.contracts';
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 { SmsApplicationLifecycleService } from './application-lifecycle.service';
import { SmsReportValidationService } from './report-validation.service'; import { SmsReportValidationService } from './report-validation.service';
import { writeUniqueSignature } from './signature-uniqueness';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsAuditService { export class SmsAuditService {
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService, private readonly reportValidation: SmsReportValidationService) {} constructor(
private readonly prisma: PrismaService,
private readonly lifecycle: SmsApplicationLifecycleService,
private readonly reportValidation: SmsReportValidationService,
) {}
listAuditRecords(targetType?: string, targetId?: string) { listAuditRecords(targetType?: string, targetId?: string) {
return this.prisma.auditRecord.findMany({ return this.prisma.auditRecord.findMany({
where: { where: {
targetType, targetType,
targetId, targetId,
}, },
include: { include: {
reviewer: { select: { id: true, username: true, displayName: true } }, reviewer: { select: { id: true, username: true, displayName: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
approveSignature(signatureId: string, data: ReviewDto) { approveSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'approved', 'approve', data); return this.reviewSignature(signatureId, 'approved', 'approve', data);
} }
rejectSignature(signatureId: string, data: ReviewDto) { rejectSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'rejected', 'reject', data); return this.reviewSignature(signatureId, 'rejected', 'reject', data);
} }
approveTemplate(templateId: string, data: ReviewDto) { approveTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'approved', 'approve', data); return this.reviewTemplate(templateId, 'approved', 'approve', data);
} }
rejectTemplate(templateId: string, data: ReviewDto) { rejectTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'rejected', 'reject', data); return this.reviewTemplate(templateId, 'rejected', 'reject', data);
} }
async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) { async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature || (tenantId && signature.tenantId !== tenantId)) { if (!signature || (tenantId && signature.tenantId !== tenantId)) {
throw new NotFoundException('Signature not found'); throw new NotFoundException('Signature not found');
} }
const status = data.status ?? 'deleted'; const status = data.status ?? 'deleted';
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } }); const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: status }, () =>
await this.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, { 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, statusBefore: signature.auditStatus,
statusAfter: status, statusAfter: status,
reason: data.reason, reason: data.reason,
}); },
return updated; );
} return updated;
}
async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) { async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template || (tenantId && template.tenantId !== tenantId)) { if (!template || (tenantId && template.tenantId !== tenantId)) {
throw new NotFoundException('Template not found'); throw new NotFoundException('Template not found');
} }
const status = data.status ?? 'deleted'; const status = data.status ?? 'deleted';
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } }); 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, { await this.lifecycle.writeOperationLog(
template.tenantId,
data.operatorId,
`sms_template.${status}`,
'sms_template',
templateId,
{
statusBefore: template.auditStatus, statusBefore: template.auditStatus,
statusAfter: status, statusAfter: status,
reason: data.reason, reason: data.reason,
}); },
return updated; );
} return updated;
}
async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) { async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) { if (!signature) {
throw new NotFoundException('Signature not found'); throw new NotFoundException('Signature not found');
} }
const reviewerId = await this.resolveReviewerId(data.reviewerId); const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsSignature.update({ const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: statusAfter }, () =>
this.prisma.smsSignature.update({
where: { id: signatureId }, where: { id: signatureId },
data: { data: {
auditStatus: statusAfter, auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null, rejectReason: statusAfter === 'rejected' ? data.reason : null,
}, },
}); }),
await this.createAuditRecord({ );
tenantId: signature.tenantId, await this.createAuditRecord({
targetType: 'sms_signature', tenantId: signature.tenantId,
targetId: signatureId, targetType: 'sms_signature',
action, targetId: signatureId,
statusBefore: signature.auditStatus, action,
statusAfter, statusBefore: signature.auditStatus,
reason: data.reason, statusAfter,
reviewerId, reason: data.reason,
}); reviewerId,
return updated; });
} return updated;
}
async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) { async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found'); if (!item) throw new NotFoundException('Drainage info not found');
if (!['pending', 'rejected'].includes(item.auditStatus)) { if (!['pending', 'rejected'].includes(item.auditStatus)) {
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核'); 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;
} }
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) { async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template) { if (!template) {
throw new NotFoundException('Template not found'); 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;
} }
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) { async resolveReviewerId(reviewerId?: string) {
if (!reviewerId) { if (!reviewerId) {
return undefined; 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;
} }
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) { createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
return this.prisma.auditRecord.create({ data }); return this.prisma.auditRecord.create({ data });
} }
} }
@@ -0,0 +1,103 @@
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
import { SignatureNameConflict, writeUniqueSignature } from './signature-uniqueness';
describe('active signature uniqueness', () => {
const identity = { tenantId: 'tenant', applicationId: 'app', name: '【测试】' };
function fixture() {
const findFirst = jest.fn().mockResolvedValue(null);
return { findFirst, prisma: { smsSignature: { findFirst } } as unknown as PrismaService };
}
it('rejects duplicates before writing and returns a business conflict', async () => {
const { prisma, findFirst } = fixture();
findFirst.mockResolvedValue({ id: 'existing' });
const write = jest.fn();
await expect(writeUniqueSignature(prisma, identity, write)).rejects.toBeInstanceOf(SignatureNameConflict);
expect(write).not.toHaveBeenCalled();
expect(new SignatureNameConflict().getStatus()).toBe(409);
});
it('scopes null applications exactly and excludes the current record', async () => {
const { prisma, findFirst } = fixture();
await expect(
writeUniqueSignature(prisma, { ...identity, id: 'self', applicationId: null }, async () => 'ok'),
).resolves.toBe('ok');
expect(findFirst).toHaveBeenCalledWith({
where: {
tenantId: 'tenant',
applicationId: null,
name: '【测试】',
auditStatus: { notIn: ['deleted', 'disabled'] },
id: { not: 'self' },
},
select: { id: true },
});
});
it.each(['draft', 'pending', 'approved', 'rejected'])('reserves names in %s status', async (auditStatus) => {
const { prisma, findFirst } = fixture();
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
expect(findFirst).toHaveBeenCalledTimes(1);
});
it.each(['deleted', 'disabled'])('releases names in %s status', async (auditStatus) => {
const { prisma, findFirst } = fixture();
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
expect(findFirst).not.toHaveBeenCalled();
});
it.each([
[['tenantId', 'applicationId', 'name']],
[['tenantId', 'name']],
['SmsSignature_active_application_name_key'],
['SmsSignature_active_unbound_name_key'],
])('maps only the signature identity race (%j)', async (target) => {
const { prisma } = fixture();
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
code: 'P2002',
clientVersion: 'test',
meta: { target },
});
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBeInstanceOf(SignatureNameConflict);
});
it('preserves unrelated database failures', async () => {
const { prisma } = fixture();
for (const error of [
new Error('offline'),
new Prisma.PrismaClientKnownRequestError('id', {
code: 'P2002',
clientVersion: 'test',
meta: { target: ['id'] },
}),
]) {
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBe(error);
}
});
it('recognizes the real Prisma pg adapter metadata and quoted identifiers', async () => {
const { prisma } = fixture();
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
code: 'P2002',
clientVersion: '7.9.0',
meta: {
modelName: 'SmsSignature',
driverAdapterError: { cause: { constraint: { fields: ['"tenantId"', '"applicationId"', 'name'] } } },
},
});
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBeInstanceOf(SignatureNameConflict);
});
});
@@ -0,0 +1,61 @@
import { ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
export class SignatureNameConflict extends ConflictException {
constructor() {
super('同一企业、同一应用下已存在同名有效签名,请修改已有签名资料');
}
}
type SignatureIdentity = {
id?: string;
tenantId: string;
applicationId?: string | null;
name: string;
auditStatus?: string;
};
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
/** The partial SQL indexes are authoritative when concurrent requests pass the precheck. */
export async function writeUniqueSignature<T>(
prisma: PrismaService,
identity: SignatureIdentity,
write: () => Promise<T>,
): Promise<T> {
if (!['deleted', 'disabled'].includes(identity.auditStatus ?? 'draft')) {
const duplicate = await prisma.smsSignature.findFirst({
where: {
tenantId: identity.tenantId,
applicationId: identity.applicationId ?? null,
name: identity.name,
auditStatus: { notIn: ['deleted', 'disabled'] },
...(identity.id ? { id: { not: identity.id } } : {}),
},
select: { id: true },
});
if (duplicate) throw new SignatureNameConflict();
}
try {
return await write();
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
// Prisma's pg adapter exposes the constraint under driverAdapterError.cause (Prisma 7).
const constraint = record(record(record(error.meta?.driverAdapterError).cause).constraint);
const target = error.meta?.target ?? constraint.fields;
const fields = Array.isArray(target)
? target.map((field: unknown) => (typeof field === 'string' ? field.replace(/^"|"$/g, '') : field))
: [];
if (
(fields.includes('tenantId') && fields.includes('name')) ||
(typeof target === 'string' && /^SmsSignature_active_(application|unbound)_name_key$/.test(target))
) {
throw new SignatureNameConflict();
}
}
throw error;
}
}
+49 -30
View File
@@ -1,3 +1,4 @@
import { writeUniqueSignature } from './signature-uniqueness';
import { selectDrainageReportTask } from '../common/drainage-report-task'; import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@@ -766,16 +767,21 @@ export class SmsSignatureService {
data.drainageInfo, data.drainageInfo,
); );
const name = validateCompleteSmsSignature(data.name); const name = validateCompleteSmsSignature(data.name);
const signature = await this.prisma.smsSignature.create({ const signature = await writeUniqueSignature(
data: { this.prisma,
tenantId: data.tenantId, { ...data, name, auditStatus: options.initialAuditStatus },
applicationId: data.applicationId, () =>
name, this.prisma.smsSignature.create({
purpose: data.purpose, data: {
auditStatus: options.initialAuditStatus, tenantId: data.tenantId,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, 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); await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
if (options.initialAuditStatus) { if (options.initialAuditStatus) {
await this.audit.createAuditRecord({ await this.audit.createAuditRecord({
@@ -805,10 +811,11 @@ export class SmsSignatureService {
throw new NotFoundException('Signature not found'); throw new NotFoundException('Signature not found');
} }
await this.reportValidation.validateSignatureReportValues( await this.reportValidation.validateSignatureReportValues(
data.applicationId ?? signature.applicationId ?? undefined, (data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined,
data.drainageInfo, data.drainageInfo,
); );
const applicationId = data.applicationId ?? signature.applicationId ?? undefined; const applicationId =
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined;
const drainageInfo = data.drainageInfo const drainageInfo = data.drainageInfo
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo) ? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
: undefined; : undefined;
@@ -822,21 +829,31 @@ export class SmsSignatureService {
const auditStatus = const auditStatus =
options.initialAuditStatus ?? options.initialAuditStatus ??
(materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus); (materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus);
const updated = await this.prisma.smsSignature.update({ const updated = await writeUniqueSignature(
where: { id: signatureId }, this.prisma,
data: { {
applicationId: data.applicationId, ...signature,
name, applicationId: applicationId ?? null,
purpose: data.purpose, name: name ?? signature.name,
auditStatus, auditStatus: auditStatus ?? signature.auditStatus,
rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: materialChanged ? { increment: 1 } : undefined,
pendingReport: materialChanged ? true : undefined,
reportChangedAt: materialChanged ? new Date() : undefined,
}, },
include: { materials: true, tenant: true, application: true }, () =>
}); this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
applicationId: data.applicationId,
name,
purpose: data.purpose,
auditStatus,
rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: materialChanged ? { increment: 1 } : undefined,
pendingReport: materialChanged ? true : undefined,
reportChangedAt: materialChanged ? new Date() : undefined,
},
include: { materials: true, tenant: true, application: true },
}),
);
await this.reportValidation.syncSignatureReportValues( await this.reportValidation.syncSignatureReportValues(
signatureId, signatureId,
updated.applicationId ?? undefined, updated.applicationId ?? undefined,
@@ -897,10 +914,12 @@ export class SmsSignatureService {
throw new NotFoundException('Signature not found'); throw new NotFoundException('Signature not found');
} }
const updated = await this.prisma.smsSignature.update({ const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: 'pending' }, () =>
where: { id: signatureId }, this.prisma.smsSignature.update({
data: { auditStatus: 'pending', rejectReason: null }, where: { id: signatureId },
}); data: { auditStatus: 'pending', rejectReason: null },
}),
);
await this.audit.createAuditRecord({ await this.audit.createAuditRecord({
tenantId: signature.tenantId, tenantId: signature.tenantId,
targetType: 'sms_signature', targetType: 'sms_signature',
+7 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TemplateOptOutController } from './template-optout.controller';
import { AdminSmsConfigController } from './admin-sms-config.controller'; import { AdminSmsConfigController } from './admin-sms-config.controller';
import { ClientSmsConfigController } from './client-sms-config.controller'; import { ClientSmsConfigController } from './client-sms-config.controller';
import { SmsConfigService } from './sms-config.service'; import { SmsConfigService } from './sms-config.service';
@@ -8,7 +9,12 @@ import { DeletionGovernanceModule } from '../deletion-governance/deletion-govern
@Module({ @Module({
imports: [DeletionGovernanceModule], imports: [DeletionGovernanceModule],
controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController], controllers: [
ClientSmsConfigController,
AdminSmsConfigController,
ReviewGovernanceController,
TemplateOptOutController,
],
providers: [SmsConfigService, ReviewGovernanceService], providers: [SmsConfigService, ReviewGovernanceService],
exports: [SmsConfigService], exports: [SmsConfigService],
}) })
@@ -88,6 +88,7 @@ function createPrismaMock() {
count: jest.fn().mockResolvedValue(0), count: jest.fn().mockResolvedValue(0),
}, },
smsSignature: { smsSignature: {
findFirst: jest.fn().mockResolvedValue(null),
groupBy: jest.fn().mockResolvedValue([]), groupBy: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0), count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([ findMany: jest.fn().mockResolvedValue([
@@ -0,0 +1,21 @@
import { TemplateOptOutController } from './template-optout.controller';
import { PrismaService } from '../prisma/prisma.service';
describe('template opt-out configuration constraints', () => {
const db = { $transaction: jest.fn() };
const controller = new TemplateOptOutController(db as unknown as PrismaService);
it.each([
{ rules: [], preserveFragments: false },
{ rules: 'bad', preserveFragments: true },
{ rules: [{ channelId: 'x', action: 'replace' }], preserveFragments: true },
{
rules: [
{ channelId: 'x', action: 'add' },
{ channelId: 'x', action: 'remove' },
],
preserveFragments: true,
},
])('rejects unsafe input without writing: %j', async (body) => {
await expect(controller.put('template', body)).rejects.toMatchObject({ status: 400 });
expect(db.$transaction).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,87 @@
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Put } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { OptOutRule } from '../send-chain/template-optout-policy';
@Controller('admin/enterprise-templates')
export class TemplateOptOutController {
constructor(private readonly db: PrismaService) {}
@Get(':id/opt-out-policy')
async get(@Param('id') id: string) {
const template = await this.db.smsTemplate.findUnique({ where: { id } });
if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在');
const channels = await this.channels(this.db, template);
return { rules: template.optOutRules, preserveFragments: true, channels };
}
@Put(':id/opt-out-policy')
async put(
@Param('id') id: string,
@Body() body: { rules?: unknown; preserveFragments?: unknown },
@CurrentSessionUserId() operatorId?: string,
) {
if (!body || body.preserveFragments !== true || !Array.isArray(body.rules) || body.rules.length > 500) {
throw new BadRequestException('请提交有效规则,并保持避免影响消息分片数');
}
const rules: OptOutRule[] = [];
for (const value of body.rules) {
if (
!value ||
typeof value.channelId !== 'string' ||
!['add', 'remove'].includes(value.action) ||
rules.some((r) => r.channelId === value.channelId)
) {
throw new BadRequestException('通道规则不合法或存在重复通道');
}
rules.push({ channelId: value.channelId, action: value.action });
}
return this.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "SmsTemplate" WHERE id=${id} FOR UPDATE`;
const template = await tx.smsTemplate.findUnique({ where: { id } });
if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在');
const channels = await this.channels(tx, template);
if (rules.some((rule) => !channels.some((c) => c.id === rule.channelId)))
throw new BadRequestException('只能选择本模板所属应用通道组中的通道');
await tx.smsTemplate.update({ where: { id }, data: { optOutRules: rules } });
await tx.operationLog.create({
data: {
tenantId: template.tenantId,
userId: operatorId,
action: 'sms_template.opt_out_policy.update',
resource: 'sms_template',
resourceId: id,
detail: { before: template.optOutRules, after: rules, preserveFragments: true } as Prisma.InputJsonValue,
},
});
return { rules, preserveFragments: true, channels };
});
}
private async channels(
db: Pick<Prisma.TransactionClient, 'channelRouteRule'>,
template: { applicationId: string; tenantId: string },
) {
const routes = await db.channelRouteRule.findMany({
where: {
applicationId: template.applicationId,
tenantId: template.tenantId,
status: 'active',
channelId: null,
group: { status: 'active' },
},
select: {
group: { select: { name: true, items: { select: { channel: { select: { id: true, name: true } } } } } },
},
});
const channels = new Map<string, { id: string; name: string; groupNames: string[] }>();
for (const route of routes)
for (const { channel } of route.group.items) {
const entry = channels.get(channel.id) ?? { ...channel, groupNames: [] };
if (!entry.groupNames.includes(route.group.name)) entry.groupNames.push(route.group.name);
channels.set(channel.id, entry);
}
return [...channels.values()].sort((a, b) => a.name.localeCompare(b.name));
}
}
+217 -181
View File
@@ -1,50 +1,31 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; 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';
import { shanghaiDateRange } from '../common/shanghai-date-range'; import { shanghaiDateRange } from '../common/shanghai-date-range';
import { PrismaService } from '../prisma/prisma.service';
import { SmsAuditService } from './audit.service';
import type {
CreateSmsTemplateDto,
CreateSmsTemplateOptions,
TemplateListQuery,
UpdateSmsTemplateDto,
} from './sms-config.contracts';
import {
estimateBillingUnits,
normalizeSmsSignature,
validateAndNormalizeTemplateVariables,
type TemplateVariableInput,
} from './sms-config.helpers';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsTemplateService { export class SmsTemplateService {
constructor(private readonly prisma: PrismaService, private readonly audit: SmsAuditService) {} constructor(
private readonly prisma: PrismaService,
private readonly audit: SmsAuditService,
) {}
listTemplates(queryOrTenantId?: string | TemplateListQuery) { listTemplates(queryOrTenantId?: string | TemplateListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
return this.prisma.smsTemplate.findMany({ return this.prisma.smsTemplate.findMany({
where: { 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,
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
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, tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
@@ -52,167 +33,222 @@ export class SmsTemplateService {
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [ OR: query.keyword
{ name: { contains: query.keyword } }, ? [
{ content: { contains: query.keyword } }, { name: { contains: query.keyword } },
{ category: { contains: query.keyword } }, { content: { contains: query.keyword } },
{ application: { name: { contains: query.keyword } } }, { category: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } },
] : undefined, { tenant: { name: { contains: query.keyword } } },
}; ]
const [items, total] = await Promise.all([ : undefined,
this.listTemplates({ ...query, page, pageSize }), },
this.prisma.smsTemplate.count({ where }), include: { variables: true, application: true, tenant: true, signature: true },
]); orderBy: { createdAt: 'desc' },
return { items, total, page, pageSize }; ...(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,
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
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) { listClientTemplates(tenantId: string | undefined, includeHistory = false) {
return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' });
} }
async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) {
const variables = validateAndNormalizeTemplateVariables(data.content, data.variables); const variables = validateAndNormalizeTemplateVariables(data.content, data.variables);
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); const application = await this.prisma.smsApplication.findUnique({
if (!application || application.tenantId !== data.tenantId) { 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'); 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({ 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: { data: {
tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId,
optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined,
signatureId: data.signatureId, signatureId: data.signatureId,
name: data.name, name: data.name,
content: data.content, content: data.content,
category: data.category, category: data.category,
auditStatus: options.initialAuditStatus, auditStatus,
billingUnits: estimateBillingUnits(data.content), rejectReason: auditStatus === 'pending' ? null : undefined,
variables: { billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
create: variables.map((variable: TemplateVariableInput) => ({ variables: variables
name: variable.name, ? {
example: variable.example, create: variables.map((variable) => ({
required: variable.required ?? true, name: variable.name,
})), example: variable.example,
}, required: variable.required ?? true,
})),
}
: undefined,
}, },
include: { variables: true, application: true, tenant: true, signature: 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) { async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found'); if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found');
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
throw new BadRequestException('当前审核状态不允许修改模板'); 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;
} }
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) { async submitTemplate(templateId: string, tenantId?: string) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) { if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) {
throw new NotFoundException('Template not found'); 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;
} }
await this.validateTemplateSignature(
template.signatureId,
template.tenantId,
template.applicationId,
template.content,
);
async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) { const updated = await this.prisma.smsTemplate.update({
if (!signatureId) { where: { id: templateId },
throw new BadRequestException('短信模板必须选择短信签名'); data: { auditStatus: 'pending', rejectReason: null },
} });
const signature = await this.prisma.smsSignature.findUnique({ await this.audit.createAuditRecord({
where: { id: signatureId }, tenantId: template.tenantId,
select: { tenantId: true, applicationId: true, name: true }, targetType: 'sms_template',
}); targetId: templateId,
if (!signature || signature.tenantId !== tenantId) { action: 'submit',
throw new BadRequestException('signatureId does not belong to the template tenant'); statusBefore: template.auditStatus,
} statusAfter: 'pending',
if (signature.applicationId && signature.applicationId !== applicationId) { });
throw new BadRequestException('signatureId does not belong to the template application'); return updated;
} }
const signaturePrefix = normalizeSmsSignature(signature.name);
if (!signaturePrefix || !content.startsWith(signaturePrefix)) { async validateTemplateSignature(
throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`); 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} 开头`);
}
}
} }
@@ -0,0 +1,211 @@
# CMPP 协议字段兼容性整改方案
日期:2026-09-20。状态:六类整改已在 `b24cd7c` 基线上实施并完成本地定向验收;目标环境未迁移、未发布。实施证据及剩余边界见第 10 节。
## 1. 目标、范围与授权
解决平台内部字段容量和编解码规则小于 CMPP 协议允许范围的问题,避免合法回执、上行和提交结果被拒绝,或被错误解释。
本方案覆盖 CMPP 2.0/3.0 的 Gateway 编解码、Redis/HTTP 事件、后端参数、Prisma、PostgreSQL 和下游投递。初稿授权仅为文档审查;后续用户明确授权基于最新代码执行方案、修复并本地提交。实施范围为本地代码、隔离基础设施验证和文档,不包含推送、环境发布、预生产迁移、再次重投或业务配置变更。应急恢复独立记录,不作为本方案验收结果。
本方案是 [发送链路设计](phase-4-send-pipeline-redesign.md) 的字段兼容性补充,不替代其发送尝试归属、终态、补发、事务和账务设计;不替代 [计费方案](phase-5-billing-plan.md)、[测试计划](testing-plan.md) 和 [部署手册](production-deployment.md)。协议兼容整改不改变客户费率、路由、补发策略或退款规则。
实施前复读 [需求](first-version-development-requirements.md)、[系统测试用例](system-functional-test-cases.md)、[测试进度](testing-progress.md) 与当前代码;将本文新增用例纳入既有测试体系,避免形成两套验收标准。
## 2. 初稿审查基线与证据边界(历史记录)
- 本地审查基线:main / `c20c224`;存在大量其他会话未提交修改,不能将工作区整体作为本方案可提交内容。上述提交包含的长短信终态修复与本方案不是同一变更。
- 预生产只读核验:2026-09-20 14:56:46(北京时间),应用标记为 `1676cfe622f648bbfe09ac027e3db91737a7789d`。通过 `information_schema.columns` 核对实际字段类型,并核对部署目录源码/后端产物中的相关实现。该记录不是以后发布时可复用的现场结论,也不是对运行 Gateway 二进制逐项反编译验证。
- 已知线上故障:上游回执头序号 `2245918284``2245918288` 超过 PostgreSQL integer 上限,原业务回执入库受阻。完整应急恢复清单、处理结果和副作用由主任务独立记录,本方案不宣称恢复已完成。
- 其余发现来自协议原文、静态调用路径和内存边界核算;尚未用真实 TCP、真实后端和隔离 PostgreSQL 完成回归,不能写成线上已经发生同类故障。
- 本次未修改代码、数据库、Redis、业务配置或服务,未发送测试报文。
协议依据为中国移动规范原文的公开镜像:
1. [CMPP 2.0](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-2.0.pdf):第 7.3 节消息头及 SP/ISMG 消息定义。
2. [CMPP 3.0](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-v30.pdf):第 8.3 节消息头、第 8.4.1.2 节连接响应、第 8.4.3 节 Submit、第 8.4.5 节 Deliver/状态报告及 Deliver 响应。
协议中的字段字节数还须结合语义限制解释,不能把字段可表示范围当作所有业务值都有效。例如 Msg_Length 虽占 1 字节,仍受对应编码的内容长度规定约束。
## 3. 问题清单
| 编号 | 优先级 | 问题及证据 | 后果与当前确认程度 |
|---|---|---|---|
| CMPP-FIELD-01 | P1,首批必修 | 5 个 sequenceId 字段为有符号 integer;协议为 4 字节无符号整数 | 已发生回执入库故障;其他字段同类风险已确认,尚未逐条实测 |
| CMPP-FIELD-02 | P2 | 两张下游表的 ackResult 为 integerCMPP 3.0 DELIVER_RESP.Result 为 4 字节无符号整数,9 及以上可表示其他错误 | 大错误码可能入库失败,常见 0~9 不触发此容量问题;未确认线上命中 |
| CMPP-FIELD-03 | P1 | 2.0/3.0 共用固定 60 字节状态报告编解码,号码字段固定 21 字节 | 3.0 应为 32 字节号码字段、71 字节报告体;可能错读 SMSC_sequence,发出不符合 3.0 格式的报告 |
| CMPP-FIELD-04 | P1 | 下游目标选择过滤 sequenceId <= 0Gateway 用 0 表示缺失 | 0 可由无符号字段表示,已核对章节未将其保留;合法 0 序号无法正确生成或恢复通知 |
| CMPP-FIELD-05 | P1 | 3.0 CONNECT_RESP.Status 从 uint32 强转 uint8 | 256 会截为 0,客户端误判认证成功;这是本地状态误判,不代表上游真的授予权限 |
| CMPP-FIELD-06 | P2 | CMPP3_PACKET_MAX 固定 3335 | 合法的 99 个收件人、140 字节内容的 Submit 总长 3471,会在解码前被拒绝 |
### 3.1 数据库字段逐项清单
协议 uint32 表示范围为 `0..4294967295`,现有 integer 为 `-2147483648..2147483647`
| 表 | 字段 | 当前实际类型 | 对应业务 |
|---|---|---|---|
| UpstreamReceiptInbox | sequenceId | integer,可空 | 上游回执接收 |
| SmsReceiptRecord | sequenceId | integer,可空 | 业务回执记录 |
| SmsUplinkMessage | sequenceId | integer,可空 | 上行短信 |
| SmsSubmitRecord | sequenceId | integer,可空 | 向上游提交结果 |
| SmsMessageSegmentAudit | sequenceId | integer,可空 | 长短信分片提交/回执审计 |
| CmppDownstreamDelivery | ackResult | integer,可空 | 下游确认结果 |
| CmppDownstreamDeliveryAttempt | ackResult | integer,可空 | 每次下游投递确认结果 |
代码入口:[schema.prisma](../api/prisma/schema.prisma)、[回执处理](../api/src/send-chain/send-receipt.service.ts)、[提交结果](../api/src/send-chain/send-gateway-result.service.ts)、[上行处理](../api/src/send-chain/send-downstream-delivery.service.ts)、[下游状态](../api/src/send-chain/send-downstream-state.service.ts)。不得只修改收件箱,否则后续落库仍可能失败。
### 3.2 协议与调用路径证据
- [Gateway 事件结构](../gateway/internal/queue/messages.go) 的 SequenceID 已是 uint32Msg_Id 转为十进制字符串进入事件,序号超限发生在后端持久化边界。
- [receipt.go](../gateway/third_party/gocmpp/receipt.go) 的 CmppReceiptPktLen=60Pack/Unpack 均使用 21 字节号码。[上游处理](../gateway/internal/upstream/deliver.go) 对 2.0/3.0 共用此解析器;[下游投递](../gateway/internal/inbound/delivery.go) 共用此打包器。
- [下游目标选择](../api/src/send-chain/downstream-receipt-targets.ts) 过滤 <=0[下游投递](../gateway/internal/inbound/delivery.go) 使用 ==0/!=0 判定是否提供原 Submit 序号。
- [client.go](../gateway/third_party/gocmpp/client.go) 执行 `status = uint8(rsp.Status)` 后按是否为 0 判断成功;[上游连接](../gateway/internal/upstream/connection.go) 实际调用该客户端。
- [packet.go](../gateway/third_party/gocmpp/packet.go) 设置 3335 上限;[conn.go](../gateway/third_party/gocmpp/conn.go) 在收包时执行;[submit.go](../gateway/third_party/gocmpp/submit.go) 的 3.0 长度公式为 `12 + 129 + 32*N + 1 + 1 + MsgLength + 20`。N=99、MsgLength=140 得到 3471,且 99 满足规范“小于100”。
内存核算还确认:正确 71 字节回执中 SMSC_sequence 的起始偏移为 67;旧代码从偏移 56 读取。对普通 11 位号码,号码可能仍正确,但后续序号会读到补零。不能以手机号看起来正常证明整个报文正确。
## 4. 目标设计
### 4.1 uint32 存储和接口边界
建议统一采用:Gateway uint32 → JSON number → 后端校验后的 number → 持久化适配器 BigInt → Prisma BigInt / PostgreSQL bigint。
- 上述 7 字段统一升级;保留可空属性,已有 NULL 表示历史未采集,不转换成 0。
- 非空合法范围为 0~4294967295;拒绝负数、小数、NaN、无穷大和超上限值。必填协议头字段不应因内部接口可空而被静默省略。
- number 精确覆盖全部 uint32;进入 ORM 时显式转换为 BigInt,读取后检查范围再转 number,保持现有事件/API 的数值契约,避免把 Prisma BigInt 直接送入 JSON。
- 使用字段专用转换函数,覆盖查询条件、create/update/upsert、原始 SQL、批量查询、DTO、队列回放、审计、报表和导出。不能靠修改全局 BigInt.toJSON 掩盖边界。
- 数据库增加非空值范围约束。旧负值先列入异常清单,不擅自加 2^32“纠正”;须有原始报文佐证并单独处理。
- API、Worker、Callback 等所有读取这些字段的进程必须一起完成兼容验证。协议字段升级不得污染既有金额 BigInt 的单位和序列化规则。
`Msg_Id` 不套用上述方案:它是 uint64,完整上限为 18446744073709551615,超出 JavaScript 安全整数及 PostgreSQL 有符号 bigint。继续使用 Gateway uint64、跨服务十进制字符串和数据库 text;必要时校验十进制格式及 uint64 上限,不使用 Number/parseInt 中转。
### 4.2 CMPP 2.0/3.0 状态报告编解码
- 显式区分协议版本:2.0 的号码字段 21 字节、报告体 60 字节;3.0 分别为 32、71 字节。
- 收包按协商版本与报告体实际长度校验后解析;发送按下游连接协商版本打包。同步更新协议日志解析路径,避免业务解析正确但日志仍错位。
- 严格校验截断、额外字节、填充和字段边界;不能仅更改总长度常量,必须同时更改字段偏移与读写长度。
- 某些供应商可能在 3.0 连接上发送 60 字节历史格式。先盘点脱敏报文;如确有兼容需求,设计显式、可审计的兼容策略并测试,不凭长度默默切换,也不强制修改通道配置。本方案不默认启用降级。
- 不新增 SMSC_sequence、LinkID、供应商时间字段的业务存储需求;它们的原始报文保留和追踪另按既有审计方案执行。暂不落库不等于可以错读字段位置。
### 4.3 序号 0 与缺失的区别
- 用 null/undefined 或明确存在标志表示未提供;0 是合法已提供的数值。
- Gateway 可用可空 uint32 或等价带存在标志的结构,检查 JSON omitempty、默认值、恢复映射、幂等键和分片目标生成的全部路径。
- 不将空字符串经 Number 转换为 0;旧空字符串仍视为缺失。分别测试数字 0、字符串 "0"、空串、null 和未提供字段。
- 保留原始客户 Submit 序号及 Msg_Id 对应关系,不生成替代序号。回卷到 0 后,未完成请求不得发生映射碰撞。
### 4.4 连接响应状态与收包长度
- 3.0 CONNECT_RESP.Status 全程使用 uint322.0 uint8 可无损提升。只有原始值 0 表示成功,未知非零值仍失败,日志保留完整原值。
- 根据支持的命令、版本、合法收件人数及内容长度重新计算收包边界;既不能保留过小总上限,也不能取消上限或直接接受任意长度。
- 检查长度公式、实际编码长度、读缓冲区和内存分配顺序;拒绝长度头与正文不符、越界人数、畸形截断包及超大包。
- 不把扩大总包上限误写成扩大单条短信正文限制;编码长度与字符数量分别校验。
## 5. 已核对的非问题项与范围限制
- 本次检查的 Msg_Id/gatewayMessageId/ackMessageId 使用字符串存储,未发现数据库容量缩小。
- 客户原始序号、下游 ACK 序号已有 text 存储,没有此次 integer 上限问题,但仍须处理序号 0 的代码语义。
- 已查手机号、接入号、正文、原始状态码等数据库列为 text,没有 varchar 长度不足问题;协议封包处的字节长度校验仍需保留。
- 长短信 8/16 位引用号、分片数量和编码值可被现有 integer 容纳;语义校验仍须保留,不能只靠数据库类型。
- CONNECT 时间戳按 MMDDHHMMSS 编码,最大合法日历组合不超过 1231235959,不属于此次 integer 超限风险。
- 本次不是完整 CMPP 一致性认证;ISMG 间路由、所有扩展命令、二进制短信全场景及国际号码产品范围不在本次结论内。
## 6. 历史数据与应急恢复衔接
1. 实施前重新取得主任务最终恢复记录,核对原始事件摘要、业务匹配、发送尝试、扣退费、通知与客户 ACK,不直接继续任何旧脚本。
2. 迁移只扩大存储能力,不自动重投历史事件、不重新生成通知、不重算金额,也不回写短信成功状态。
3. 临时恢复时省略的原始序号,仅在存在原始事件且与记录唯一匹配时才可计划补录。补录审计字段不得重新触发补发、退款或下游推送;需要独立清单和恢复授权。
4. 事件匹配必须包含通道/供应商身份、Msg_Id、号码和发送尝试,不以号码单独认领,不能跨客户合并。
5. 原始事件可在确认耐久业务接管后按现有恢复流程收尾,但 Inbox matched 不能单独证明短信终态、账务或客户通知已经完成;还须核对收尾工作和最终结果。
6. 再次恢复时使用原业务幂等键,尊重已完成的最终决策,禁止为让客户“看到成功”覆盖真实失败。
## 7. 实施、迁移与发布顺序
建议同一维护窗口覆盖全部问题;如必须拆批,首批至少完成 FIELD-01~04 并独立闭环验证,FIELD-05/06 必须有明确后续计划。优先级是实施顺序,不代表本轮授权执行。
1. 固定当前分支、精确提交、工作区保护清单、线上版本;重新盘点受影响调用点和数据规模。将本文用例与需求、设计和测试进度同步。
2. 完成字段适配、编解码、序号存在性、状态和长度修复及测试。既有第三方库以仓库内 fork 修改留痕,不顺带升级整套依赖。
3. 在隔离 PostgreSQL 上演练旧 schema 升级,保留旧值、NULL、索引、约束及关联,测量迁移锁持有时间、磁盘/WAL 增量和失败回滚。
4. integer → bigint 可能引发表重写和强锁;按真实表规模选维护窗口。设置明确 lock_timeout/statement_timeout,超时退出而非无限等待,不在未知流量下直接执行。
5. 迁移、Prisma Client 与全部相关进程作为兼容整体交付。约束可按演练结果使用 NOT VALID 后校验,但上线不能留下未登记的未验证约束。
6. 授权发布后按 [部署手册](production-deployment.md) 使用标准 `npm run release -- ...`,依次 plan、有效测试证据、preflight、prepare、deploy、verify/status/report。每个目标环境独立核验,不用临时脚本替代应用发布。
7. 发布前保存独立数据库/应用恢复资产,确认主任务恢复不会与迁移同时处理同一批数据。需要暂停消费者时明确范围和时长,防止新旧进程交叉写入。
8. 发布后核对精确版本、实际列类型、迁移状态、全链路与队列;真实业务发送验收需另有明确环境和流量授权,不能借回归向真实客户发送测试短信。
回退限制:旧代码/旧 Prisma Client 可能无法读取新写入的大序号。应用回退不代表兼容,更不能把 bigint 直接缩回 integer。保留宽字段,优先前向修复;确需回退时必须证明旧版本兼容、未完成事件可接续。不能删除大值记录来让回退成功,已发送短信和客户已确认回执也无法用数据库快照撤销。
## 8. 验收用例
以下全部为新增待执行用例,不计入既有通过数。使用独立隔离数据;mock 只用于隔离,不替代真实 API/PG/Redis/Gateway 证据。
| 用例 | 场景 | 预期与证据 |
|---|---|---|
| CMPP-FIELD-T01 | 7 字段依次写入 0、2147483647、2147483648、4294967295 | 真实后端/PG精确保存及读回,JSON不丢精度 |
| CMPP-FIELD-T02 | -1、4294967296、小数、NaN、空串及缺失 | 按必填/可空规则拒绝或保留缺失;不误转0,不产生假成功 |
| CMPP-FIELD-T03 | 真实回执高序号,单条与批量回调入口 | 耐久接收、匹配、分片聚合、终态及通知闭环 |
| CMPP-FIELD-T04 | 上行高序号及重复事件 | 上行入库、应用归属及投递正确,无重复业务记录 |
| CMPP-FIELD-T05 | Submit/分片高序号返回 | 提交结果、分片记录及后续回执均可关联 |
| CMPP-FIELD-T06 | 3.0 DELIVER_RESP 的大非零 Result | 保存完整错误码,进入失败/重试路径,不判为成功 |
| CMPP-FIELD-T07 | uint64 Msg_Id 最大值跨 Gateway/Redis/API/PG/UI | 全链路字符串精确一致,无 Number 中转 |
| CMPP-FIELD-T08 | 2.0/3.0 标准60/71字节状态报告收发 | 逐字段、偏移、长度正确;实际TCP对端解析通过 |
| CMPP-FIELD-T09 | 3.0 32字节号码字段、最大SMSC_sequence;错版本/截断包 | 不错位,异常显式拒绝;兼容例外仅按批准策略执行 |
| CMPP-FIELD-T10 | 客户Submit序号0、4294967295及回卷 | 单条/长短信回执正常,原Msg_Id一致,不漏目标 |
| CMPP-FIELD-T11 | 序号0在Gateway重启、重连后恢复 | 使用持久化身份恢复;缺失序号不伪造为0 |
| CMPP-FIELD-T12 | CONNECT_RESP.Status=0、5、255、256、4294967295 | 只有0成功;全部非零失败并保留原码 |
| CMPP-FIELD-T13 | 3.0 Submit 99人×140字节、编码允许边界 | 3471字节合法包进入正常校验;100人按规范限制拒绝 |
| CMPP-FIELD-T14 | 巨大/过小Total_Length、错长度、截断包 | 有界读取与内存占用,明确失败,无崩溃或无界分配 |
| CMPP-FIELD-T15 | 旧值/NULL升级,迁移锁超时/中断 | 数据不变、失败可识别、恢复步骤真实演练 |
| CMPP-FIELD-T16 | 高序号重复、乱序、跨尝试、并发与故障接管 | 关联不串客户/尝试;不重复补发、扣退费或最终通知 |
| CMPP-FIELD-T17 | 真实下游ACK与断线重连 | 区分待发送、已发未确认和客户已确认,不将入队当送达 |
| CMPP-FIELD-T18 | 应急省略序号的历史记录与原始事件核对 | 只补审计不重开业务;证据不足保留待核查 |
自动检查按 [测试计划](testing-plan.md) 执行 API 定向/全量测试、类型检查、构建及质量门禁,Gateway `go test ./...``go vet ./...`。覆盖 vendored 协议库本身的测试:不能默认 Gateway 的 `./...` 会跨越嵌套 Go module。若影响前端接口或展示,补前端定向与必要回归、真实页面验收。保留未执行原因,不以编译通过替代协议互通。
停止条件:新增重复Submit、账务不一致、终态被覆盖、租户串联、无法恢复的协议解析异常、持续积压或迁移锁超时。停止放量并保留证据,不靠手工改最终状态掩盖失败。
## 9. 完成标准与交接
仅在以下事实全部具备后,才能声明目标环境整改完成:
- 7字段覆盖、版本化编解码、序号0、连接状态与收包上限已实现,并满足选定发布范围。
- 上述用例有对应证据,真实PG及TCP互通通过;历史数据兼容和故障恢复经过验证。
- 精确提交已通过标准发布,目标环境版本与实际schema一致。
- 原始事件、耐久接收、业务终态、补发尝试、账务和客户ACK分层对账,无未解释缺口;客户离线等外部阻塞单独列明。
- 更新需求、设计、系统测试用例和测试进度,分别报告本地修改、提交、推送、测试部署、预生产部署状态。
目标环境实施人接手时重新核对主任务最终恢复结果和现场,不从本方案推定任何待处理队列已排空或获得生产操作授权。
## 10. 2026-09-20 实施审查与本地验收
### 实施基线与规则补充
- 最新本地基线为 `b24cd7c`(模板拒收策略与运营页面),已包含 `c20c224` 的终态/发送尝试归属修复;已核对远端 main 仍为 `5e4d644`,本轮不推送。其他会话未提交文件及共享文档原有差异保留。
- 五个 `sequenceId` 与两个 `ackResult` 改为 nullable BigInt,迁移 `20260920160000_cmpp_protocol_uint32` 在一个事务内扩容并加入 0..4294967295 检查,锁等待 5 秒、单语句超时 5 分钟。负数历史值阻断并回滚,NULL 不回填。发布前需要按真实表规模重新评估重写/WAL/锁时间。
- `protocol-uint32.ts` 明确执行数值校验、BigInt 写入、校验后读回;API 与 callback 使用专用字段响应转换,仅处理 `sequenceId`/`ackResult`,金额 BigInt 和日期保持原有行为。协议输入不接受数字字符串、空串、浮点及越界;历史文本 Submit 序号单独解析,字符串 `"0"` 有效,空白/缺失无效。Msg_Id 保持十进制字符串。
- 版本化 `PackVersion`/`UnpackVersion` 严格检查 2.0/2.1 的 60 字节与 3.0 的 71 字节。上游、协议日志、下游均显式传版本;原 Pack/Unpack 仅作为 2.0 兼容调用保留,3.0 不自动接受 60 字节。目的号码字段为 21/32 字节,SMSC_sequence 最后 4 字节,超长字段不能截断。
- 原 Submit 序号使用可空指针区分缺失与 0;入站 JSON 不省略 0。长短信首段判断改为实际段位置。上游分配序号时避开在途 Submit/心跳,下游 ACK 注册冲突则保留原记录、返回可重试错误,不覆盖待确认回执。
- CONNECT_RESP 状态全程 uint32,非 0 一律失败并保留完整错误码。3.0 收包上限复用现有 Submit 最大长度常量 3491,覆盖 99 号码、140 字节的 3471,以及 ASCII 159 字节的 3490;规范原文为 ASCII <160,因此 160 字节拒绝。3491 只是保守的有界内存上限,不代表允许 160 字节内容。打包验证号码个数和数组、声明长度和实际内容一致;解包拒绝截断和多余字节。
- T16 并发真实验收复现一个相邻缺陷:可选 connectionId 未传时 Prisma 的空 update upsert 可能退化为先查后插,两个首次相同回执发生 P2002。本轮仅在该错误后按相同 receiptKey 回读已持久化记录再确认接收;没有对应记录或其他数据库错误仍抛出,避免假成功。
### 验收映射
| 用例 | 本地证据与结果 | 尚未覆盖的目标环境项 |
|---|---|---|
| T01T06 | 新脚本 `tools/testing/verify-protocol-fields.mjs` 用真实 Nest callback、PG、Redis 验证四个边界、七字段、单/批回执、Submit 分片、上行去重、大 ACK 非成功、非法输入无写入;通过 | 真实供应商/客户在线业务回归 |
| T07 | 最大 Msg_Id `18446744073709551615` 经过 Redis/HTTP/Prisma 保持字符串;Go TCP/回执往返不截断 | 线上供应商端到端互通 |
| T08T09 | vendored codec 与 upstream tests 验证 60/71 字节、32 字节号码、最大 SMSC、错误版本/截断;下游 2.0/3.0 TCP 回执验证通过 | 实际通道 3.0 是否存在非规范 60 字节回执需发布前抽样 |
| T10T11、T17 | 入站序号 0 JSON、缺失与 null 区分;新连接无原消息内存映射时恢复 Msg_Id2.0/3.0 真 TCP DELIVER/DELIVER_RESP Result=0;最大/0 序号冲突保护测试通过 | 实际客户端重连验证 |
| T12T14 | 真 TCP CONNECT 状态 0/5/255/256/429496729599 号码 3471/3490100 号码、超长内容、截断/多余内容、巨大/过短头拒绝;通过 | 目标环境日志、吞吐与异常隔离观察 |
| T15 | 全量 113 个已提交基线+本轮迁移在新隔离库成功;缩小结构演练末表非法值导致前面 DDL 整体回滚、5 秒锁超时回滚、NULL/0/2147483647 与七个索引保留 | 生产规模 WAL/耗时、发布切换与实际备份恢复未演练 |
| T16 | 同一大序号回执并发接收、重复批回调、相反迟到回执;另现有 receipt-finality/attempt-completion 的并发、跨尝试、故障事务恢复真实 PG 测试通过 | 整个 Gateway→Redis→API→客户单进程链路未联合运行;各边界分别验证 |
| T18 | 模拟已持久化但省略序号的历史回执,重复接收后仍 NULL,终态/通知数量不重新打开;通过 | 本轮不对线上应急历史数据做字段回填 |
验收脚本要求**新建空的本机 `cmpp_qa_*` 数据库**,应用基线及本轮迁移后构建 API,设置 `PROTOCOL_TEST_DATABASE_URL``PROTOCOL_TEST_REDIS_URL`。最高 Msg_Id 为固定边界 fixture,不能把多轮 fixture 混在同库当成新的发送尝试。脚本只连接本机,通道禁用、地址 127.0.0.1:1,不启动发送工作进程;产生合成业务/通知记录,不向真实运营商或客户发报文。
证据目录 `.local-data/protocol-fields-20260920/` 不入 Git。关键日志为 `real-chain-verified.log``migrate-verified.log``api-coverage-final.log``api-incremental.log``go-final.log``gocmpp-final.log``receipt-finality.log``attempt-completion.log`。最终迁移小样本为七表 21 行,扩容阶段约 106ms、WAL 81800 字节;不是线上容量承诺。保留首次 Redis 从错误工作目录读取旧 RDB 而启动失败、重复使用固定边界 fixture 库而匹配冲突、以及实际 P2002 并发失败日志,最终以专用 Redis 目录和全新库复验。
本地 Redis 为 5.0.14.1,实际 Stream 读写通过,但低于 BullMQ 推荐的 6.2;没有以此宣称发布环境队列版本验收。PG 驱动给出并发 query 弃用提示,当前测试通过,未修改驱动。API 响应结构保持数字/字符串不变,无前端代码或 CSS 改动;未重复浏览器验收。部署时必须一起发布 schema、API/callback/相关 worker 及 Gateway,旧 Prisma 客户端不能视为已支持超 31 位数据;不能通过缩回 integer 做应用回滚。
门禁:API 全量和增量覆盖率各 87 套 / 990 项通过,TypeScript 构建通过;Gateway 全量测试及根模块 vet 通过,vendored 模块测试通过;5 个队列契约、变更代码 lint(0 error32 个存量 any warning)、格式检查和 diff 检查通过。额外运行 vendored 模块完整 vet 发现两个原有 stdmethods 提示:`packetWriter.WriteByte`/`packetReader.ReadByte` 使用累积错误接口而非标准 io 签名;本轮未更名整个协议库,记录为既有待治理项,不宣称该额外检查通过。没有通过禁用检查隐藏问题。
测试在保留其他会话修改的当前工作区执行,提交仅纳入本轮文件和共享文档新增段落;这些本地结果不能冒充标准发布工具绑定精确提交的发布证据。隔离 PostgreSQL、Redis 已关闭,数据/日志保留。上线前仍应按标准工具从精确提交构建并生成对应验证证据。
@@ -2364,3 +2364,31 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
## 2026-09-17 首页V2实施修订 ## 2026-09-17 首页V2实施修订
用户最终要求执行[首页方案](homepage-receipt-metrics-redesign-20260917.md)并提交推送:一行三块“今日业务/今日回执/今日营业状况”,原企业消费排行保留,在消费后增加今日返还金额列,不替换排行;企业详情、导出包含返还。统计按今日有效接收回执及原提交日T-3~T,长短信完整成功、缺片补计、业务去重与旧总体成功率按方案执行。此前V1返还区域替换解释与V2“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。 用户最终要求执行[首页方案](homepage-receipt-metrics-redesign-20260917.md)并提交推送:一行三块“今日业务/今日回执/今日营业状况”,原企业消费排行保留,在消费后增加今日返还金额列,不替换排行;企业详情、导出包含返还。统计按今日有效接收回执及原提交日T-3~T,长短信完整成功、缺片补计、业务去重与旧总体成功率按方案执行。此前V1返还区域替换解释与V2“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。
## 2026-09-17 有效签名名称唯一性
同一企业、同一应用、相同完整签名名称只能存在一条有效签名;未绑定应用单独作为一个范围。有效状态包括草稿、待审、通过、驳回,停用及删除不占用名称;新增、改名、换应用、审核和恢复均不可绕过,接口及数据库同时防重。批量导入仍更新已有资料,保留未映射字段、用途及关联记录,并发创建后重查已有签名补资料。历史重复不自动删除或合并。设计见 [通道与报备方案](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性)。本轮授权代码修改及本地提交,不含推送和部署。
## 2026-09-18 长短信回执终态与归属补充
最终失败(含明确回执超时)与成功必须保持消息、账务、客户通知一致。后续同次失败分片不重复选路或退款;矛盾成功/失败回执只留原始事实并生成异常,不自动改账或重发客户通知。unknown、缺分片、普通提交超时仍可接续。回执须按业务消息、手机号、逻辑通道/上游身份和唯一发送尝试共同匹配;相同Msg_Id不能跨尝试批量更新,有歧义留待匹配。设计见phase-4-send-pipeline-redesign.md第10.13节,测试见TC-RC-20260918-01~07。此次不改协议、数据库结构和线上历史数据。
## 2026-09-20 签名质量、通道能力与模板拒收指令
1. 签名质量成功率条按业务短信总提交数展示已到达、提交失败、回执失败、未收到回执四段,合计100%;灰色未知段的数量、比例仅悬停展示,零提交空轨道,不拆成多条。当前查询和新生成日报将没有明确失败回执的超时归未知;既有冻结日报保留原口径,不因查询重算。
2. 通道允许减少运营商能力;保留通道组引用,发送选路按当前能力排除不支持运营商。不自动改动客户通道组或历史报备。
3. 企业模板管理可按所属应用通道组中的通道配置固定末尾指令“拒收请回复R”的增加/删除;默认保持原文。明确模板不串用另一模板规则;无模板ID时独立匹配有效已审核模板,包括 direct_send 应用,未匹配内容不受影响。
4. “避免影响消息分片数”固定选中,接口不可关闭;增删均保持计费单位与Gateway编码分片数,否则原文发送。只处理末尾精确指令,不修改正文、标点。重试换通道从原文计算,不叠加。
5. 短信列表显示提交通道的实际内容,详情保留原始内容及改写过消息的各次提交快照;客户端保留自身原文查看能力。现有计费、回执和报表单位不变。
6. 设计及兼容边界见 [模板拒收策略方案](template-optout-policy-design-20260920.md)。本轮授权本地修改和提交,不推送或部署。
## 2026-09-20 CMPP 协议字段兼容性整改
- 协议 Sequence_Id、CMPP 3.0 ACK Result 完整支持 0..4294967295;七个历史 nullable 字段扩容并约束范围,前端/HTTP 仍接收数字,Msg_Id 仍为精确十进制字符串。金额、客户费用、路由与终态规则保持既有设计。
- 按已协商的 CMPP 2.0/2.1、3.0 编解码 60/71 字节回执;不得在 3.0 静默使用 2.0 布局。合法序号 0 可恢复下游原 Msg_Id,回绕不得覆盖在途记录;所有非零 CONNECT_RESP 状态失败,合法多号码大包可接收且异常长度有界拒绝。
- 同一回执并发首次接收只形成一个持久化事实。历史应急记录缺失序号不自动补填,不再次触发通知、补发或账务。
- 详细字段、迁移发布边界及验收依据:[CMPP 字段兼容性方案](cmpp-protocol-field-compatibility-remediation-20260920.md)。本轮授权并完成本地实现/验收/提交,环境上线另行执行标准发布。
+48
View File
@@ -0,0 +1,48 @@
# 2026-09-18 长短信回执优化复核
范围:用户要求检查最近长短信回执优化仍有无Bug。本轮审查、隔离复现与预生产只读核对,不修改业务代码,不提交/推送/部署,不触发线上短信发送、补发、重投、账务或配置变更。实际应用1676cfe,关注phase-4-send-pipeline-redesign.md第10节。
## 确认问题(修复前1676cfe基线,行号为当时版本)
### P1:最终失败后,矛盾成功回执可以改成成功,但不恢复账务或客户通知
位置:api/src/send-chain/send-receipt.service.ts:307356。当前只有RECEIPT_TIMEOUT终态和delivered→failed方向保护,没有failed→delivered终态规则。recordReceiptSegment直接覆盖分片状态,当同次尝试的失败分片随后变成成功、其余分片也成功时,aggregate成为delivered,主消息直接更新成功。成功分支不处理已退款账务;CMPP dedupeKey与HTTP eventId固定,旧失败通知继续保留。
独立真实PG复现:构造合法的“最终失败+refunded账单+已生成失败通知”快照;同次两分片随后各到成功回执,通过实际SendChainService/耐久工作协调器处理。结果消息delivered、账单refunded、通知payload.receiptStatus=undelivered。事务和通知唯一键无法修复互相矛盾的终态规则。本轮使用真实持久层、实际回执/通知代码,不运行网络投递进程。该复现从已退款快照开始,不宣称执行了实际线上退款。
建议:明确并固化最终失败后的矛盾回执策略。若最终结果不可逆,保留审计并生成异常;若业务允许纠正,必须设计账务与通知的完整补偿,不可只改消息状态。
### P1:同一业务短信不同尝试复用上游Msg_Id时,回执可能跨通道匹配且同时更新两次尝试
位置:send-receipt.service.ts:604620 exactMessage分支,仅按messageRecordId+gatewayMessageId选最新分片,未限定incoming channel/上游身份;494519 recordReceiptSegment的updateMany同样未使用已传入submitRecordId或channelId。
独立PG复现两个不同通道、同业务短信两次尝试的相同gatewayMessageId,通过实际chain.handleReceipt入口:resolved.channelId指向另一通道,两次尝试的分片都被改成delivered(预期仅一条,无法唯一确认应拒绝匹配)。Msg_Id仅供应商作用域内有意义,不能以全平台无碰撞为前提。这会污染历史审计及当前成功汇总;工作表按sourceSubmitRecordId加锁不能修正此前错误归属。
建议:关联时结合逻辑通道/真实上游身份和唯一发送尝试;分片更新必须带resolved.submitRecordId(历史兼容使用可确认的submitId及身份),禁止一条回执批量跨尝试修改。
### P2:已经最终失败的后续分片仍重新执行补发选路
位置:send-receipt.service.ts:334342、send-retry.service.ts:334406。仅存在retryOfSubmitRecordId时复用已有补发,未保存“本次已确认无路可补、最终失败”的不可重复决策。后续合法失败分片成为新事件,会再次执行findApplicationRoute/selectChannelForMessage,并重新走终态副作用。新工作revision不意味着应该重复执行同一终态决策。
独立PG复现两分片先后失败:首片处理后消息已经failed,第二片仍调用选路;选路计数2。该用例仅将选路边界隔离为确定的BadRequest“无可用通道”,其余实际SendReceipt/SendRetry、工作表及数据库/通知路径真实执行;不是全量真实路由配置验收,不启动供应商网络。
预生产证据:2026-09-18 10:1210:20,回调/发送worker stderr共719条sms_retry_route_failed、关联692条消息,错误均“无已报备通过且在线的可用通道”。21条消息重复2~3次,核验当前全部failed;逐条比对最早CMPP最终通知createdAt和日志秒时间,至少5条在最终通知已创建后仍出现选路失败日志。日志时间只有秒,不能据此否定其余同秒的重复。该问题增加查询/日志/事务开销,不能把1655次正常补发尝试全部归因于它,也不能量化其占CPU56.8%的比例。
建议:给发送尝试保存明确终态决策,后续事实仍审计/异常检测,但复用已提交的最终失败、退款及通知事实,不再选路。和第一项统一状态机处理,不能粗暴丢弃所有后到回执或unknown转明确结果。
## 已排除及线上边界
- 怀疑“第二片回执先于其SubmitSegmentResult造成永久丢失”未复现:实际外层缺少可靠sourceSubmit关联时抛出404,由Inbox等待;补齐分片元数据后重处理可正确齐段成功。保留初次该断言失败日志,最终作为通过用例而非Bug。
- 以昨晚21:24上线后为边界查询:当前delivered且billingStatus=refunded计0;新分片中同messageRecordId+gatewayMessageId跨submitRecordId碰撞组0。只能说该窗口当前数据未发现上述两类命中,不是永久不可能发生,也不是全历史完整审计。
- 当时18136个SmsAttemptCompletionWork全部idle;上一轮CPU诊断未发现旧的重复补发/下游回执唯一键冲突。上一轮原子认领与防重复创建机制有效,但不等同所有状态机/关联边界正确。
## 验证和保护
新建本机独立PostgreSQL16集群与cmpp_qa_receipt_review数据库,监听127.0.0.1:16439,全部111迁移成功;实际API TypeScript构建通过。未启动Gateway、HTTP投递、API调度或真实短信发送。首次尝试复用旧隔离集群时发现默认端口/角色不同,未修改旧数据库,关闭本轮启动的旧集群,改建独立集群;两套本轮启动进程均已关闭,数据及失败日志保留。
隔离证据.local-data/receipt-review-20260918/repro-final.log确认3项缺陷及1项通过;online.json、repeated-routes-stderr.json、route-confirm.json为只读现场聚合。日志ANSI导致首次关联统计为0,去除ANSI后重新核验21组/至少5组,原失败与修正结果明确区分。未重跑与只读审查无关的全量前端/Go测试,不宣称修复完成。
## 后续整改(2026-09-18
用户后续授权修改并本地提交,三项按主设计第10.13节实施;上文保留原审查证据,不代表修复后实现。真实PG回归复现结果已改变:终态之后不重复选路,矛盾成功不改失败/退款/通知,跨尝试仅更新目标记录;详情、全部测试及未验证边界见testing-progress.md同日“长短信三项缺陷整改”。未推送、未部署,线上CPU改善尚未验证。
+11
View File
@@ -49,3 +49,14 @@
- 通道、通道组、路由规则、报备字段、报备任务基础接口存在。 - 通道、通道组、路由规则、报备字段、报备任务基础接口存在。
- 报备导出/导入记录和报备状态同步接口存在。 - 报备导出/导入记录和报备状态同步接口存在。
- 阶段 4 进度文档记录验证结果。 - 阶段 4 进度文档记录验证结果。
## 2026-09-17 有效签名名称唯一性
本节补充签名新增、修改和状态恢复规则;批量导入仍遵循 [补资料方案](reporting-batch-import-records-remediation-plan-20260902.md) 的字段合并规则。
- 同一企业、同一应用下,完整签名名称精确相同时只能存在一条有效记录。有效指审核状态不是 `deleted``disabled`,包含草稿、待审、通过和驳回。未绑定应用作为独立范围,同企业未绑定应用的同名有效签名也唯一;不同企业、不同应用允许同名。
- 新增、改名、换应用、提交审核、审核及状态恢复均执行接口查重,修改排除自身;冲突返回 HTTP 409 和“同一企业、同一应用下已存在同名有效签名,请修改已有签名资料”。保留既有认证、租户校验、审核及报备资料行为,无新增权限、页面和短信链路变更。
- PostgreSQL 使用两个部分唯一索引:绑定应用的 `(tenantId, applicationId, name)`,未绑定应用的 `(tenantId, name)`,都排除停用及删除状态。并发以数据库最终约束为准,唯一冲突转为相同业务错误;Prisma schema 用注释指明 SQL 所有权,不用普通复合唯一键冒充部分索引。
- 迁移在事务和写锁内检查历史有效重名;发现冲突则明确报错、整体回滚,禁止自动删除、合并或改状态。发布前需只读盘点并另行确认历史治理,不把迁移阻断当作成功;本轮只本地提交,不部署。
- 批量导入仍是补资料:优先匹配同范围有效签名,其次沿用未删除的停用记录;原签名 ID、未映射字段、用途及关联记录保留。导入暂存后新增了同名签名时,审核阶段重新匹配并更新;并发创建冲突时重新读取有效记录转为资料更新,仅重试一次,不吞其他错误。已有指定目标不会暗中改写为另一条记录,恢复冲突明确失败。
- 验收覆盖新增/编辑/换应用/空应用/状态恢复、不同企业应用、并发创建、直接 SQL 防重、历史迁移失败回滚,以及导入暂存与审核后补资料保留。使用本机隔离 PostgreSQL 和真实服务/API,不发送短信,不改线上资料;线上历史数据与部署留作未验证项。
+16
View File
@@ -352,3 +352,19 @@ GatewaySubmitOutbox
a350aca测试环境长短信验收发现两条消息首尝试分别仅写出2/4、1/3段,Gateway在已收到即时应答时仍等待60秒后误判SUBMIT_TIMEOUT;补发及最终账务/通知收尾正常,但不能据此视作无异常验收。只读代码证据:submitPart在SendReqPkt、异步日志启动之后才登记pending[seq],readLoop可能提前消费应答并因不存在等待者丢弃。新增真实TCP回归在修改前分别于CMPP2.0第206次、3.0第7次复现。 a350aca测试环境长短信验收发现两条消息首尝试分别仅写出2/4、1/3段,Gateway在已收到即时应答时仍等待60秒后误判SUBMIT_TIMEOUT;补发及最终账务/通知收尾正常,但不能据此视作无异常验收。只读代码证据:submitPart在SendReqPkt、异步日志启动之后才登记pending[seq],readLoop可能提前消费应答并因不存在等待者丢弃。新增真实TCP回归在修改前分别于CMPP2.0第206次、3.0第7次复现。
最小修复保持现有协议、接口、存储和补发策略:使用与heartbeat一致的mu→sendMu锁序,将连接有效性检查、写包和登记pending置于同一临界区,响应读取须等登记完成。网络失败仍走原连接关闭/失败处理。锁内不得执行日志、业务回调或数据库操作。验证两种协议各500次即时应答、Gateway全量test/vet及测试环境新的长短信样本;原异常证据保留,不将旧样本改成无补发成功。 最小修复保持现有协议、接口、存储和补发策略:使用与heartbeat一致的mu→sendMu锁序,将连接有效性检查、写包和登记pending置于同一临界区,响应读取须等登记完成。网络失败仍走原连接关闭/失败处理。锁内不得执行日志、业务回调或数据库操作。验证两种协议各500次即时应答、Gateway全量test/vet及测试环境新的长短信样本;原异常证据保留,不将旧样本改成无补发成功。
### 10.13 2026-09-18 终态与回执归属整改(本轮授权修改并提交)
本节修订10.2、10.5的实现约束,针对[专项复核](long-sms-receipt-review-20260918.md)三项问题;本轮不推送、部署或修正线上历史数据。
- 复用现有消息终态与SmsAttemptCompletionWork.decision作为已提交决定,不新增数据库结构。原有工作→消息事务锁保证最终失败、退款、通知和工作决定同时提交。消息failed/delivered和明确RECEIPT_TIMEOUT不再因后续供应商回执自动逆转;后续同向分片仅补审计,不再选路/退款/创建通知。unknown、未齐片、普通提交timeout仍可继续处理;旧尝试事实不修改当前尝试决定。
- 同次终态的矛盾明确回执保留SmsCompletionEvent和SmsReceiptRecord原始事实,按消息尝试生成稳定异常键,不覆盖已用于结算的规范分片结果,不改账务或已生成客户通知。失败转成功异常独立标识;重复同一回执不能增加异常计数或重复业务动作。已有message_level成功后失败异常类型兼容。
- 回执关联同时核验可用的业务消息身份、手机号、逻辑通道/真实上游身份、上游Msg_Id、发送尝试。优先精确逻辑通道;同供应商跨连接仅在相同账号/主机/端口/协议/版本且唯一候选时匹配。Submit与Segment候选须共同消歧,不能分别取最新。多个尝试冲突保留Inbox待匹配;候选超出查询上限时保守拒绝。历史缺submitRecordId时只按可确认submitId回读归属,不能猜当前尝试。
- 分片写入限制messageRecordId、submitRecordId/明确submitId及channelId;不得仅凭messageRecordId+gatewayMessageId批量跨尝试更新。提交分片记录同样在明确submitId存在时优先精确匹配,避免OR条件被另一尝试同Msg_Id干扰。
- API和协议不变、鉴权/租户规则不变、无新权限。冲突/未确认回执继续走现有Inbox恢复与人工排查。发布回退只涉及应用;不自动重放已完成事件或历史退款。
- 验收:真实PG验证顺序/并发失败分片仅一次终态选路,矛盾回执账务/通知/分片均不逆转,跨通道与同通道碰撞拒绝或准确关联,同供应商跨连接、早到回执、unknown转成功、旧尝试迟到及工作故障恢复。隔离固定输入比较选路次数;不能将隔离开销降低推算为线上CPU降幅。
## 2026-09-20 模板拒收指令补充
参见 [模板拒收指令策略](template-optout-policy-design-20260920.md)。发送链在选路候选阶段按模板/通道生成内容快照,敏感词按各候选真实内容评估;每次尝试从不可变原文开始。Submit、消息内容与Outbox同事务保存,Gateway授权校验使用对应Submit的内容快照。保留既有计费单位、原收尾状态机及Outbox稳定提交身份,配置变化不改写已生成命令。未配置模板规则时正文保持原样;明确模板ID不串用其他模板规则。
+69
View File
@@ -5661,3 +5661,72 @@ TC-SQA-0114:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
### HOME0917 实施验收更新 ### HOME0917 实施验收更新
最终UI保留企业消费排行并增加今日返还列,替代前述返还模块假设。HOME0917-01~12已通过对应本地真实PG/规则测试与故障注入;HOME0917-13三尺寸真实Nest/PG/Redis页面、按需请求、导出返还、详情、刷新和请求失败真值保留通过。HOME0917-14仅本地2020消息/6000新增片查询样本及110迁移通过,生产规模、线上队列影响和现场回退未执行。证据及边界见[方案第9节](homepage-receipt-metrics-redesign-20260917.md#9-2026-09-17-实施与验收结果)。所有真实截图使用本地隔离验收记录,不表示目标环境已上线。 最终UI保留企业消费排行并增加今日返还列,替代前述返还模块假设。HOME0917-01~12已通过对应本地真实PG/规则测试与故障注入;HOME0917-13三尺寸真实Nest/PG/Redis页面、按需请求、导出返还、详情、刷新和请求失败真值保留通过。HOME0917-14仅本地2020消息/6000新增片查询样本及110迁移通过,生产规模、线上队列影响和现场回退未执行。证据及边界见[方案第9节](homepage-receipt-metrics-redesign-20260917.md#9-2026-09-17-实施与验收结果)。所有真实截图使用本地隔离验收记录,不表示目标环境已上线。
## 2026-09-17 有效签名唯一性(TC-SIG-UQ-20260917
设计见 [通道与报备方案](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性)。验收脚本 `tools/testing/verify-signature-uniqueness.mjs` 仅允许本机 `cmpp_qa_signature_unique_*` 新库;先在 api 目录完成迁移和构建,设置 SIGNATURE_TEST_DATABASE_URL、SIGNATURE_TEST_REDIS_URL 后执行。
| 编号 | 场景及预期 |
| --- | --- |
| 01 | 运营端和客户端新增同企业同应用同名签名返回409;数据库只有一条;未登录401、伪造企业头403、非法签名400。 |
| 02 | 同名在不同企业/应用允许;未绑定应用的同名也唯一;自身原名更新允许,改为已占用名称/应用/空应用返回409;跨企业修改失败。 |
| 03 | 草稿、待审、通过、驳回占用名称;停用/删除释放;恢复、审核、重新提交不得绕过。 |
| 04 | 12路真实HTTP并发新增仅一次201,其余409;直接数据库新增命中绑定和空应用两个唯一索引;真实Prisma适配器P2002转业务冲突。 |
| 05 | 导入暂存识别update,审核后原ID、用途、未映射资料、链接保留;暂存后存在同名记录则更新,优先有效记录而非停用重名。 |
| 06 | 并发导入同一新名称收敛为同一ID,仅一条有效签名;只对名称冲突重查,其他异常继续报错。 |
| 07 | 迁移遇历史有效重复明确失败并回滚;全部历史记录保留,没有留下半套索引;新库全部111迁移成功。 |
本机真实Nest/PG/Redis覆盖01、02、0407及03恢复分支;四种有效/两种无效状态和错误分类另由定向单元测试覆盖。线上重复盘点、目标环境迁移及浏览器验收未执行,不等同已发布。
## 2026-09-18 长短信终态与回执归属回归
| 编号 | 场景与预期 | 本地证据 |
|---|---|---|
| TC-RC-20260918-01 | 已最终失败后另一失败分片及并发重复到达;选路共一次,终态不重开 | verify-receipt-finality.mjs,真实PG、仅无可用路由边界隔离 |
| TC-RC-20260918-02 | 已失败/已退款/已有失败通知后两片成功;消息、账单、通知不变,原始回执及一条尝试异常保留,规范分片不改成功 | 同上,真实PG退款快照 |
| TC-RC-20260918-03 | 同业务不同通道尝试复用Msg_Id;仅命中通道的那次分片更新 | 同上,真实PG |
| TC-RC-20260918-04 | 同通道某尝试主Msg_Id与另一尝试分片Msg_Id碰撞;拒绝歧义,不写规范回执或分片 | 同上,真实PG |
| TC-RC-20260918-05 | 非首片回执先于提交分片元数据;先拒绝关联、补元数据后齐片成功;unknown仍可并发齐片完成 | 同上,真实PG |
| TC-RC-20260918-06 | 最终失败后迟到SubmitResult拒绝;不得重开终态或创建补发;旧尝试不得覆盖当前决定 | 同上真实PGsend-chain.service.spec.ts旧尝试单测 |
| TC-RC-20260918-07 | 同供应商跨连接唯一匹配、歧义拒绝、身份变更、手机号不符、跨租户关系、历史submitId、候选截断和72小时超时恢复 | receipt-attempt-resolver.spec.ts12项隔离单测;历史分片补关联并齐片完成另有真实PG用例 |
既有TC-RC-20260916收尾并发与故障用例继续执行tools/testing/verify-attempt-completion.mjs(真实PG、双OS进程、事务回滚、fence、一次补发Outbox、非零扣退费、通知持久化)。本轮不启动Gateway、Redis消费者或网络通知投递,不以数据库集成代替线上完整短信链路验收;目标环境与线上CPU改善待另行授权发布后验证。
## 2026-09-20 模板拒收指令及运营界面验收
设计:[模板拒收策略](template-optout-policy-design-20260920.md)。所有写入夹具限本地隔离数据库,不能据此向测试/预生产发送短信。
| 编号 | 场景 | 预期 |
|---|---|---|
| TC-OPT-20260920-01 | 四类混合、零提交、统计不一致 | 单条四段合计100%,未知灰色且仅悬停显示数量比例;零提交空条;不一致明确提示而不编造比例 |
| TC-OPT-20260920-02 | 超时无回执及明确失败回执 | 当前质量查询分别计入未知/回执失败;历史冻结报表不重算 |
| TC-OPT-20260920-03 | 活动通道组仍引用通道,缩减运营商 | 保存成功,组引用保留,被移除运营商不能选该通道,不触发无关重连 |
| TC-OPT-20260920-04 | 策略GET/PUT、未登录/客户端、重复/非法/外应用通道 | 鉴权拒绝非法入口,所属应用范围严格校验,有效保存留审计 |
| TC-OPT-20260920-05 | 固定/变量模板、direct_send、明确其他模板、无匹配 | 指定模板准确命中,direct_send不绕过策略,其他短信原文不变 |
| TC-OPT-20260920-06 | 69→75、71→77、删除跨70字、Unicode代理对 | 同计费单位且同Gateway分片才执行;否则跳过;保护框默认选中且不可取消 |
| TC-OPT-20260920-07 | 正文出现指令、末尾重复添加、补发换通道 | 正文不删、添加不叠加、换通道从原文重新计算 |
| TC-OPT-20260920-08 | 通道敏感词与增删策略同时存在 | 按各候选真实改写内容筛选通道,保存对应内容摘要 |
| TC-OPT-20260920-09 | 单条/微批/补发、事务中断、配置变化 | 消息/Submit/Outbox一致,失败一起回滚,旧命令快照不变化,费用及分片单位不变化 |
| TC-OPT-20260920-10 | 短信列表和详情,历史空字段 | 列表真实提交内容,详情原文及尝试快照,历史空字段正常,客户端仅返回自身消息 |
| TC-OPT-20260920-11 | API失败、重试、通道组移除后失效规则 | 无假成功,输入保留,显式删除失效规则后可保存 |
| TC-OPT-20260920-12 | 1600×1000/1366×768/390×844 | 进度条不换行;模板保存、通道缩减、原文详情、刷新跨路由正常,无控制台异常 |
代码级与真实本地API/PG验收分别见 testing-progress.md;无运营商真实发送授权,因此不将Outbox构造/回滚测试称为真实短信送达验收。
## 2026-09-20 CMPP 协议字段兼容性用例登记
将 [整改方案第 8、10 节](cmpp-protocol-field-compatibility-remediation-20260920.md) 的 CMPP-FIELD-T01T18 纳入本表体系,ID 与预期不另行重定义。
| 用例组 | 执行入口 | 本轮结果与边界 |
|---|---|---|
| T01T07 | protocol-uint32.spec.ts、protocol-receipt-intake.spec.ts、tools/testing/verify-protocol-fields.mjs | 数字校验/七字段真实 PG/API/Redis、大小 Msg_Id、单批重复、高值 ACK;本地通过 |
| T08T14 | gateway/third_party/gocmpp/protocol_compatibility_test.go、inbound/upstream protocol_fields_test.go | 版本布局、32 字节号码、序号 0/回绕冲突、CONNECT 完整状态、大包及异常报文;本地 TCP/单元通过 |
| T15 | verify-protocol-fields.mjs 迁移演练 | 113 迁移新库成功;21 行小样本非法末表数据整体回滚、锁超时、NULL/旧值/索引保留通过;不是线上重写容量验证 |
| T16 | verify-protocol-fields.mjs、verify-receipt-finality.mjs、verify-attempt-completion.mjs | 高值并发去重/迟到相反回执、本机 PG 8 项终态与 11 项完成态回归通过;边界分别验证,未联合运行整套供应商到客户链路 |
| T17T18 | inbound/protocol_fields_test.go、verify-protocol-fields.mjs | 2.0/3.0 本机 TCP 重连零序号回执与 ACK,历史缺失序号不回填且终态/通知不重开;通过 |
后续上线须追加目标 schema/版本、真实供应商/客户互通、队列排空和账务对账证据。当前不标记目标环境完成,也不执行历史回执重投。
@@ -0,0 +1,28 @@
# 模板拒收指令策略与运营页面修正
日期:2026-09-20。状态:本地已实施,隔离API/PG及三尺寸浏览器验收通过;未推送、未部署、未进行运营商发送。执行证据见 testing-progress.md。本方案补充发送链路设计,不替代其事务、账务、回执和 Outbox 规则。
## 业务规则与影响
- 签名质量列表使用一个四段横向条,按总提交数计算已到达、提交失败、回执失败、未收到回执的占比;未知使用灰色,数量及比例只放悬停提示。保留筛选、分页和详情。零提交显示空轨道。
- 通道缩减运营商能力允许保存,保留已有通道组引用及历史报备;选路实时按通道能力过滤,失去可用通道时沿用既有无路由失败处理,不偷偷迁移客户配置。恢复能力后原引用可继续使用。验收发现既有窄屏查询按钮遮挡与运营商选项溢出,同页CSS增加780px以下单列/换行规则;保持桌面及筛选语义不变,所有权清单更新对应已验收摘要。
- 运营端企业模板列表增加“拒收指令”配置入口。按模板及应用通道组中的通道选择“保持原文 / 末尾增加 / 末尾删除”,固定指令为 `拒收请回复R`。只删除末尾精确匹配的指令,不删除正文相似字样,不改其他文字或标点。重复增加不叠加。
- “避免影响消息分片数”固定选中,后端也不接受关闭。增加和删除都必须同时保持原计费单位和 Gateway 实际编码分片数,否则原文发送并保留跳过原因。不能以本需求修改计费、回执或报表口径。
- 仅匹配当前企业、应用下的有效已审核模板;存在明确模板ID时仅使用该模板规则,不串用其他模板。没有模板ID时按精确正文、变量模板匹配(沿用模板匹配规则和排序)。即使 direct_send 绕过模板准入,发送前仍独立识别策略模板;完全不匹配任何模板的短信保持原文。配置不赋予未审核模板发送权限。
- 每次选路基于不可变原文产生该通道的候选内容;通道敏感词继续检查实际候选内容。换通道补发重新从原文计算,不能累计增删。已持久化的 Submit/Outbox 使用当时快照,不因配置修改而重写。
## 数据与接口
- SmsTemplate 增加 optOutRules JSON(缺省空数组),每项 channelId/action;后台验证动作、重复通道、所属应用的活动通道组成员关系。仅运营端专用 GET/PUT enterprise-templates/:id/opt-out-policy;客户端模板编辑不接受此字段。配置变更留操作审计。
- SmsMessageRecord 增加 nullable originalContent;首次改变时保留输入原文,后续永久保留。content 沿用发送内容字段,在提交事务内与 Submit 和 Outbox 一致更新。
- SmsSubmitRecord 增加 nullable sentContent 及 contentPolicy JSON,记录每次尝试内容、命中模板/动作和应用或跳过原因。迁移不重写历史短信;旧记录字段为空时维持原展示。
- 发送列表展示最近一次提交内容,详情在改写过时另列原始内容;已排队未获得供应商受理不能标称送达。尝试快照用于历史通道发送内容追溯。
- 单条、微批和补发统一使用相同改写函数,所有费用及计费单位保持不变;数据库事务失败不留下单独内容改写,Outbox 重发不重新计算策略。
## 验收与兼容
验证四类加和、零数据、悬停、通道缩减/恢复/不可路由;策略越权及非法参数、变量模板和 direct_send、无匹配不影响其他短信、69→75跳过/71→77执行、删除跨分片跳过、Unicode与编码边界、多通道补发不叠加、事务失败回滚、Outbox 快照稳定、原文详情及三尺寸页面。
使用隔离 PostgreSQL、真实 API/Redis和页面验证;不连接运营商发送,不操作线上客户或通道配置。执行定向及全量回归、类型检查、构建和质量门禁。线上验证和部署独立列为未执行。本轮只提交本轮代码与文档,不推送或部署。
质量分类补充:当前查询及今后生成的日报使用互斥四类,超时无明确失败回执归未知。已发布冻结日报不重算,其历史分类保留。进度条比例均用总提交数,原接口 successRate 字段保留兼容,不改变详情其他既有口径。未改变资金流水、客户回执数量、报表计费单位;本地验证的是生成意图及费用字段、事务一致性,并未执行运营商真实发送和资金结算。每次Submit额外保存一份内容快照用于追溯,归入原提交记录的留存治理范围。
+49
View File
@@ -5161,3 +5161,52 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页
- API854、前端163及覆盖率门禁通过;类型/生产构建/定向ESLint/样式/结构/CSS治理/包体检查通过。真实数据库故障与并发认领、四日及缺片/跨日/重复/退款边界通过;页面无明细预请求,错误保留真值、CSV/详情通过,页面异常0。本地样本2020消息/6000新增片,总览p95约55ms、明细约20ms,不推断线上CPU/容量。 - API854、前端163及覆盖率门禁通过;类型/生产构建/定向ESLint/样式/结构/CSS治理/包体检查通过。真实数据库故障与并发认领、四日及缺片/跨日/重复/退款边界通过;页面无明细预请求,错误保留真值、CSV/详情通过,页面异常0。本地样本2020消息/6000新增片,总览p95约55ms、明细约20ms,不推断线上CPU/容量。
- 原始证据 `.local-data/homepage-implementation-20260917/`。数据库端口、Redis旧RDB、浏览器两个定位失败已修正并保留原日志;Redis版本建议保留。尚未验证线上源时间覆盖、真实大数据与现场回退、完整供应商短信闭环。 - 原始证据 `.local-data/homepage-implementation-20260917/`。数据库端口、Redis旧RDB、浏览器两个定位失败已修正并保留原日志;Redis版本建议保留。尚未验证线上源时间覆盖、真实大数据与现场回退、完整供应商短信闭环。
- 起点main627fa7e、实际远端4eb7b16、暂存区空;67项原工作已备份并核对保护。仅本轮代码、迁移、方案/UI/截图、验收脚本和文档精确追加进入提交;版本/metrics/发布工具等不夹带。用户授权提交推送,不含部署;测试环境、预生产环境均未改动。最终提交与推送结果单独补记。 - 起点main627fa7e、实际远端4eb7b16、暂存区空;67项原工作已备份并核对保护。仅本轮代码、迁移、方案/UI/截图、验收脚本和文档精确追加进入提交;版本/metrics/发布工具等不夹带。用户授权提交推送,不含部署;测试环境、预生产环境均未改动。最终提交与推送结果单独补记。
## 2026-09-17 有效签名唯一性与导入补资料兼容
- 授权:修改并本地提交。起点 main/实际远端均5e4d644,暂存区空;保留版本、metrics、发布工具及文档已有修改,本轮不推送、不部署。
- 根因:创建/更新无名称查重,数据库仅ID唯一;状态审核/恢复也可重新占用名称。按 [通道与报备设计](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性) 增加有效状态查重与两个部分唯一索引,空应用独立处理。新增/改名/换应用/审核/提交/恢复均覆盖,并发冲突返回409。补齐Prisma7 pg驱动真实P2002嵌套元信息识别,不吞其他数据库错误;audit.service清理原未使用导入并格式化,业务改动仅签名防重。
- 批量导入保留原ID和字段合并逻辑;优先有效记录,其次停用历史记录;审核时重查及并发创建冲突后补资料,未映射资料、用途、链接保留。已指定的目标不暗中替换为另一条签名。
- 验证:本机独立PG16、Redis16436、完整Nest真实API;新库cmpp_qa_signature_unique_v3全部111迁移成功。verify-signature-uniqueness.mjs七组通过(TC-SIG-UQ-20260917-0107),12路新增仅一条201/其余409,三个并发导入同一ID;两类索引直接写入阻断、状态恢复、空应用变更、租户隔离、真实驱动冲突和临时历史重名表迁移回滚均通过。未发送短信、未创建外部通知消费者业务、不修改线上资料。
- API全量80套868项及覆盖率门禁通过(语句67.90%、分支53.22%、函数68.78%、行70.68%;新防重模块语句/函数/行100%、分支96.66%)。API构建/类型检查、定向ESLint/Prettier及diff检查通过;最终仅清理旧未使用导入后补跑定向测试。前端/Go无改动,未重跑无关测试。
- 原始证据在忽略目录.local-data/signature-uniqueness-20260917/。保留初次测试夹具缺邮箱、映射字段键错误、根目录Prisma配置路径错误及真实驱动冲突识别失败的日志,修正后新库v3验收通过;Redis5.0版本建议及pg查询弃用提示仍存在,未扩展升级依赖。
- 历史兼容:线上有效重名尚未盘点;如存在则迁移明确失败并整体回滚,不能自动删除或合并。未执行测试/预生产部署、线上迁移、浏览器页面验收、文件上传/解析及MinIO回归;本轮真实导入验收覆盖资料暂存/审核应用层,文件解析路径未改变。
- 本地修改、测试和需求/设计/用例同步完成,提交前仅本轮文件及共享文档精确追加进入暂存;其他原始修改保留。提交号在交付回复中报告;推送、测试部署、预生产部署均未执行。
## 2026-09-18 长短信三项缺陷整改(本地修改及提交)
授权范围:修复、测试、文档及本地提交,不推送/部署。开始核验本地main1676cfe、实际远端main5e4d644,暂存区为空;版本、metrics、发布工具/脚本及其他文档草稿保留,不纳入本轮。设计先补第10.13节。
已修复:同次最终失败/成功及明确回执超时不再重开收尾,后续失败片仅审计;矛盾回执留原始记录并产生异常,保持账务和通知;统一Submit和Segment候选按通道/上游身份消歧,分片更新限定发送尝试,显式submitId不再被同Msg_Id另一尝试覆盖。保留unknown、未齐片、历史超时恢复和同供应商跨连接匹配。无需迁移,无线上历史数据修正。
验证(2026-09-18):新隔离cmpp_qa_receipt_fix全111迁移通过;verify-receipt-finality.mjs真实PG八组场景通过,重复终态选路由复现的2次变为1次,跨尝试更新2条变为目标1条,失败/退款/失败通知保持一致。选路仅在确定无可用路由边界隔离;账单用已退款快照,非线上退款。verify-attempt-completion.mjs十一组真实PG回归通过,包括24并发、双OS进程、回滚/接管/fence、三段通知、非零账务与唯一补发Outbox。
API全量81套/880项通过并达覆盖率门禁(语句67.73%、分支52.89%、函数68.76%、行70.60%);最终小幅兼容调整后定向2套/150项及八组真实PG、十一组并发恢复复跑通过。TypeScript生产构建通过;本轮文件ESLint零错误、30条既有any警告,Prettier及diff检查通过。新匹配器单测行覆盖100%、分支89.65%。早期旧mock未提供真实关联造成14项失败,补全关系及查询形状后通过;未降低关联约束。真实PG保留pg驱动并发query弃用警告,未出现事务失败。
可重复运行:先构建api并迁移独立本机cmpp_qa_*库,设置COMPLETION_TEST_DATABASE_URL后运行tools/testing/verify-receipt-finality.mjs和verify-attempt-completion.mjs;脚本拒绝非本机或非隔离库。原始日志保留.local-data/receipt-fix-20260918/,不进Git。此轮仅调用真实后端服务/持久层,不启动Gateway、HTTP通知投递或在线发送。前端/Go未变,未重跑其验收;目标环境、Redis/Gateway完整网络闭环、线上CPU降幅未验证。不得把本地通过视作测试/预生产已修复。本地隔离数据库进程收尾关闭,数据及日志保留。
## 2026-09-20 签名质量四段条、通道运营商缩减及模板拒收策略
- 基线:本地main c20c2246b263b22a6a84c8ac10d9f320197a8fb6,实际远端main 5e4d644788b453528e513b1c14c28b120e221b64;开始暂存区为空。保留版本3.0、metrics、发布工具/部署脚本、手机号规则迁移及其他草稿,不纳入本轮提交。
- 需求/设计:first-version-development-requirements.md“2026-09-20”节、template-optout-policy-design-20260920.md、phase-4-send-pipeline-redesign.md本轮补充;用例TC-OPT-20260920-0112。
- 根因:原质量条仅绘制成功比例;通道后端在检测到被活动通道组引用的已移除运营商时直接拒绝;原发送链没有模板/通道内容改写及尝试内容快照。本轮改为互斥四段条、仅悬停未知数据;允许能力缩减并保留原路由能力过滤;模板策略独立匹配且不得改变计费及Gateway分片数,单条/微批/补发共享,原文/Submit/Outbox同事务。Gateway授权按尝试快照核对,短信详情增加原文与尝试内容,客户端只返回自身原文。
- 迁移:新增20260920090000_template_optout_policy,旧内容不重写。初次工作区数据库包含113条迁移;随后复制原已提交迁移及本轮迁移,排除未提交20260918110000_refine_mobile_drainage_prefixes,在独立cmpp_qa_optout_commit_20260920执行112条迁移全部通过,并重新执行真实API/PG和浏览器验收。
- 自动验证:API全量85套/971例通过,覆盖率语句67.95%、分支53.55%、函数68.91%、行70.72%;前端37文件/168例通过,现有覆盖率门禁88.48%/85.09%/84%/88.19%。TypeScript、API构建、前端production构建、npm run lint、format:check、stylelint/CSS治理、bundle:verify及git diff --check通过;lint保留9条既有hooks警告,production大chunk提示但包体门禁通过。Gateway go test ./...及go vet ./...通过,队列契约未改动。
- 初次覆盖率运行并行竞争资源,前端24例触发原5000ms超时;串行安排并限制maxWorkers=2后168例全通过,没有放宽时限或断言。新增通道单测首次缺少required desiredConnections导致类型失败,补齐测试夹具后971例全通过。原失败记录保留。
- 真实验收:tools/testing/verify-template-optout.mjs使用真实NestJS、PostgreSQL16及本机Redis5.0.14.1(运行库提示建议6.2+,已记录环境差异)。验证登录/客户端入口拒绝、外应用/非法策略拒绝、固定分片保护、审计持久化、活动通道组引用下缩减保存、direct_send无templateId仍应用、单条及微批快照、换通道补发恢复原文、费用字段不变、事务回滚及旧Outbox快照不变;Outbox全部pending,未启动Gateway/SMSC或发布器,未实际发短信。
- 浏览器:当前环境无Browser插件,按前端验收技能使用独立Playwright/Edge、真实API及会话,1600×1000、1366×768、390×844分别验证策略保存、固定勾选、四段条、刷新/跨路由、原文/尝试详情、通道弹窗减少运营商后保存及PG回读,无pageerror。发现并修复既有窄屏查询遮挡/弹窗运营商溢出,仅增加同页响应式规则,保持桌面布局;已查看截图核对。
- 证据:.local-data/template-optout-20260920/ 下 api-coverage-final.log、frontend-coverage-final.log、migrations-commit.log、browser-accepted.log、lint-accepted.log、format-accepted.log、api-build-accepted.log、build-accepted.log、bundle-accepted.log、go-test.log、go-vet.log及template/quality/detail/channel三尺寸截图。验证脚本不保存会话或密码,fixture.json仅含隔离业务ID。
- 边界/未执行:未操作测试或预生产配置;未推送、未部署、未执行运营商发送/实际回执推送/资金结算;冻结历史报表不重算。测试证明本地真实持久化和页面,不能冒称线上或实际送达验收。提交仅纳入本轮源文件、迁移和本轮文档增量,提交号以本轮Git提交为准。
## 2026-09-20 CMPP 字段兼容性方案实施与本地提交
- 用户授权:在另一个会话最新代码基础上审查侧边方案、执行修复并本地提交。核验 main 为 b24cd7c,远端 main 为 5e4d644,工作区有其他会话修改,已保护并仅暂存本轮文件/共享文档新增段落。不推送、不部署、不修改预生产数据/配置、不重投真实短信或回执。
- 实施 CMPP-FIELD-0106:七个 uint32 字段 BigInt+CHECK、显式协议数值适配与响应序列化、2.0/3.0 回执布局、序号 0 与在途回绕保护、CONNECT 非零完整状态、3.0 大包上限及计数/长度校验。并发实测发现 Prisma 空更新 upsert 首次竞态 P2002,增加同 receiptKey 已持久化确认,不掩盖其他失败。与最新终态/发送尝试归属代码兼容;仅清理受影响模块无用 import/格式,无业务规则扩展。
- 验收:API 全量及增量覆盖率均 87 套 / 990 项;API 构建、Gateway 全量 test/vet、嵌套 gocmpp test、5 个队列契约、变更代码 lint0 error/32 存量 warning)、格式与 diff 检查通过。额外嵌套库 vet 存在原有两条 WriteByte/ReadByte 标准接口签名提示,未隐瞒或跳过配置,详细登记于方案第 10 节。
- 真实本机 Nest callback + PostgreSQL + Redis0/2147483647/2147483648/4294967295 七字段、最大 uint64 字符串、并发/批量重复、上行去重、大非零 ACK、非法输入无写入、历史 NULL 不重开通过。另真实 PG 终态 8 项、完成态 11 项通过。CMPP 2.0/3.0 本机真 TCP 回执、零序号恢复/ACK、认证状态 256/max、99 号码 3471/3490 与异常长度拒绝通过。不是线上供应商/客户验收,也不是整套进程联调。
- 迁移:从已提交基线排除其他会话未提交 mobile-rule 迁移,新空库部署 113 迁移成功。七表 21 行小样本证明末表负值使此前 DDL 整体回滚,锁等待 5 秒失败回滚,旧值/NULL/7 索引保留;最终有效扩容约 106ms、WAL 81800 字节。生产规模、真实发布备份/恢复与 3.0 供应商非规范布局抽样待上线前补验,禁止把小样本当容量承诺。
- 证据:.local-data/protocol-fields-20260920/ 保留原失败和复验日志;最终 real-chain-verified.log、migrate-verified.log、api-coverage-final.log、api-incremental.log、go-final.log、gocmpp-final.log。专用 Redis 5.0.14.1 的真实 Stream 验证有效,但低于 BullMQ 推荐 6.2;PG 并发 query 弃用提示记为存量,未修改依赖。测试脚本要求全新本机 cmpp_qa_* 数据库,避免固定最大 Msg_Id fixture 跨轮冲突。
- 方案/需求/用例同步:docs/cmpp-protocol-field-compatibility-remediation-20260920.md 第 10 节完整映射 T01~T18。无前端/CSS改动、未执行浏览器回归;客户端 JSON 类型保持不变。业务状态与资金未通过迁移回填。后续所有相关进程须与 BigInt schema 配套,旧客户端不能读取高位值;应用回退不执行 bigint 缩回 integer。
@@ -80,6 +80,10 @@ func registerDownstreamAck(session *downstreamSession, deliveryID string, claimI
} }
key := downstreamAckKey(session.conn, sequenceID) key := downstreamAckKey(session.conn, sequenceID)
downstreamAckRegistry.Lock() downstreamAckRegistry.Lock()
if downstreamAckRegistry.items[key] != nil {
downstreamAckRegistry.Unlock()
return nil
}
downstreamAckRegistry.items[key] = tracker downstreamAckRegistry.items[key] = tracker
tracker.timer = time.AfterFunc(time.Until(deadline), func() { tracker.timer = time.AfterFunc(time.Until(deadline), func() {
timedOut := takeDownstreamAck(session.conn, sequenceID) timedOut := takeDownstreamAck(session.conn, sequenceID)
+29 -19
View File
@@ -15,19 +15,19 @@ import (
// arbitrary account session would acknowledge a message with the wrong Msg_Id. // arbitrary account session would acknowledge a message with the wrong Msg_Id.
type DownstreamReceipt struct { type DownstreamReceipt struct {
DeliveryID string `json:"deliveryId,omitempty"` DeliveryID string `json:"deliveryId,omitempty"`
ClaimID string `json:"claimId,omitempty"` ClaimID string `json:"claimId,omitempty"`
Account string `json:"account,omitempty"` Account string `json:"account,omitempty"`
ApplicationID string `json:"applicationId,omitempty"` ApplicationID string `json:"applicationId,omitempty"`
MessageID string `json:"messageId"` MessageID string `json:"messageId"`
GatewayMessageID string `json:"gatewayMessageId,omitempty"` GatewayMessageID string `json:"gatewayMessageId,omitempty"`
PhoneNumber string `json:"phoneNumber,omitempty"` PhoneNumber string `json:"phoneNumber,omitempty"`
ReceiptStatus string `json:"receiptStatus"` ReceiptStatus string `json:"receiptStatus"`
RawStatus string `json:"rawStatus,omitempty"` RawStatus string `json:"rawStatus,omitempty"`
ErrorCode string `json:"errorCode,omitempty"` ErrorCode string `json:"errorCode,omitempty"`
SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"` SubmitSequenceID *uint32 `json:"submitSequenceId,omitempty"`
SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"` SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"`
DeliveredAt string `json:"deliveredAt,omitempty"` DeliveredAt string `json:"deliveredAt,omitempty"`
} }
type DownstreamUplink struct { type DownstreamUplink struct {
@@ -108,7 +108,7 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
session = recoverReceiptSession(event) session = recoverReceiptSession(event)
} }
if session == nil { if session == nil {
if event.SubmitSequenceID == 0 { if event.SubmitSequenceID == nil {
return DownstreamSendResult{ return DownstreamSendResult{
Retryable: false, Retryable: false,
ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID", ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID",
@@ -158,7 +158,11 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber), DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber),
SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff), SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff),
} }
receiptBytes, err := receipt.Pack() version := cmpp.V30
if session.protocol == "cmpp20" || session.protocol == "cmpp21" {
version = cmpp.V20
}
receiptBytes, err := receipt.PackVersion(version)
if err != nil { if err != nil {
return DownstreamSendResult{}, err return DownstreamSendResult{}, err
} }
@@ -167,8 +171,8 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
} }
func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 { func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 {
if event.SubmitSequenceID != 0 { if event.SubmitSequenceID != nil {
return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), *event.SubmitSequenceID)
} }
if session == nil { if session == nil {
return 0 return 0
@@ -189,7 +193,7 @@ func findReceiptSession(messageID string, account string) *downstreamSession {
} }
func recoverReceiptSession(event DownstreamReceipt) *downstreamSession { func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
if event.MessageID == "" || event.SubmitSequenceID == 0 || event.Account == "" { if event.MessageID == "" || event.SubmitSequenceID == nil || event.Account == "" {
return nil return nil
} }
downstreamRegistry.RLock() downstreamRegistry.RLock()
@@ -200,7 +204,7 @@ func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
} }
recovered := *accountSession recovered := *accountSession
recovered.messageID = event.MessageID recovered.messageID = event.MessageID
recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), *event.SubmitSequenceID)
return &recovered return &recovered
} }
@@ -293,6 +297,12 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID
AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt), AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt),
} }
tracker := registerDownstreamAck(session, deliveryID, claimID, sequenceID, messageID, ackDeadlineAt) tracker := registerDownstreamAck(session, deliveryID, claimID, sequenceID, messageID, ackDeadlineAt)
// A wrapped sequence cannot overwrite another unacknowledged delivery.
if deliveryID != "" && tracker == nil {
result.Retryable = true
result.ReasonCode = "SEQUENCE_IN_USE"
return result, nil
}
if err := session.conn.SendPkt(deliver, sequenceID); err != nil { if err := session.conn.SendPkt(deliver, sequenceID); err != nil {
removeDownstreamAck(tracker) removeDownstreamAck(tracker)
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err) session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err)
@@ -0,0 +1,146 @@
package inbound
import (
"encoding/json"
cmpp "github.com/bigwhite/gocmpp"
"net"
"sync"
"testing"
"time"
)
func TestZeroSequenceReceiptRecoversAndAcknowledgesOverTCP(t *testing.T) {
for _, version := range []cmpp.Type{cmpp.V20, cmpp.V30} {
t.Run(version.String(), func(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
tcp, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
remote, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
server, client := cmpp.NewConn(remote, version), cmpp.NewConn(tcp, version)
server.SetState(cmpp.CONN_AUTHOK)
client.SetState(cmpp.CONN_AUTHOK)
defer server.Close()
defer client.Close()
acknowledged := make(chan downstreamDeliveryLifecycleEvent, 2)
session := &downstreamSession{account: "qa", conn: server, protocol: version.String(), connectionID: "reconnected", mu: &sync.Mutex{}, deliveryReport: func(e downstreamDeliveryLifecycleEvent) {
if e.Kind == "acknowledged" {
acknowledged <- e
}
}}
// Simulate a fresh connection with no in-memory original Submit mapping.
downstreamRegistry.Lock()
downstreamRegistry.byAccount["qa"] = session
downstreamRegistry.Unlock()
var event DownstreamReceipt
if err = json.Unmarshal([]byte(`{"deliveryId":"qa-zero","account":"qa","messageId":"original","submitSequenceId":0,"receiptStatus":"delivered","phoneNumber":"13800138000"}`), &event); err != nil {
t.Fatal(err)
}
result, err := PushReceiptWithResult(event)
if err != nil || !result.Sent {
t.Fatalf("zero cannot recover: %+v %v", result, err)
}
p, err := client.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
var content string
var seq uint32
var msgID uint64
switch pkt := p.(type) {
case *cmpp.Cmpp2DeliverReqPkt:
content, seq, msgID = pkt.MsgContent, pkt.SeqId, pkt.MsgId
case *cmpp.Cmpp3DeliverReqPkt:
content, seq, msgID = pkt.MsgContent, pkt.SeqId, pkt.MsgId
default:
t.Fatalf("bad packet %T", p)
}
var receipt cmpp.CmppReceiptPkt
if err = receipt.UnpackVersion([]byte(content), version); err != nil {
t.Fatal(err)
}
if receipt.MsgId != messageIDFrom("original", 0) || msgID != receipt.MsgId {
t.Fatalf("wrong original Msg_Id: %d", receipt.MsgId)
}
var response cmpp.Packer = &cmpp.Cmpp3DeliverRspPkt{MsgId: msgID, Result: 0}
if version == cmpp.V20 {
response = &cmpp.Cmpp2DeliverRspPkt{MsgId: msgID, Result: 0}
}
if err = client.SendPkt(response, seq); err != nil {
t.Fatal(err)
}
p, err = server.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
switch pkt := p.(type) {
case *cmpp.Cmpp2DeliverRspPkt:
handleDownstreamAcknowledgement(server, pkt.SeqId, pkt.MsgId, uint32(pkt.Result), nil)
case *cmpp.Cmpp3DeliverRspPkt:
handleDownstreamAcknowledgement(server, pkt.SeqId, pkt.MsgId, pkt.Result, nil)
}
select {
case ack := <-acknowledged:
if ack.Result != 0 || ack.MessageID != receipt.MsgId {
t.Fatal("wrong ACK")
}
case <-time.After(time.Second):
t.Fatal("missing ACK")
}
raw, err := json.Marshal(submitRequest{SequenceID: 0})
if err != nil {
t.Fatal(err)
}
var fields map[string]any
json.Unmarshal(raw, &fields)
if value, ok := fields["sequenceId"]; !ok || value != float64(0) {
t.Fatal("zero omitted from Submit callback")
}
})
}
}
func TestDownstreamAckCollisionDoesNotReplacePendingDelivery(t *testing.T) {
conn := &cmpp.Conn{}
session := &downstreamSession{conn: conn}
for _, sequence := range []uint32{^uint32(0), 0} {
first := registerDownstreamAck(session, "first", "a", sequence, 1, time.Now().Add(time.Minute))
if first == nil {
t.Fatal("first registration failed")
}
if registerDownstreamAck(session, "second", "b", sequence, 2, time.Now().Add(time.Minute)) != nil {
t.Fatal("overwrote pending delivery")
}
if takeDownstreamAck(conn, sequence) != first {
t.Fatal("lost original delivery")
}
}
}
func TestAbsentAndZeroSubmitSequenceRemainDistinct(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
for _, payload := range []string{`{}`, `{"submitSequenceId":null}`, `{"submitSequenceId":0}`} {
event := DownstreamReceipt{Account: "qa", MessageID: "original"}
if err := json.Unmarshal([]byte(payload), &event); err != nil {
t.Fatal(err)
}
result, err := PushReceiptWithResult(event)
if err != nil {
t.Fatal(err)
}
if result.Retryable != (event.SubmitSequenceID != nil) {
t.Fatalf("missing confused with zero: %s %+v", payload, result)
}
}
}
+10 -8
View File
@@ -212,7 +212,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
t.Fatalf("expected receipt deliver, got %+v", deliver) t.Fatalf("expected receipt deliver, got %+v", deliver)
} }
var receipt cmpp.CmppReceiptPkt var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(deliver.MsgContent)); err != nil { if err := receipt.UnpackVersion([]byte(deliver.MsgContent), cmpp.V30); err != nil {
t.Fatalf("unpack pushed receipt: %v", err) t.Fatalf("unpack pushed receipt: %v", err)
} }
if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId { if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId {
@@ -535,7 +535,7 @@ func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) {
} }
payload, _ := json.Marshal(DownstreamReceipt{ payload, _ := json.Marshal(DownstreamReceipt{
Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696", Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696",
ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: submit.SequenceID, ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: sequencePointer(submit.SequenceID),
}) })
pendingReturned = true pendingReturned = true
_ = json.NewEncoder(w).Encode([]pendingDelivery{{ _ = json.NewEncoder(w).Encode([]pendingDelivery{{
@@ -667,7 +667,7 @@ func TestRecoverableReceiptWaitsForClientConnection(t *testing.T) {
DeliveryID: "delivery-retry", DeliveryID: "delivery-retry",
Account: "100001", Account: "100001",
MessageID: "MSG-RETRY", MessageID: "MSG-RETRY",
SubmitSequenceID: 77, SubmitSequenceID: sequencePointer(77),
ReceiptStatus: "delivered", ReceiptStatus: "delivered",
}) })
if err != nil { if err != nil {
@@ -1161,7 +1161,7 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
} }
recovered := recoverReceiptSession(DownstreamReceipt{ recovered := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: 1216579149, MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: sequencePointer(1216579149),
}) })
if recovered == nil { if recovered == nil {
t.Fatal("expected persisted submit sequence to recover receipt session") t.Fatal("expected persisted submit sequence to recover receipt session")
@@ -1170,10 +1170,10 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID) t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID)
} }
first := recoverReceiptSession(DownstreamReceipt{ first := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: sequencePointer(77),
}) })
second := recoverReceiptSession(DownstreamReceipt{ second := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: sequencePointer(77),
}) })
if first == nil || second == nil || first.gatewayMsgID != second.gatewayMsgID || first.gatewayMsgID != messageIDFrom("MSG-GROUP", 77) { if first == nil || second == nil || first.gatewayMsgID != second.gatewayMsgID || first.gatewayMsgID != messageIDFrom("MSG-GROUP", 77) {
t.Fatalf("multi-destination recovery did not preserve the original Msg_Id: first=%+v second=%+v", first, second) t.Fatalf("multi-destination recovery did not preserve the original Msg_Id: first=%+v second=%+v", first, second)
@@ -1183,10 +1183,10 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) { func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) {
session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)} session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)}
first := downstreamReceiptMessageID(DownstreamReceipt{ first := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 101, MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(101),
}, session) }, session)
second := downstreamReceiptMessageID(DownstreamReceipt{ second := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 102, MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(102),
}, session) }, session)
if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) { if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) {
t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second) t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second)
@@ -1324,3 +1324,5 @@ func recvSubmitRsp20(t *testing.T, client *cmpp.Client) *cmpp.Cmpp2SubmitRspPkt
t.Fatal("timed out waiting CMPP2 submit response") t.Fatal("timed out waiting CMPP2 submit response")
return nil return nil
} }
func sequencePointer(n uint32) *uint32 { return &n }
+1 -1
View File
@@ -27,7 +27,7 @@ type submitRequest struct {
Content string `json:"content"` Content string `json:"content"`
SrcID string `json:"srcId,omitempty"` SrcID string `json:"srcId,omitempty"`
DestID string `json:"destId,omitempty"` DestID string `json:"destId,omitempty"`
SequenceID uint32 `json:"sequenceId,omitempty"` SequenceID uint32 `json:"sequenceId"`
RegisteredDelivery uint8 `json:"registeredDelivery"` RegisteredDelivery uint8 `json:"registeredDelivery"`
RemoteIP string `json:"remoteIp,omitempty"` RemoteIP string `json:"remoteIp,omitempty"`
LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"` LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"`
+7
View File
@@ -16,6 +16,13 @@ import (
// The reader and heartbeat goroutines share one connection lifecycle. Closing // The reader and heartbeat goroutines share one connection lifecycle. Closing
// the connection must wake pending submitters before scheduling pool recovery. // the connection must wake pending submitters before scheduling pool recovery.
// Caller holds c.mu across sequence selection, wire write and pending registration.
func (c *connection) sequenceAvailable(sequence uint32) bool {
_, submit := c.pending[sequence]
_, heartbeat := c.heartbeatPending[sequence]
return !submit && !heartbeat
}
type connection struct { type connection struct {
channelID string channelID string
config queue.UpstreamConfig config queue.UpstreamConfig
+4 -3
View File
@@ -15,6 +15,7 @@ import (
// mobile-originated content uses a separate long-message assembly path. // mobile-originated content uses a separate long-message assembly path.
type deliverPacket struct { type deliverPacket struct {
version cmpp.Type
seqID uint32 seqID uint32
msgID uint64 msgID uint64
destID string destID string
@@ -26,7 +27,7 @@ type deliverPacket struct {
} }
func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket { func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
return deliverPacket{ return deliverPacket{version: cmpp.V20,
seqID: pkt.SeqId, seqID: pkt.SeqId,
msgID: pkt.MsgId, msgID: pkt.MsgId,
destID: pkt.DestId, destID: pkt.DestId,
@@ -39,7 +40,7 @@ func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
} }
func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket { func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
return deliverPacket{ return deliverPacket{version: cmpp.V30,
seqID: pkt.SeqId, seqID: pkt.SeqId,
msgID: pkt.MsgId, msgID: pkt.MsgId,
destID: pkt.DestId, destID: pkt.DestId,
@@ -54,7 +55,7 @@ func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
func (c *connection) handleDeliver(pkt deliverPacket) error { func (c *connection) handleDeliver(pkt deliverPacket) error {
if pkt.registerDelivery == 1 { if pkt.registerDelivery == 1 {
var receipt cmpp.CmppReceiptPkt var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(pkt.msgContent)); err != nil { if err := receipt.UnpackVersion([]byte(pkt.msgContent), pkt.version); 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) 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 return err
} }
+1 -1
View File
@@ -166,7 +166,7 @@ func (c *connection) sendHeartbeat() bool {
return false return false
} }
c.sendMu.Lock() c.sendMu.Lock()
seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{}) seq, err := c.client.SendReqPktAvailable(&cmpp.CmppActiveTestReqPkt{}, c.sequenceAvailable)
c.sendMu.Unlock() c.sendMu.Unlock()
if err != nil { if err != nil {
c.mu.Unlock() c.mu.Unlock()
@@ -0,0 +1,50 @@
package upstream
import (
"cmpp-platform/gateway/internal/queue"
"encoding/json"
cmpp "github.com/bigwhite/gocmpp"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCMPP3ReceiptKeepsUnsignedFieldsAndFullDestination(t *testing.T) {
events := make(chan queue.ReceiptEvent, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var e queue.ReceiptEvent
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
t.Error(err)
}
events <- e
w.WriteHeader(200)
}))
defer api.Close()
receipt := cmpp.CmppReceiptPkt{MsgId: ^uint64(0), Stat: "DELIVRD", DestTerminalId: strings.Repeat("9", 32), SmscSequence: ^uint32(0)}
raw, err := receipt.PackVersion(cmpp.V30)
if err != nil {
t.Fatal(err)
}
conn := &connection{channelID: "qa", apiBaseURL: api.URL, httpClient: api.Client()}
packet := deliverPacketFromCMPP3(&cmpp.Cmpp3DeliverReqPkt{SeqId: ^uint32(0), RegisterDelivery: 1, MsgContent: string(raw)})
if err = conn.handleDeliver(packet); err != nil {
t.Fatal(err)
}
event := <-events
if event.GatewayMessageID != "18446744073709551615" || event.SequenceID != ^uint32(0) || event.PhoneNumber != receipt.DestTerminalId {
t.Fatalf("truncated callback: %+v", event)
}
packet.msgContent = string(raw[:60])
if conn.handleDeliver(packet) == nil {
t.Fatal("legacy 60-byte body silently accepted on CMPP3")
}
}
func TestUpstreamSequenceAvailabilityIncludesHeartbeatAndSubmit(t *testing.T) {
conn := &connection{pending: map[uint32]chan submitPartResponse{0: make(chan submitPartResponse)}, heartbeatPending: map[uint32]time.Time{^uint32(0): time.Now()}}
if conn.sequenceAvailable(0) || conn.sequenceAvailable(^uint32(0)) || !conn.sequenceAvailable(1) {
t.Fatal("wrapped sequence overwrites outstanding request")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) {
channelID := c.channelID channelID := c.channelID
if pkt.registerDelivery == 1 { if pkt.registerDelivery == 1 {
var receipt cmpp.CmppReceiptPkt var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(pkt.msgContent)); err == nil { if err := receipt.UnpackVersion([]byte(pkt.msgContent), pkt.version); err == nil {
gatewayMessageID = fmt.Sprint(receipt.MsgId) gatewayMessageID = fmt.Sprint(receipt.MsgId)
phone = strings.TrimSpace(receipt.DestTerminalId) phone = strings.TrimSpace(receipt.DestTerminalId)
if cmd, ok := c.commandFor(receipt.MsgId); ok { if cmd, ok := c.commandFor(receipt.MsgId); ok {
+2 -2
View File
@@ -101,7 +101,7 @@ func (p *connectionPool) submit(
return result, publishErr return result, publishErr
} }
} }
if firstSequence == 0 { if len(segments) == 1 {
firstSequence = seq firstSequence = seq
} }
if firstGatewayMessageID == "" { if firstGatewayMessageID == "" {
@@ -146,7 +146,7 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
} }
c.sendMu.Lock() c.sendMu.Lock()
wireSource = "write_uncertain" wireSource = "write_uncertain"
seq, err := client.SendReqPkt(pkt) seq, err := client.SendReqPktAvailable(pkt, c.sequenceAvailable)
if err == nil { if err == nil {
at := time.Now().UTC() at := time.Now().UTC()
wireAt = &at wireAt = &at
+22 -7
View File
@@ -15,6 +15,7 @@ package cmpp
import ( import (
"errors" "errors"
"fmt"
"net" "net"
"time" "time"
) )
@@ -72,7 +73,7 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
} }
var ok bool var ok bool
var status uint8 var status uint32
if cli.typ == V20 || cli.typ == V21 { if cli.typ == V20 || cli.typ == V21 {
var rsp *Cmpp2ConnRspPkt var rsp *Cmpp2ConnRspPkt
rsp, ok = p.(*Cmpp2ConnRspPkt) rsp, ok = p.(*Cmpp2ConnRspPkt)
@@ -80,7 +81,7 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
err = ErrRespNotMatch err = ErrRespNotMatch
return err return err
} }
status = rsp.Status status = uint32(rsp.Status)
} else { } else {
var rsp *Cmpp3ConnRspPkt var rsp *Cmpp3ConnRspPkt
rsp, ok = p.(*Cmpp3ConnRspPkt) rsp, ok = p.(*Cmpp3ConnRspPkt)
@@ -88,15 +89,16 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
err = ErrRespNotMatch err = ErrRespNotMatch
return err return err
} }
status = uint8(rsp.Status) status = rsp.Status
} }
if status != 0 { if status != 0 {
if status <= ErrnoConnOthers { //ErrnoConnOthers = 5 if status <= uint32(ErrnoConnOthers) { //ErrnoConnOthers = 5
err = ConnRspStatusErrMap[status] err = ConnRspStatusErrMap[uint8(status)]
} else { } else {
err = ConnRspStatusErrMap[ErrnoConnOthers] err = ConnRspStatusErrMap[ErrnoConnOthers]
} }
err = fmt.Errorf("CMPP CONNECT_RESP status=%d: %w", status, err)
return err return err
} }
@@ -112,8 +114,21 @@ func (cli *Client) Disconnect() {
// SendReqPkt pack the cmpp request packet structure and send it to the other peer. // SendReqPkt pack the cmpp request packet structure and send it to the other peer.
func (cli *Client) SendReqPkt(packet Packer) (uint32, error) { func (cli *Client) SendReqPkt(packet Packer) (uint32, error) {
seq := <-cli.conn.SeqId return cli.SendReqPktAvailable(packet, nil)
return seq, cli.conn.SendPkt(packet, seq) }
// The caller holds its pending-request lock until the returned sequence is registered.
// A wrapped sequence must never replace an outstanding request.
func (cli *Client) SendReqPktAvailable(packet Packer, available func(uint32) bool) (uint32, error) {
for {
seq, ok := <-cli.conn.SeqId
if !ok {
return 0, ErrConnIsClosed
}
if available == nil || available(seq) {
return seq, cli.conn.SendPkt(packet, seq)
}
}
} }
// SendRspPkt pack the cmpp response packet structure and send it to the other peer. // SendRspPkt pack the cmpp response packet structure and send it to the other peer.
+1 -1
View File
@@ -46,7 +46,7 @@ const (
CMPP_HEADER_LEN uint32 = 12 CMPP_HEADER_LEN uint32 = 12
CMPP2_PACKET_MAX uint32 = 2477 CMPP2_PACKET_MAX uint32 = 2477
CMPP2_PACKET_MIN uint32 = 12 CMPP2_PACKET_MIN uint32 = 12
CMPP3_PACKET_MAX uint32 = 3335 CMPP3_PACKET_MAX uint32 = Cmpp3SubmitReqPktMaxLen
CMPP3_PACKET_MIN uint32 = 12 CMPP3_PACKET_MIN uint32 = 12
) )
@@ -0,0 +1,181 @@
package cmpp
import (
"encoding/binary"
"fmt"
"net"
"reflect"
"strings"
"testing"
"time"
)
func TestReceiptVersionLayout(t *testing.T) {
for _, version := range []Type{V20, V21, V30} {
width, size := 21, 60
if version == V30 {
width, size = 32, 71
}
original := CmppReceiptPkt{MsgId: ^uint64(0), Stat: "DELIVRD", SubmitTime: "2609201200", DoneTime: "2609201201", DestTerminalId: strings.Repeat("9", width), SmscSequence: ^uint32(0)}
raw, err := original.PackVersion(version)
if err != nil {
t.Fatal(err)
}
if len(raw) != size || binary.BigEndian.Uint32(raw[size-4:]) != ^uint32(0) {
t.Fatalf("wrong layout: %x", raw)
}
var decoded CmppReceiptPkt
if err = decoded.UnpackVersion(raw, version); err != nil || !reflect.DeepEqual(decoded, original) {
t.Fatalf("roundtrip: %+v %v", decoded, err)
}
for _, bad := range [][]byte{raw[:len(raw)-1], append(append([]byte{}, raw...), 0)} {
if decoded.UnpackVersion(bad, version) == nil {
t.Fatal("invalid length accepted")
}
}
other := V30
if version == V30 {
other = V20
}
if decoded.UnpackVersion(raw, other) == nil {
t.Fatal("wrong version accepted")
}
original.DestTerminalId += "1"
if _, err = original.PackVersion(version); err == nil {
t.Fatal("truncated destination")
}
}
}
// Real TCP peers exercise the bounded reader, not only Pack/Unpack in memory.
func tcpPair(t *testing.T, version Type) (*Conn, net.Conn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
client, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
server, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
conn := NewConn(server, version)
conn.SetState(CONN_AUTHOK)
t.Cleanup(func() { conn.Close(); client.Close() })
return conn, client
}
func TestCMPP3LargeSubmitAndMalformedPackets(t *testing.T) {
for _, length := range []int{140, 159} {
p := Cmpp3SubmitReqPkt{DestUsrTl: 99, DestTerminalId: make([]string, 99), MsgLength: uint8(length), MsgContent: strings.Repeat("x", length)}
if length == 140 {
p.MsgFmt = 8
}
raw, err := p.Pack(^uint32(0))
if err != nil {
t.Fatal(err)
}
if len(raw) != 3331+length {
t.Fatalf("size %d", len(raw))
}
conn, peer := tcpPair(t, V30)
go peer.Write(raw)
pkt, err := conn.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
decoded := pkt.(*Cmpp3SubmitReqPkt)
if decoded.SeqId != ^uint32(0) || len(decoded.DestTerminalId) != 99 || decoded.MsgContent != p.MsgContent {
t.Fatal("wire mismatch")
}
var d Cmpp3SubmitReqPkt
if d.Unpack(raw[8:len(raw)-1]) == nil || d.Unpack(append(raw[8:], 0)) == nil {
t.Fatal("malformed body accepted")
}
p.DestUsrTl = 100
p.DestTerminalId = append(p.DestTerminalId, "")
if _, err = p.Pack(0); err == nil {
t.Fatal("100 destinations accepted")
}
}
for _, size := range []uint32{0, 11, CMPP3_PACKET_MAX + 1, ^uint32(0)} {
conn, peer := tcpPair(t, V30)
raw := make([]byte, 4)
binary.BigEndian.PutUint32(raw, size)
go peer.Write(raw)
if _, err := conn.RecvAndUnpackPkt(time.Second); err == nil {
t.Fatalf("accepted length %d", size)
}
}
p := Cmpp3SubmitReqPkt{DestUsrTl: 1, DestTerminalId: []string{"1"}, MsgFmt: 8, MsgLength: 141, MsgContent: strings.Repeat("a", 141)}
if _, err := p.Pack(0); err == nil {
t.Fatal("oversized non-ASCII accepted")
}
p.MsgFmt, p.MsgLength, p.MsgContent = 0, 160, strings.Repeat("a", 160)
if _, err := p.Pack(0); err == nil {
t.Fatal("ASCII must be strictly shorter than 160 bytes")
}
}
func TestConnectStatusKeepsAll32Bits(t *testing.T) {
for _, status := range []uint32{0, 5, 255, 256, ^uint32(0)} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
peer, e := listener.Accept()
if e != nil {
done <- e
return
}
conn := NewConn(peer, V30)
defer conn.Close()
conn.SetState(CONN_CONNECTED)
req, e := conn.RecvAndUnpackPkt(time.Second)
if e == nil {
e = conn.SendPkt(&Cmpp3ConnRspPkt{Status: status, Version: V30}, req.(*CmppConnReqPkt).SeqId)
}
done <- e
}()
client := NewClient(V30)
defer client.Disconnect()
err = client.Connect(listener.Addr().String(), "123456", "secret", time.Second)
if status == 0 && err != nil {
t.Fatal(err)
}
if status != 0 && (err == nil || !strings.Contains(err.Error(), fmt.Sprintf("status=%d", status))) {
t.Fatalf("status truncated: %v", err)
}
if e := <-done; e != nil {
t.Fatal(e)
}
})
}
}
func TestRequestSequenceSkipsInFlightAcrossWrap(t *testing.T) {
conn, peer := tcpPair(t, V30)
sequences := make(chan uint32, 3)
sequences <- ^uint32(0)
sequences <- 0
sequences <- 1
conn.SeqId = sequences
client := &Client{conn: conn, typ: V30}
read := make(chan error, 1)
go func() { raw := make([]byte, 12); _, err := peer.Read(raw); read <- err }()
seq, err := client.SendReqPktAvailable(&CmppActiveTestReqPkt{}, func(n uint32) bool { return n != ^uint32(0) })
if err != nil || seq != 0 {
t.Fatalf("zero lost: %d %v", seq, err)
}
if err = <-read; err != nil {
t.Fatal(err)
}
}
+41 -5
View File
@@ -13,11 +13,15 @@
package cmpp package cmpp
import "encoding/binary" import (
"encoding/binary"
"fmt"
)
// Packet length const for cmpp receipt packet. // Packet length const for cmpp receipt packet.
const ( const (
CmppReceiptPktLen uint32 = 60 //60d, 0x3c Cmpp3ReceiptPktLen uint32 = 71
CmppReceiptPktLen uint32 = 60 //60d, 0x3c
) )
type CmppReceiptPkt struct { type CmppReceiptPkt struct {
@@ -31,7 +35,18 @@ type CmppReceiptPkt struct {
// Pack packs the CmppReceiptPkt to bytes stream for client side. // Pack packs the CmppReceiptPkt to bytes stream for client side.
func (p *CmppReceiptPkt) Pack() ([]byte, error) { func (p *CmppReceiptPkt) Pack() ([]byte, error) {
var pktLen uint32 = CmppReceiptPktLen return p.PackVersion(V20)
}
// PackVersion uses the negotiated connection version, never a body-length guess.
func (p *CmppReceiptPkt) PackVersion(version Type) ([]byte, error) {
pktLen, width, err := receiptLayout(version)
if err != nil {
return nil, err
}
if len(p.Stat) > 7 || len(p.SubmitTime) > 10 || len(p.DoneTime) > 10 || len(p.DestTerminalId) > width {
return nil, fmt.Errorf("receipt field exceeds protocol width")
}
var w = newPacketWriter(pktLen) var w = newPacketWriter(pktLen)
@@ -39,7 +54,7 @@ func (p *CmppReceiptPkt) Pack() ([]byte, error) {
w.WriteFixedSizeString(p.Stat, 7) w.WriteFixedSizeString(p.Stat, 7)
w.WriteFixedSizeString(p.SubmitTime, 10) w.WriteFixedSizeString(p.SubmitTime, 10)
w.WriteFixedSizeString(p.DoneTime, 10) w.WriteFixedSizeString(p.DoneTime, 10)
w.WriteFixedSizeString(p.DestTerminalId, 21) w.WriteFixedSizeString(p.DestTerminalId, width)
w.WriteInt(binary.BigEndian, p.SmscSequence) w.WriteInt(binary.BigEndian, p.SmscSequence)
return w.Bytes() return w.Bytes()
@@ -49,6 +64,27 @@ func (p *CmppReceiptPkt) Pack() ([]byte, error) {
// After unpack, you will get all value of fields in // After unpack, you will get all value of fields in
// CmppReceiptPkt struct. // CmppReceiptPkt struct.
func (p *CmppReceiptPkt) Unpack(data []byte) error { func (p *CmppReceiptPkt) Unpack(data []byte) error {
return p.UnpackVersion(data, V20)
}
func receiptLayout(version Type) (uint32, int, error) {
switch version {
case V20, V21:
return CmppReceiptPktLen, 21, nil
case V30:
return Cmpp3ReceiptPktLen, 32, nil
}
return 0, 0, fmt.Errorf("unsupported receipt version: %v", version)
}
func (p *CmppReceiptPkt) UnpackVersion(data []byte, version Type) error {
size, width, err := receiptLayout(version)
if err != nil {
return err
}
if len(data) != int(size) {
return fmt.Errorf("invalid receipt length %d for version %v (expected %d)", len(data), version, size)
}
var r = newPacketReader(data) var r = newPacketReader(data)
r.ReadInt(binary.BigEndian, &p.MsgId) r.ReadInt(binary.BigEndian, &p.MsgId)
@@ -62,7 +98,7 @@ func (p *CmppReceiptPkt) Unpack(data []byte) error {
doneTime := r.ReadCString(10) doneTime := r.ReadCString(10)
p.DoneTime = string(doneTime) p.DoneTime = string(doneTime)
destTerminalId := r.ReadCString(21) destTerminalId := r.ReadCString(width)
p.DestTerminalId = string(destTerminalId) p.DestTerminalId = string(destTerminalId)
r.ReadInt(binary.BigEndian, &p.SmscSequence) r.ReadInt(binary.BigEndian, &p.SmscSequence)
+39 -2
View File
@@ -152,6 +152,9 @@ type Cmpp3SubmitRspPkt struct {
// Before calling Pack, you should initialize a Cmpp2SubmitReqPkt variable // Before calling Pack, you should initialize a Cmpp2SubmitReqPkt variable
// with correct field value. // with correct field value.
func (p *Cmpp2SubmitReqPkt) Pack(seqId uint32) ([]byte, error) { func (p *Cmpp2SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
if err := validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent); err != nil {
return nil, err
}
var pktLen uint32 = CMPP_HEADER_LEN + 117 + uint32(p.DestUsrTl)*21 + 1 + uint32(p.MsgLength) + 8 var pktLen uint32 = CMPP_HEADER_LEN + 117 + uint32(p.DestUsrTl)*21 + 1 + uint32(p.MsgLength) + 8
var w = newPacketWriter(pktLen) var w = newPacketWriter(pktLen)
@@ -200,6 +203,8 @@ func (p *Cmpp2SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
// Usually it is used in server side. After unpack, you will get all value of fields in // Usually it is used in server side. After unpack, you will get all value of fields in
// Cmpp2SubmitReqPkt struct. // Cmpp2SubmitReqPkt struct.
func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error { func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error {
p.DestTerminalId = nil
var r = newPacketReader(data) var r = newPacketReader(data)
// Sequence Id // Sequence Id
@@ -259,7 +264,13 @@ func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error {
reserve := r.ReadCString(8) reserve := r.ReadCString(8)
p.Reserve = string(reserve) p.Reserve = string(reserve)
return r.Error() if err := r.Error(); err != nil {
return err
}
if len(data) != 130+int(p.DestUsrTl)*21+int(p.MsgLength) {
return errSubmitInvalidStruct
}
return validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent)
} }
// Pack packs the Cmpp2SubmitRspPkt to bytes stream for Server side. // Pack packs the Cmpp2SubmitRspPkt to bytes stream for Server side.
@@ -302,6 +313,9 @@ func (p *Cmpp2SubmitRspPkt) Unpack(data []byte) error {
// Before calling Pack, you should initialize a Cmpp3SubmitReqPkt variable // Before calling Pack, you should initialize a Cmpp3SubmitReqPkt variable
// with correct field value. // with correct field value.
func (p *Cmpp3SubmitReqPkt) Pack(seqId uint32) ([]byte, error) { func (p *Cmpp3SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
if err := validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent); err != nil {
return nil, err
}
var pktLen uint32 = CMPP_HEADER_LEN + 129 + uint32(p.DestUsrTl)*32 + 1 + 1 + uint32(p.MsgLength) + 20 var pktLen uint32 = CMPP_HEADER_LEN + 129 + uint32(p.DestUsrTl)*32 + 1 + 1 + uint32(p.MsgLength) + 20
var w = newPacketWriter(pktLen) var w = newPacketWriter(pktLen)
@@ -352,6 +366,8 @@ func (p *Cmpp3SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
// Usually it is used in server side. After unpack, you will get all value of fields in // Usually it is used in server side. After unpack, you will get all value of fields in
// Cmpp3SubmitReqPkt struct. // Cmpp3SubmitReqPkt struct.
func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error { func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error {
p.DestTerminalId = nil
var r = newPacketReader(data) var r = newPacketReader(data)
// Sequence Id // Sequence Id
@@ -413,7 +429,13 @@ func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error {
linkId := r.ReadCString(20) linkId := r.ReadCString(20)
p.LinkId = string(linkId) p.LinkId = string(linkId)
return r.Error() if err := r.Error(); err != nil {
return err
}
if len(data) != 155+int(p.DestUsrTl)*32+int(p.MsgLength) {
return errSubmitInvalidStruct
}
return validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent)
} }
// Pack packs the Cmpp3SubmitRspPkt to bytes stream for Server side. // Pack packs the Cmpp3SubmitRspPkt to bytes stream for Server side.
@@ -451,3 +473,18 @@ func (p *Cmpp3SubmitRspPkt) Unpack(data []byte) error {
return r.Error() return r.Error()
} }
// The receive buffer is bounded separately; validate counts before accepting the body.
func validateSubmit(count uint8, destinations []string, format, length uint8, content string) error {
if count == 0 || count > 99 || int(count) != len(destinations) {
return errSubmitInvalidStruct
}
limit := 140
if format == 0 {
limit = 159 // CMPP specifies ASCII <160 bytes; other formats <=140.
}
if int(length) != len(content) || len(content) > limit {
return errSubmitInvalidMsgLength
}
return nil
}
+410 -128
View File
@@ -1,5 +1,29 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; import { request, withQuery } from '../core/httpClient';
import type { AdministrativeRegion, AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types'; import type {
AdministrativeRegion,
AuditRecord,
ClientSmsSignature,
ClientSmsTemplate,
CommonReportField,
DictionaryItem,
DrainageDetectionResult,
DrainageDetectionRule,
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 // Review, risk and billing mutations keep their original URLs, payloads and
// response types behind one governance boundary. // response types behind one governance boundary.
@@ -7,15 +31,38 @@ export const adminGovernanceApi = {
listAdministrativeRegions: () => request<AdministrativeRegion[]>('/admin/dictionaries/administrative-regions'), listAdministrativeRegions: () => request<AdministrativeRegion[]>('/admin/dictionaries/administrative-regions'),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'), listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) => updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, {
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })), method: 'POST',
listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => body: JSON.stringify(body),
request<PagedResult<RechargeOrder>>(withQuery('/admin/billing/manual-recharges', query)), }),
listManualRecharges: (tenantId?: string) =>
request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
listManualRechargesPage: (query: {
enterpriseKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<RechargeOrder>>(withQuery('/admin/billing/manual-recharges', query)),
preflightManualRecharge: (body: { tenantId: string; amountCents: number }) => preflightManualRecharge: (body: { tenantId: string; amountCents: number }) =>
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', {
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => method: 'POST',
body: JSON.stringify(body),
}),
createManualRecharge: (body: {
tenantId: string;
amountCents: number;
expectedAccountUpdatedAt: string;
idempotencyKey: string;
remark?: string;
}) =>
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listTemplateAudits: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => { listTemplateAudits: (query: {
keyword?: string;
status?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
}) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query.keyword) params.set('keyword', query.keyword); if (query.keyword) params.set('keyword', query.keyword);
if (query.status && query.status !== 'all') params.set('status', query.status); if (query.status && query.status !== 'all') params.set('status', query.status);
@@ -24,60 +71,187 @@ export const adminGovernanceApi = {
const suffix = params.toString() ? `?${params}` : ''; const suffix = params.toString() ? `?${params}` : '';
return request<SmsTemplateAudit[]>(`/admin/enterprise-templates${suffix}`); return request<SmsTemplateAudit[]>(`/admin/enterprise-templates${suffix}`);
}, },
approveTemplate: (id: string) => request<SmsTemplateAudit>(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), approveTemplate: (id: string) =>
rejectTemplate: (id: string, reason = '运营审核驳回') => request<SmsTemplateAudit>(`/admin/templates/${id}/reject`, { request<SmsTemplateAudit>(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
method: 'POST', rejectTemplate: (id: string, reason = '运营审核驳回') =>
body: JSON.stringify({ reason }), request<SmsTemplateAudit>(`/admin/templates/${id}/reject`, {
}), method: 'POST',
approveSignature: (id: string) => request<ClientSmsSignature>(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), body: JSON.stringify({ reason }),
rejectSignature: (id: string, reason = '运营审核驳回') => request<ClientSmsSignature>(`/admin/signatures/${id}/reject`, { }),
method: 'POST', approveSignature: (id: string) =>
body: JSON.stringify({ reason }), request<ClientSmsSignature>(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
}), rejectSignature: (id: string, reason = '运营审核驳回') =>
request<ClientSmsSignature>(`/admin/signatures/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
getReviewPreflight: (type: 'signature' | 'template', id: string) => getReviewPreflight: (type: 'signature' | 'template', id: string) =>
request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`), request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`),
submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) => submitReviewDecision: (
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), type: 'signature' | 'template',
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => id: string,
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)), body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string },
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => ) =>
request<PagedResult<ClientSmsSignature> & { pendingReportMaterialTotal: number; pendingReportDetailTotal: number }>(withQuery('/admin/enterprise-signatures', query)), request<ReviewDecisionResult>(`/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;
submittedAtFrom?: string;
submittedAtTo?: string;
} = {},
) => request<ClientSmsSignature[]>(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<PagedResult<ClientSmsSignature> & { pendingReportMaterialTotal: number; pendingReportDetailTotal: number }>(
withQuery('/admin/enterprise-signatures', query),
),
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)), request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
getEnterpriseSignature: (id: string) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`), getEnterpriseSignature: (id: string) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`),
getEnterpriseSignatureReportTargets: (id: string) => request<NonNullable<ClientSmsSignature['reportTargets']>>(`/admin/enterprise-signatures/${id}/report-targets`), getEnterpriseSignatureReportTargets: (id: string) =>
getDrainageInfoReportTargets: (id: string) => request<NonNullable<ClientSmsSignature['drainageReportTargets']>[string]>(`/admin/drainage-infos/${id}/report-targets`), request<NonNullable<ClientSmsSignature['reportTargets']>>(`/admin/enterprise-signatures/${id}/report-targets`),
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) => getDrainageInfoReportTargets: (id: string) =>
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), request<NonNullable<ClientSmsSignature['drainageReportTargets']>[string]>(
updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record<string, unknown> }) => `/admin/drainage-infos/${id}/report-targets`,
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), ),
createEnterpriseSignature: (body: {
tenantId: string;
applicationId?: string;
name: string;
purpose?: string;
drainageInfo?: Record<string, unknown>;
}) => request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseSignature: (
id: string,
body: {
applicationId?: string | null;
name?: string;
purpose?: string;
auditStatus?: string;
drainageInfo?: Record<string, unknown>;
},
) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, {
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => method: 'POST',
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)), body: JSON.stringify({ status, reason }),
}),
listDrainageInfos: (
query: {
tenantId?: string;
signatureId?: string;
keyword?: string;
status?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
} = {},
) => request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) => listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) =>
request<AuditRecord[]>(withQuery('/admin/audit-records', query)), request<AuditRecord[]>(withQuery('/admin/audit-records', query)),
createDrainageInfo: (signatureId: string, body: { url: string; remark?: string; reportValues?: Record<string, unknown> }) => createDrainageInfo: (
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), signatureId: string,
body: { url: string; remark?: string; reportValues?: Record<string, unknown> },
) =>
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateDrainageInfo: (id: string, body: { url?: string; remark?: string; reportValues?: Record<string, unknown> }) => updateDrainageInfo: (id: string, body: { url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<SmsDrainageInfo>(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
approveDrainageInfo: (id: string) => approveDrainageInfo: (id: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectDrainageInfo: (id: string, reason: string) => rejectDrainageInfo: (id: string, reason: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
changeDrainageInfoStatus: (id: string, status: string, reason?: string) => changeDrainageInfoStatus: (id: string, status: string, reason?: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/status`, {
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) => method: 'POST',
request<ClientSmsTemplate[]>(withQuery('/admin/enterprise-templates', query)), body: JSON.stringify({ status, reason }),
listEnterpriseTemplatesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; page: number; pageSize: number }) => }),
request<PagedResult<ClientSmsTemplate>>(withQuery('/admin/enterprise-templates', query)), listEnterpriseTemplates: (
createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => query: {
request<ClientSmsTemplate>('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), tenantId?: string;
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 }> }) => keyword?: string;
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
nameKeyword?: string;
contentKeyword?: string;
} = {},
) => request<ClientSmsTemplate[]>(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<PagedResult<ClientSmsTemplate>>(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<ClientSmsTemplate>('/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<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) => changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}/status`, {
listEnterpriseCertifications: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => { method: 'POST',
body: JSON.stringify({ status, reason }),
}),
getTemplateOptOutPolicy: (id: string) =>
request<{
rules: Array<{ channelId: string; action: 'add' | 'remove' }>;
preserveFragments: boolean;
channels: Array<{ id: string; name: string; groupNames: string[] }>;
}>(`/admin/enterprise-templates/${id}/opt-out-policy`),
updateTemplateOptOutPolicy: (
id: string,
body: { rules: Array<{ channelId: string; action: 'add' | 'remove' }>; preserveFragments: true },
) => request(`/admin/enterprise-templates/${id}/opt-out-policy`, { method: 'PUT', body: JSON.stringify(body) }),
listEnterpriseCertifications: (query: {
keyword?: string;
status?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
}) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query.keyword) params.set('keyword', query.keyword); if (query.keyword) params.set('keyword', query.keyword);
if (query.status && query.status !== 'all') params.set('status', query.status); if (query.status && query.status !== 'all') params.set('status', query.status);
@@ -86,16 +260,21 @@ export const adminGovernanceApi = {
const suffix = params.toString() ? `?${params}` : ''; const suffix = params.toString() ? `?${params}` : '';
return request<EnterpriseCertification[]>(`/admin/enterprise-certifications${suffix}`); return request<EnterpriseCertification[]>(`/admin/enterprise-certifications${suffix}`);
}, },
getEnterpriseCertification: (id: string) => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}`), getEnterpriseCertification: (id: string) =>
approveEnterpriseCertification: (id: string) => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/approve`, { request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}`),
method: 'POST', approveEnterpriseCertification: (id: string) =>
body: JSON.stringify({}), request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/approve`, {
}), method: 'POST',
rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/reject`, { body: JSON.stringify({}),
method: 'POST', }),
body: JSON.stringify({ reason }), rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') =>
}), request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/reject`, {
listRiskReviewTasks: (query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)), method: 'POST',
body: JSON.stringify({ reason }),
}),
listRiskReviewTasks: (
query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {},
) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
listRiskRules: (applicationId?: string) => listRiskRules: (applicationId?: string) =>
request<RiskRuleItem[]>(withQuery('/admin/risk-review/rules', { applicationId })), request<RiskRuleItem[]>(withQuery('/admin/risk-review/rules', { applicationId })),
createRiskRule: (body: { createRiskRule: (body: {
@@ -107,55 +286,68 @@ export const adminGovernanceApi = {
priority?: number; priority?: number;
config?: RiskRuleItem['config']; config?: RiskRuleItem['config'];
}) => request<RiskRuleItem>('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }), }) => request<RiskRuleItem>('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }),
updateRiskRule: (id: string, body: { updateRiskRule: (
thresholdValue?: number; id: string,
action?: RiskRuleItem['action']; body: {
status?: RiskRuleItem['status']; thresholdValue?: number;
priority?: number; action?: RiskRuleItem['action'];
config?: RiskRuleItem['config']; status?: RiskRuleItem['status'];
}) => request<RiskRuleItem>(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), priority?: number;
listPhoneFrequencyHits: (query: { config?: RiskRuleItem['config'];
tenantId?: string; },
applicationId?: string; ) => request<RiskRuleItem>(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
phoneNumber?: string; listPhoneFrequencyHits: (
status?: 'active' | 'expired' | 'released'; query: {
createdAtFrom?: string; tenantId?: string;
createdAtTo?: string; applicationId?: string;
page?: number; phoneNumber?: string;
pageSize?: number; status?: 'active' | 'expired' | 'released';
} = {}) => request<PagedResult<PhoneFrequencyHit>>(withQuery('/admin/risk-review/phone-frequency-hits', query)), createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<PhoneFrequencyHit>>(withQuery('/admin/risk-review/phone-frequency-hits', query)),
releasePhoneFrequencyHit: (id: string, reason: string) => releasePhoneFrequencyHit: (id: string, reason: string) =>
request<PhoneFrequencyHit>(`/admin/risk-review/phone-frequency-hits/${id}/release`, { request<PhoneFrequencyHit>(`/admin/risk-review/phone-frequency-hits/${id}/release`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ reason }), body: JSON.stringify({ reason }),
}), }),
listPhoneFrequencyWhitelist: (query: { listPhoneFrequencyWhitelist: (
phoneNumber?: string; query: {
keyword?: string; phoneNumber?: string;
status?: 'active' | 'inactive' | 'deleted'; keyword?: string;
updatedAtFrom?: string; status?: 'active' | 'inactive' | 'deleted';
updatedAtTo?: string; updatedAtFrom?: string;
page?: number; updatedAtTo?: string;
pageSize?: number; page?: number;
} = {}) => request<PagedResult<PhoneFrequencyWhitelistItem>>(withQuery('/admin/risk-review/phone-frequency-whitelist', query)), pageSize?: number;
} = {},
) =>
request<PagedResult<PhoneFrequencyWhitelistItem>>(withQuery('/admin/risk-review/phone-frequency-whitelist', query)),
createPhoneFrequencyWhitelist: (body: { createPhoneFrequencyWhitelist: (body: {
phoneNumber: string; phoneNumber: string;
reason: string; reason: string;
remark?: string; remark?: string;
status?: 'active' | 'inactive'; status?: 'active' | 'inactive';
}) => request<PhoneFrequencyWhitelistItem>('/admin/risk-review/phone-frequency-whitelist', { }) =>
method: 'POST', request<PhoneFrequencyWhitelistItem>('/admin/risk-review/phone-frequency-whitelist', {
body: JSON.stringify(body), method: 'POST',
}), body: JSON.stringify(body),
updatePhoneFrequencyWhitelist: (id: string, body: { }),
phoneNumber?: string; updatePhoneFrequencyWhitelist: (
reason?: string; id: string,
remark?: string; body: {
status?: 'active' | 'inactive'; phoneNumber?: string;
}) => request<PhoneFrequencyWhitelistItem>(`/admin/risk-review/phone-frequency-whitelist/${id}`, { reason?: string;
method: 'PUT', remark?: string;
body: JSON.stringify(body), status?: 'active' | 'inactive';
}), },
) =>
request<PhoneFrequencyWhitelistItem>(`/admin/risk-review/phone-frequency-whitelist/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deletePhoneFrequencyWhitelist: (id: string, reason: string) => deletePhoneFrequencyWhitelist: (id: string, reason: string) =>
request<PhoneFrequencyWhitelistItem>(`/admin/risk-review/phone-frequency-whitelist/${id}`, { request<PhoneFrequencyWhitelistItem>(`/admin/risk-review/phone-frequency-whitelist/${id}`, {
method: 'DELETE', method: 'DELETE',
@@ -164,58 +356,148 @@ export const adminGovernanceApi = {
listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
request<RiskTaskMessagePage>(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)), request<RiskTaskMessagePage>(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)),
approveRiskReviewTask: (id: string, reason?: string) => approveRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }), request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
rejectRiskReviewTask: (id: string, reason?: string) => rejectRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
rejectRiskReviewTasks: (ids: string[], reason: string) => rejectRiskReviewTasks: (ids: string[], reason: string) =>
request<RiskReviewTask[]>('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }), request<RiskReviewTask[]>('/admin/risk-review/tasks/batch/reject', {
listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/sensitive-words', query)), method: 'POST',
body: JSON.stringify({ ids, reason }),
}),
listSensitiveWords: (query: { keyword?: string; status?: string } = {}) =>
request<DictionaryItem[]>(withQuery('/admin/dictionaries/sensitive-words', query)),
createSensitiveWord: (body: { word: string; level?: string; status?: string }) => createSensitiveWord: (body: { word: string; level?: string; status?: string }) =>
request<DictionaryItem>('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }),
deleteSensitiveWord: (id: string) => request<DictionaryItem>(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }), deleteSensitiveWord: (id: string) =>
listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/global', query)), request<DictionaryItem>(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }),
listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) =>
request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/global', query)),
createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }),
deleteGlobalBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), deleteGlobalBlacklist: (id: string) =>
listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)), request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }),
createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => listEnterpriseBlacklist: (
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), query: {
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), tenantId?: string;
applicationId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
phoneNumber?: string;
reasonKeyword?: string;
} = {},
) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
createEnterpriseBlacklist: (body: {
tenantId: string;
applicationId: string;
phoneNumber: string;
reason?: string;
status?: string;
operatorId?: string;
}) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', {
method: 'POST',
body: JSON.stringify(body),
}),
deleteEnterpriseBlacklist: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)), 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 }) => createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
deletePhoneSegment: (id: string) => request<DictionaryItem>(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }), deletePhoneSegment: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }),
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => withQuery('/admin/dictionaries/phone-carrier-rules', query),
),
createPhoneCarrierRule: (body: {
carrier: string;
pattern: string;
priority?: number;
status?: string;
remark?: string;
}) =>
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
deletePhoneCarrierRule: (id: string) => request<DictionaryItem>(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }), deletePhoneCarrierRule: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }),
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'), listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => createDrainageField: (body: {
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), code: string;
updateDrainageField: (id: string, body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }) => name: string;
fieldType: 'string' | 'image' | 'file';
required?: boolean;
status?: string;
description?: string;
}) => request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
updateDrainageField: (
id: string,
body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string },
) =>
request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), deleteDrainageField: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) => listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)), request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
createDrainageDetectionRule: (body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) => createDrainageDetectionRule: (body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) =>
request<DrainageDetectionRule>('/admin/dictionaries/drainage-detection-rules', { method: 'POST', body: JSON.stringify(body) }), request<DrainageDetectionRule>('/admin/dictionaries/drainage-detection-rules', {
updateDrainageDetectionRule: (id: string, body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) => method: 'POST',
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), body: JSON.stringify(body),
}),
updateDrainageDetectionRule: (
id: string,
body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>,
) =>
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
changeDrainageDetectionRuleStatus: (id: string, status: 'active' | 'inactive') => changeDrainageDetectionRuleStatus: (id: string, status: 'active' | 'inactive') =>
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}/status`, {
testDrainageDetectionRule: (body: { content: string; rule?: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'> }) => method: 'POST',
request<DrainageDetectionResult>('/admin/dictionaries/drainage-detection-rules/test', { method: 'POST', body: JSON.stringify(body) }), body: JSON.stringify({ status }),
}),
testDrainageDetectionRule: (body: {
content: string;
rule?: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>;
}) =>
request<DrainageDetectionResult>('/admin/dictionaries/drainage-detection-rules/test', {
method: 'POST',
body: JSON.stringify(body),
}),
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'), listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => createCommonReportField: (body: {
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), drainageFieldId: string;
reportType: 'signature' | 'drainage';
required: boolean;
sortOrder?: number;
}) =>
request<CommonReportField>('/admin/dictionaries/common-report-fields', {
method: 'POST',
body: JSON.stringify(body),
}),
reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) => reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) =>
request<CommonReportField[]>('/admin/dictionaries/common-report-fields/order', { request<CommonReportField[]>('/admin/dictionaries/common-report-fields/order', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), deleteCommonReportField: (id: string) =>
updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }), updateCommonReportField: (
id: string,
body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean },
) =>
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
}; };
+3
View File
@@ -119,6 +119,7 @@ export type SendQualityResponse = {
}; };
export type SmsMessageRecord = { export type SmsMessageRecord = {
originalContent?: string | null;
channelWordDecisions?: Array<{ channelWordDecisions?: Array<{
id: string; id: string;
decidedAt: string; decidedAt: string;
@@ -185,6 +186,8 @@ export type SmsMessageRecord = {
}; };
export type SmsSubmitRecord = { export type SmsSubmitRecord = {
sentContent?: string | null;
contentPolicy?: { templateId: string | null; action: string; reason: string; preserveFragments: boolean } | null;
id: string; id: string;
channelId: string; channelId: string;
channelGroupName?: string | null; channelGroupName?: string | null;
+2 -12
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { QualityStatusBar } from './QualityStatusBar';
import { BarChart3, Eye, Search, X } from 'lucide-react'; import { BarChart3, Eye, Search, X } from 'lucide-react';
import { import {
adminApi, adminApi,
@@ -229,7 +230,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
key: 'successRate', key: 'successRate',
title: '成功率', title: '成功率',
width: '170px', width: '170px',
render: (record) => <QualityRate value={record.successRate} />, render: (record) => <QualityStatusBar metric={record} />,
}, },
{ {
key: 'averageArrivalMs', key: 'averageArrivalMs',
@@ -928,17 +929,6 @@ function MatrixMetric({
); );
} }
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div>
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
</div>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
}
function normalizeCarrier(value: string) { function normalizeCarrier(value: string) {
const normalized = value.toLowerCase(); const normalized = value.toLowerCase();
if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile'; if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile';
+316 -141
View File
@@ -1,9 +1,27 @@
import { useEffect, useRef, useState } from 'react'; import {
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react'; adminApi,
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi'; type ClientSmsApplication,
type ClientSmsSignature,
type ClientSmsTemplate,
type TenantOption,
} from '@/api/adminApi';
import {
Breadcrumb,
Button,
DeleteRiskAction,
Input,
Modal,
Pagination,
Select,
Tabs,
Tag,
Textarea,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature'; import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
import { Edit3, Eye, Plus, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { TemplateOptOutModal } from './TemplateOptOutModal';
type TemplateFormState = { type TemplateFormState = {
tenantId: string; tenantId: string;
@@ -89,7 +107,7 @@ function TemplateFormModal({
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId); const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name) ? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? ''; : (item?.content ?? '');
const [form, setForm] = useState<TemplateFormState>({ const [form, setForm] = useState<TemplateFormState>({
tenantId: item?.tenantId ?? '', tenantId: item?.tenantId ?? '',
applicationId: item?.applicationId ?? '', applicationId: item?.applicationId ?? '',
@@ -97,16 +115,24 @@ function TemplateFormModal({
name: item?.name ?? '', name: item?.name ?? '',
content: initialContent, content: initialContent,
category: item?.category ?? '行业通知', category: item?.category ?? '行业通知',
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [], variables:
item?.variables?.map((variable) => ({
name: variable.name,
example: variable.example ?? undefined,
required: variable.required ?? true,
})) ?? [],
}); });
const initialForm = useRef(form).current; const [initialForm] = useState(form);
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm); const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted'); const tenantApplications = applications.filter(
const tenantSignatures = signatures.filter((signature) => ( (application) => application.tenantId === form.tenantId && application.status !== 'deleted',
signature.tenantId === form.tenantId );
&& signature.auditStatus !== 'deleted' const tenantSignatures = signatures.filter(
&& (!signature.applicationId || signature.applicationId === form.applicationId) (signature) =>
)); signature.tenantId === form.tenantId &&
signature.auditStatus !== 'deleted' &&
(!signature.applicationId || signature.applicationId === form.applicationId),
);
const currentVariables = form.variables.length ? form.variables : extractVariables(form.content); const currentVariables = form.variables.length ? form.variables : extractVariables(form.content);
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) { function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
@@ -142,7 +168,9 @@ function TemplateFormModal({
} }
function updateVariableExample(name: string, example: string) { function updateVariableExample(name: string, example: string) {
const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable); const variables = currentVariables.map((variable) =>
variable.name === name ? { ...variable, example } : variable,
);
update('variables', variables); update('variables', variables);
} }
@@ -151,14 +179,26 @@ function TemplateFormModal({
dirty={dirty} dirty={dirty}
footer={({ requestClose }) => ( footer={({ requestClose }) => (
<> <>
<Button onClick={requestClose} variant="ghost"></Button> <Button onClick={requestClose} variant="ghost">
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}></Button>
</Button>
<Button
disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
onClick={() => onSubmit({ ...form, variables: currentVariables })}
>
</Button>
</> </>
)} )}
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
title={<div className="template-modal-title"><h2>{item ? '编辑短信模板' : '添加短信模板'}</h2><p></p></div>} title={
<div className="template-modal-title">
<h2>{item ? '编辑短信模板' : '添加短信模板'}</h2>
<p></p>
</div>
}
> >
<div className="template-form"> <div className="template-form">
<Select <Select
@@ -192,8 +232,19 @@ function TemplateFormModal({
required required
value={form.signatureId} value={form.signatureId}
/> />
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" required value={form.name} /> <Input
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} /> label="模板名称"
onChange={(event) => update('name', event.target.value)}
placeholder="请输入模板名称"
required
value={form.name}
/>
<Input
label="模板分类"
onChange={(event) => update('category', event.target.value)}
placeholder="行业通知/营销推广/验证码"
value={form.category}
/>
<Textarea <Textarea
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。" hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
label="模板内容" label="模板内容"
@@ -208,7 +259,9 @@ function TemplateFormModal({
<button onClick={() => setVariablesOpen((current) => !current)} type="button"> <button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'} <Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button> </button>
<span>{form.content.length} {billingUnits(form.content)} </span> <span>
{form.content.length} {billingUnits(form.content)}
</span>
</div> </div>
{variablesOpen ? ( {variablesOpen ? (
<div className="template-variable-panel"> <div className="template-variable-panel">
@@ -222,22 +275,37 @@ function TemplateFormModal({
</div> </div>
<h3></h3> <h3></h3>
<div className="template-custom-variable"> <div className="template-custom-variable">
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} /> <Input
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}></Button> onChange={(event) => setCustomVariable(event.target.value)}
placeholder="英文字符或数字"
value={customVariable}
/>
<Button
onClick={() => {
insertVariable(customVariable);
setCustomVariable('');
}}
>
</Button>
</div> </div>
</div> </div>
) : null} ) : null}
<div className="template-variable-panel"> <div className="template-variable-panel">
<h3></h3> <h3></h3>
{currentVariables.length ? currentVariables.map((variable) => ( {currentVariables.length ? (
<Input currentVariables.map((variable) => (
key={variable.name} <Input
label={`\${${variable.name}}`} key={variable.name}
onChange={(event) => updateVariableExample(variable.name, event.target.value)} label={`\${${variable.name}}`}
placeholder="请输入变量示例值" onChange={(event) => updateVariableExample(variable.name, event.target.value)}
value={variable.example ?? ''} placeholder="请输入变量示例值"
/> value={variable.example ?? ''}
)) : <p className="muted"></p>} />
))
) : (
<p className="muted"></p>
)}
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -248,36 +316,37 @@ function TemplatePreviewModal({ item, onClose }: { item: ClientSmsTemplate; onCl
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="模板预览"> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="模板预览">
<div className="detail-grid"> <div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div> <div>
<div><span></span><strong>{item.application?.name ?? item.applicationId}</strong></div> <span></span>
<div><span></span><strong>{item.signature?.name ?? '-'}</strong></div> <strong>{item.tenant?.name ?? item.tenantId}</strong>
<div><span></span><strong>{billingUnits(item.content)} </strong></div> </div>
<div className="detail-grid__wide"><span></span><strong>{item.content}</strong></div> <div>
<div className="detail-grid__wide"><span></span><strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong></div> <span></span>
<strong>{item.application?.name ?? item.applicationId}</strong>
</div>
<div>
<span></span>
<strong>{item.signature?.name ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{billingUnits(item.content)} </strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{item.content}</strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong>
</div>
</div> </div>
</Modal> </Modal>
); );
} }
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
</>
)}
onClose={onCancel}
open
title="删除确认"
>
<p className="admin-confirm-text">{message}</p>
</Modal>
);
}
export function AdminEnterpriseTemplatesPage() { export function AdminEnterpriseTemplatesPage() {
const [policyTemplate, setPolicyTemplate] = useState<ClientSmsTemplate | null>(null);
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms'); const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]); const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
@@ -300,23 +369,37 @@ export function AdminEnterpriseTemplatesPage() {
const pageSize = 10; const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }, targetPage = page) { function loadData(
filters = {
enterpriseKeyword: appliedEnterpriseKeyword,
applicationKeyword: appliedApplicationKeyword,
nameKeyword: appliedTemplateNameKeyword,
contentKeyword: appliedTemplateContentKeyword,
},
targetPage = page,
) {
const sequence = ++listRequestSequence.current; const sequence = ++listRequestSequence.current;
try { return adminApi
const templateResult = await adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize }); .listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize })
if (sequence !== listRequestSequence.current) return; .then((templateResult) => {
setTemplates(templateResult.items); if (sequence !== listRequestSequence.current) return;
setTotal(templateResult.total); setTemplates(templateResult.items);
setError(''); setTotal(templateResult.total);
} catch (failure) { setError('');
if (sequence !== listRequestSequence.current) return; })
setError(failure instanceof Error ? failure.message : '企业模板加载失败'); .catch((failure) => {
} if (sequence !== listRequestSequence.current) return;
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
});
} }
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listEnterpriseSignatureOptions()]) void Promise.all([
adminApi.listTenantOptions(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listEnterpriseSignatureOptions(),
])
.then(([tenantItems, applicationItems, signatureList]) => { .then(([tenantItems, applicationItems, signatureList]) => {
if (cancelled) return; if (cancelled) return;
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted')); setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
@@ -326,7 +409,9 @@ export function AdminEnterpriseTemplatesPage() {
.catch((failure: Error) => { .catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业模板选项加载失败'); if (!cancelled) setError(failure.message || '企业模板选项加载失败');
}); });
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -370,42 +455,86 @@ export function AdminEnterpriseTemplatesPage() {
return ( return (
<section className="page-stack admin-customer-split-page"> <section className="page-stack admin-customer-split-page">
{policyTemplate ? (
<TemplateOptOutModal template={policyTemplate} onClose={() => setPolicyTemplate(null)} />
) : null}
<div className="page-heading"> <div className="page-heading">
<div> <div>
<Breadcrumb items={['客户管理', '企业模板管理']} /> <Breadcrumb items={['客户管理', '企业模板管理']} />
<h1></h1> <h1></h1>
</div> </div>
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}></Button> <Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}>
</Button>
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-split-filter"> <div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} /> <Input
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} /> label="企业名称"
<Input label="模板名称" onChange={(event) => setTemplateNameKeyword(event.target.value)} placeholder="请输入模板名称" prefix={<Search size={16} />} value={templateNameKeyword} /> onChange={(event) => setEnterpriseKeyword(event.target.value)}
<Input label="模板内容" onChange={(event) => setTemplateContentKeyword(event.target.value)} placeholder="请输入模板内容" prefix={<Search size={16} />} value={templateContentKeyword} /> placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Input
label="企业应用"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Input
label="模板名称"
onChange={(event) => setTemplateNameKeyword(event.target.value)}
placeholder="请输入模板名称"
prefix={<Search size={16} />}
value={templateNameKeyword}
/>
<Input
label="模板内容"
onChange={(event) => setTemplateContentKeyword(event.target.value)}
placeholder="请输入模板内容"
prefix={<Search size={16} />}
value={templateContentKeyword}
/>
<div className="admin-split-filter__actions"> <div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { <Button
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), nameKeyword: templateNameKeyword.trim(), contentKeyword: templateContentKeyword.trim() }; icon={<Search size={16} />}
setAppliedEnterpriseKeyword(filters.enterpriseKeyword); onClick={() => {
setAppliedApplicationKeyword(filters.applicationKeyword); const filters = {
setAppliedTemplateNameKeyword(filters.nameKeyword); enterpriseKeyword: enterpriseKeyword.trim(),
setAppliedTemplateContentKeyword(filters.contentKeyword); applicationKeyword: applicationKeyword.trim(),
if (page !== 1) setPage(1); nameKeyword: templateNameKeyword.trim(),
else void loadData(filters, 1); contentKeyword: templateContentKeyword.trim(),
}}></Button> };
<Button onClick={() => { setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' }; setAppliedApplicationKeyword(filters.applicationKeyword);
setEnterpriseKeyword(''); setAppliedTemplateNameKeyword(filters.nameKeyword);
setApplicationKeyword(''); setAppliedTemplateContentKeyword(filters.contentKeyword);
setTemplateNameKeyword(''); if (page !== 1) setPage(1);
setTemplateContentKeyword(''); else void loadData(filters, 1);
setAppliedEnterpriseKeyword(''); }}
setAppliedApplicationKeyword(''); >
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword(''); </Button>
if (page !== 1) setPage(1); <Button
else void loadData(filters, 1); onClick={() => {
}} variant="ghost"></Button> const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setTemplateNameKeyword('');
setTemplateContentKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword('');
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}
variant="ghost"
>
</Button>
</div> </div>
</div> </div>
<div className="surface section-stack"> <div className="surface section-stack">
@@ -413,51 +542,93 @@ export function AdminEnterpriseTemplatesPage() {
onChange={(value) => setActiveTab(value as 'sms' | 'mms')} onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
value={activeTab} value={activeTab}
items={[ items={[
{ label: '短信模板', value: 'sms', content: ( {
<div className="admin-enterprise-template-list"> label: '短信模板',
{visibleTemplates.map((template) => ( value: 'sms',
<article className="admin-enterprise-template-row" key={template.id}> content: (
<div className="admin-enterprise-template-row__identity"> <div className="admin-enterprise-template-list">
<div className="admin-enterprise-template-row__title"> {visibleTemplates.map((template) => (
<strong>{template.name}</strong> <article className="admin-enterprise-template-row" key={template.id}>
<Tag tone={statusTone(template.auditStatus)}>{auditStatusLabel[template.auditStatus] ?? template.auditStatus}</Tag> <div className="admin-enterprise-template-row__identity">
<div className="admin-enterprise-template-row__title">
<strong>{template.name}</strong>
<Tag tone={statusTone(template.auditStatus)}>
{auditStatusLabel[template.auditStatus] ?? template.auditStatus}
</Tag>
</div>
<dl>
<div>
<dt></dt>
<dd>{template.tenant?.name ?? template.tenantId}</dd>
</div>
<div>
<dt></dt>
<dd>{template.application?.name ?? template.applicationId}</dd>
</div>
<div>
<dt></dt>
<dd>{template.signature?.name ?? '未绑定'}</dd>
</div>
</dl>
</div> </div>
<dl> <div className="admin-enterprise-template-row__content">
<div><dt></dt><dd>{template.tenant?.name ?? template.tenantId}</dd></div> <span></span>
<div><dt></dt><dd>{template.application?.name ?? template.applicationId}</dd></div> <p>{template.content}</p>
<div><dt></dt><dd>{template.signature?.name ?? '未绑定'}</dd></div> <small>{template.variables?.length ?? 0} </small>
</dl> </div>
</div> <div className="admin-enterprise-template-row__meta">
<div className="admin-enterprise-template-row__content"> <span></span>
<span></span> <strong>{formatDate(template.updatedAt)}</strong>
<p>{template.content}</p> </div>
<small>{template.variables?.length ?? 0} </small> <div className="admin-enterprise-template-row__actions">
</div> <Button
<div className="admin-enterprise-template-row__meta"> icon={<Eye size={15} />}
<span></span> onClick={() => setTemplatePreview(template)}
<strong>{formatDate(template.updatedAt)}</strong> size="sm"
</div> variant="ghost"
<div className="admin-enterprise-template-row__actions"> >
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost"></Button> </Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={template.id} targetType="template" /> <Button
</div> icon={<Edit3 size={15} />}
</article> onClick={() => setTemplateModal(template)}
))} size="sm"
{filteredTemplates.length === 0 ? <div className="ui-table__empty"></div> : null} variant="ghost"
<Pagination >
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))} </Button>
onPageChange={setPage} <Button onClick={() => setPolicyTemplate(template)} size="sm" variant="ghost">
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage} </Button>
previousDisabled={currentPage <= 1} <DeleteRiskAction
total={total} onCompleted={() => void loadData()}
totalPages={totalPages} portal="admin"
/> targetId={template.id}
</div> targetType="template"
) }, />
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> }, </div>
</article>
))}
{filteredTemplates.length === 0 ? <div className="ui-table__empty"></div> : null}
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={total}
totalPages={totalPages}
/>
</div>
),
},
{
label: '彩信模板',
pending: true,
value: 'mms',
content: <div className="ui-table__empty"></div>,
},
]} ]}
/> />
</div> </div>
@@ -467,12 +638,16 @@ export function AdminEnterpriseTemplatesPage() {
applications={applications} applications={applications}
item={templateModal === 'new' ? undefined : templateModal} item={templateModal === 'new' ? undefined : templateModal}
onClose={() => setTemplateModal(null)} onClose={() => setTemplateModal(null)}
onSubmit={(state) => { void saveTemplate(state); }} onSubmit={(state) => {
void saveTemplate(state);
}}
signatures={signatureItems} signatures={signatureItems}
tenants={tenants} tenants={tenants}
/> />
) : null} ) : null}
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null} {templatePreview ? (
<TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} />
) : null}
</section> </section>
); );
} }
+41
View File
@@ -0,0 +1,41 @@
.quality-status-bar {
display: flex;
align-items: center;
gap: var(--space-2);
}
.quality-status-bar .quality-status-bar__track {
display: flex;
flex: 1;
min-width: 60px;
height: 8px;
overflow: hidden;
border-radius: 99px;
background: var(--color-surface-muted);
}
.quality-status-bar .quality-status-bar__segment {
flex-shrink: 0;
height: 100%;
}
.quality-status-bar .quality-status-bar__segment--0 {
background: var(--color-success);
}
.quality-status-bar .quality-status-bar__segment--1 {
background: var(--color-warning);
}
.quality-status-bar .quality-status-bar__segment--2 {
background: var(--color-danger);
}
.quality-status-bar .quality-status-bar__segment--3 {
background: var(--color-text-muted);
}
.quality-status-bar strong {
min-width: 50px;
text-align: right;
}
+33
View File
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { QualityStatusBar } from './QualityStatusBar';
describe('quality status composition', () => {
it('shows four segments in one bar and puts unknown only in tooltip', () => {
render(
<QualityStatusBar
metric={{ total: 100, successCount: 60, submitFailureCount: 10, failureCount: 20, unknownCount: 10 }}
/>,
);
const bar = screen.getByRole('img');
expect(bar.children).toHaveLength(4);
expect([...bar.children].map((el) => (el as HTMLElement).style.width)).toEqual(['60%', '10%', '20%', '10%']);
expect(bar.getAttribute('title')).toContain('未收到回执:10 条(10.0%');
expect(screen.queryByText(/未收到回执/)).not.toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
});
it('handles no submissions and reports inconsistent counts without invented values', () => {
const view = render(
<QualityStatusBar
metric={{ total: 0, successCount: 0, submitFailureCount: 0, failureCount: 0, unknownCount: 0 }}
/>,
);
expect(screen.getByRole('img')).toHaveAttribute('title', '暂无提交');
view.rerender(
<QualityStatusBar
metric={{ total: 2, successCount: 2, submitFailureCount: 1, failureCount: 0, unknownCount: 0 }}
/>,
);
expect(screen.getByRole('img').children).toHaveLength(0);
expect(screen.getByText('—')).toBeInTheDocument();
});
});
+38
View File
@@ -0,0 +1,38 @@
import './QualityStatusBar.css';
type Counts = {
total: number;
successCount: number;
submitFailureCount: number;
failureCount: number;
unknownCount: number;
};
export function QualityStatusBar({ metric }: { metric: Counts }) {
const counts = [metric.successCount, metric.submitFailureCount, metric.failureCount, metric.unknownCount];
const labels = ['已到达', '提交失败', '回执失败', '未收到回执'];
const valid = counts.every((n) => Number.isFinite(n) && n >= 0) && counts.reduce((a, b) => a + b, 0) === metric.total;
const percentage = (count: number) => (metric.total > 0 ? (count / metric.total) * 100 : 0);
const title = !valid
? '统计数据不一致,请刷新后重试'
: metric.total === 0
? '暂无提交'
: counts
.map((count, i) => `${labels[i]}${count.toLocaleString('zh-CN')} 条(${percentage(count).toFixed(1)}%`)
.join('\n');
return (
<div className="quality-status-bar">
<div className="quality-status-bar__track" title={title} aria-label={title} role="img" tabIndex={0}>
{valid && metric.total > 0
? counts.map((count, i) => (
<span
key={labels[i]}
className={`quality-status-bar__segment quality-status-bar__segment--${i}`}
style={{ width: `${percentage(count)}%` }}
/>
))
: null}
</div>
<strong>{valid ? `${percentage(metric.successCount).toFixed(1)}%` : '—'}</strong>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
.template-optout {
display: grid;
gap: var(--space-4);
}
.template-optout .template-optout__preserve {
display: flex;
align-items: center;
gap: var(--space-2);
}
.template-optout .template-optout__row {
display: grid;
grid-template-columns: 1fr 240px;
align-items: center;
gap: var(--space-4);
padding-block: var(--space-3);
border-top: 1px solid var(--color-border);
}
.template-optout .template-optout__row small {
display: block;
color: var(--color-text-muted);
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
@media (max-width: 600px) {
.template-optout .template-optout__row {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TemplateOptOutModal } from './TemplateOptOutModal';
const api = vi.hoisted(() => ({ getTemplateOptOutPolicy: vi.fn(), updateTemplateOptOutPolicy: vi.fn() }));
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
const policy = {
rules: [{ channelId: 'a', action: 'add' }],
preserveFragments: true,
channels: [{ id: 'a', name: '通道甲', groupNames: ['移动组'] }],
};
describe('opt-out policy editor', () => {
beforeEach(() => {
vi.clearAllMocks();
api.getTemplateOptOutPolicy.mockResolvedValue(policy);
});
it('keeps failed saves visible and cannot disable fragment protection', async () => {
api.updateTemplateOptOutPolicy.mockRejectedValueOnce(new Error('保存失败')).mockResolvedValueOnce({});
const close = vi.fn();
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={close} />);
await screen.findByLabelText('通道甲的拒收指令');
expect(screen.getByLabelText('避免影响消息分片数')).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '保存策略' }));
await screen.findByRole('alert');
expect(close).not.toHaveBeenCalled();
expect(api.updateTemplateOptOutPolicy).toHaveBeenCalledWith('t', { rules: policy.rules, preserveFragments: true });
fireEvent.click(screen.getByRole('button', { name: '保存策略' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
});
it('does not allow saving an unloaded policy and supports retry', async () => {
api.getTemplateOptOutPolicy.mockRejectedValueOnce(new Error('加载失败'));
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={vi.fn()} />);
await screen.findByRole('alert');
expect(screen.getByRole('button', { name: '保存策略' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
await screen.findByLabelText('通道甲的拒收指令');
expect(screen.getByRole('button', { name: '保存策略' })).toBeEnabled();
});
it('requires removing stale channel rules explicitly', async () => {
api.getTemplateOptOutPolicy.mockResolvedValueOnce({ ...policy, channels: [] });
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={vi.fn()} />);
const remove = await screen.findByRole('button', { name: '移除失效规则' });
expect(screen.getByRole('button', { name: '保存策略' })).toBeDisabled();
fireEvent.click(remove);
expect(screen.getByRole('button', { name: '保存策略' })).toBeEnabled();
});
});
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from 'react';
import { adminApi } from '@/api/adminApi';
import { Button, Modal, Select } from '@/components/ui';
import './TemplateOptOutModal.css';
type Rule = { channelId: string; action: 'add' | 'remove' };
type Policy = {
rules: Rule[];
preserveFragments: boolean;
channels: { id: string; name: string; groupNames: string[] }[];
};
export function TemplateOptOutModal({
template,
onClose,
}: {
template: { id: string; name: string };
onClose: () => void;
}) {
const [policy, setPolicy] = useState<Policy>();
const [rules, setRules] = useState<Rule[]>([]);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [reload, setReload] = useState(0);
useEffect(() => {
let active = true;
setLoading(true);
setError('');
adminApi
.getTemplateOptOutPolicy(template.id)
.then((value) => {
if (active) {
setPolicy(value);
setRules(value.rules);
}
})
.catch((e) => {
if (active) setError(e instanceof Error ? e.message : '策略加载失败');
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [template.id, reload]);
async function save() {
setSaving(true);
setError('');
try {
await adminApi.updateTemplateOptOutPolicy(template.id, { rules, preserveFragments: true });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : '策略保存失败');
} finally {
setSaving(false);
}
}
const unavailable = rules.filter((rule) => !policy?.channels.some((c) => c.id === rule.channelId));
return (
<Modal
open
title="模板拒收指令"
size="xl"
onClose={onClose}
dirty={Boolean(policy && JSON.stringify(rules) !== JSON.stringify(policy.rules))}
footer={({ requestClose }) => (
<>
<Button variant="ghost" disabled={saving} onClick={requestClose}>
</Button>
<Button disabled={loading || saving || !policy || unavailable.length > 0} onClick={() => void save()}>
{saving ? '保存中…' : '保存策略'}
</Button>
</>
)}
>
<div className="template-optout">
<p>
<strong>{template.name}</strong>
</p>
<p className="muted">R</p>
<label className="template-optout__preserve">
<input type="checkbox" checked disabled />
</label>
<p className="muted">
</p>
{error ? (
<div role="alert" className="form-error">
{error}
{!policy ? (
<Button variant="ghost" onClick={() => setReload((n) => n + 1)}>
</Button>
) : null}
</div>
) : null}
{loading ? (
<p role="status"></p>
) : policy?.channels.length === 0 ? (
<p></p>
) : null}
{!loading
? policy?.channels.map((channel) => (
<div className="template-optout__row" key={channel.id}>
<div>
<strong>{channel.name}</strong>
<small>{channel.groupNames.join('、')}</small>
</div>
<Select
label={`${channel.name}的拒收指令`}
value={rules.find((r) => r.channelId === channel.id)?.action ?? 'none'}
disabled={saving}
options={[
{ value: 'none', label: '保持原文' },
{ value: 'remove', label: '末尾删除拒收指令' },
{ value: 'add', label: '末尾增加拒收指令' },
]}
onChange={(event) => {
const action = event.target.value;
setRules((current) => [
...current.filter((r) => r.channelId !== channel.id),
...(action === 'none' ? [] : [{ channelId: channel.id, action: action as Rule['action'] }]),
]);
}}
/>
</div>
))
: null}
{unavailable.length ? (
<div role="alert">
<Button
onClick={() => setRules((current) => current.filter((r) => !unavailable.includes(r)))}
variant="ghost"
>
</Button>
</div>
) : null}
</div>
</Modal>
);
}
@@ -478,6 +478,27 @@
} }
@media (max-width: 780px) { @media (max-width: 780px) {
.sms-channel-filter-grid,
.sms-channel-form-grid,
.sms-channel-inline-field {
grid-template-columns: minmax(0, 1fr);
}
.sms-channel-form-grid > .ui-field,
.sms-channel-radio-row,
.sms-channel-inline-field {
grid-column: 1;
}
.sms-channel-radio-row {
flex-wrap: wrap;
gap: var(--space-4);
}
.sms-channel-radio-row > span {
flex-basis: 100%;
}
.channel-connection-summary article { .channel-connection-summary article {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -156,6 +156,24 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
<p className="admin-sms-detail-content"> <p className="admin-sms-detail-content">
<DrainageContent record={record} /> <DrainageContent record={record} />
</p> </p>
{record.originalContent != null ? (
<>
<h3></h3>
<p className="admin-sms-detail-content">{record.originalContent}</p>
</>
) : null}
{record.originalContent != null
? record.submitRecords
?.filter((submit) => submit.sentContent != null)
.map((submit, index) => (
<div key={submit.id}>
<h3>
{index + 1} · {submit.channel?.name ?? submit.channelId}
</h3>
<p className="admin-sms-detail-content">{submit.sentContent}</p>
</div>
))
: null}
</section> </section>
<section> <section>
+6
View File
@@ -291,6 +291,12 @@ export function ClientSendDetailPage() {
<div className="send-detail-content-block"> <div className="send-detail-content-block">
<span></span> <span></span>
<p>{record.content}</p> <p>{record.content}</p>
{record.originalContent != null ? (
<>
<span></span>
<p>{record.originalContent}</p>
</>
) : null}
</div> </div>
</td> </td>
</tr> </tr>
+179 -54
View File
@@ -12,39 +12,59 @@
"files": [ "files": [
{ {
"file": "src/apps/admin/AdminHome.css", "file": "src/apps/admin/AdminHome.css",
"owners": ["src/apps/admin/AdminHome.tsx"], "owners": [
"src/apps/admin/AdminHome.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["admin-dashboard"] "roots": [
"admin-dashboard"
]
}, },
{ {
"file": "src/apps/admin/AdminAnalyticsPage.css", "file": "src/apps/admin/AdminAnalyticsPage.css",
"owners": ["src/apps/admin/AdminAnalyticsPage.tsx"], "owners": [
"src/apps/admin/AdminAnalyticsPage.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["admin-analytics-page"] "roots": [
"admin-analytics-page"
]
}, },
{ {
"file": "src/layouts/AlertNotificationMenu.css", "file": "src/layouts/AlertNotificationMenu.css",
"owners": ["src/layouts/AlertNotificationMenu.tsx"], "owners": [
"src/layouts/AlertNotificationMenu.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["alert-notification-menu"] "roots": [
"alert-notification-menu"
]
}, },
{ {
"file": "src/apps/admin/ReportMaterialImportModal.css", "file": "src/apps/admin/ReportMaterialImportModal.css",
"owners": ["src/apps/admin/ReportMaterialImportModal.tsx"], "owners": [
"src/apps/admin/ReportMaterialImportModal.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["report-material-import-modal"] "roots": [
"report-material-import-modal"
]
}, },
{ {
"file": "src/apps/admin/channels/AdminChannelsPage.css", "file": "src/apps/admin/channels/AdminChannelsPage.css",
"owners": ["src/apps/admin/AdminChannelsPage.tsx"], "owners": [
"legacyFingerprint": "fc2d168035c9adac5b330a7526424831ac18725da92a8c2392600e08a0891c69", "src/apps/admin/AdminChannelsPage.tsx"
],
"legacyFingerprint": "2c5e14e0747d1c05a82155f4b49321b01984cb38007a57322c1c3cf119fa22db",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。 2026-09-20 按模板拒收策略专项方案修复通道页窄屏查询遮挡及运营商选项换行,三尺寸验收后更新基线。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
}, },
{ {
"file": "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css", "file": "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
"owners": ["src/apps/admin/AdminEnterpriseApplicationsPage.tsx"], "owners": [
"src/apps/admin/AdminEnterpriseApplicationsPage.tsx"
],
"legacyFingerprint": "ac5abed49be7edb910ef8746c36f72e953f199b6073d13617594f1158fe4c69b", "legacyFingerprint": "ac5abed49be7edb910ef8746c36f72e953f199b6073d13617594f1158fe4c69b",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -52,7 +72,9 @@
}, },
{ {
"file": "src/apps/admin/security-detection/AdminSecurityDetectionPage.css", "file": "src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
"owners": ["src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx"], "owners": [
"src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx"
],
"legacyFingerprint": "ba5bc7ea3d09615685fee210c625d85ddf1c368d587922fb4953b24591675e06", "legacyFingerprint": "ba5bc7ea3d09615685fee210c625d85ddf1c368d587922fb4953b24591675e06",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -60,7 +82,9 @@
}, },
{ {
"file": "src/apps/admin/sms-records/AdminSmsRecordsPage.css", "file": "src/apps/admin/sms-records/AdminSmsRecordsPage.css",
"owners": ["src/apps/admin/AdminSmsRecordsPage.tsx"], "owners": [
"src/apps/admin/AdminSmsRecordsPage.tsx"
],
"legacyFingerprint": "a7cdbded6c765662dee0769c9c02159bb01045af08e2fa1842a11f79699fad02", "legacyFingerprint": "a7cdbded6c765662dee0769c9c02159bb01045af08e2fa1842a11f79699fad02",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -68,7 +92,9 @@
}, },
{ {
"file": "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css", "file": "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
"owners": ["src/apps/admin/AdminSmsTaskProgressPage.tsx"], "owners": [
"src/apps/admin/AdminSmsTaskProgressPage.tsx"
],
"legacyFingerprint": "a222961a94f15a388892a272e42bf64e122ea55f067bf618126e67ed107014b2", "legacyFingerprint": "a222961a94f15a388892a272e42bf64e122ea55f067bf618126e67ed107014b2",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -76,7 +102,9 @@
}, },
{ {
"file": "src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css", "file": "src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
"owners": ["src/apps/admin/system-monitoring/AdminSystemMonitoringPage.tsx"], "owners": [
"src/apps/admin/system-monitoring/AdminSystemMonitoringPage.tsx"
],
"legacyFingerprint": "59d09cdce53ef804773d46b1e5807e0e273f2c3ba7e2aa21b864d0d718f75db2", "legacyFingerprint": "59d09cdce53ef804773d46b1e5807e0e273f2c3ba7e2aa21b864d0d718f75db2",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -84,7 +112,9 @@
}, },
{ {
"file": "src/apps/client/ClientUsersPage.css", "file": "src/apps/client/ClientUsersPage.css",
"owners": ["src/apps/client/ClientUsersPage.tsx"], "owners": [
"src/apps/client/ClientUsersPage.tsx"
],
"legacyFingerprint": "af9cdb6f0229056437dab22fc0533fa9b0df2e3b013b69088a9815c4468518db", "legacyFingerprint": "af9cdb6f0229056437dab22fc0533fa9b0df2e3b013b69088a9815c4468518db",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -92,7 +122,9 @@
}, },
{ {
"file": "src/styles/admin.css", "file": "src/styles/admin.css",
"owners": ["src/main.tsx"], "owners": [
"src/main.tsx"
],
"legacyFingerprint": "b31d1360ca34c0bd5bc81d3687f5db649d5eb775db41faaec58c1ffd358891a1", "legacyFingerprint": "b31d1360ca34c0bd5bc81d3687f5db649d5eb775db41faaec58c1ffd358891a1",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -100,7 +132,9 @@
}, },
{ {
"file": "src/styles/client.css", "file": "src/styles/client.css",
"owners": ["src/main.tsx"], "owners": [
"src/main.tsx"
],
"legacyFingerprint": "b496831577a2296cca8bd1e3ba5a5c9574068f73f8f4c8176fe023957f803219", "legacyFingerprint": "b496831577a2296cca8bd1e3ba5a5c9574068f73f8f4c8176fe023957f803219",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -108,7 +142,9 @@
}, },
{ {
"file": "src/styles/components.css", "file": "src/styles/components.css",
"owners": ["src/main.tsx"], "owners": [
"src/main.tsx"
],
"legacyFingerprint": "8adee9fa7adcc5df32137c6428b944d9a79fd7aab0ceb8ae699aca6686294702", "legacyFingerprint": "8adee9fa7adcc5df32137c6428b944d9a79fd7aab0ceb8ae699aca6686294702",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -116,7 +152,9 @@
}, },
{ {
"file": "src/styles/domains/01-operations-dashboard.css", "file": "src/styles/domains/01-operations-dashboard.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "a9b28b65f14fb518f16620c4ae0fe337fe7807202818154c134b612384f9167d", "legacyFingerprint": "a9b28b65f14fb518f16620c4ae0fe337fe7807202818154c134b612384f9167d",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -124,7 +162,9 @@
}, },
{ {
"file": "src/styles/domains/02-client-sending.css", "file": "src/styles/domains/02-client-sending.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "b84920adb1891089df78af28066fe0bc1c0d2452057a294a798554312be57b41", "legacyFingerprint": "b84920adb1891089df78af28066fe0bc1c0d2452057a294a798554312be57b41",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -132,7 +172,9 @@
}, },
{ {
"file": "src/styles/domains/03-client-records.css", "file": "src/styles/domains/03-client-records.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "1e3eb6377c5436bc008f77cf1721575f5ace209cf0296d635002249272dcf794", "legacyFingerprint": "1e3eb6377c5436bc008f77cf1721575f5ace209cf0296d635002249272dcf794",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -140,7 +182,9 @@
}, },
{ {
"file": "src/styles/domains/04-signatures.css", "file": "src/styles/domains/04-signatures.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "0ff9be0753940b7d3312042a93b6a97d1cca92d3e045bfa3f13d064467377874", "legacyFingerprint": "0ff9be0753940b7d3312042a93b6a97d1cca92d3e045bfa3f13d064467377874",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -148,7 +192,9 @@
}, },
{ {
"file": "src/styles/domains/05-templates.css", "file": "src/styles/domains/05-templates.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "79d3e6a2c2e1cef9dcecaac985344cdc36ce30a31ccfae6d5c29f5801152bd76", "legacyFingerprint": "79d3e6a2c2e1cef9dcecaac985344cdc36ce30a31ccfae6d5c29f5801152bd76",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -156,7 +202,9 @@
}, },
{ {
"file": "src/styles/domains/06-auth-enterprise.css", "file": "src/styles/domains/06-auth-enterprise.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "f391234e8afe2085bc3d64c4e86d2f89e9f4aa2554d84abf9ea9f6228a35fbc7", "legacyFingerprint": "f391234e8afe2085bc3d64c4e86d2f89e9f4aa2554d84abf9ea9f6228a35fbc7",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -164,7 +212,9 @@
}, },
{ {
"file": "src/styles/domains/07-admin-operations.css", "file": "src/styles/domains/07-admin-operations.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "e8414200ee4b8354130880f1e3c7b71ea3972d278b1b72414897fb14d554958b", "legacyFingerprint": "e8414200ee4b8354130880f1e3c7b71ea3972d278b1b72414897fb14d554958b",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -172,7 +222,9 @@
}, },
{ {
"file": "src/styles/domains/08-reporting.css", "file": "src/styles/domains/08-reporting.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "db762c6fef81aac2b4a76746b5a78a607e9711aaeb4e509e2800b86b17eee6a5", "legacyFingerprint": "db762c6fef81aac2b4a76746b5a78a607e9711aaeb4e509e2800b86b17eee6a5",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -180,7 +232,9 @@
}, },
{ {
"file": "src/styles/domains/09-channels.css", "file": "src/styles/domains/09-channels.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "a32eab767f85b49324e17721b70b8dd0c8f0e5a14d6ef2d3502a56c69f9c0e7a", "legacyFingerprint": "a32eab767f85b49324e17721b70b8dd0c8f0e5a14d6ef2d3502a56c69f9c0e7a",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -188,7 +242,9 @@
}, },
{ {
"file": "src/styles/domains/10-signature-quality.css", "file": "src/styles/domains/10-signature-quality.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "9d79afed6c1550e747dcf6c5ab554a4977b62a93d66421a0a01add9f875e2c30", "legacyFingerprint": "9d79afed6c1550e747dcf6c5ab554a4977b62a93d66421a0a01add9f875e2c30",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -196,7 +252,9 @@
}, },
{ {
"file": "src/styles/domains/11-deliveries-reporting.css", "file": "src/styles/domains/11-deliveries-reporting.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "9792e6aedb4c74dcd6aeb26925391315e7df662c038a32be5ffcf3eee282374d", "legacyFingerprint": "9792e6aedb4c74dcd6aeb26925391315e7df662c038a32be5ffcf3eee282374d",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -204,7 +262,9 @@
}, },
{ {
"file": "src/styles/domains/12-admin-configuration.css", "file": "src/styles/domains/12-admin-configuration.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "60ac29279c18da425f52efe88b2de9a686a20c4e54ec54d6a424d027490c8e81", "legacyFingerprint": "60ac29279c18da425f52efe88b2de9a686a20c4e54ec54d6a424d027490c8e81",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -212,7 +272,9 @@
}, },
{ {
"file": "src/styles/domains/13-client-signatures.css", "file": "src/styles/domains/13-client-signatures.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "c90bb9ea3bf4ab278379a806b94187a559a46557de344c934be55968fef5287b", "legacyFingerprint": "c90bb9ea3bf4ab278379a806b94187a559a46557de344c934be55968fef5287b",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -220,7 +282,9 @@
}, },
{ {
"file": "src/styles/domains/14-responsive-requeue.css", "file": "src/styles/domains/14-responsive-requeue.css",
"owners": ["src/styles/domains/index.css"], "owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "3d3daaca99d4efbdaaea4dd1ec6a0239274ed52f6de658ed9f889a6d565ec567", "legacyFingerprint": "3d3daaca99d4efbdaaea4dd1ec6a0239274ed52f6de658ed9f889a6d565ec567",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -228,7 +292,9 @@
}, },
{ {
"file": "src/styles/domains/index.css", "file": "src/styles/domains/index.css",
"owners": ["src/main.tsx"], "owners": [
"src/main.tsx"
],
"legacyFingerprint": "3f287af46d6c7e7f921c43913b52a9a727a8e343a80ad46c45d25baaed073177", "legacyFingerprint": "3f287af46d6c7e7f921c43913b52a9a727a8e343a80ad46c45d25baaed073177",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -236,7 +302,9 @@
}, },
{ {
"file": "src/styles/reset.css", "file": "src/styles/reset.css",
"owners": ["src/main.tsx"], "owners": [
"src/main.tsx"
],
"legacyFingerprint": "4be47c806f9a6981a8b73f3844d707fd5cdaaf0d343323de07c6e39089224df1", "legacyFingerprint": "4be47c806f9a6981a8b73f3844d707fd5cdaaf0d343323de07c6e39089224df1",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -244,7 +312,9 @@
}, },
{ {
"file": "src/styles/shell.css", "file": "src/styles/shell.css",
"owners": ["src/main.tsx"], "owners": [
"src/main.tsx"
],
"legacyFingerprint": "93e26e9dcaa88447e054aeff8e20d4902cdaea54f0ec5a5b5659c84d9c4b9bd8", "legacyFingerprint": "93e26e9dcaa88447e054aeff8e20d4902cdaea54f0ec5a5b5659c84d9c4b9bd8",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -252,7 +322,9 @@
}, },
{ {
"file": "src/styles/tokens.css", "file": "src/styles/tokens.css",
"owners": ["src/main.tsx"], "owners": [
"src/main.tsx"
],
"legacyFingerprint": "754135f86b0828fa004270a4be6e7a087cb794eefa3860e8d45f89a6eb1f7223", "legacyFingerprint": "754135f86b0828fa004270a4be6e7a087cb794eefa3860e8d45f89a6eb1f7223",
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -260,50 +332,103 @@
}, },
{ {
"file": "src/apps/admin/AdminChannelGroupFormPage.css", "file": "src/apps/admin/AdminChannelGroupFormPage.css",
"owners": ["src/apps/admin/AdminChannelGroupFormPage.tsx"], "owners": [
"src/apps/admin/AdminChannelGroupFormPage.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["channel-group-editor"] "roots": [
"channel-group-editor"
]
}, },
{ {
"file": "src/apps/admin/channel-groups/RouteConfigModal.css", "file": "src/apps/admin/channel-groups/RouteConfigModal.css",
"owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"], "owners": [
"src/apps/admin/channel-groups/RouteConfigModal.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["channel-route-editor"] "roots": [
"channel-route-editor"
]
}, },
{ {
"file": "src/apps/report-notifications/report-notifications.css", "file": "src/apps/report-notifications/report-notifications.css",
"owners": ["src/apps/report-notifications/ReportNotificationsPage.tsx"], "owners": [
"src/apps/report-notifications/ReportNotificationsPage.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["report-notifications-page"] "roots": [
"report-notifications-page"
]
}, },
{ {
"file": "src/apps/admin/AdminMonitorPage.css", "file": "src/apps/admin/AdminMonitorPage.css",
"owners": ["src/apps/admin/AdminMonitorPage.tsx"], "owners": [
"src/apps/admin/AdminMonitorPage.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["sending-monitor"] "roots": [
"sending-monitor"
]
}, },
{ {
"file": "src/apps/admin/sending-monitor/MonitorRuleManager.css", "file": "src/apps/admin/sending-monitor/MonitorRuleManager.css",
"owners": ["src/apps/admin/sending-monitor/MonitorRuleManager.tsx"], "owners": [
"src/apps/admin/sending-monitor/MonitorRuleManager.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["monitor-rules"] "roots": [
"monitor-rules"
]
}, },
{ {
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css", "file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
"owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx", "src/apps/client/http-docs/ClientHttpDocsPage.tsx"], "owners": [
"src/apps/client/http-docs/HttpDeveloperDocs.tsx",
"src/apps/client/http-docs/ClientHttpDocsPage.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["client-http-docs"] "roots": [
"client-http-docs"
]
}, },
{ {
"file": "src/apps/client/ClientTemplatesPage.css", "file": "src/apps/client/ClientTemplatesPage.css",
"owners": ["src/apps/client/ClientTemplatesPage.tsx"], "owners": [
"src/apps/client/ClientTemplatesPage.tsx"
],
"stylelintLegacy": false, "stylelintLegacy": false,
"roots": ["client-templates-page"] "roots": [
"client-templates-page"
]
}, },
{ {
"file": "src/apps/shared/http-signature/HttpSignaturePage.css", "file": "src/apps/shared/http-signature/HttpSignaturePage.css",
"owners": ["src/apps/shared/http-signature/HttpSignaturePage.tsx"], "owners": [
"roots": ["http-signature-page"] "src/apps/shared/http-signature/HttpSignaturePage.tsx"
],
"roots": [
"http-signature-page"
]
},
{
"file": "src/apps/admin/QualityStatusBar.css",
"owners": [
"src/apps/admin/QualityStatusBar.tsx"
],
"stylelintLegacy": false,
"roots": [
"quality-status-bar"
]
},
{
"file": "src/apps/admin/TemplateOptOutModal.css",
"owners": [
"src/apps/admin/TemplateOptOutModal.tsx"
],
"stylelintLegacy": false,
"roots": [
"template-optout"
]
} }
] ]
} }
+319
View File
@@ -0,0 +1,319 @@
// Local-only real PostgreSQL, Redis and Nest callback validation. No supplier connection or SMS submitter.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomUUID, randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
const url = new URL(process.env.PROTOCOL_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'));
const redisUrl = new URL(process.env.PROTOCOL_TEST_REDIS_URL || 'redis://127.0.0.1:16441');
assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname));
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.href,
REDIS_URL: redisUrl.href,
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
CMPP_PROCESS_ROLE: 'gateway-callback',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:1',
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
API_ENABLE_SEND_WORKER: 'false',
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
});
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
// Match the existing bootstrap money serializer. Protocol field conversion is separately asserted below.
Object.defineProperty(BigInt.prototype, 'toJSON', {
configurable: true,
value() {
assert(this <= BigInt(Number.MAX_SAFE_INTEGER));
return Number(this);
},
});
const { NestFactory } = require('@nestjs/core');
const { GatewayCallbackModule } = require('./dist/gateway-callback.module');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SendChainService } = require('./dist/send-chain/send-chain.service');
const { protocolFieldsToJson } = require('./dist/common/protocol-uint32');
const { Client } = require('pg');
const Redis = require('ioredis');
const key = () => randomUUID();
const pass = (name, detail) => console.log(JSON.stringify({ passed: name, ...detail }));
const app = await NestFactory.create(GatewayCallbackModule, { logger: ['error'] });
app.setGlobalPrefix('api');
const redis = new Redis(redisUrl.href);
try {
await app.listen(0, '127.0.0.1');
const base = await app.getUrl(),
db = app.get(PrismaService),
chain = app.get(SendChainService);
assert.equal(
await db.smsMessageRecord.count(),
0,
'Use a fresh isolated database: fixed maximum Msg_Id fixtures must not collide with earlier runs',
);
const post = async (path, body, expected = 201) => {
const r = await fetch(`${base}/api/gateway/events/${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const json = await r.json();
assert.equal(r.status, expected, JSON.stringify(json));
return json;
};
const tenant = await db.tenant.create({ data: { name: 'protocol QA', code: key() } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: 'protocol QA',
cmppAccount: key(),
cmppEnterpriseCode: '000001',
secretHash: 'disabled',
interfaceEnabled: true,
},
});
const channel = await db.smsChannel.create({
data: {
name: 'protocol QA disabled',
code: key(),
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
carriers: ['mobile'],
status: 'disabled',
},
});
const maxMsgId = '18446744073709551615';
for (const [index, sequenceId] of [0, 2147483647, 2147483648, 4294967295].entries()) {
const gatewayMessageId = (BigInt(maxMsgId) - BigInt(index)).toString();
const m = await db.smsMessageRecord.create({
data: {
messageId: key(),
phoneNumber: `1380013800${index}`,
content: 'isolated',
billingUnits: 1,
status: 'submitted',
channelId: channel.id,
submitId: key(),
tenantId: tenant.id,
applicationId: application.id,
cmppSubmitSequenceId: String(sequenceId),
cmppRegisteredDelivery: true,
},
});
await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
tenantId: tenant.id,
channelId: channel.id,
submitId: m.submitId,
submitStatus: 'accepted',
},
});
await post('submit-segment-result', {
messageId: m.messageId,
channelId: channel.id,
submitId: m.submitId,
gatewayMessageId,
sequenceId,
segmentIndex: 1,
segmentTotal: 1,
submitStatus: 'accepted',
});
const receipt = {
messageId: m.messageId,
channelId: channel.id,
sequenceId,
gatewayMessageId,
phoneNumber: m.phoneNumber,
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
deliveredAt: new Date().toISOString(),
};
const stream = `qa:protocol:${key()}`;
try {
await redis.xadd(stream, '*', 'payload', JSON.stringify(receipt));
const entries = await redis.xrange(stream, '-', '+');
const wire = JSON.parse(entries[0][1][1]);
assert.equal(wire.gatewayMessageId, gatewayMessageId);
assert.equal(wire.sequenceId, sequenceId);
const [intake, concurrent] = await Promise.all([post('receipt/intake', wire), post('receipt/intake', wire)]);
assert.equal(intake.inboxId, concurrent.inboxId);
for (let i = 0; i < 100; i++) {
const inbox = await db.upstreamReceiptInbox.findUnique({ where: { id: intake.inboxId } });
if (inbox.status === 'matched') break;
await new Promise((resolve) => setTimeout(resolve, 30));
}
const batch = await post('batch', {
batchId: key(),
gatewayInstanceId: 'qa',
events: [{ eventId: key(), type: 'receipt_intake', payload: wire }],
});
assert(
batch.results.every((r) => r.accepted),
JSON.stringify(batch),
);
const inbox = await db.upstreamReceiptInbox.findUnique({ where: { id: intake.inboxId } });
assert.equal(inbox.status, 'matched');
assert.equal(inbox.sequenceId, BigInt(sequenceId));
const receipts = await db.smsReceiptRecord.findMany({ where: { messageRecordId: m.id } });
assert.equal(receipts.length, 1);
assert.equal(receipts[0].sequenceId, BigInt(sequenceId));
const [submit, segment] = await Promise.all([
db.smsSubmitRecord.findFirst({ where: { messageRecordId: m.id } }),
db.smsMessageSegmentAudit.findFirst({ where: { messageRecordId: m.id } }),
]);
assert.equal(submit.sequenceId, BigInt(sequenceId));
assert.equal(segment.sequenceId, BigInt(sequenceId));
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
const delivery = await db.cmppDownstreamDelivery.findFirst({ where: { messageRecordId: m.id } });
assert(delivery);
assert.equal(delivery.payload.submitSequenceId, sequenceId);
await chain.acknowledgeDownstreamDelivery({
id: delivery.id,
claimId: key(),
connectionId: 'qa',
sequenceId: String(sequenceId),
messageId: gatewayMessageId,
result: sequenceId,
});
const ack = await db.cmppDownstreamDelivery.findUnique({
where: { id: delivery.id },
include: { attempts: true },
});
assert.equal(ack.ackResult, BigInt(sequenceId));
assert.equal(ack.attempts[0].ackResult, BigInt(sequenceId));
assert.equal(ack.status === 'delivered', sequenceId === 0);
assert.equal(protocolFieldsToJson(ack).ackResult, sequenceId);
const uplink = {
eventId: key(),
channelId: channel.id,
sequenceId,
gatewayMessageId,
phoneNumber: m.phoneNumber,
destId: '1069',
content: 'QA',
receivedAt: new Date().toISOString(),
};
const first = await post('uplink', uplink),
duplicate = await post('uplink', uplink);
assert.equal(first.id, duplicate.id);
assert.equal(first.sequenceId, sequenceId);
assert.equal(first.gatewayMessageId, gatewayMessageId);
assert.equal((await db.smsUplinkMessage.findUnique({ where: { id: first.id } })).sequenceId, BigInt(sequenceId));
pass('uint32_real_chain', {
sequenceId,
gatewayMessageId,
deduplicated: true,
sevenColumns: true,
notificationQueued: true,
});
if (sequenceId === 4294967295) {
// Model the emergency legacy case: receipt fact exists but optional sequence was omitted.
const notices = await db.cmppDownstreamDelivery.count({ where: { messageRecordId: m.id } });
await db.upstreamReceiptInbox.update({ where: { id: inbox.id }, data: { sequenceId: null } });
await db.smsReceiptRecord.update({ where: { id: receipts[0].id }, data: { sequenceId: null } });
await post('receipt/intake', wire);
await post('receipt', { ...wire, receiptStatus: 'undelivered', rawStatus: 'REJECTD' });
assert.equal((await db.upstreamReceiptInbox.findUnique({ where: { id: inbox.id } })).sequenceId, null);
assert.equal((await db.smsReceiptRecord.findUnique({ where: { id: receipts[0].id } })).sequenceId, null);
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
assert.equal(await db.cmppDownstreamDelivery.count({ where: { messageRecordId: m.id } }), notices);
pass('historical_null_and_contradictory_high_sequence_no_reopen');
}
} finally {
await redis.del(stream);
}
}
const count = await db.upstreamReceiptInbox.count();
for (const bad of [-1, 4294967296, 1.5, '', '0'])
await post('receipt/intake', { channelId: channel.id, sequenceId: bad }, 400);
assert.equal(await db.upstreamReceiptInbox.count(), count);
const invalid = await post('batch', {
batchId: key(),
gatewayInstanceId: 'qa',
events: [{ eventId: 'bad', type: 'receipt_intake', payload: { channelId: channel.id, sequenceId: -1 } }],
});
assert.equal(invalid.results[0].accepted, false);
assert.equal(invalid.results[0].retryable, false);
pass('invalid_events_no_writes');
const models = [
'UpstreamReceiptInbox',
'SmsReceiptRecord',
'SmsUplinkMessage',
'SmsSubmitRecord',
'SmsMessageSegmentAudit',
'CmppDownstreamDelivery',
'CmppDownstreamDeliveryAttempt',
];
for (const [i, table] of models.entries()) {
const col = i < 5 ? 'sequenceId' : 'ackResult';
for (const bad of [-1, 4294967296])
await assert.rejects(db.$executeRawUnsafe(`UPDATE "${table}" SET "${col}" = ${bad}`));
}
pass('database_constraints_all_seven');
// Rehearse the exact migration with old values and an unrelated index, then a bad historical value and lock contention.
const sql = readFileSync(
new URL('../../api/prisma/migrations/20260920160000_cmpp_protocol_uint32/migration.sql', import.meta.url),
'utf8',
);
const pg = new Client({ connectionString: url.href }),
blocker = new Client({ connectionString: url.href });
await pg.connect();
await blocker.connect();
try {
const schema = 'qa_' + key().replaceAll('-', '');
await pg.query(`CREATE SCHEMA "${schema}"; SET search_path TO "${schema}"`);
for (const [i, table] of models.entries()) {
const col = i < 5 ? 'sequenceId' : 'ackResult';
await pg.query(
`CREATE TABLE "${table}" (id int, "${col}" integer); CREATE INDEX "${table}_qa" ON "${table}"(id); INSERT INTO "${table}" VALUES (1,NULL),(2,0),(3,2147483647)`,
);
}
await pg.query('UPDATE "CmppDownstreamDeliveryAttempt" SET "ackResult"=-1 WHERE id=2');
await assert.rejects(pg.query(sql));
await pg.query('ROLLBACK');
assert.equal(
(await pg.query('SELECT pg_typeof("sequenceId")::text AS typ FROM "UpstreamReceiptInbox" LIMIT 1')).rows[0].typ,
'integer',
);
await pg.query('UPDATE "CmppDownstreamDeliveryAttempt" SET "ackResult"=0 WHERE id=2');
await blocker.query(`BEGIN; LOCK TABLE "${schema}"."UpstreamReceiptInbox" IN ACCESS EXCLUSIVE MODE`);
await assert.rejects(pg.query(sql), (e) => e.code === '55P03');
await pg.query('ROLLBACK');
await blocker.query('ROLLBACK');
const before = await pg.query('SELECT pg_current_wal_lsn() AS lsn');
const started = Date.now();
await pg.query(sql);
const wal = await pg.query('SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), $1) AS bytes', [before.rows[0].lsn]);
for (const [i, table] of models.entries()) {
const col = i < 5 ? 'sequenceId' : 'ackResult';
assert.deepEqual(
(await pg.query(`SELECT "${col}" FROM "${table}" ORDER BY id`)).rows.map((r) => r[col]),
[null, '0', '2147483647'],
);
}
assert.equal(
Number((await pg.query('SELECT count(*) AS n FROM pg_indexes WHERE schemaname=$1', [schema])).rows[0].n),
7,
);
pass('migration_rollback_lock_timeout_and_preservation', {
durationMs: Date.now() - started,
walBytes: wal.rows[0].bytes,
fixtureRows: 21,
});
} finally {
await pg.end();
await blocker.end();
}
} finally {
await redis.quit();
await app.close();
}
+315
View File
@@ -0,0 +1,315 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
const url = new URL(process.env.COMPLETION_TEST_DATABASE_URL || '');
assert(['localhost', '127.0.0.1'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'));
process.env.NODE_ENV = 'test';
process.env.DATABASE_URL = url.toString();
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SendChainService } = require('./dist/send-chain/send-chain.service');
const { BillingService } = require('./dist/billing/billing.service');
const db = new PrismaService();
const chain = new SendChainService(db, new BillingService(db), {}, {});
const key = () => randomUUID();
const results = [];
try {
const tenant = await db.tenant.create({ data: { name: '隔离审查', code: key() } });
const app = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '隔离',
cmppAccount: key(),
cmppEnterpriseCode: '000001',
secretHash: 'disabled',
interfaceEnabled: true,
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离不联网',
code: key(),
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
carriers: ['mobile'],
status: 'disabled',
},
});
async function fixture(business = false) {
const mid = key(),
sid = key();
const m = await db.smsMessageRecord.create({
data: {
messageId: mid,
phoneNumber: '13800138000',
content: '隔离'.repeat(90),
billingUnits: 2,
status: 'submitted',
channelId: channel.id,
submitId: sid,
...(business
? { tenantId: tenant.id, applicationId: app.id, cmppSubmitSequenceId: '42', cmppRegisteredDelivery: true }
: {}),
},
});
const s = await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
tenantId: m.tenantId,
channelId: channel.id,
submitId: sid,
submitStatus: 'accepted',
},
});
return { m, s };
}
const receipt = (m, g, status = 'delivered') => ({
messageId: m.messageId,
channelId: channel.id,
gatewayMessageId: g,
phoneNumber: m.phoneNumber,
receiptStatus: status,
rawStatus: status === 'delivered' ? 'DELIVRD' : 'FAIL',
deliveredAt: new Date().toISOString(),
});
async function segment(m, s, index, g) {
return chain.handleSubmitSegmentResult({
messageId: m.messageId,
channelId: channel.id,
submitId: s.submitId,
gatewayMessageId: g,
sequenceId: index,
segmentIndex: index,
segmentTotal: 2,
submitStatus: 'accepted',
});
}
// 1. Two attempts reuse one supplier ID: only target attempt should change.
{
const { m, s } = await fixture();
const g = key();
await segment(m, s, 1, g);
const ch2 = await db.smsChannel.create({
data: {
name: '隔离通道2',
code: key(),
gatewayHost: '127.0.0.1',
gatewayPort: 2,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
carriers: ['mobile'],
status: 'disabled',
},
});
const other = await db.smsSubmitRecord.create({
data: { messageRecordId: m.id, channelId: ch2.id, submitId: key(), submitStatus: 'accepted' },
});
await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: m.id,
submitRecordId: other.id,
submitId: other.submitId,
channelId: ch2.id,
gatewayMessageId: g,
segmentIndex: 1,
segmentTotal: 2,
},
});
const resolved = await chain.resolveReceiptMessage(receipt(m, g));
await chain.handleReceipt(receipt(m, g));
const rows = await db.smsMessageSegmentAudit.findMany({ where: { messageRecordId: m.id } });
assert.equal(rows.filter((r) => r.receiptStatus === 'delivered').length, 1);
assert.equal(resolved.channelId, channel.id);
assert.equal(rows.find((r) => r.submitRecordId === other.id).receiptStatus, null);
results.push({
passed: 'cross_attempt_update',
updatedAttempts: 1,
expected: 1,
matchedWrongChannel: resolved.channelId !== channel.id,
});
}
// 2. Final failure preserves accounting and downstream result despite contradictory success.
{
const { m, s } = await fixture(true);
const gs = [key(), key()];
await segment(m, s, 1, gs[0]);
await segment(m, s, 2, gs[1]);
await db.smsMessageRecord.update({
where: { id: m.id },
data: { status: 'failed', receiptStatus: 'undelivered', amountCents: 100n },
});
await db.smsBillingRecord.create({
data: {
tenantId: tenant.id,
applicationId: app.id,
messageId: m.messageId,
phoneNumber: m.phoneNumber,
contentLength: 180,
billingUnits: 2,
unitPrice: 50n,
amountCents: 100n,
billingStatus: 'refunded',
},
});
await db.cmppDownstreamDelivery.create({
data: {
tenantId: tenant.id,
applicationId: app.id,
messageRecordId: m.id,
messageId: m.messageId,
dedupeKey: 'receipt:' + m.id,
deliveryType: 'receipt',
payload: { receiptStatus: 'undelivered' },
status: 'delivered',
},
});
await chain.handleReceipt(receipt(m, gs[0]));
await chain.handleReceipt(receipt(m, gs[1]));
const updated = await db.smsMessageRecord.findUnique({ where: { id: m.id } });
const bill = await db.smsBillingRecord.findFirst({ where: { messageId: m.messageId } });
const notice = await db.cmppDownstreamDelivery.findUnique({ where: { dedupeKey: 'receipt:' + m.id } });
assert.equal(updated.status, 'failed');
assert.equal(await db.smsReceiptAnomaly.count({ where: { messageRecordId: m.id } }), 1);
assert.equal(
await db.smsMessageSegmentAudit.count({ where: { messageRecordId: m.id, receiptStatus: 'delivered' } }),
0,
);
assert.equal(bill.billingStatus, 'refunded');
assert.equal(notice.payload.receiptStatus, 'undelivered');
results.push({
passed: 'contradictory_final',
message: updated.status,
billing: bill.billingStatus,
notice: notice.payload.receiptStatus,
});
}
// 3. Second-fragment receipt precedes its SubmitSegmentResult metadata.
{
const { m, s } = await fixture();
const gs = [key(), key()];
await segment(m, s, 1, gs[0]);
await assert.rejects(() => chain.handleReceipt(receipt(m, gs[1])), /提交尝试关联/);
await segment(m, s, 2, gs[1]);
await chain.handleReceipt(receipt(m, gs[1]));
await chain.handleReceipt(receipt(m, gs[0]));
const rows = await db.smsMessageSegmentAudit.findMany({
where: { messageRecordId: m.id },
orderBy: { segmentIndex: 'asc' },
});
const updated = await db.smsMessageRecord.findUnique({ where: { id: m.id } });
const count = await db.smsReceiptRecord.count({ where: { messageRecordId: m.id, receiptStatus: 'delivered' } });
assert.equal(count, 2);
assert.equal(updated.status, 'delivered');
results.push({
passed: 'early_fragment_defers_then_recovers',
receiptFacts: count,
message: updated.status,
segments: rows.map((r) => ({ index: r.segmentIndex, status: r.receiptStatus })),
});
}
// 4. Late failed fragments must not repeat route decisions after final failure.
{
const { m, s } = await fixture(true);
const task = await db.smsBatchTask.create({
data: { tenantId: tenant.id, applicationId: app.id, taskNo: key(), content: '隔离', phoneTotal: 1 },
});
await db.smsMessageRecord.update({ where: { id: m.id }, data: { batchTaskId: task.id } });
const gs = [key(), key()];
await segment(m, s, 1, gs[0]);
await segment(m, s, 2, gs[1]);
const priorFind = chain.findApplicationRoute,
priorSelect = chain.selectChannelForMessage;
let selections = 0;
chain.findApplicationRoute = async () => ({ group: { retryEnabled: true, retryTimeLimitMinutes: 60 } });
chain.selectChannelForMessage = async () => {
selections++;
throw new (require('@nestjs/common').BadRequestException)('isolated no route');
};
try {
await chain.handleReceipt(receipt(m, gs[0], 'undelivered'));
const afterFirst = await db.smsMessageRecord.findUnique({ where: { id: m.id } });
assert.equal(afterFirst.status, 'failed');
await Promise.all(Array.from({ length: 8 }, () => chain.handleReceipt(receipt(m, gs[1], 'undelivered'))));
assert.equal(selections, 1);
results.push({
passed: 'repeated_terminal_routing',
routeSelections: selections,
statusAfterFirst: afterFirst.status,
routingBoundaryIsolated: true,
});
} finally {
chain.findApplicationRoute = priorFind;
chain.selectChannelForMessage = priorSelect;
}
}
// 5. Same-channel collision between a primary ID and another attempt's fragment is ambiguous.
{
const { m, s } = await fixture();
const g = key();
await segment(m, s, 1, g);
const other = await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
channelId: channel.id,
submitId: key(),
gatewayMessageId: g,
submitStatus: 'accepted',
},
});
await assert.rejects(() => chain.handleReceipt(receipt(m, g)), /提交尝试关联/);
assert.equal(await db.smsReceiptRecord.count({ where: { messageRecordId: m.id } }), 0);
assert.equal(
await db.smsMessageSegmentAudit.count({ where: { messageRecordId: m.id, receiptStatus: 'delivered' } }),
0,
);
results.push({ passed: 'ambiguous_primary_fragment_rejected', otherAttempt: other.id });
}
// 6. Unknown is not a final failure; all actual fragments may still complete it.
{
const { m, s } = await fixture();
const gs = [key(), key()];
await segment(m, s, 1, gs[0]);
await segment(m, s, 2, gs[1]);
await db.smsMessageRecord.update({ where: { id: m.id }, data: { status: 'unknown' } });
await Promise.all(gs.map((g) => chain.handleReceipt(receipt(m, g))));
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
results.push({ passed: 'unknown_and_concurrent_fragments_recover' });
}
// 7. Late submit rejection cannot reopen a final failure or create a successor.
{
const { m, s } = await fixture();
const g = key();
await segment(m, s, 1, g);
await db.smsMessageRecord.update({ where: { id: m.id }, data: { status: 'failed', receiptStatus: 'undelivered' } });
await chain.handleSubmitResult({
messageId: m.messageId,
channelId: channel.id,
submitId: s.submitId,
gatewayMessageId: g,
submitStatus: 'rejected',
});
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'failed');
assert.equal(await db.smsSubmitRecord.count({ where: { messageRecordId: m.id } }), 1);
results.push({ passed: 'late_submit_rejection_preserves_final' });
}
// 8. Legacy audits missing the FK must join the verified attempt before aggregation.
{
const { m, s } = await fixture();
const gs = [key(), key()];
await segment(m, s, 1, gs[0]);
await segment(m, s, 2, gs[1]);
await db.smsMessageSegmentAudit.updateMany({ where: { messageRecordId: m.id }, data: { submitRecordId: null } });
for (const g of gs) await chain.handleReceipt(receipt(m, g));
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
assert.equal(await db.smsMessageSegmentAudit.count({ where: { messageRecordId: m.id, submitRecordId: s.id } }), 2);
results.push({ passed: 'legacy_fragment_relation_recovered' });
}
console.log(JSON.stringify(results));
} finally {
await db.$disconnect();
}
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(
['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_unique_'),
'isolated local database required',
);
const redisUrl = new URL(process.env.SIGNATURE_TEST_REDIS_URL || 'redis://127.0.0.1:16436');
assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname), 'isolated local Redis required');
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.toString(),
REDIS_URL: redisUrl.toString(),
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
MINIO_ENDPOINT: '127.0.0.1:19400',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
SIGNATURE_ANALYTICS_ENABLED: 'false',
HOME_DASHBOARD_ENABLED: 'false',
});
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
Object.defineProperty(BigInt.prototype, 'toJSON', {
value() {
return Number(this);
},
configurable: true,
});
const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('./dist/app.module');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SmsConfigService } = require('./dist/sms-config/sms-config.service');
const { SessionService } = require('./dist/auth/session.service');
const { UsersService } = require('./dist/users/users.service');
const { ReportImportReviewService } = require('./dist/report-materials/import-review.service');
const { FilesService } = require('./dist/files/files.service');
const { ReportImportParserService } = require('./dist/report-materials/import-parser.service');
const { SignatureNameConflict } = require('./dist/sms-config/signature-uniqueness');
const { Client } = require('pg');
const pass = (name) => console.log('PASS', name);
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.setGlobalPrefix('api');
try {
const db = app.get(PrismaService),
sms = app.get(SmsConfigService);
assert.equal(await db.smsSignature.count(), 0, 'fresh database required; do not delete existing data');
await app.listen(0, '127.0.0.1');
const base = (await app.getUrl()) + '/api';
const stamp = randomUUID().slice(0, 8);
const tenant = await db.tenant.create({ data: { name: '签名唯一性隔离企业', code: stamp } });
const other = await db.tenant.create({ data: { name: '另一个隔离企业', code: stamp + 'b' } });
const makeApplication = (n) =>
db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '隔离应用' + n,
cmppAccount: stamp + n,
cmppEnterpriseCode: 'test',
secretHash: 'unused',
interfaceEnabled: false,
},
});
const a = await makeApplication('a'),
b = await makeApplication('b');
const users = app.get(UsersService),
sessions = app.get(SessionService);
const admin = await users.create({
username: stamp,
email: stamp + '@example.invalid',
displayName: '隔离审核员',
password: randomBytes(24).toString('hex'),
roleCode: 'platform_admin',
});
const client = await users.create({
username: stamp + 'c',
email: stamp + 'c@example.invalid',
displayName: '隔离客户',
password: randomBytes(24).toString('hex'),
roleCode: 'enterprise_admin',
tenantId: tenant.id,
});
async function headersFor(user, portal) {
const session = await sessions.create(user.id, portal, 0);
return { 'content-type': 'application/json', cookie: `${sessions.cookieName(portal)}=${session.token}` };
}
const adminHeaders = await headersFor(admin, 'admin'),
clientHeaders = await headersFor(client, 'client');
const body = { tenantId: tenant.id, applicationId: a.id, name: '【唯一验证】' };
const request = (path, data, headers = adminHeaders, method = 'POST') =>
fetch(base + path, { method, headers, body: JSON.stringify(data) });
const create = (data, headers) => request('/admin/enterprise-signatures', data, headers);
assert.equal((await create(body, {})).status, 401);
const first = await create(body);
assert.equal(first.status, 201);
const signature = await first.json();
const duplicate = await create(body);
assert.equal(duplicate.status, 409);
assert.match((await duplicate.json()).message, /同名有效签名/);
const clientBody = { applicationId: a.id, name: body.name };
assert.equal((await request('/client/signatures', clientBody, clientHeaders)).status, 409);
assert.equal(
(await request('/client/signatures', clientBody, { ...clientHeaders, 'x-tenant-id': other.id })).status,
403,
);
assert.equal((await request('/client/signatures', { name: '无括号' }, clientHeaders)).status, 400);
pass('admin/client HTTP duplicate 409, authentication, tenant header isolation and format validation');
const unbound = await sms.createSignature({ tenantId: tenant.id, name: body.name });
await assert.rejects(() => sms.createSignature({ tenantId: tenant.id, name: body.name }), SignatureNameConflict);
const secondApp = await sms.createSignature({ ...body, applicationId: b.id });
await sms.createSignature({ tenantId: other.id, name: body.name });
await sms.updateSignature(signature.id, { name: body.name, purpose: '保留用途' });
await assert.rejects(() => sms.updateSignature(secondApp.id, { applicationId: a.id }), SignatureNameConflict);
await assert.rejects(() => sms.updateSignature(secondApp.id, { applicationId: null }), SignatureNameConflict);
const renamed = await sms.createSignature({ ...body, name: '【另一个名称】' });
assert.equal(
(await request('/admin/enterprise-signatures/' + renamed.id, { name: body.name }, adminHeaders, 'PUT')).status,
409,
);
await assert.rejects(
() => sms.updateSignature(signature.id, { purpose: '禁止跨租户' }, other.id),
/Signature not found/,
);
pass('self edit, rename, application change, null application scope and cross-tenant separation');
await sms.changeSignatureStatus(unbound.id, { status: 'disabled' });
const replacement = await sms.createSignature({ tenantId: tenant.id, name: body.name });
for (const restore of [
() => sms.changeSignatureStatus(unbound.id, { status: 'approved' }),
() => sms.approveSignature(unbound.id, { reviewerId: admin.id }),
() => sms.submitSignature(unbound.id),
]) {
await assert.rejects(restore, SignatureNameConflict);
}
await sms.changeSignatureStatus(replacement.id, { status: 'deleted' });
await sms.changeSignatureStatus(unbound.id, { status: 'approved' });
pass('disable/delete release names; status restoration, review and submit cannot bypass uniqueness');
const races = await Promise.all(Array.from({ length: 12 }, () => create({ ...body, name: '【并发名称】' })));
assert.equal(races.filter((r) => r.status === 201).length, 1);
assert.equal(races.filter((r) => r.status === 409).length, 11);
assert.equal(
await db.smsSignature.count({ where: { tenantId: tenant.id, applicationId: a.id, name: '【并发名称】' } }),
1,
);
for (const applicationId of [a.id, null]) {
await assert.rejects(
() => db.smsSignature.create({ data: { tenantId: tenant.id, applicationId, name: body.name } }),
(e) => e.code === 'P2002',
);
}
pass('12 concurrent real HTTP creates produce one row; both database indexes reject direct writes');
const files = app.get(FilesService),
importer = new ReportImportReviewService(db, files, sms, new ReportImportParserService(db, files, sms));
await sms.updateSignature(signature.id, {
drainageInfo: {
signatureReportValues: { old: '必须保留', changed: '旧值' },
links: [{ url: 'https://example.invalid' }],
},
});
const mappings = [
{
sourceHeader: '签名',
sourceColumnIndex: 0,
targetFieldCode: 'name',
targetKind: 'signatureName',
fieldType: 'string',
},
{
sourceHeader: '新资料',
sourceColumnIndex: 1,
targetFieldCode: 'changed',
targetKind: 'dynamic',
fieldType: 'string',
},
];
const staged = await importer.stageSignatureRow(tenant.id, a.id, mappings, { name: body.name, changed: '新值' });
assert.equal(staged.operation, 'update');
assert.equal(staged.targetId, signature.id);
const batch = { tenantId: tenant.id, applicationId: a.id, reportType: 'signature' };
const item = { reportType: 'signature', targetId: staged.targetId, payload: staged.payload };
assert.equal(await importer.applyImportItem(batch, item, admin.id), signature.id);
const saved = await db.smsSignature.findUniqueOrThrow({ where: { id: signature.id } });
assert.equal(saved.purpose, '保留用途');
assert.equal(saved.drainageInfo.signatureReportValues.old, '必须保留');
assert.equal(saved.drainageInfo.signatureReportValues.changed, '新值');
assert.equal(saved.drainageInfo.links.length, 1);
assert.equal(await importer.applyImportItem(batch, { ...item, targetId: null }, admin.id), signature.id);
// A stopped historical duplicate must not be chosen over the effective signature.
await db.smsSignature.create({ data: { ...body, auditStatus: 'disabled' } });
assert.equal(
(await importer.stageSignatureRow(tenant.id, a.id, mappings, { name: body.name })).targetId,
signature.id,
);
pass('batch staging/apply preserves ID, unmapped materials, purpose and links; delayed create becomes update');
const concurrentItem = { ...item, targetId: null, payload: { ...item.payload, name: '【并发导入】' } };
const importedIds = await Promise.all(
Array.from({ length: 3 }, () => importer.applyImportItem(batch, concurrentItem, admin.id)),
);
assert.equal(new Set(importedIds).size, 1);
assert.equal(
await db.smsSignature.count({ where: { tenantId: tenant.id, applicationId: a.id, name: '【并发导入】' } }),
1,
);
const { writeUniqueSignature } = require('./dist/sms-config/signature-uniqueness');
const raceIdentity = { ...body, name: '【数据库竞态】' };
await assert.rejects(
() =>
writeUniqueSignature(db, raceIdentity, async () => {
await db.smsSignature.create({ data: raceIdentity });
return db.smsSignature.create({ data: raceIdentity });
}),
SignatureNameConflict,
);
pass('concurrent batch imports converge to one signature and real P2002 maps to HTTP conflict');
// Exercise migration failure on a session-local shadow table, never modify application history.
const pg = new Client({ connectionString: url.toString() });
await pg.connect();
try {
await pg.query(
'CREATE TEMP TABLE "SmsSignature" ("tenantId" text, "applicationId" text, "name" text, "auditStatus" text)',
);
await pg.query(
`INSERT INTO "SmsSignature" VALUES ('history', NULL, 'duplicate', 'approved'), ('history', NULL, 'duplicate', 'pending')`,
);
const sql = readFileSync(
new URL('../../api/prisma/migrations/20260917120000_signature_active_name_unique/migration.sql', import.meta.url),
'utf8',
);
await assert.rejects(() => pg.query(sql), /duplicate active signatures exist/);
await pg.query('ROLLBACK');
assert.equal((await pg.query('SELECT count(*) FROM "SmsSignature"')).rows[0].count, '2');
assert.equal(
(await pg.query("SELECT count(*) FROM pg_indexes WHERE schemaname LIKE 'pg_temp_%' AND tablename='SmsSignature'"))
.rows[0].count,
'0',
);
} finally {
await pg.end();
}
pass('historical duplicates block migration and roll back without modifying records');
console.log('Signature uniqueness acceptance complete; no SMS, external notifications or live environment writes.');
} finally {
await app.close();
}
+400
View File
@@ -0,0 +1,400 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { writeFileSync } from 'node:fs';
const url = new URL(process.env.OPT_OUT_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_optout_'));
const redis = new URL(process.env.OPT_OUT_TEST_REDIS_URL || 'redis://127.0.0.1:16436');
assert(['127.0.0.1', 'localhost'].includes(redis.hostname));
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.toString(),
REDIS_URL: redis.toString(),
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
MINIO_ENDPOINT: '127.0.0.1:19400',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
SIGNATURE_ANALYTICS_ENABLED: 'false',
HOME_DASHBOARD_ENABLED: 'false',
SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED: 'true',
});
// No workers/publishers/Gateway are started. Commands stay in this isolated database.
const require = createRequire(new URL('../../api/package.json', import.meta.url));
Object.assign(process.env, {
GATEWAY_STARTUP_RECONNECT_DELAY_MS: '3600000',
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
CMPP_PROCESS_ROLE: 'api',
API_ENABLE_SEND_WORKER: 'false',
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
});
require('reflect-metadata');
Object.defineProperty(BigInt.prototype, 'toJSON', {
value() {
return Number(this);
},
configurable: true,
});
const { NestFactory } = require('@nestjs/core'),
{ AppModule } = require('./dist/app.module');
const { PrismaService } = require('./dist/prisma/prisma.service'),
{ SessionService } = require('./dist/auth/session.service');
const { UsersService } = require('./dist/users/users.service'),
{ SendChainService } = require('./dist/send-chain/send-chain.service');
const { completionContext } = require('./dist/send-chain/completion-context');
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.setGlobalPrefix('api');
const pass = (name) => console.log('PASS', name);
try {
await app.listen(Number(process.env.OPT_OUT_TEST_PORT || 0), '127.0.0.1');
const base = (await app.getUrl()) + '/api',
db = app.get(PrismaService),
chain = app.get(SendChainService);
const stamp = randomUUID().slice(0, 8),
key = () => randomUUID();
const tenant = await db.tenant.create({ data: { name: '拒收策略验收企业', code: key() } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '拒收策略隔离应用',
cmppAccount: key(),
cmppEnterpriseCode: '000001',
secretHash: 'unused',
interfaceEnabled: false,
templateMismatchMode: 'direct_send',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: application.id, name: '【拒收验收】', auditStatus: 'approved' },
});
const content = signature.name + '文'.repeat(65); // 71 characters, two parts.
assert.equal(content.length, 71);
const template = await db.smsTemplate.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
name: '拒收策略验收模板' + stamp,
content,
auditStatus: 'approved',
billingUnits: 2,
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离通道甲' + stamp,
code: key(),
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
status: 'active',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
sendRegion: '全国',
unitPrice: 325,
config: { serviceId: 'SMS' },
},
});
const backup = await db.smsChannel.create({
data: {
name: '隔离通道乙',
code: key(),
carrier: 'mobile',
carriers: ['mobile'],
status: 'active',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
sendRegion: '全国',
unitPrice: 325,
config: { serviceId: 'SMS' },
},
});
for (const c of [channel, backup]) {
await db.cmppConnectionState.create({
data: { channelId: c.id, connectionId: key(), status: 'connected', currentConnections: 1 },
});
await db.channelSignatureReportTask.create({
data: {
tenantId: tenant.id,
signatureId: signature.id,
channelId: c.id,
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
},
});
}
const group = await db.smsChannelGroup.create({
data: {
code: key(),
name: '隔离移动组',
carrier: 'mobile',
items: {
create: [
{ channelId: channel.id, carrier: 'mobile', priority: 1 },
{ channelId: backup.id, carrier: 'mobile', priority: 2 },
],
},
},
});
const unicom = await db.smsChannelGroup.create({
data: {
code: key(),
name: '隔离联通组',
carrier: 'unicom',
items: { create: { channelId: channel.id, carrier: 'unicom' } },
},
});
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile' },
});
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: unicom.id, carrier: 'unicom' },
});
const users = app.get(UsersService),
sessions = app.get(SessionService);
const user = await users.create({
username: 'optout' + stamp,
email: stamp + '@example.invalid',
displayName: '隔离运营验收',
password: randomBytes(24).toString('hex'),
roleCode: 'platform_admin',
});
const session = await sessions.create(user.id, 'admin', 0),
cookie = sessions.cookieName('admin');
const headers = { 'content-type': 'application/json', cookie: cookie + '=' + session.token };
const req = (path, body, method = body === undefined ? 'GET' : 'PUT', head = headers) =>
fetch(base + path, { method, headers: head, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const path = '/admin/enterprise-templates/' + template.id + '/opt-out-policy';
assert.equal((await req(path, undefined, 'GET', {})).status, 401);
const customer = await users.create({
username: 'client' + stamp,
email: 'c' + stamp + '@example.invalid',
displayName: '隔离客户',
password: randomBytes(24).toString('hex'),
roleCode: 'enterprise_admin',
tenantId: tenant.id,
});
const clientSession = await sessions.create(customer.id, 'client', 0);
assert.equal(
(await req(path, undefined, 'GET', { cookie: sessions.cookieName('client') + '=' + clientSession.token })).status,
401,
);
assert.equal((await req(path, { rules: [], preserveFragments: false })).status, 400);
assert.equal(
(await req(path, { rules: [{ channelId: 'foreign', action: 'add' }], preserveFragments: true })).status,
400,
);
const rules = [
{ channelId: channel.id, action: 'add' },
{ channelId: backup.id, action: 'remove' },
];
assert.equal((await req(path, { rules, preserveFragments: true })).status, 200);
assert.deepEqual((await req(path).then((r) => r.json())).rules, rules);
assert.equal(
await db.operationLog.count({ where: { resourceId: template.id, action: 'sms_template.opt_out_policy.update' } }),
1,
);
pass('real HTTP policy save/read, authentication, scope, mandatory fragment protection and durable audit');
const reduced = await req('/admin/channels/' + channel.id, { carriers: ['mobile'] });
assert.equal(reduced.status, 200, await reduced.text());
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, ['mobile']);
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: unicom.id, channelId: channel.id } }), 1);
pass('carrier reduction saves with active group references preserved');
const batch = await db.smsBatchTask.create({
data: { tenantId: tenant.id, applicationId: application.id, taskNo: key(), content, phoneTotal: 1 },
});
const message = await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
batchTaskId: batch.id,
messageId: key(),
phoneNumber: '13800138000',
carrier: 'mobile',
content,
billingUnits: 2,
unitPrice: 425,
amountCents: 850,
},
});
// No templateId intentionally: direct_send must still match its template.
const route = await chain.selectChannelForMessage(message);
assert.equal(route.contentPolicy.content, content + '拒收请回复R');
await chain.submitMessageToGateway(message, route, 0);
const first = await db.smsSubmitRecord.findFirstOrThrow({ where: { messageRecordId: message.id } });
const written = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const outbox = await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } });
assert.equal(written.originalContent, content);
assert.equal(written.content, outbox.payload.content);
assert.equal(first.sentContent, written.content);
assert.equal(written.billingUnits, 2);
assert.equal(Number(written.amountCents), 850);
assert.equal(Number(first.costAmountCents), 650);
const route2 = await chain.selectChannelForMessage(written, { excludeChannelIds: [channel.id] });
await chain.submitMessageToGateway(written, route2, 1, first.id);
const retried = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
assert.equal(retried.content, content);
assert.equal(retried.originalContent, content);
assert.equal(
(await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } })).payload.content,
content + '拒收请回复R',
);
assert.equal(await db.gatewaySubmitOutbox.count({ where: { messageRecordId: message.id, status: 'pending' } }), 2);
pass('single submit and alternate-channel retry: real transactional content/Outbox snapshots and unchanged charges');
const before = await db.smsSubmitRecord.count();
await assert.rejects(
db.$transaction((tx) =>
completionContext.run({ tx, messageRecordId: message.id }, async () => {
await chain.submitMessageToGateway(retried, route, 2);
throw new Error('rollback verification');
}),
),
/rollback verification/,
);
assert.equal(await db.smsSubmitRecord.count(), before);
assert.equal((await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } })).content, content);
pass('failed transaction rolls back content, submit and Outbox together');
const gateway = chain.submission.gatewaySubmit;
const many = [];
for (let i = 0; i < 2; i++)
many.push(
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
batchTaskId: batch.id,
messageId: key(),
phoneNumber: '13800138000',
carrier: 'mobile',
content,
billingUnits: 2,
},
}),
);
await gateway.processSendJobBatch(many.map((m) => ({ messageRecordId: m.id })));
for (const m of many) {
const row = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: m.id } });
assert.equal(row.content, content + '拒收请回复R');
assert.equal(row.originalContent, content);
}
pass('microbatch uses the same policy and persists original text');
for (const state of [
{ status: 'delivered', submitStatus: 'accepted', receiptStatus: 'delivered' },
{ status: 'submit_failed', submitStatus: 'rejected' },
{ status: 'failed', submitStatus: 'accepted', receiptStatus: 'undelivered' },
{ status: 'timeout', submitStatus: 'accepted' },
]) {
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
messageId: key(),
phoneNumber: '13800138000',
content,
...state,
},
});
}
const quality = await req('/admin/operations/signature-quality?keyword=' + encodeURIComponent(tenant.name)).then(
(r) => r.json(),
);
const stat = quality.items.find((i) => i.signatureId === signature.id);
assert.equal(stat.total, stat.successCount + stat.submitFailureCount + stat.failureCount + stat.unknownCount);
assert.equal(stat.failureCount, 1);
assert(stat.unknownCount >= 1);
pass('real quality query yields four exclusive categories, including timeout without receipt');
if (process.env.OPT_OUT_TEST_BROWSER === 'true') {
const {
chromium,
} = require('C:/Users/hectorzhao/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
const ui = process.env.OPT_OUT_TEST_UI_URL || 'http://127.0.0.1:17438';
assert.equal(new URL(ui).hostname, '127.0.0.1');
const auth = await req('/admin/auth/session').then((r) => r.json());
await page
.context()
.addCookies([{ name: cookie, value: session.token, url: ui, httpOnly: true, sameSite: 'Lax' }]);
await page.addInitScript((data) => localStorage.setItem('cmpp-auth-session:admin', JSON.stringify(data)), auth);
for (const [width, height] of [
[1600, 1000],
[1366, 768],
[390, 844],
]) {
await page.setViewportSize({ width, height });
await page.goto(ui + '/#/admin/enterprise-templates');
const row = page.locator('.admin-enterprise-template-row').filter({ hasText: template.name }).first();
await row.getByRole('button', { name: '拒收指令', exact: true }).click();
await page.getByLabel(channel.name + '的拒收指令').waitFor();
assert(await page.getByLabel('避免影响消息分片数').isDisabled());
await page.screenshot({ path: `.local-data/template-optout-20260920/template-${width}.png`, fullPage: true });
await page.getByRole('button', { name: '保存策略', exact: true }).click();
await page.getByRole('heading', { name: '模板拒收指令', exact: true }).waitFor({ state: 'hidden' });
await page.goto(ui + '/#/admin/analytics');
await page.locator('.quality-status-bar__track').first().waitFor();
assert.match(await page.locator('.quality-status-bar__track').first().getAttribute('title'), /未收到回执/);
await page.screenshot({ path: `.local-data/template-optout-20260920/quality-${width}.png`, fullPage: true });
await page.reload();
await page.locator('.quality-status-bar__track').first().waitFor();
await page.goto(ui + '/#/admin/sms-records');
await page
.locator('.admin-sms-record-card')
.filter({ hasText: '拒收请回复R' })
.first()
.getByRole('button', { name: '查看发送详情' })
.click();
await page.getByRole('heading', { name: '原始短信内容', exact: true }).waitFor();
await page.getByRole('heading', { name: /第 1 次提交通道内容/ }).waitFor();
await page.screenshot({ path: `.local-data/template-optout-20260920/detail-${width}.png`, fullPage: true });
await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'unicom'] });
await page.goto(ui + '/#/admin/channels');
await page.getByLabel('通道名称', { exact: true }).fill(channel.name);
await page.getByRole('button', { name: '查询', exact: true }).click();
await page
.locator('.sms-channel-table__row')
.filter({ hasText: channel.name })
.getByRole('button', { name: '编辑', exact: true })
.click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor();
await page.getByRole('checkbox', { name: '联通', exact: true }).uncheck();
await page.screenshot({ path: `.local-data/template-optout-20260920/channel-${width}.png`, fullPage: true });
await page.getByRole('button', { name: '确认', exact: true }).click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor({ state: 'hidden' });
assert.deepEqual((await db.smsChannel.findUniqueOrThrow({ where: { id: channel.id } })).carriers, ['mobile']);
}
assert.deepEqual(errors, []);
pass('real API browser saves, locked checkbox, route/refresh, four-segment bar and three sizes');
} finally {
await browser.close();
}
}
assert.equal(
await db.gatewaySubmitOutbox.count({
where: { messageRecordId: { in: [message.id, ...many.map((m) => m.id)] }, status: { not: 'pending' } },
}),
0,
);
writeFileSync(
'.local-data/template-optout-20260920/fixture.json',
JSON.stringify({ tenantId: tenant.id, templateId: template.id, messageId: message.id }),
);
pass('no command published, no Gateway/SMSC started, no external SMS sent');
} finally {
await app.close();
}