feat: add access number routing and admin list improvements

This commit is contained in:
hectorzhao
2026-07-15 15:45:27 +08:00
parent 27c464246b
commit 3e114ee729
19 changed files with 547 additions and 508 deletions
@@ -0,0 +1,12 @@
ALTER TABLE "SmsApplication"
ADD COLUMN "cmppApplicationExtension" TEXT,
ADD COLUMN "cmppAccessNumberFillEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "cmppAccessNumberFillPrefix" TEXT,
ADD COLUMN "cmppClientSrcId" TEXT;
CREATE UNIQUE INDEX "SmsApplication_cmppClientSrcId_key"
ON "SmsApplication"("cmppClientSrcId");
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "clientSrcId" TEXT,
ADD COLUMN "applicationExtension" TEXT;
+6
View File
@@ -355,6 +355,10 @@ model SmsApplication {
callbackUrl String? callbackUrl String?
cmppAccount String @unique cmppAccount String @unique
cmppEnterpriseCode String cmppEnterpriseCode String
cmppApplicationExtension String?
cmppAccessNumberFillEnabled Boolean @default(false)
cmppAccessNumberFillPrefix String?
cmppClientSrcId String? @unique
secretHash String secretHash String
interfaceEnabled Boolean @default(true) interfaceEnabled Boolean @default(true)
interfaceType String @default("cmpp20") interfaceType String @default("cmpp20")
@@ -1007,6 +1011,8 @@ model SmsMessageRecord {
submitId String? submitId String?
gatewayMessageId String? gatewayMessageId String?
cmppSubmitSequenceId String? cmppSubmitSequenceId String?
clientSrcId String?
applicationExtension String?
status String @default("queued") status String @default("queued")
submitStatus String? submitStatus String?
receiptStatus String? receiptStatus String?
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { SendChainService } from '../send-chain/send-chain.service'; import { SendChainService } from '../send-chain/send-chain.service';
import { import {
BatchReviewSmsTasksDto,
CreateRiskRuleDto, CreateRiskRuleDto,
ReviewSmsTaskDto, ReviewSmsTaskDto,
RiskReviewService, RiskReviewService,
@@ -44,6 +45,16 @@ export class AdminRiskReviewController {
return task; return task;
} }
@Post('tasks/batch/reject')
async rejectTasks(@Body() body: BatchReviewSmsTasksDto) {
const tasks = await this.riskReview.rejectTasks(body);
const reason = body.reason?.trim() ?? '';
for (const task of tasks) {
await this.sendChain.handleReviewDecision(task.id, 'rejected', reason);
}
return tasks;
}
@Post('tasks/:id/reject') @Post('tasks/:id/reject')
async rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) { async rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
const task = await this.riskReview.rejectTask(taskId, body); const task = await this.riskReview.rejectTask(taskId, body);
@@ -92,6 +92,34 @@ describe('RiskReviewService', () => {
expect(prisma.smsSendTask.update).not.toHaveBeenCalled(); expect(prisma.smsSendTask.update).not.toHaveBeenCalled();
}); });
it('batch rejects unique tasks with one required rejection reason', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findUnique.mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve({
id: where.id,
status: 'pending_review',
reviewReason: '命中风控',
}));
prisma.smsSendTask.update.mockImplementation(({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => Promise.resolve({
id: where.id,
...data,
riskHits: [],
}));
const service = new RiskReviewService(prisma as never);
await expect(service.rejectTasks({ ids: ['task-1', 'task-2', 'task-1'], reason: '批量人工拒绝' })).resolves.toEqual([
expect.objectContaining({ id: 'task-1', status: 'rejected', rejectReason: '批量人工拒绝' }),
expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }),
]);
expect(prisma.smsSendTask.update).toHaveBeenCalledTimes(2);
});
it('requires task ids and a reason for batch rejection', async () => {
const service = new RiskReviewService(createPrismaMock() as never);
await expect(service.rejectTasks({ ids: [], reason: '拒绝' })).rejects.toThrow('At least one SMS send task id is required');
await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required');
});
it('rejects tasks over the application max phone threshold', async () => { it('rejects tasks over the application max phone threshold', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 }); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 });
@@ -33,6 +33,10 @@ export interface ReviewSmsTaskDto {
reason?: string; reason?: string;
} }
export interface BatchReviewSmsTasksDto extends ReviewSmsTaskDto {
ids: string[];
}
export interface AggregateTemplateMismatchDto { export interface AggregateTemplateMismatchDto {
tenantId: string; tenantId: string;
applicationId: string; applicationId: string;
@@ -366,6 +370,25 @@ export class RiskReviewService {
}); });
} }
async rejectTasks(data: BatchReviewSmsTasksDto) {
const taskIds = [...new Set((data.ids ?? []).map((id) => id.trim()).filter(Boolean))];
if (taskIds.length === 0) {
throw new BadRequestException('At least one SMS send task id is required');
}
if (taskIds.length > 100) {
throw new BadRequestException('A maximum of 100 SMS send tasks can be rejected at once');
}
if (!data.reason?.trim()) {
throw new BadRequestException('Batch rejection reason is required');
}
const rejected = [];
for (const taskId of taskIds) {
rejected.push(await this.rejectTask(taskId, { ...data, reason: data.reason.trim() }));
}
return rejected;
}
private async ensureDefaultRules() { private async ensureDefaultRules() {
for (const rule of DEFAULT_RULES) { for (const rule of DEFAULT_RULES) {
const exists = await this.prisma.riskRule.findFirst({ const exists = await this.prisma.riskRule.findFirst({
@@ -571,6 +571,67 @@ describe('SendChainService', () => {
}); });
}); });
it('accepts only the filled client Src_Id and snapshots the real application extension', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
cmppEnterpriseCode: 'SP0001',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
secretHash: 'secret-hash',
status: 'active',
interfaceEnabled: true,
templateMismatchMode: 'reject',
customerUnitPrice: 3,
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: 'hello',
srcId: '000001',
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ accepted: true }));
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clientSrcId: '000001', applicationExtension: '0001' }),
});
});
it('rejects a client Src_Id that does not match the configured fill prefix and extension', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
status: 'active',
interfaceEnabled: true,
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: 'hello',
srcId: '0001',
remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP Src_Id must equal the access number assigned to this application: 000001');
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
});
it('records an unreported CMPP message and returns success before delivering the template failure receipt', async () => { it('records an unreported CMPP message and returns success before delivering the template failure receipt', async () => {
const { service, prisma, riskReview } = createService(); const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findFirst.mockResolvedValue(null); prisma.smsTemplate.findFirst.mockResolvedValue(null);
@@ -888,6 +949,26 @@ describe('SendChainService', () => {
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything()); expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
}); });
it('appends the real application extension to the upstream channel base number', async () => {
const { service, prisma } = createService();
const queuedMessage = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({
...queuedMessage,
applicationExtension: '0001',
clientSrcId: '000001',
});
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
await service.processSendJob({ messageRecordId: 'record-1' });
expect(gatewayAdd).toHaveBeenCalledWith(
'submit-command',
expect.objectContaining({ cmpp: expect.objectContaining({ srcId: '106900000001' }) }),
);
});
it('routes a partially reported signature only through its approved backup channel', async () => { it('routes a partially reported signature only through its approved backup channel', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
const baseRoute = await prisma.channelRouteRule.findFirst(); const baseRoute = await prisma.channelRouteRule.findFirst();
+67 -4
View File
@@ -282,9 +282,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const phones = [...new Set(data.phones ?? [])]; const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data); const schedule = parseSchedule(data);
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId); await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const messageClassification = await this.resolveTemplateMessageClassification(data.templateId, data.content); const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId); this.resolveTemplateMessageClassification(data.templateId, data.content),
const queuePriority = await this.resolveQueuePriority(data.tenantId, data.applicationId); this.resolveUnitPrice(data.tenantId, data.applicationId),
this.resolveQueuePriority(data.tenantId, data.applicationId),
this.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
]);
const risk = await this.riskReview.evaluateTask({ const risk = await this.riskReview.evaluateTask({
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId,
@@ -376,6 +379,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
unitPrice: billing.unitPrice, unitPrice: billing.unitPrice,
amountCents: billing.billingUnitsPerMessage * billing.unitPrice, amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
queuePriority, queuePriority,
clientSrcId: accessNumber.clientSrcId,
applicationExtension: accessNumber.applicationExtension,
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus, status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined, errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
})), })),
@@ -1686,6 +1691,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) { if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
throw new BadRequestException('CMPP submit phone number is invalid'); throw new BadRequestException('CMPP submit phone number is invalid');
} }
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
const template = await this.resolveInboundTemplateCandidate(application.id, data.content); const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {}; const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
const unitPrice = application.customerUnitPrice ?? 0; const unitPrice = application.customerUnitPrice ?? 0;
@@ -1735,6 +1741,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
amountCents: billing.amountCents, amountCents: billing.amountCents,
queuePriority, queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
clientSrcId,
applicationExtension: application.cmppApplicationExtension,
status: 'validating', status: 'validating',
}, },
}); });
@@ -1966,6 +1974,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
content: string; content: string;
billingUnits: number; billingUnits: number;
queuePriority?: string | null; queuePriority?: string | null;
clientSrcId?: string | null;
applicationExtension?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null; template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
signature?: { id?: string | null; name?: string | null } | null; signature?: { id?: string | null; name?: string | null } | null;
}, },
@@ -1973,6 +1983,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
attempt: number, attempt: number,
) { ) {
const channel = routed.channel; const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
await this.ensureSignatureReportedForChannel(message, channel.id); await this.ensureSignatureReportedForChannel(message, channel.id);
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond); await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
const submitId = `SUB-${randomUUID()}`; const submitId = `SUB-${randomUUID()}`;
@@ -2039,7 +2050,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
? String(channel.config.serviceId) ? String(channel.config.serviceId)
: 'SMS', : 'SMS',
srcId: channel.srcId, srcId: upstreamSrcId,
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0), extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
registeredDelivery: 1, registeredDelivery: 1,
msgFmt: 8, msgFmt: 8,
@@ -2073,6 +2084,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
content: string; content: string;
billingUnits: number; billingUnits: number;
queuedAt?: Date; queuedAt?: Date;
clientSrcId?: string | null;
applicationExtension?: string | null;
}, },
reason: string, reason: string,
) { ) {
@@ -2238,6 +2251,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return normalizeQueuePriority(application.queuePriority); return normalizeQueuePriority(application.queuePriority);
} }
private async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
if (!applicationId) {
return { clientSrcId: null, applicationExtension: null };
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true },
});
if (!application || application.tenantId !== tenantId) {
return { clientSrcId: null, applicationExtension: null };
}
return {
clientSrcId: application.cmppClientSrcId,
applicationExtension: application.cmppApplicationExtension,
};
}
private findInboundApplication(account: string) { private findInboundApplication(account: string) {
return this.prisma.smsApplication.findFirst({ return this.prisma.smsApplication.findFirst({
where: { cmppAccount: account }, where: { cmppAccount: account },
@@ -3096,6 +3126,39 @@ function isProvinceChannel(item: { province?: string | null; channel: { sendRegi
return itemProvince === target || sendRegion === target; return itemProvince === target || sendRegion === target;
} }
function validateInboundApplicationSrcId(
srcId: string | undefined,
application: {
cmppApplicationExtension?: string | null;
cmppAccessNumberFillEnabled?: boolean | null;
cmppAccessNumberFillPrefix?: string | null;
cmppClientSrcId?: string | null;
},
) {
const submittedSrcId = srcId?.trim() ?? '';
const applicationExtension = application.cmppApplicationExtension?.trim() ?? '';
if (!applicationExtension) {
return submittedSrcId || null;
}
const fillPrefix = application.cmppAccessNumberFillEnabled
? application.cmppAccessNumberFillPrefix?.trim() ?? ''
: '';
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`);
}
return submittedSrcId;
}
function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) {
const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`;
if (upstreamSrcId.length > 21) {
throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits');
}
return upstreamSrcId;
}
function positiveInteger(value: string | undefined, fallback: number) { function positiveInteger(value: string | undefined, fallback: number) {
const parsed = Number(value); const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
@@ -10,6 +10,10 @@ function createPrismaMock() {
status: 'active', status: 'active',
cmppAccount: '100001', cmppAccount: '100001',
cmppEnterpriseCode: 'APP-EC', cmppEnterpriseCode: 'APP-EC',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
cmppMaxConnections: 2, cmppMaxConnections: 2,
cmppWindowSize: 32, cmppWindowSize: 32,
interfaceEnabled: true, interfaceEnabled: true,
@@ -24,6 +28,10 @@ function createPrismaMock() {
status: 'active', status: 'active',
cmppAccount: '100001', cmppAccount: '100001',
cmppEnterpriseCode: 'APP-EC', cmppEnterpriseCode: 'APP-EC',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
cmppMaxConnections: 2, cmppMaxConnections: 2,
cmppWindowSize: 32, cmppWindowSize: 32,
interfaceEnabled: true, interfaceEnabled: true,
@@ -225,6 +233,33 @@ describe('SmsConfigService', () => {
})); }));
}); });
it('sorts enterprise applications by today send count descending with a stable name tie-breaker', async () => {
const prisma = createPrismaMock();
const baseApplication = {
tenantId: 'tenant-1',
status: 'active',
interfaceEnabled: true,
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
ipAllowlist: [],
};
prisma.smsApplication.findMany.mockResolvedValue([
{ ...baseApplication, id: 'app-1', name: '乙应用' },
{ ...baseApplication, id: 'app-2', name: '甲应用' },
{ ...baseApplication, id: 'app-3', name: '丙应用' },
]);
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([]);
prisma.smsMessageRecord.groupBy.mockResolvedValue([
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 2 } },
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 2 } },
{ applicationId: 'app-3', status: 'delivered', _count: { _all: 5 } },
]);
const service = new SmsConfigService(prisma as never);
const applications = await service.listApplications({ includeConnections: true });
expect(applications.map((application) => application.id)).toEqual(['app-3', 'app-2', 'app-1']);
});
it('returns CMPP params from persisted application and channel config', async () => { it('returns CMPP params from persisted application and channel config', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never); const service = new SmsConfigService(prisma as never);
@@ -235,6 +270,10 @@ describe('SmsConfigService', () => {
account: '100001', account: '100001',
enterpriseCode: 'APP-EC', enterpriseCode: 'APP-EC',
passwordCipher: '0123456789abcdef', passwordCipher: '0123456789abcdef',
srcId: '000001',
applicationExtension: '0001',
accessNumberFillEnabled: true,
accessNumberFillPrefix: '00',
gatewayHost: '127.0.0.1', gatewayHost: '127.0.0.1',
gatewayPort: 17890, gatewayPort: 17890,
interfaceEnabled: true, interfaceEnabled: true,
@@ -283,6 +322,54 @@ describe('SmsConfigService', () => {
})); }));
}); });
it('persists a filled client Src_Id separately from the real application extension', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
const service = new SmsConfigService(prisma as never);
await service.createApplication({
tenantId: 'tenant-1',
name: '接入号应用',
cmppAccount: '123456',
passwordCipher: '1234567890abcdef',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
});
expect(prisma.smsApplication.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
}),
}));
});
it('rejects access number filling without a numeric prefix and application extension', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue(null);
const service = new SmsConfigService(prisma as never);
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '缺少扩展码',
cmppAccount: '123456',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
})).rejects.toThrow('cmppApplicationExtension is required');
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '错误前缀',
cmppAccount: '123456',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: 'AB',
})).rejects.toThrow('cmppAccessNumberFillPrefix must contain digits only');
});
it('rejects invalid enterprise application queue priority', async () => { it('rejects invalid enterprise application queue priority', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never); const service = new SmsConfigService(prisma as never);
+85 -2
View File
@@ -10,6 +10,9 @@ export interface CreateSmsApplicationDto {
callbackUrl?: string; callbackUrl?: string;
cmppAccount?: string; cmppAccount?: string;
cmppEnterpriseCode?: string; cmppEnterpriseCode?: string;
cmppApplicationExtension?: string;
cmppAccessNumberFillEnabled?: boolean;
cmppAccessNumberFillPrefix?: string;
passwordCipher?: string; passwordCipher?: string;
interfaceEnabled?: boolean; interfaceEnabled?: boolean;
interfaceType?: string; interfaceType?: string;
@@ -206,7 +209,9 @@ export class SmsConfigService {
sentToday: todayTotal, sentToday: todayTotal,
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0, deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
}; };
}); }).sort((left, right) => right.sentToday - left.sentToday
|| left.name.localeCompare(right.name, 'zh-CN')
|| left.id.localeCompare(right.id));
} }
async getApplication(applicationId: string, tenantId?: string) { async getApplication(applicationId: string, tenantId?: string) {
@@ -350,6 +355,8 @@ export class SmsConfigService {
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType); const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount(); const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
const cmppEnterpriseCode = cmppAccount; const cmppEnterpriseCode = cmppAccount;
const accessNumber = normalizeCmppAccessNumberConfig(data);
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId);
return this.prisma.smsApplication.create({ return this.prisma.smsApplication.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
@@ -358,6 +365,10 @@ export class SmsConfigService {
callbackUrl: data.callbackUrl, callbackUrl: data.callbackUrl,
cmppAccount, cmppAccount,
cmppEnterpriseCode, cmppEnterpriseCode,
cmppApplicationExtension: accessNumber.applicationExtension,
cmppAccessNumberFillEnabled: accessNumber.fillEnabled,
cmppAccessNumberFillPrefix: accessNumber.fillPrefix,
cmppClientSrcId: accessNumber.clientSrcId,
secretHash: secret, secretHash: secret,
interfaceEnabled: data.interfaceEnabled ?? true, interfaceEnabled: data.interfaceEnabled ?? true,
interfaceType, interfaceType,
@@ -396,6 +407,15 @@ export class SmsConfigService {
const secretHash = data.passwordCipher === undefined const secretHash = data.passwordCipher === undefined
? undefined ? undefined
: normalizeApplicationPassword(data.passwordCipher); : normalizeApplicationPassword(data.passwordCipher);
const accessNumberChanged = data.cmppApplicationExtension !== undefined
|| data.cmppAccessNumberFillEnabled !== undefined
|| data.cmppAccessNumberFillPrefix !== undefined;
const accessNumber = accessNumberChanged
? normalizeCmppAccessNumberConfig(data, application)
: undefined;
if (accessNumber?.clientSrcId && accessNumber.clientSrcId !== application.cmppClientSrcId) {
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId, applicationId);
}
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
if (data.ipAllowlist) { if (data.ipAllowlist) {
@@ -409,6 +429,10 @@ export class SmsConfigService {
callbackUrl: data.callbackUrl, callbackUrl: data.callbackUrl,
cmppAccount, cmppAccount,
cmppEnterpriseCode, cmppEnterpriseCode,
cmppApplicationExtension: accessNumber?.applicationExtension,
cmppAccessNumberFillEnabled: accessNumber?.fillEnabled,
cmppAccessNumberFillPrefix: accessNumber?.fillPrefix,
cmppClientSrcId: accessNumber?.clientSrcId,
secretHash, secretHash,
interfaceEnabled: data.interfaceEnabled, interfaceEnabled: data.interfaceEnabled,
interfaceType, interfaceType,
@@ -570,7 +594,10 @@ export class SmsConfigService {
enterpriseCode: application.cmppEnterpriseCode, enterpriseCode: application.cmppEnterpriseCode,
account: application.cmppAccount, account: application.cmppAccount,
passwordCipher: application.secretHash, passwordCipher: application.secretHash,
srcId: channel?.srcId ?? '', srcId: application.cmppClientSrcId ?? '',
applicationExtension: application.cmppApplicationExtension,
accessNumberFillEnabled: application.cmppAccessNumberFillEnabled,
accessNumberFillPrefix: application.cmppAccessNumberFillPrefix,
interfaceEnabled: application.interfaceEnabled, interfaceEnabled: application.interfaceEnabled,
interfaceType: application.interfaceType, interfaceType: application.interfaceType,
maxConnections: application.cmppMaxConnections, maxConnections: application.cmppMaxConnections,
@@ -591,6 +618,14 @@ export class SmsConfigService {
return cmppAccount; return cmppAccount;
} }
private async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string) {
if (!clientSrcId) return;
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppClientSrcId: clientSrcId } });
if (exists && exists.id !== currentApplicationId) {
throw new BadRequestException('client CMPP Src_Id already exists');
}
}
private async generateCmppAccount() { private async generateCmppAccount() {
for (let attempt = 0; attempt < 20; attempt += 1) { for (let attempt = 0; attempt < 20; attempt += 1) {
const cmppAccount = String(randomInt(100000, 1000000)); const cmppAccount = String(randomInt(100000, 1000000));
@@ -1422,6 +1457,54 @@ function normalizeApplicationInterfaceType(value?: string): ApplicationInterface
return interfaceType as ApplicationInterfaceType; return interfaceType as ApplicationInterfaceType;
} }
function normalizeCmppAccessNumberConfig(
data: Pick<CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'>,
current?: {
cmppApplicationExtension?: string | null;
cmppAccessNumberFillEnabled?: boolean | null;
cmppAccessNumberFillPrefix?: string | null;
},
) {
const applicationExtension = (
data.cmppApplicationExtension === undefined
? current?.cmppApplicationExtension
: data.cmppApplicationExtension
)?.trim() || null;
const fillEnabled = data.cmppAccessNumberFillEnabled
?? current?.cmppAccessNumberFillEnabled
?? false;
const configuredPrefix = (
data.cmppAccessNumberFillPrefix === undefined
? current?.cmppAccessNumberFillPrefix
: data.cmppAccessNumberFillPrefix
)?.trim() || null;
if (applicationExtension && !/^\d+$/.test(applicationExtension)) {
throw new BadRequestException('cmppApplicationExtension must contain digits only');
}
if (applicationExtension && applicationExtension.length > 21) {
throw new BadRequestException('cmppApplicationExtension must not exceed 21 digits');
}
if (fillEnabled && !applicationExtension) {
throw new BadRequestException('cmppApplicationExtension is required when access number filling is enabled');
}
if (fillEnabled && !configuredPrefix) {
throw new BadRequestException('cmppAccessNumberFillPrefix is required when access number filling is enabled');
}
if (configuredPrefix && !/^\d+$/.test(configuredPrefix)) {
throw new BadRequestException('cmppAccessNumberFillPrefix must contain digits only');
}
const fillPrefix = fillEnabled ? configuredPrefix : null;
const clientSrcId = applicationExtension
? `${fillPrefix ?? ''}${applicationExtension}`
: null;
if (clientSrcId && clientSrcId.length > 21) {
throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits');
}
return { applicationExtension, fillEnabled, fillPrefix, clientSrcId };
}
function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) { function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return fallback; return fallback;
+19
View File
@@ -114,4 +114,23 @@ describe('TenantsService', () => {
_sum: { amountCents: true }, _sum: { amountCents: true },
})); }));
}); });
it('sorts management rows by today spend descending with a stable name tie-breaker', async () => {
const prisma = createPrismaMock();
prisma.tenant.findMany.mockResolvedValue([
{ id: 'tenant-1', name: '乙企业', code: 'T1', status: 'active', enterpriseCertifications: [] },
{ id: 'tenant-2', name: '甲企业', code: 'T2', status: 'active', enterpriseCertifications: [] },
{ id: 'tenant-3', name: '丙企业', code: 'T3', status: 'active', enterpriseCertifications: [] },
]);
prisma.smsMessageRecord.groupBy.mockResolvedValue([
{ tenantId: 'tenant-1', _sum: { amountCents: 300 } },
{ tenantId: 'tenant-2', _sum: { amountCents: 300 } },
{ tenantId: 'tenant-3', _sum: { amountCents: 500 } },
]);
const service = new TenantsService(prisma as never);
const rows = await service.listManagementRows();
expect(rows.map((row) => row.id)).toEqual(['tenant-3', 'tenant-2', 'tenant-1']);
});
}); });
+3 -1
View File
@@ -71,7 +71,9 @@ export class TenantsService {
account: accountsByTenant.get(tenant.id) ?? null, account: accountsByTenant.get(tenant.id) ?? null,
todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0, todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0,
todayRefundCents: todayRefundByTenant.get(tenant.id) ?? 0, todayRefundCents: todayRefundByTenant.get(tenant.id) ?? 0,
})); })).sort((left, right) => right.todaySpendCents - left.todaySpendCents
|| left.name.localeCompare(right.name, 'zh-CN')
|| left.id.localeCompare(right.id));
} }
get(id: string) { get(id: string) {
@@ -1,492 +0,0 @@
# CMPP 接入号上下游配置与匹配流程设计
## 1. 文档目的
本文梳理当前 CMPP 短信平台接入号的真实代码、生产数据和协议字段使用情况,并给出客户侧下游 CMPP 接入号、运营商侧上游 CMPP 接入号、号码映射、普通上行匹配和历史追溯的目标设计。
本文仅描述设计结论,不代表相关改造已经完成。后续实现仍必须基于真实 NestJS API、Prisma/PostgreSQL、Go Gateway、Redis 和生产 CMPP 链路验证,不允许使用前端静态数据、localStorage 或 mock 代替业务完成。
## 2. 结论摘要
当前系统已经具备“通道接入号下发、上行接收、模糊匹配、人工认领”的第一版链路,但尚未形成真正的应用级接入号分配和上下游映射模型。
现状核心问题如下:
1. 上游通道接入号和客户应用接入号混用。
2. 客户侧展示的接入地址、端口和接入号来自任意一条上游通道,而不是平台客户接入端点和本应用号码。
3. 客户 CMPP SUBMIT 中的 `Src_Id` 虽已传给 NestJS,但未校验是否属于当前应用,也未参与后续通道号码映射。
4. 多个应用绑定同一通道组时,上行 `Dest_Id` 会匹配到该组全部应用,无法唯一定位。
5. `extensionDigits` 目前只是配置和队列元数据,Go Gateway 没有根据它生成或匹配应用扩展号。
6. 上游 CMPP SUBMIT 的 `MsgSrc` 当前使用通道登录账号,而不是通道企业代码。
7. 普通上行没有完整保留 `Service_Id``LinkID`、原始接入号和规范化接入号。
8. 应用级 `cmppEnterpriseCode` 当前允许最长 32 字符,与 CMPP SUBMIT 中 `MsgSrc` 固定 6 字节的字段边界不一致。
生产只读检查显示:
- 当前 3 条通道的 `srcId` 均为 `1069999999`
- 其中 2 条通道为 active1 条为 disabled。
- 生产通道未配置 `serviceId` 和有效扩展位数。
- 4 个真实应用路由到同一个通道组和同一基础接入号。
- 当前 2 条普通上行的 `destId` 都是 `1069999999`,匹配状态均为 `ambiguous`
- 生产存在大量批量验证应用,不应自动分配正式生产接入号。
因此,当前共享基础接入号只能支撑模糊候选和人工认领,不能作为多应用上行自动路由的生产方案。
## 3. CMPP 字段边界
接入号设计必须区分以下概念:
| 概念 | CMPP 字段 | 含义 | 当前状态 |
| --- | --- | --- | --- |
| 登录账号 | CONNECT `Source_Addr` | 建立 CMPP TCP 会话的账号,协议字段为 6 字节 | 上下游已有账号字段 |
| 企业代码 | SUBMIT `MsgSrc` | SP 身份、地址翻译、计费和结算标识,协议字段为 6 字节 | 下游应用允许过长;上游错误使用登录账号 |
| 业务代码 | `Service_Id` | 业务类型,最长 10 字节 | 藏在通道 JSON 中,生产未配置 |
| 下发接入号 | SUBMIT `Src_Id` | 服务代码或以服务代码为前缀的长号码,最长 21 字节 | 当前只有通道级基础号码 |
| 上行接入号 | DELIVER `Dest_Id` | 用户实际回复到的服务代码或长号码 | 当前仅按通道基础号反查路由组 |
| 上行手机号 | DELIVER `Src_Terminal_Id` | 发送普通上行的用户手机号 | 当前已处理 |
| 点播关联标识 | `LinkID` | 点播类业务的关联标识 | 当前未传入 NestJS |
CMPP 2.0/3.0 协议要求:
- `MsgSrc` 和 CONNECT 登录账号是两个不同概念,即使运营商配置值相同,也必须在数据模型中分开保存。
- `Src_Id` 可以是基础服务代码,也可以是以服务代码为前缀的长号码。
- 普通上行 DELIVER 中,`Dest_Id` 是用户回复的接入号,`Src_Terminal_Id` 是用户手机号。
- 状态报告 DELIVER 和普通上行 DELIVER 必须通过 `Registered_Delivery` 严格分流。
- 普通上行自身的 `Msg_Id` 不能默认当作原下发消息 ID。
协议参考:
- [CMPP 2.0 协议](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-2.0.pdf)
- [CMPP 3.0 协议](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-v30.pdf)
行业监管要求端口类短信按照批准的码号结构、位长、用途、使用范围和期限使用,并保存发送时间、接收时间、发送端、接收端和内容等记录。因此系统不能因为协议字段最大长度为 21 字节,就允许任意拼接扩展码;扩展长度必须依据具体运营商合同和报备结果配置。
行业规则参考:
- [通信短信息服务管理规定](https://www.miit.gov.cn/zwgk/zcwj/flfg/art/2020/art_77bc7219833c4a08b4563ba42ad23e1f.html)
- [电信网编号计划(2017 年版)](https://www.miit.gov.cn/jgsj/xgj/wjfb/art/2020/art_eb0adf5b6e7148cbb70802b264878b1e.html)
- [电信网码号资源审批服务指南](https://qhca.miit.gov.cn/zwgk/txfz/hmzy/art/2021/art_754e60bc98664ad4a91748a37d29c6bc.html)
## 4. 当前真实实现
### 4.1 当前下发流程
1. 应用通过页面、HTTP API 或客户侧 CMPP 提交短信。
2. NestJS 根据应用、运营商、省份和通道组选择上游通道。
3. `SubmitCommand.cmpp.srcId` 直接取 `SmsChannel.srcId`
4. Go Gateway 构造运营商侧 CMPP SUBMIT。
5. 上游 `MsgSrc` 当前取通道登录账号。
6. 上游 `Src_Id` 取通道基础接入号。
7. 所有走同一通道的应用对外使用同一基础号码。
相关代码:
- `api/src/send-chain/send-chain.service.ts`:生成 `SubmitCommand`
- `gateway/internal/upstream/manager.go`:构造 CMPP 2.0/3.0 SUBMIT 报文。
### 4.2 当前客户 CMPP 入站流程
1. 客户通过 6 位账号 CONNECT。
2. Gateway 根据会话账号定位应用,并校验应用企业代码。
3. 客户 SUBMIT 的 `Src_Id` 被传给 NestJS。
4. NestJS 未校验该号码是否分配给当前应用。
5. NestJS 未把客户 `Src_Id` 保存到短信记录或提交记录。
6. 后续上游发送重新取通道 `srcId`,客户提交号码被丢弃。
相关代码:
- `gateway/internal/inbound/server.go`:客户 CMPP CONNECT/SUBMIT 解析。
- `api/src/send-chain/send-chain.service.ts``submitInboundMessage`
### 4.3 当前普通上行流程
1. 运营商通过 CMPP DELIVER 返回用户手机号和 `Dest_Id`
2. Gateway 将手机号、接入号和内容回调 NestJS。
3. NestJS 尝试按 messageId 匹配。
4. 未匹配时,根据“上行通道所属通道组”查询所有绑定该组的应用。
5. 多应用共用通道组时生成 `ambiguous` 候选。
6. 无接入号候选时,再按手机号和最近 72 小时下发记录匹配。
7. 最后进入人工认领。
当前流程能避免多候选时误推客户,但不能提供生产级接入号唯一路由。
相关代码:
- `gateway/internal/upstream/manager.go`:解析普通上行 DELIVER。
- `api/src/send-chain/send-chain.service.ts``resolveUplinkMatch`
### 4.4 当前客户 CMPP 参数接口问题
`SmsApplication` 没有应用接入号字段。`getApplicationCmppParams` 会查询最新一条未删除的 `SmsChannel`,将该上游通道的地址、端口和 `srcId` 返回给客户。
这会产生以下错误:
- 客户看到运营商侧上游网关地址,而不是平台客户接入地址。
- 不同应用得到同一个任意通道接入号。
- 上游通道新增、删除或排序变化可能改变客户展示参数。
- 应用接入参数与实际客户连接到 `8.160.169.106:17890` 的生产事实不一致。
相关代码:`api/src/sms-config/sms-config.service.ts``getApplicationCmppParams`
## 5. 目标数据模型
目标模型分为平台接入端点、上游号码能力、应用虚拟接入号和上下游绑定四层。
### 5.1 `CmppDownstreamEndpoint`
保存客户连接本平台的公共端点:
- `id`
- `name`
- `gatewayHost`
- `gatewayPort`
- `supportedVersions`
- `heartbeatSeconds`
- `status`
- `effectiveFrom`
- `effectiveTo`
生产客户参数应返回平台端点 `8.160.169.106:17890` 或后续正式域名/TLS 地址,不再读取任意上游通道。
### 5.2 `UpstreamAccessNumber`
表示运营商或上游供应商在某条通道上实际开通的号码能力:
- `id`
- `channelId`
- `enterpriseCode`:上游 `MsgSrc`,严格 6 字节。
- `serviceId`:最长 10 字节。
- `baseNumber`
- `extensionMode``none/fixed/allocated/shared`
- `allowedExtensionLengths`:由合同或报备决定。
- `maxTotalLength`:不得超过 21。
- `uplinkReturnMode``full/base_only/truncated/custom`
- `replySupported`
- `reportStatus`
- `effectiveFrom`
- `effectiveTo`
- `status`
不能把扩展位数设成全平台统一常量。不同运营商、供应商和通道允许的扩展位数可能不同,应由通道号码能力明确配置。
### 5.3 `ApplicationAccessNumber`
表示客户应用看到、提交和收到普通上行时使用的号码:
- `id`
- `tenantId`
- `applicationId`
- `endpointId`
- `accessNumber`
- `serviceId`
- `isDefault`
- `direction``mt/mo/both`
- `replyEnabled`
- `shareMode``exclusive/shared`
- `effectiveFrom`
- `effectiveTo`
- `status`
约束:
1. 同一活动号码原则上只能属于一个应用。
2. 允许共享时必须显式标记 `shared`
3. 共享号码不能仅按接入号直接自动投递。
4. 应用开放 CMPP 提交前必须至少有一个默认活动号码。
5. 客户 SUBMIT 中的 `Src_Id` 必须属于当前已鉴权应用。
### 5.4 `AccessNumberBinding`
表示客户应用号码与各上游通道号码之间的真实映射:
- `id`
- `applicationAccessNumberId`
- `channelId`
- `upstreamAccessNumberId`
- `upstreamFullNumber`
- `upstreamExtension`
- `serviceIdOverride`
- `carrier`
- `province`
- `priority`
- `matchMode`
- `effectiveFrom`
- `effectiveTo`
- `status`
关键约束:
- 可自动回复的号码,活动状态下 `(channelId, upstreamFullNumber)` 必须唯一。
- 多应用共享同一上游号码时,必须标记共享并禁止仅凭接入号自动投递。
- 激活绑定前校验号码前缀、长度、运营商、报备状态和通道状态。
- 每个需要发送的运营商/省份路由必须有可用号码绑定,否则该通道不是有效候选。
### 5.5 短信和提交快照
`SmsMessageRecord``SmsSubmitRecord` 中增加不可变快照:
- `applicationAccessNumberId`
- `accessNumberBindingId`
- `clientSrcId`
- `upstreamSrcId`
- `upstreamMsgSrc`
- `upstreamServiceId`
- `upstreamChannelId`
这样即使以后修改应用号码、通道或绑定,历史回执、上行和审计仍可按提交当时的号码关系追溯。
## 6. 目标下发流程
```mermaid
flowchart LR
A["客户应用提交短信"] --> B["CONNECT账号定位应用"]
B --> C["校验MsgSrc等于应用企业代码"]
C --> D["校验或补全客户Src_Id"]
D --> E["运营商、省份和通道组路由"]
E --> F["查询应用号码到候选通道的有效绑定"]
F --> G["生成上游MsgSrc、Service_Id、Src_Id"]
G --> H["保存客户号和上游号快照"]
H --> I["Gateway发送上游CMPP SUBMIT"]
```
详细规则:
1. CONNECT 账号只用于定位应用。
2. 客户 `MsgSrc` 必须与应用企业代码严格匹配;建议统一为 6 位 ASCII,现有超长数据需迁移或重新分配。
3. 客户 `Src_Id`
- 未填且应用只有一个默认号码时,自动补全默认号码。
- 已填写时,必须精确属于当前应用。
- 号码停用、过期、未分配或不允许下发时,返回明确的 `Src_Id` 非法错误。
- 校验失败不得进入计费、任务和上游发送队列。
4. 选出上游通道后,再查找应用号码在该通道上的有效绑定。
5. 主通道没有有效号码绑定时,可按通道组规则选择下一候选,但不能退化使用通道基础号码。
6. 上游 CMPP 报文必须使用:
- `MsgSrc = UpstreamAccessNumber.enterpriseCode`
- `Service_Id = binding.serviceIdOverride` 或上游号码默认值
- `Src_Id = AccessNumberBinding.upstreamFullNumber`
- `Dest_Terminal_Id = 用户手机号`
7. 最终客户号、上游号、企业代码、业务代码和绑定 ID 必须持久化为提交快照。
## 7. 目标普通上行流程
```mermaid
flowchart TD
A["收到上游普通DELIVER"] --> B["保存Channel、原始Dest_Id、Service_Id、LinkID"]
B --> C["按通道规则规范化Dest_Id"]
C --> D{"channelId和完整接入号唯一绑定?"}
D -- "是" --> E["定位应用和客户接入号"]
D -- "否" --> F{"通道明确配置截断或基础号回传?"}
F -- "是" --> G["手机号和近期下发号码快照辅助匹配"]
F -- "否" --> H["生成候选"]
G --> I{"得到唯一高置信结果?"}
I -- "是" --> E
I -- "否" --> H
H --> J["ambiguous或unmatched,进入人工认领"]
E --> K["转换为客户侧CMPP DELIVER"]
```
匹配优先级:
1. `(channelId, rawDestId)` 精确匹配活动绑定。
2. 按该通道明确配置的规范化、截断或别名规则匹配。
3. 手机号、通道和近期下发记录中的 `upstreamSrcId` 快照匹配。
4. 使用 `Service_Id``LinkID`、运营商、时间窗口作为辅助条件。
5. 多候选进入人工认领。
6. 完全无候选标记 `unmatched`
禁止:
- 仅因为多个应用绑定同一通道组,就把全部应用视为接入号候选。
- 选择任意 active 应用兜底。
- 对所有通道统一执行前缀匹配或截断。
- 把普通上行自身的 `Msg_Id` 当成原下发消息 ID。
- 在没有其他证据时将共享接入号自动投递给某个客户。
匹配成功后,客户侧普通上行 DELIVER 应使用:
- `Dest_Id = ApplicationAccessNumber.accessNumber`
- `Src_Terminal_Id = 用户手机号`
- `Service_Id = 客户侧业务代码`
- `LinkID = 可用时保留关联值`
原始上游号码仍需保存在数据库和运营审计中,但不能直接作为转换后的客户号码。
## 8. 配置界面设计
### 8.1 平台客户接入端点
独立配置:
- 公网域名/IP
- 端口
- CMPP 版本
- TLS 状态
- 心跳间隔
- 启停状态
### 8.2 上游通道页面
通道连接配置和号码能力分区展示:
- 登录账号 `Source_Addr`
- 企业代码 `MsgSrc`
- 默认 `Service_Id`
- 基础接入号
- 扩展模式
- 允许扩展长度
- 最大总长度
- 是否支持上行
- 上行回传模式
- 报备状态和生效时间
### 8.3 应用页面
应用 CMPP 参数区展示:
- 平台接入地址和端口
- 账号
- 密码
- 企业代码
- 协议版本
- 连接数和窗口
- 已分配客户接入号列表
- 默认号码
- 上行/下发能力
- 生效状态
### 8.4 上下游号码绑定矩阵
按应用号码、运营商、通道组和通道展示:
- 客户号码
- 上游基础号
- 上游扩展码
- 上游完整号码
- `Service_Id`
- 报备状态
- 是否支持上行
- 主备优先级
- 生效时间
绑定不完整时,不允许把对应通道标记为该应用的可用发送候选。
## 9. API 建议
建议新增或调整:
- `GET /api/admin/cmpp-downstream-endpoints`
- `POST /api/admin/cmpp-downstream-endpoints`
- `GET /api/admin/channels/:id/access-numbers`
- `POST /api/admin/channels/:id/access-numbers`
- `GET /api/admin/applications/:id/access-numbers`
- `POST /api/admin/applications/:id/access-numbers`
- `GET /api/admin/applications/:id/access-number-bindings`
- `POST /api/admin/applications/:id/access-number-bindings`
- `POST /api/admin/access-number-bindings/:id/activate`
- `POST /api/admin/access-number-bindings/:id/disable`
- `GET /api/client/applications/:id/cmpp-params`
客户参数接口返回结构建议:
```json
{
"gatewayHost": "8.160.169.106",
"gatewayPort": 17890,
"account": "123456",
"enterpriseCode": "900001",
"protocolVersion": "CMPP2.0",
"maxConnections": 1,
"windowSize": 16,
"accessNumbers": [
{
"accessNumber": "10699999990001",
"serviceId": "NOTICE",
"isDefault": true,
"replyEnabled": true,
"status": "active"
}
]
}
```
示例号码只用于说明结构,不能在未获得上游合同和报备确认时直接用于生产配置。
## 10. 生产数据迁移原则
1. 将现有 `SmsChannel.srcId=1069999999` 迁移为各通道的上游基础号码记录。
2. 从上游供应商合同或后台确认:
- 上游企业代码。
- `Service_Id`
- 允许扩展位数。
- 是否完整回传扩展号。
- 是否支持普通上行。
- 多连接或多通道是否共享同一号段。
3. 在未确认扩展规则前,不为多个应用自动生成扩展号码。
4. 现有 2 条 `ambiguous` 上行继续保留人工认领,不自动修改历史归属。
5. 大量批量验证应用不得自动分配正式号码。
6. 修复或迁移超过 6 字节的 `cmppEnterpriseCode`
7. 修复客户参数接口,不再返回任意上游通道地址和号码。
8. 对现有两条 active、配置相同的复制通道确认是否属于真实独立上游连接,避免重复通道与号码能力重复生效。
## 11. 实施优先级
### P0:数据正确性
- 新增上游号码、应用号码和绑定表。
- 修复客户 CMPP 参数接口。
- 严格校验客户 `MsgSrc``Src_Id`
- 上游 `MsgSrc` 改用通道企业代码。
- 持久化客户号码和上游号码快照。
- 上行事件补充 `Service_Id``LinkID` 和原始号码。
- 删除“通道组内所有应用都是接入号候选”的匹配路径。
### P1:配置与运营
- 增加通道号码能力配置。
- 增加应用接入号分配。
- 增加应用号码到三网通道的绑定矩阵。
- 激活前检查报备状态和三网映射完整性。
- 人工认领可生成规则建议,但不得自动修改正式绑定。
### P2:治理与指标
- 接入号精确匹配率、歧义率、未匹配率。
- 每个号码的下发量、上行量和最后活跃时间。
- 共享号码积压告警。
- 非法 `Src_Id`、未报备号码和异常截断告警。
- 接入号到期、报备失效和停用后的发送阻断。
当前数据规模不需要为接入号表提前做分区;优先保证唯一约束、有效期索引、通道和号码组合索引,以及历史快照完整性。
## 12. 验收清单
后续实现至少覆盖以下真实链路用例:
1. 客户参数接口只返回平台 CMPP 端点和当前应用号码。
2. 客户提交未分配 `Src_Id` 时被拒绝,且不计费、不入队。
3. 客户不填 `Src_Id` 且应用只有一个默认号时自动补全。
4. 同一客户号码在移动、联通、电信映射为不同上游号码。
5. 主备通道切换后使用各自绑定的上游 `Src_Id`
6. 上游 `MsgSrc` 与通道登录账号不同时仍能正常提交。
7. 完整扩展号上行能唯一匹配应用。
8. 运营商只返回基础号或截断号时,严格按该通道规则辅助匹配。
9. 两个应用共享号码时不得自动误投。
10. 普通上行和状态报告严格分流。
11. 长上行完成重组后仍按完整接入号匹配。
12. 修改号码配置后,历史消息仍按提交快照关联。
13. 接入号停用、过期或报备失效后阻断新发送,但历史记录仍可查询。
14. 上行匹配失败时真实写入 PostgreSQL 候选或未匹配记录,并支持人工认领。
15. Gateway、NestJS、PostgreSQL 和客户 CMPP 连接上的号码值可以相互核对。
## 13. 后续落地建议
建议后续另开实现批次,按以下顺序完成:
1. 先取得上游供应商的真实企业代码、业务代码、扩展位数和上行回传规则。
2. 建立 Prisma 模型、迁移和唯一约束。
3. 修复客户 CMPP 参数接口和应用企业代码校验。
4. 改造下发路由,在选中通道后解析号码绑定并保存快照。
5. 扩展 Gateway 上行事件字段。
6. 重写普通上行匹配优先级。
7. 补齐运营端配置页面和人工认领页面。
8. 使用真实 PostgreSQL、Redis、Gateway 和生产同类 CMPP 测试对端执行完整验收。
@@ -10,6 +10,8 @@
- 企业签名的站点字段统一显示为“引流信息”,列表、表单和详情不展示引流信息提交时间。 - 企业签名的站点字段统一显示为“引流信息”,列表、表单和详情不展示引流信息提交时间。
- 企业应用的企业代码必须始终等于 CMPP 6 位账号,由系统同步且不可单独编辑;账号留空时,创建应用时自动生成二者。每任务号码数超过应用上限时拒绝整个任务并提示拆分,不允许静默截断。 - 企业应用的企业代码必须始终等于 CMPP 6 位账号,由系统同步且不可单独编辑;账号留空时,创建应用时自动生成二者。每任务号码数超过应用上限时拒绝整个任务并提示拆分,不允许静默截断。
- 企业应用支持配置纯数字“应用扩展码”。开启“客户接入号填充”后才显示并允许编辑纯数字填充前缀,客户侧 `Src_Id` 等于“填充前缀 + 应用扩展码”;关闭时前缀必须清空且客户侧 `Src_Id` 等于应用扩展码。计算结果全局唯一且不超过 CMPP 的 21 位边界。
- 客户接入号填充只解决客户系统的最小长度兼容,NestJS 必须在创建任务、冻结余额和入队前精确校验客户 `Src_Id`。上游实际 `Src_Id` 始终为“通道基础接入号 + 应用扩展码”,不得把客户填充前缀带入上游;新短信保存客户侧接入号和应用扩展码快照,补发继续使用原快照。未配置扩展码的历史应用保持通道基础接入号行为。
- 手机号段支持真实删除;报备字段库展示被通道报备配置引用的通道数,引用数大于 0 时前后端均禁止删除,未引用字段才允许真实删除。 - 手机号段支持真实删除;报备字段库展示被通道报备配置引用的通道数,引用数大于 0 时前后端均禁止删除,未引用字段才允许真实删除。
- 企业应用连接详情只展示当前已连接会话;断开或心跳超时会话从活跃连接表删除,不保留为连接历史。 - 企业应用连接详情只展示当前已连接会话;断开或心跳超时会话从活跃连接表删除,不保留为连接历史。
- 短信审核批量通过必须基于明确勾选,只处理已选待审核任务;不得默认通过当前筛选结果或全部数据。 - 短信审核批量通过必须基于明确勾选,只处理已选待审核任务;不得默认通过当前筛选结果或全部数据。
@@ -438,12 +440,14 @@
- 支持企业认证资料审核。 - 支持企业认证资料审核。
- 支持查看企业下应用、签名、模板、发送记录、报备记录。 - 支持查看企业下应用、签名、模板、发送记录、报备记录。
- 企业管理列表不提供无业务意义的“详情”按钮;需要查看明细时从企业应用、签名、模板、发送记录等业务入口进入。 - 企业管理列表不提供无业务意义的“详情”按钮;需要查看明细时从企业应用、签名、模板、发送记录等业务入口进入。
- 企业列表按真实 PostgreSQL 当日短信消费金额从大到小排序;金额相同时按企业名称和 id 稳定排序。
- 企业应用管理编辑保存后返回企业应用管理列表。 - 企业应用管理编辑保存后返回企业应用管理列表。
- 企业应用列表展示 CMPP 连接数;点击连接数打开连接详情弹窗,内容包含连接 id、状态、连接建立时间、最近心跳时间、上次提交时间、窗口占用等。 - 企业应用列表展示 CMPP 连接数;点击连接数打开连接详情弹窗,内容包含连接 id、状态、连接建立时间、最近心跳时间、上次提交时间、窗口占用等。
- 企业应用连接详情支持删除连接;删除连接必须调用真实后端接口或 Gateway 回写接口,并写入系统日志。 - 企业应用连接详情支持删除连接;删除连接必须调用真实后端接口或 Gateway 回写接口,并写入系统日志。
- 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。 - 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。
- 企业应用新增/编辑必须展示发送队列等级,并真实保存普通队列或优先队列配置;列表或详情应能看到该配置,便于运营核对高优先级应用。 - 企业应用新增/编辑必须展示发送队列等级,并真实保存普通队列或优先队列配置;列表或详情应能看到该配置,便于运营核对高优先级应用。
- 企业应用新增时选择企业必须使用通用下拉控件和真实企业接口;下拉控件的视觉、尺寸、禁用态、错误态应与系统内其他 Select 保持一致。 - 企业应用新增时选择企业必须使用通用下拉控件和真实企业接口;下拉控件的视觉、尺寸、禁用态、错误态应与系统内其他 Select 保持一致。
- 企业应用列表按真实 PostgreSQL 当日发送数量从大到小排序;数量相同时按应用名称和 id 稳定排序,不能只对前端当前结果临时排序。
- 所有企业和企业应用下拉控件必须支持按名称搜索;企业应用管理列表不展示 AppID。 - 所有企业和企业应用下拉控件必须支持按名称搜索;企业应用管理列表不展示 AppID。
- 企业短信模板列表必须采用自适应布局,在常用桌面及平板视口下无需水平滚动即可看到编辑、删除等操作。 - 企业短信模板列表必须采用自适应布局,在常用桌面及平板视口下无需水平滚动即可看到编辑、删除等操作。
- 运营端“企业模板管理”以响应式列表行展示模板、归属、内容、状态和更新时间;预览、编辑、删除操作在常用视口内始终可见,不得依赖水平滚动。 - 运营端“企业模板管理”以响应式列表行展示模板、归属、内容、状态和更新时间;预览、编辑、删除操作在常用视口内始终可见,不得依赖水平滚动。
@@ -455,7 +459,7 @@
- 企业认证详情必须展示客户提交的主体资料、统一社会信用代码、法定代表人、注册地址、营业执照附件、对公账户验证资料、联系人信息、提交时间、审核备注、驳回原因等;通过/驳回必须更新认证记录和租户认证状态,并写操作日志。 - 企业认证详情必须展示客户提交的主体资料、统一社会信用代码、法定代表人、注册地址、营业执照附件、对公账户验证资料、联系人信息、提交时间、审核备注、驳回原因等;通过/驳回必须更新认证记录和租户认证状态,并写操作日志。
- 短信模板审核:通过、驳回、敏感词提示、变量检查。 - 短信模板审核:通过、驳回、敏感词提示、变量检查。
- 短信模板审核必须支持按客户、应用、模板内容、审核编号和审核状态搜索;搜索应由真实后端 API 支持,前端只做展示和交互。 - 短信模板审核必须支持按客户、应用、模板内容、审核编号和审核状态搜索;搜索应由真实后端 API 支持,前端只做展示和交互。
- 短信审核:查看短信内容、号码量、计费条数、进入审核原因、命中规则、风险原因;支持通过、驳回 - 短信审核:查看短信内容、号码量、计费条数、进入审核原因、命中规则、风险原因;支持单条通过、单条驳回、批量通过和批量驳回。批量驳回必须选择待审核任务、填写统一驳回原因并调用真实批量 API,逐项驱动发送链路拒绝处理
- 审核动作集中在审核中心;企业应用/签名/模板管理页面只做查询、维护、停用、备注和查看审核记录。 - 审核动作集中在审核中心;企业应用/签名/模板管理页面只做查询、维护、停用、备注和查看审核记录。
### 5.13 运营端通道管理 ### 5.13 运营端通道管理
@@ -465,6 +469,7 @@
- 支持复制通道:复制后新建一个通道,除 id/code 自动生成外,通道配置、CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段配置均需从源通道复制;名称默认追加“副本”;若源通道为 active,新副本默认保存为 disabled,避免复制后立即占用上游连接。 - 支持复制通道:复制后新建一个通道,除 id/code 自动生成外,通道配置、CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段配置均需从源通道复制;名称默认追加“副本”;若源通道为 active,新副本默认保存为 disabled,避免复制后立即占用上游连接。
- 支持发送测试短信。 - 支持发送测试短信。
- 支持查看通道成功率、未知率、失败率、累计发送量。 - 支持查看通道成功率、未知率、失败率、累计发送量。
- 通道列表的通道成本以“分”为单位展示并保留两位小数,例如 `3.00 分`;展示格式不改变 PostgreSQL 中的真实成本单价及计费快照。
- 支持进入通道报备详情。 - 支持进入通道报备详情。
- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。 - 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。
- 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。通道连接状态、连接数和最近错误必须来自 Gateway 真实上游连接池回写,不能以一次性探测拨号成功代替长连接在线状态。 - 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。通道连接状态、连接数和最近错误必须来自 Gateway 真实上游连接池回写,不能以一次性探测拨号成功代替长连接在线状态。
+21 -1
View File
@@ -37,6 +37,7 @@
| TC-ADMIN-RECORD-0715-07 | P1 | 查看桌面/窄屏短信记录及失败详情 | 卡片不产生页面横向滚动,信息分组清晰;失败原因独立突出;详情仍读取真实 submit、receipt 和分片审计 API。 | | TC-ADMIN-RECORD-0715-07 | P1 | 查看桌面/窄屏短信记录及失败详情 | 卡片不产生页面横向滚动,信息分组清晰;失败原因独立突出;详情仍读取真实 submit、receipt 和分片审计 API。 |
| TC-ADMIN-MISC-0715-08 | P2 | 查看签名引流信息、零待审核通知和充值弹窗 | 使用“引流信息”标题且无提交时间;0 为黑字灰底;充值弹窗无操作人字段。 | | TC-ADMIN-MISC-0715-08 | P2 | 查看签名引流信息、零待审核通知和充值弹窗 | 使用“引流信息”标题且无提交时间;0 为黑字灰底;充值弹窗无操作人字段。 |
| TC-ADMIN-NOTICE-0715-09 | P1 | 制造下游投递告警后打开右上角“待审核任务” | 待审核总数和菜单仅包含五类真实审核任务,不出现“下游投递告警”;运营看板和下游投递页仍展示下游告警。 | | TC-ADMIN-NOTICE-0715-09 | P1 | 制造下游投递告警后打开右上角“待审核任务” | 待审核总数和菜单仅包含五类真实审核任务,不出现“下游投递告警”;运营看板和下游投递页仍展示下游告警。 |
| TC-ADMIN-CMPP-0715-10 | P0 | 为企业应用配置扩展码 `0001`,切换接入号填充并配置前缀 `00` | 开关关闭时前缀不显示且不可编辑;开启后才显示;API/PostgreSQL 保存扩展码、开关、前缀及客户侧 `Src_Id=000001`,上游 Submit 使用“通道基础号 + 0001”,不携带 `00`。 |
| 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 | | 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 |
| 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 | | 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 |
| 非法内容 | 控制字符、emoji、换行、不可见字符、超长变量、签名外置内容、敏感词内容。 | | 非法内容 | 控制字符、emoji、换行、不可见字符、超长变量、签名外置内容、敏感词内容。 |
@@ -475,6 +476,19 @@
- 不再进入发送队列。 - 不再进入发送队列。
- 客户端可见驳回原因。 - 客户端可见驳回原因。
### TC-ADMIN-010A 短信审核批量驳回
- 优先级:P0
- 前置条件:至少存在两条因风控进入 `pending_review` 的真实发送任务。
- 步骤:
1. 在短信审核列表勾选两条待审核任务。
2. 点击“驳回已选”,填写统一驳回原因并确认。
3. 刷新审核列表和客户端任务状态。
- 预期结果:
- 前端只提交已选任务 id 和非空原因到真实 `/admin/risk-review/tasks/batch/reject` API。
- 两条任务均持久化为 `rejected`,保留相同驳回原因和审核时间,并逐项执行发送链路拒绝处理。
- 未勾选任务不受影响;空选择、空原因和超过单批上限的请求被拒绝。
### TC-ADMIN-011 运营看板与监控 ### TC-ADMIN-011 运营看板与监控
- 优先级:P1 - 优先级:P1
@@ -599,7 +613,7 @@
1. 打开运营端企业应用管理,点击新增短信应用。 1. 打开运营端企业应用管理,点击新增短信应用。
2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。 2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。
3. 选择企业后进入应用参数表单。 3. 选择企业后进入应用参数表单。
4. 配置应用名称、客户单价、IP 白名单、发送队列等级、短信接口开关、接口类型、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、移动/联通/电信通道组后保存。 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、短信接口开关、接口类型、CMPP 6 位账号、企业代码、应用扩展码、接入号填充开关/前缀、16 位接口密码、客户最大连接数、移动/联通/电信通道组后保存。
5. 刷新列表并打开编辑页。 5. 刷新列表并打开编辑页。
- 预期结果: - 预期结果:
- 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。 - 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。
@@ -609,6 +623,9 @@
- “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。 - “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。
- `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。 - `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。
- `cmppEnterpriseCode` 来自应用自身配置,不透传上游通道企业代码;`passwordCipher` 为 16 位,编辑留空不覆盖原密码。 - `cmppEnterpriseCode` 来自应用自身配置,不透传上游通道企业代码;`passwordCipher` 为 16 位,编辑留空不覆盖原密码。
- 填充开关关闭时不渲染填充前缀输入框;开启后前缀才显示并可编辑。前后端只接受数字扩展码/前缀,客户侧接入号为二者拼接且不超过 21 位;关闭填充后前缀清空。
- 客户 CMPP Submit 的 `Src_Id` 必须精确等于当前应用客户侧接入号,错误或缺失时在创建任务、计费和入队前拒绝;正确值写入 `SmsMessageRecord.clientSrcId`,真实扩展码写入 `applicationExtension` 快照。
- Gateway 上游 Submit 的 `Src_Id` 为通道基础接入号拼接短信快照中的应用扩展码;客户填充前缀不参与上游号码,上游号码超过 21 位时受控失败且不产生半成品提交记录。
- 后端真实保存应用队列等级和 CMPP 参数,刷新列表、编辑页和 CMPP 参数弹窗后仍显示正确。 - 后端真实保存应用队列等级和 CMPP 参数,刷新列表、编辑页和 CMPP 参数弹窗后仍显示正确。
- 不选择任何通道组或缺少必填字段时不能保存,并显示可读提示。 - 不选择任何通道组或缺少必填字段时不能保存,并显示可读提示。
@@ -3234,6 +3251,9 @@ npm run verify:phase8
| TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 企业黑名单必须绑定短信应用且按应用生效;搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 | | TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 企业黑名单必须绑定短信应用且按应用生效;搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 |
| TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 | | TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 |
| TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 | | TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 |
| TC-ADMIN-023 | 准备多个企业产生不同的当日真实消费金额后打开企业列表。 | API 结果按 `todaySpendCents` 降序;相同金额按企业名称和 id 稳定排序;金额来自 PostgreSQL 当日短信记录聚合。 |
| TC-ADMIN-024 | 准备多个企业应用产生不同的当日真实发送数量后打开企业应用列表。 | API 结果按 `sentToday` 降序;相同数量按应用名称和 id 稳定排序;数量来自 PostgreSQL 当日短信记录聚合。 |
| TC-ADMIN-025 | 将通道真实成本分别设为 3、3.5、3.456 分并打开通道列表。 | 分别显示 `3.00 分``3.50 分``3.46 分`;仅改变展示精度,不改写数据库成本及历史提交成本快照。 |
### 17.4 Dashboard 指标细化 ### 17.4 Dashboard 指标细化
+14
View File
@@ -1,5 +1,19 @@
# 第一版系统化测试进度 # 第一版系统化测试进度
## 2026-07-15 运营列表排序、短信批量驳回与通道成本展示(未提交、未部署)
- 短信审核页增加“驳回已选”,统一填写非空原因后调用真实 `/admin/risk-review/tasks/batch/reject`;NestJS 对 id 去重、限制单批最多 100 条并逐项执行现有风控拒绝和发送链路拒绝处理,不使用前端本地状态冒充完成。
- 企业管理接口按 PostgreSQL 当日短信消费 `todaySpendCents` 降序返回;企业应用管理接口按 PostgreSQL 当日短信记录 `sentToday` 降序返回;两者相同指标均以名称和 id 稳定排序。
- 通道列表成本继续读取真实 `unitPrice`,仅将展示格式调整为“X.XX 分”,不修改计费或成本快照精度。
- 已补充对应后端单元测试和系统功能用例;定向 3 suites/46 项、API 全量 18 suites/192 项、API build、前端 build、Gateway 全量 Go 测试、Prisma validate/generate/migrate status47 条 migration 已应用)和 `git diff --check` 均通过。Jest 仍有既有 open-handle 退出提示,使用相同全量测试集加 `--forceExit` 复核退出码为 0;提交、推送、生产备份与部署结果待本次发布完成后回填。
## 2026-07-15 企业应用接入号填充与扩展码(未提交、未部署)
- 已删除未落地且容易被误作实现依据的 `docs/access-number-upstream-downstream-design.md`,正式口径改为企业应用级“应用扩展码 + 可选客户接入号填充前缀”。
- 企业应用新增真实 PostgreSQL 配置:应用扩展码、填充开关、填充前缀和全局唯一客户侧 `Src_Id`。管理端仅在开启填充时展示可编辑前缀,并实时只读预览客户侧接入号。
- 配置扩展码后,NestJS 在任务、计费和入队前严格校验客户 CMPP `Src_Id`;新短信保存客户侧号码与应用扩展码快照。上游 Submit 只使用“通道基础号 + 应用扩展码”,客户填充前缀不进入上游。未配置扩展码的历史应用保持原通道基础号行为。
- 本地 PostgreSQL 已应用第 47 条 migrationPrisma format/validate/generate/migrate status 通过。SmsConfig/SendChain 定向 2 suites/88 项、API 全量 18 suites/188 项、API build、前端 build、Gateway `go test ./...` 均通过;前端仅有既有 Vite chunk size warning。Jest 全量用例全部通过但存在既有 open handle,使用 `--forceExit` 取得明确退出码 0。应用内浏览器确认本地站点、运营登录页和控制台正常,但真实登录及图形验证码阻断表单页,未将条件显示交互误记为浏览器已验收。
## 2026-07-15 报表与通用报备字段生产发布 ## 2026-07-15 报表与通用报备字段生产发布
- 功能提交 `8c3336600e900667d8bb77fd6e0fb5fe40986c11` 已 push 到 `origin/main` 并部署生产,覆盖对账单、利润报表、发送质量报表和通用报备字段/签名资料动态化全部工作区改动。发布包本地与服务器 SHA-256 均为 `94f59561f1a5a2255bb188007a6ce73fd1549ba33ca9241d05ba3ac4118f2d89` - 功能提交 `8c3336600e900667d8bb77fd6e0fb5fe40986c11` 已 push 到 `origin/main` 并部署生产,覆盖对账单、利润报表、发送质量报表和通用报备字段/签名资料动态化全部工作区改动。发布包本地与服务器 SHA-256 均为 `94f59561f1a5a2255bb188007a6ce73fd1549ba33ca9241d05ba3ac4118f2d89`
+11 -2
View File
@@ -780,6 +780,10 @@ export type EnterpriseApplication = {
downstreamUplinkRetryEnabled?: boolean | null; downstreamUplinkRetryEnabled?: boolean | null;
cmppAccount?: string | null; cmppAccount?: string | null;
cmppEnterpriseCode?: string | null; cmppEnterpriseCode?: string | null;
cmppApplicationExtension?: string | null;
cmppAccessNumberFillEnabled?: boolean | null;
cmppAccessNumberFillPrefix?: string | null;
cmppClientSrcId?: string | null;
interfaceEnabled?: boolean | null; interfaceEnabled?: boolean | null;
interfaceType?: 'cmpp20' | string | null; interfaceType?: 'cmpp20' | string | null;
cmppMaxConnections?: number | null; cmppMaxConnections?: number | null;
@@ -846,6 +850,9 @@ export type ApplicationCmppParams = {
account: string; account: string;
passwordCipher: string; passwordCipher: string;
srcId: string; srcId: string;
applicationExtension?: string | null;
accessNumberFillEnabled?: boolean;
accessNumberFillPrefix?: string | null;
interfaceEnabled?: boolean; interfaceEnabled?: boolean;
interfaceType?: string; interfaceType?: string;
maxConnections: number; maxConnections: number;
@@ -1028,9 +1035,9 @@ export const adminApi = {
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)), request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
getEnterpriseApplication: (id: string) => getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`), request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) => changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, { request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
@@ -1195,6 +1202,8 @@ export const adminApi = {
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) =>
request<RiskReviewTask[]>('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }),
listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/sensitive-words', query)), 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) }),
+1 -1
View File
@@ -621,7 +621,7 @@ export function AdminChannelsPage() {
</div> </div>
<div className="sms-channel-carrier-price"> <div className="sms-channel-carrier-price">
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag> <Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
<strong>{channel.unitPrice.toFixed(3)} </strong> <strong>{channel.unitPrice.toFixed(2)} </strong>
</div> </div>
<div className="sms-channel-status-cell"> <div className="sms-channel-status-cell">
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag> <Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
@@ -24,6 +24,9 @@ export function AdminSmsApplicationFormPage() {
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300'); const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal'); const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
const [cmppAccount, setCmppAccount] = useState(''); const [cmppAccount, setCmppAccount] = useState('');
const [applicationExtension, setApplicationExtension] = useState('');
const [accessNumberFillEnabled, setAccessNumberFillEnabled] = useState(false);
const [accessNumberFillPrefix, setAccessNumberFillPrefix] = useState('');
const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword()); const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword());
const [interfaceEnabled, setInterfaceEnabled] = useState(true); const [interfaceEnabled, setInterfaceEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20'); const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
@@ -84,6 +87,9 @@ export function AdminSmsApplicationFormPage() {
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3)); setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal'); setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? ''); setCmppAccount(application.cmppAccount ?? '');
setApplicationExtension(application.cmppApplicationExtension ?? '');
setAccessNumberFillEnabled(application.cmppAccessNumberFillEnabled === true);
setAccessNumberFillPrefix(application.cmppAccessNumberFillPrefix ?? '');
setPasswordCipher(''); setPasswordCipher('');
setInterfaceEnabled(application.interfaceEnabled !== false); setInterfaceEnabled(application.interfaceEnabled !== false);
setInterfaceType('cmpp20'); setInterfaceType('cmpp20');
@@ -119,6 +125,24 @@ export function AdminSmsApplicationFormPage() {
setError('请至少配置一个运营商通道组'); setError('请至少配置一个运营商通道组');
return; return;
} }
const normalizedExtension = applicationExtension.trim();
const normalizedFillPrefix = accessNumberFillPrefix.trim();
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
setError('应用扩展码只能填写数字');
return;
}
if (accessNumberFillEnabled && !normalizedExtension) {
setError('开启接入号填充时必须填写应用扩展码');
return;
}
if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) {
setError('开启接入号填充时必须填写数字格式的填充前缀');
return;
}
if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) {
setError('客户侧接入号不能超过 21 位');
return;
}
const payload = { const payload = {
name: appName, name: appName,
scene, scene,
@@ -126,6 +150,9 @@ export function AdminSmsApplicationFormPage() {
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100), customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
queuePriority, queuePriority,
cmppAccount: cmppAccount.trim() || undefined, cmppAccount: cmppAccount.trim() || undefined,
cmppApplicationExtension: normalizedExtension,
cmppAccessNumberFillEnabled: accessNumberFillEnabled,
cmppAccessNumberFillPrefix: accessNumberFillEnabled ? normalizedFillPrefix : '',
passwordCipher: passwordCipher.trim() || undefined, passwordCipher: passwordCipher.trim() || undefined,
interfaceEnabled, interfaceEnabled,
interfaceType, interfaceType,
@@ -164,6 +191,7 @@ export function AdminSmsApplicationFormPage() {
...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })), ...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })),
]; ];
const selectedGroupCount = [mobileGroupId, unicomGroupId, telecomGroupId].filter(Boolean).length; const selectedGroupCount = [mobileGroupId, unicomGroupId, telecomGroupId].filter(Boolean).length;
const clientSrcIdPreview = `${accessNumberFillEnabled ? accessNumberFillPrefix.trim() : ''}${applicationExtension.trim()}`;
const routeCards: Array<{ carrier: Carrier; groupId: string; onChange: (value: string) => void }> = [ const routeCards: Array<{ carrier: Carrier; groupId: string; onChange: (value: string) => void }> = [
{ carrier: 'mobile', groupId: mobileGroupId, onChange: setMobileGroupId }, { carrier: 'mobile', groupId: mobileGroupId, onChange: setMobileGroupId },
{ carrier: 'unicom', groupId: unicomGroupId, onChange: setUnicomGroupId }, { carrier: 'unicom', groupId: unicomGroupId, onChange: setUnicomGroupId },
@@ -249,6 +277,35 @@ export function AdminSmsApplicationFormPage() {
</div> </div>
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> <Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} /> <Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
<Input
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
label="应用扩展码"
onChange={(event) => setApplicationExtension(event.target.value)}
placeholder="例如 0001"
value={applicationExtension}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button">
<span />
{accessNumberFillEnabled ? '开启' : '关闭'}
</button>
<div className="admin-app-form-tip">
<Info size={17} />
<span> Src_Id </span>
</div>
</div>
{accessNumberFillEnabled ? (
<Input
hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。"
label="填充前缀"
onChange={(event) => setAccessNumberFillPrefix(event.target.value)}
placeholder="例如 00"
required
value={accessNumberFillPrefix}
/>
) : null}
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
<Input <Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'} hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="接口密码" label="接口密码"
+15 -4
View File
@@ -26,7 +26,7 @@ export function AdminSmsAuditPage() {
const [date, setDate] = useState(''); const [date, setDate] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null); const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | null>(null); const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
@@ -67,13 +67,22 @@ export function AdminSmsAuditPage() {
} }
async function rejectRecord() { async function rejectRecord() {
if (!rejectTarget) return; if (!rejectTarget || rejectTarget === 'batch') return;
await adminApi.rejectRiskReviewTask(rejectTarget.id, rejectReason || '运营审核驳回'); await adminApi.rejectRiskReviewTask(rejectTarget.id, rejectReason || '运营审核驳回');
setRejectTarget(null); setRejectTarget(null);
setRejectReason(''); setRejectReason('');
loadData(); loadData();
} }
async function rejectBatch() {
if (!rejectReason.trim()) return;
await adminApi.rejectRiskReviewTasks(selectedIds, rejectReason.trim());
setRejectTarget(null);
setRejectReason('');
setSelectedIds([]);
loadData();
}
const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id); const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id)); const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const columns: Array<TableColumn<RiskReviewTask>> = [ const columns: Array<TableColumn<RiskReviewTask>> = [
@@ -137,6 +146,7 @@ export function AdminSmsAuditPage() {
<div className="sms-bulk-actions"> <div className="sms-bulk-actions">
<span> {selectedIds.length} </span> <span> {selectedIds.length} </span>
<Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button> <Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button>
<Button disabled={selectedIds.length === 0} icon={<X size={16} />} onClick={() => setRejectTarget('batch')} variant="danger"></Button>
</div> </div>
</div> </div>
@@ -162,13 +172,14 @@ export function AdminSmsAuditPage() {
footer={( footer={(
<> <>
<Button onClick={() => setRejectTarget(null)} variant="ghost"></Button> <Button onClick={() => setRejectTarget(null)} variant="ghost"></Button>
<Button disabled={!rejectReason.trim()} onClick={() => void rejectRecord()} variant="danger"></Button> <Button disabled={!rejectReason.trim()} onClick={() => rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord()} variant="danger"></Button>
</> </>
)} )}
onClose={() => setRejectTarget(null)} onClose={() => setRejectTarget(null)}
open={Boolean(rejectTarget)} open={Boolean(rejectTarget)}
title="确认驳回" title={rejectTarget === 'batch' ? '批量驳回' : '确认驳回'}
> >
{rejectTarget === 'batch' ? <p> {selectedIds.length} </p> : null}
<Textarea label="驳回原因" onChange={(event) => setRejectReason(event.target.value)} rows={4} value={rejectReason} /> <Textarea label="驳回原因" onChange={(event) => setRejectReason(event.target.value)} rows={4} value={rejectReason} />
</Modal> </Modal>
</section> </section>