fix: enforce drainage uniqueness and carrier-specific reporting

This commit is contained in:
hectorzhao
2026-09-14 18:12:38 +08:00
parent ac6449028c
commit 7f9abe3da0
26 changed files with 2637 additions and 965 deletions
@@ -0,0 +1,10 @@
-- Preserve all legacy rows; independent carrier states require distinct business keys.
BEGIN;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_carrier_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId", "carrier")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NOT NULL;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_legacy_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NULL;
DROP INDEX "ChannelSignatureReportTask_drainage_target_key";
COMMIT;
+41 -15
View File
@@ -1,3 +1,4 @@
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -450,17 +451,25 @@ export class ChannelReportingService {
}),
);
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
channels
.map((channel) => {
const existing = signature.reportTasks.find(
(task) =>
task.reportType === 'drainage' &&
task.channelId === channel.id &&
task.drainageItemId === drainageInfo.id,
channels.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).flatMap((carrier) => {
const tasks = signature.reportTasks.filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageInfo.id,
);
return existing ? { ...existing, signature } : undefined;
})
.filter(Boolean),
const existing = selectDrainageReportTask(tasks, channel.id, carrier);
return existing
? [
{
...existing,
id: existing.carrier ? existing.id : `virtual:${drainageInfo.id}:${channel.id}:${carrier}`,
carrier,
virtual: !existing.carrier,
signature,
},
]
: [];
}),
),
);
return [...signatureDetails, ...drainageDetails];
})
@@ -551,6 +560,9 @@ export class ChannelReportingService {
throw new BadRequestException('unsupported report task source entry');
}
return this.prisma.$transaction(async (tx) => {
for (const signatureId of [...new Set(data.items.map((item) => item.signatureId))].sort()) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
}
const signatureIds = [
...new Set(
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
@@ -561,6 +573,7 @@ export class ChannelReportingService {
reportType: 'drainage';
drainageItemId: string;
channelId: string;
carrier: string | null;
status: string;
}> = [];
for (const item of data.items) {
@@ -577,7 +590,7 @@ export class ChannelReportingService {
if (drainageInfo.auditStatus !== 'approved')
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
const carrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
@@ -587,11 +600,23 @@ export class ChannelReportingService {
channelId: item.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier: reportType === 'signature' ? carrier : null,
carrier,
},
});
if (reportType === 'drainage' && !existing)
throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
if (reportType === 'drainage' && !existing) {
const legacy = carrier
? await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: item.drainageItemId,
carrier: null,
},
})
: null;
if (!legacy) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
}
if (reportType === 'signature' && !carrier && !existing)
throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt =
@@ -603,7 +628,7 @@ export class ChannelReportingService {
const task = existing
? await tx.channelSignatureReportTask.update({
where: { id: existing.id },
data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) },
data: { status: item.status, reason: data.reason, approvedAt },
})
: await tx.channelSignatureReportTask.create({
data: {
@@ -638,6 +663,7 @@ export class ChannelReportingService {
reportType,
drainageItemId: item.drainageItemId!,
channelId: item.channelId,
carrier,
status: item.status,
});
}
@@ -370,6 +370,7 @@ describe('ChannelsService', () => {
updatedAt: new Date(),
};
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
channelReportField: {
findMany: jest
.fn()
@@ -519,6 +520,7 @@ describe('ChannelsService', () => {
async (sourceEntry) => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
@@ -601,6 +603,7 @@ describe('ChannelsService', () => {
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
@@ -655,6 +658,7 @@ describe('ChannelsService', () => {
it('changes a drainage report task without overwriting the signature report summary', async () => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn(),
@@ -699,6 +703,7 @@ describe('ChannelsService', () => {
reportType: 'drainage',
drainageItemId: 'drain-1',
channelId: 'channel-1',
carrier: null,
status: 'approved',
},
]);
@@ -1259,6 +1264,7 @@ describe('ChannelsService', () => {
const transactionCallback = prisma.$transaction.mock.calls[0][0];
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
smsChannelGroup: {
update: jest.fn(),
+13
View File
@@ -0,0 +1,13 @@
/** A carrier-specific decision overrides a legacy channel decision, including rejection. */
export function selectDrainageReportTask<
T extends {
channelId: string;
carrier?: string | null;
approvalScope?: string;
},
>(tasks: T[], channelId: string, carrier?: string) {
return (
(carrier ? tasks.find((task) => task.channelId === channelId && task.carrier === carrier) : undefined) ??
tasks.find((task) => task.channelId === channelId && !task.carrier && task.approvalScope !== 'carrier_specific')
);
}
@@ -235,7 +235,10 @@ export class ReportBatchGenerationService {
continue;
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
continue;
if (scope.batchItem.reportType === 'signature' && carriers.size && task.carrier && !carriers.has(task.carrier))
if (
carriers.size &&
(task.carrier ? !carriers.has(task.carrier) : !carriers.has('all') && !carriers.has('legacy'))
)
continue;
const key = `${scope.batchItem.id}:${task.id}`;
if (seen.has(key)) continue;
@@ -664,7 +667,7 @@ export class ReportBatchGenerationService {
where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const targetCarriers = selected.reportType === 'signature' ? [...carriers].sort() : ['all'];
const targetCarriers = [...carriers].sort();
for (const carrier of targetCarriers) {
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
@@ -677,11 +680,13 @@ export class ReportBatchGenerationService {
if (missing.length)
targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const existingTask = currentTasks.find(
(task) => task.channelId === channel.id && (selected.reportType === 'drainage' || task.carrier === carrier),
);
const existingTask = currentTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备');
const duplicateBatchId = priorKeys.get(businessKey);
const duplicateBatchId =
priorKeys.get(businessKey) ??
(selected.reportType === 'drainage'
? priorKeys.get(businessKey.replace(/:carrier:[^:]+$/, ':carrier:all'))
: undefined);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({
id: `${channel.id}:${carrier}`,
@@ -71,47 +71,48 @@ export class ReportChannelExportService {
: missing.length
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
: null;
const reportCarriers =
reportType === 'signature'
? item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom')
: [null];
const reportCarriers = item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
[];
for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
const entry = await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`;
const existingTask = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
});
const task = existingTask
? await tx.channelSignatureReportTask.update({
where: { id: existingTask.id },
data: {
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
approvedAt: null,
},
})
: await tx.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: 'carrier_specific',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
return { task, existingTask };
});
const task = existingTask
? await this.prisma.channelSignatureReportTask.update({
where: { id: existingTask.id },
data: {
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
...(reportType === 'signature' ? { approvedAt: null } : {}),
},
})
: await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
tasks.push({ task, existingTask });
tasks.push(entry);
}
const task = tasks[0].task;
if (missingReason) {
@@ -128,3 +128,17 @@ describe('drainage authorization', () => {
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false);
});
});
describe('drainage carrier override', () => {
it('uses explicit rejection over legacy approval and keeps other carriers independent', () => {
const row = material('m', 'example.com', []);
row.reportTasks = [
{ id: 'legacy', channelId: 'c', carrier: null, status: 'approved' },
{ id: 'mobile', channelId: 'c', carrier: 'mobile', status: 'failed' },
{ id: 'unicom', channelId: 'c', carrier: 'unicom', status: 'approved' },
];
expect(assessDrainage([target('example.com')], [row], 'mobile').allowedChannelIds).toEqual([]);
expect(assessDrainage([target('example.com')], [row], 'unicom').allowedChannelIds).toEqual(['c']);
expect(assessDrainage([target('example.com')], [row], 'telecom').allowedChannelIds).toEqual(['c']);
});
});
+5 -3
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { isIP } from 'node:net';
import { parse } from 'tldts';
import type { PrismaService } from '../prisma/prisma.service';
@@ -19,7 +20,7 @@ export type DrainageMaterial = {
url: string;
auditStatus: string;
materialVersion: number;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; approvalScope?: string; status: string }>;
};
export type DrainageAssessment = {
version: string;
@@ -138,8 +139,9 @@ export function assessDrainage(
}
const channels = new Set(
approved.flatMap((item) =>
item.reportTasks
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
[...new Set(item.reportTasks.map((task) => task.channelId))]
.map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier))
.filter((task): task is NonNullable<typeof task> => task?.status === 'approved')
.map((task) => task.channelId),
),
);
+195 -110
View File
@@ -1,12 +1,15 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import type {
CreateSmsDrainageInfoDto,
CreateSmsSignatureOptions,
DrainageInfoListQuery,
ReviewDto,
StatusChangeDto,
UpdateSmsDrainageInfoDto,
} from './sms-config.contracts';
import { isRecord } from './sms-config.helpers';
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
@@ -20,67 +23,98 @@ function normalizeDrainageTarget(value?: string) {
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsDrainageService {
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
constructor(
private readonly prisma: PrismaService,
private readonly reportValidation: SmsReportValidationService,
private readonly audit: SmsAuditService,
) {}
private async assertUniqueTarget(
tx: Prisma.TransactionClient,
signatureId: string,
target: string,
excludeId?: string,
) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
const duplicate = await tx.smsDrainageInfo.findFirst({
where: {
signatureId,
url: target,
auditStatus: { not: 'deleted' },
...(excludeId ? { id: { not: excludeId } } : {}),
},
select: { id: true },
});
if (duplicate) throw new BadRequestException('同一签名下已存在相同的引流信息');
}
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
return this.prisma.smsDrainageInfo.findMany({
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
select: {
id: true,
tenantId: true,
signatureId: true,
applicationId: true,
siteName: true,
url: true,
remark: true,
reportValues: true,
auditStatus: true,
rejectReason: true,
submittedAt: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
signature: { select: { id: true, name: true, auditStatus: true } },
application: { select: { id: true, name: true, status: true } },
},
orderBy: { updatedAt: 'desc' },
});
}
return this.prisma.smsDrainageInfo.findMany({
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
select: {
id: true,
tenantId: true,
signatureId: true,
applicationId: true,
siteName: true,
url: true,
remark: true,
reportValues: true,
auditStatus: true,
rejectReason: true,
submittedAt: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
signature: { select: { id: true, name: true, auditStatus: true } },
application: { select: { id: true, name: true, status: true } },
},
orderBy: { updatedAt: 'desc' },
});
}
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
if (!item) throw new NotFoundException('Drainage info not found');
return item;
}
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
if (!item) throw new NotFoundException('Drainage info not found');
return item;
}
listDrainageInfos(query: DrainageInfoListQuery = {}) {
return this.prisma.smsDrainageInfo.findMany({
where: {
tenantId: query.tenantId,
signatureId: query.signatureId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [
{ siteName: { contains: query.keyword } },
{ url: { contains: query.keyword } },
{ signature: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
orderBy: { updatedAt: 'desc' },
});
}
return this.prisma.smsDrainageInfo.findMany({
where: {
tenantId: query.tenantId,
signatureId: query.signatureId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword
? [
{ siteName: { contains: query.keyword } },
{ url: { contains: query.keyword } },
{ signature: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
]
: undefined,
},
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
orderBy: { updatedAt: 'desc' },
});
}
async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
const target = normalizeDrainageTarget(data.url);
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
const auditStatus = options.initialAuditStatus ?? 'pending';
const item = await this.prisma.smsDrainageInfo.create({
async createDrainageInfo(
signatureId: string,
data: CreateSmsDrainageInfoDto,
options: CreateSmsSignatureOptions = {},
tenantId?: string,
) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
const target = normalizeDrainageTarget(data.url);
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
const auditStatus = options.initialAuditStatus ?? 'pending';
const item = await this.prisma.$transaction(async (tx) => {
await this.assertUniqueTarget(tx, signatureId, target);
return tx.smsDrainageInfo.create({
data: {
tenantId: signature.tenantId,
signatureId,
@@ -94,28 +128,65 @@ export class SmsDrainageService {
},
include: { tenant: true, signature: true, application: true },
});
await this.audit.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: item.id,
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
return item;
}
});
await this.audit.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: item.id,
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
return item;
}
async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url);
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
const auditStatus = options.initialAuditStatus ?? 'pending';
const updated = await this.prisma.smsDrainageInfo.update({
async updateDrainageInfo(
itemId: string,
data: UpdateSmsDrainageInfoDto,
options: CreateSmsSignatureOptions = {},
tenantId?: string,
) {
const current = await this.prisma.smsDrainageInfo.findUnique({
where: { id: itemId },
include: { signature: true },
});
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url);
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
await this.reportValidation.validateDrainageReportValues(
applicationId,
data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}),
);
const auditStatus = options.initialAuditStatus ?? 'pending';
const updated = await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${current.signatureId}, 910))`;
const latest = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!latest || latest.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
if (target !== undefined && target !== latest.url)
await this.assertUniqueTarget(tx, current.signatureId, target, itemId);
const priorTasks = await tx.channelSignatureReportTask.findMany({
where: { drainageItemId: itemId, reportType: 'drainage' },
});
for (const task of priorTasks) {
await tx.channelSignatureReportTask.update({
where: { id: task.id },
data: { status: 'waiting_review', approvedAt: null, reason: '引流资料修改,原报备失效' },
});
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: task.channelId,
action: 'material_changed',
statusBefore: task.status,
statusAfter: 'waiting_review',
reason: '引流资料修改,原报备失效',
},
});
}
return tx.smsDrainageInfo.update({
where: { id: itemId },
data: {
applicationId,
@@ -133,37 +204,51 @@ export class SmsDrainageService {
},
include: { tenant: true, signature: true, application: true },
});
await this.audit.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
statusBefore: current.auditStatus,
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
return updated;
}
});
await this.audit.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
statusBefore: current.auditStatus,
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
return updated;
}
approveDrainageInfo(itemId: string, data: ReviewDto) {
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
}
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
}
rejectDrainageInfo(itemId: string, data: ReviewDto) {
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
}
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
}
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
const status = data.status ?? 'deleted';
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
if (status === 'deleted') await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
return updated;
}
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
const status = data.status ?? 'deleted';
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
const updated = await this.prisma.$transaction(async (tx) => {
// Restoration must obey the same uniqueness lock as create and edit.
if (status !== 'deleted') await this.assertUniqueTarget(tx, current.signatureId, current.url, itemId);
return tx.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
});
if (status === 'deleted')
await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
await this.audit.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: status,
statusBefore: current.auditStatus,
statusAfter: status,
reason: data.reason,
});
return updated;
}
}
+161 -97
View File
@@ -1,122 +1,186 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
import { BadRequestException, NotFoundException } from '@nestjs/common';
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 { hasReportValue, isRecord, reportValueParts } from './sms-config.helpers';
import { SmsApplicationConfigService } from './application-config.service';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsReportValidationService {
constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {}
constructor(
private readonly prisma: PrismaService,
private readonly applications: SmsApplicationConfigService,
) {}
async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo) return drainageInfo;
const fields = await this.applications.getApplicationReportFields(applicationId);
return {
...drainageInfo,
reportRequirementSnapshot: {
capturedAt: new Date().toISOString(),
applicationId,
fields: fields.map((field) => ({
id: field.id,
code: field.code,
name: field.name,
fieldType: field.fieldType,
required: field.required,
reportTypes: field.reportTypes,
commonReportTypes: field.commonReportTypes,
channels: field.channels,
})),
},
};
}
if (!drainageInfo) return drainageInfo;
const fields = await this.applications.getApplicationReportFields(applicationId);
return {
...drainageInfo,
reportRequirementSnapshot: {
capturedAt: new Date().toISOString(),
applicationId,
fields: fields.map((field) => ({
id: field.id,
code: field.code,
name: field.name,
fieldType: field.fieldType,
required: field.required,
reportTypes: field.reportTypes,
commonReportTypes: field.commonReportTypes,
channels: field.channels,
})),
},
};
}
async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo) return;
const fields = await this.applications.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
const value = reportValueParts(signatureValues[field.code]);
for (const channel of field.channels) {
await this.prisma.signatureReportMaterial.upsert({
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
update: value,
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
});
}
if (!drainageInfo) return;
const fields = await this.applications.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
for (const field of fields.filter((item) =>
item.reportTypes.some((type) => type === 'signature' || type === 'both'),
)) {
const value = reportValueParts(signatureValues[field.code]);
for (const channel of field.channels) {
await this.prisma.signatureReportMaterial.upsert({
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
update: value,
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
}
async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
const fields = await this.applications.getApplicationReportFields(applicationId, 'signature');
const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {};
const missingSignature = fields
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
.filter((field) => !hasReportValue(signatureValues[field.code]));
if (missingSignature.length > 0) {
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
}
const fields = await this.applications.getApplicationReportFields(applicationId, 'signature');
const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {};
const missingSignature = fields
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
.filter((field) => !hasReportValue(signatureValues[field.code]));
if (missingSignature.length > 0) {
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
}
}
async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
}
async activateDrainageReporting(itemId: string) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!item) throw new NotFoundException('Drainage info not found');
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
if (!applicationId) return;
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage'))
.filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
const values = isRecord(item.reportValues) ? item.reportValues : {};
await this.prisma.$transaction(async (tx) => {
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
for (const field of fields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await tx.drainageReportMaterial.create({
data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task]));
for (const channel of channels.values()) {
const existing = existingByChannel.get(channel.id);
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } });
await tx.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' },
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!item) throw new NotFoundException('Drainage info not found');
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
if (!applicationId) return;
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage')).filter((field) =>
field.reportTypes.some((type) => type === 'drainage' || type === 'both'),
);
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
const values = isRecord(item.reportValues) ? item.reportValues : {};
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signatureId}, 910))`;
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
for (const field of fields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await tx.drainageReportMaterial.create({
data: {
signatureId: item.signatureId,
drainageItemId: item.id,
channelId: channel.id,
fieldCode: field.code,
...value,
},
});
}
for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } });
}
}
const existingTasks = await tx.channelSignatureReportTask.findMany({
where: { drainageItemId: item.id, reportType: 'drainage' },
});
}
const configuredChannels = await tx.smsChannel.findMany({
where: { id: { in: [...channels.keys()] }, status: { not: 'deleted' } },
});
const activeKeys = new Set<string>();
for (const channel of configuredChannels) {
for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) {
const key = `${channel.id}:${carrier}`;
activeKeys.add(key);
const existing = existingTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
const task = existing
? await tx.channelSignatureReportTask.update({
where: { id: existing.id },
data: { status: 'pending', reason: null, approvedAt: null },
})
: await tx.channelSignatureReportTask.create({
data: {
tenantId: item.tenantId,
signatureId: item.signatureId,
channelId: channel.id,
carrier,
approvalScope: 'carrier_specific',
reportType: 'drainage',
drainageItemId: item.id,
status: 'pending',
},
});
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: channel.id,
action: existing ? 'audit_approved_reset' : 'audit_approved_create',
statusBefore: existing?.status,
statusAfter: 'pending',
reason: '引流信息运营审核通过,按运营商重新报备',
},
});
}
}
for (const task of existingTasks.filter(
(current) => !activeKeys.has(`${current.channelId}:${current.carrier}`) && current.status !== 'abandoned',
)) {
const reason = '资料版本更新或应用路由已不包含此通道运营商';
await tx.channelSignatureReportTask.update({
where: { id: task.id },
data: { status: 'abandoned', reason, approvedAt: null },
});
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: task.channelId,
action: 'route_removed',
statusBefore: task.status,
statusAfter: 'abandoned',
reason,
},
});
}
});
}
async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
await this.prisma.$transaction(async (tx) => {
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } });
}
await this.prisma.$transaction(async (tx) => {
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
const tasks = await tx.channelSignatureReportTask.findMany({
where: { drainageItemId: item.id, reportType: 'drainage' },
});
}
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: task.channelId,
action: 'audit_suspended',
statusBefore: task.status,
statusAfter,
reason,
},
});
}
});
}
}
+72 -109
View File
@@ -1,64 +1,15 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type {
ApplicationListQuery,
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
CreateSmsDrainageInfoDto,
CreateSmsSignatureDto,
CreateSmsSignatureOptions,
CreateSmsTemplateDto,
CreateSmsTemplateOptions,
DrainageInfoListQuery,
GatewayDownstreamConnectionEventDto,
ReplaceApplicationRouteRulesDto,
ReviewDto,
SignatureListQuery,
StatusChangeDto,
TemplateListQuery,
UpdateSmsApplicationDto,
UpdateSmsDrainageInfoDto,
UpdateSmsSignatureDto,
UpdateSmsTemplateDto,
} from './sms-config.contracts';
import {
APPLICATION_DISABLE_GRACE_MS,
DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS,
DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS,
UNRESOLVED_DOWNSTREAM_STATUSES,
type TemplateVariableInput,
estimateBillingUnits,
generateApplicationPassword,
getPositiveInteger,
getPositiveIntegerEnv,
hasReportValue,
inferTemplateVariables,
isRecord,
normalizeApplicationCmppStatus,
normalizeApplicationInterfaceType,
normalizeApplicationPassword,
normalizeApplicationQueuePriority,
normalizeCmppAccessNumberConfig,
normalizeSmsSignature,
parseGatewayDate,
reportValueParts,
startOfToday,
validateAndNormalizeTemplateVariables,
validateCompleteSmsSignature,
} from './sms-config.helpers';
import { isRecord, normalizeSmsSignature, validateCompleteSmsSignature } from './sms-config.helpers';
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
@@ -202,6 +153,7 @@ export class SmsSignatureService {
.then((count) => count > 0);
return signatures.map((signature) => {
const { reportBatchItems: _reportBatchItems, ...signatureView } = signature;
void _reportBatchItems;
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const applicationChannels = [
...new Map(
@@ -305,64 +257,70 @@ export class SmsSignatureService {
pendingReportBlockedReason,
drainageReportTargets: Object.fromEntries(
signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
const tasks = (signature.reportTasks ?? []).filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id,
);
const targets = applicationChannels
.filter(
(channel) =>
channel.status !== 'deleted' &&
(hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
)),
hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
),
)
.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
const task = selectDrainageReportTask(tasks, channel.id, carrier);
return {
channel,
channelId: channel.id,
carrier,
status: task?.status ?? 'pending',
taskId: task?.id,
approvedAt: task?.approvedAt,
approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific',
};
}),
);
const taskByChannel = new Map(
(signature.reportTasks ?? [])
.filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId)
.map((task) => [task.channelId, task]),
);
return [
drainageItemId,
[...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
const task = taskByChannel.get(channel.id);
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
}),
];
return [drainageItem.id, targets];
}),
),
drainageCarrierReportSummary: Object.fromEntries(
signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
const tasks = (signature.reportTasks ?? []).filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id,
);
const targets = applicationChannels
.filter(
(channel) =>
channel.status !== 'deleted' &&
(hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
)),
);
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map(
(signature.reportTasks ?? [])
.filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId)
.map((task) => [task.channelId, task]),
);
return [
drainageItemId,
Object.fromEntries(
['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
const statuses = carrierTargets.flatMap((channel) =>
taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : [],
);
return [carrier, summarizeReportStatuses(statuses)];
hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
),
)
.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
const task = selectDrainageReportTask(tasks, channel.id, carrier);
return {
channel,
channelId: channel.id,
carrier,
status: task?.status ?? 'pending',
taskId: task?.id,
approvedAt: task?.approvedAt,
approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific',
};
}),
);
return [
drainageItem.id,
Object.fromEntries(
['mobile', 'unicom', 'telecom'].map((carrier) => [
carrier,
summarizeReportStatuses(
targets.filter((target) => target.carrier === carrier).map((target) => target.status),
),
]),
),
];
}),
@@ -400,10 +358,14 @@ export class SmsSignatureService {
drainageReportTargets: _drainageReportTargets,
...summary
} = view;
void [_materials, _reportTasks, _reportTargets, _drainageReportTargets];
return {
...summary,
drainageInfo: {
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link),
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => {
void _reportValues;
return link;
}),
},
};
});
@@ -508,7 +470,10 @@ export class SmsSignatureService {
for (const value of businessKeys) {
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
if (!match) continue;
for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean))
for (const carrier of match[2]
.split(',')
.map((entry) => entry.trim())
.filter(Boolean))
generatedTargets.add(`${match[1]}:${carrier}`);
}
}
@@ -534,7 +499,8 @@ export class SmsSignatureService {
candidate.approvalScope === 'legacy_channel',
);
if (task?.status === 'abandoned') continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`))
continue;
detailTotal += 1;
hasPendingTarget = true;
}
@@ -552,7 +518,7 @@ export class SmsSignatureService {
async getSignatureReportTargets(id: string) {
const item = await this.getSignature(id);
return 'reportTargets' in item ? item.reportTargets ?? [] : [];
return 'reportTargets' in item ? (item.reportTargets ?? []) : [];
}
async getDrainageReportTargets(id: string) {
@@ -562,7 +528,7 @@ export class SmsSignatureService {
});
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found');
const signature = await this.getSignature(drainage.signatureId);
return 'drainageReportTargets' in signature ? signature.drainageReportTargets?.[id] ?? [] : [];
return 'drainageReportTargets' in signature ? (signature.drainageReportTargets?.[id] ?? []) : [];
}
listSignatureOptions(tenantId?: string) {
@@ -876,10 +842,7 @@ export class SmsSignatureService {
updated.applicationId ?? undefined,
drainageInfo,
);
if (
options.initialAuditStatus === 'approved' &&
(materialChanged || signature.auditStatus !== 'approved')
) {
if (options.initialAuditStatus === 'approved' && (materialChanged || signature.auditStatus !== 'approved')) {
await this.audit.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
File diff suppressed because it is too large Load Diff
@@ -177,3 +177,19 @@ Gateway 每个分片等待可用连接后通过仅本机直连的 POST /api/gate
新增迁移 20260910130000_drainage_send_gate 只加列、索引、决策表及锁触发器,不改既有批准/客户/余额/短信记录。测试发布按标准工具执行,包含此前九项运营修复提交;回退旧程序会失去本门禁,不能未经评估恢复发送。原治理工具草稿和备份/候选均保留。
验证:独立本机 PostgreSQL 克隆库完成新迁移,真实规则/API验证 NFKC号码、全部目标交集、审核撤销、报备撤销、并发锁等待、URL三种伪装拒绝、决策持久化及报备SQL。真实浏览器连接该API验证拦截详情、刷新、路由切换和1600×1000、1366×768、390×844;无Browser插件,使用既有Playwright/Edge。发送Worker与Gateway传输未启动,不以这些证据替代供应商零Submit、客户回执ACK、长短信物理发送、费用对账或容量测试,以上须专项发送授权后验证。自动回归及发布结果以 testing-progress.md 最新记录为准。
## 11. 2026-09-14 引流唯一性与通道运营商报备设计
状态:本地实现与隔离验收完成;线上未部署,版本状态以 testing-progress.md 本轮记录为准。此节替代引流报备只按channelId及carrier=null通配的新增配置方式;域名匹配、平台审核、多目标交集、计费和Gateway复核协议保持。
1. 新增/修改及恢复引流:同一signatureId下未删除资料的引流值不得重复。按登记值trim比较,不把不同URL路径、协议或电话号码格式擅自合并;不同签名可相同。修改自身原值允许,已有重复记录不自动删除或合并,变更为其他已占用值拒绝。共同使用既有签名advisory事务锁,检查和写入同事务,覆盖管理端/客户端及并发请求;返回可读400。
2. 新引流报备任务键为signatureId+drainageItemId+channelId+carriercarrier为通道支持的mobile/unicom/telecom;复用已有carrier/approvalScope字段,无新表。页面与签名一样三网分组,按通道与运营商编辑;后端校验通道范围、引流归属及审核状态,返回相同维度并参与统计。新增或修改材料后,各适用运营商独立回到pending,移出通道/运营商及旧无运营商任务不继续保留旧授权。
3. 历史carrier=null任务不批量迁移或猜测运营商;在尚无明确运营商任务时保留原通道级兼容读法,并在页面标记历史通道级继承。某运营商已有明确任务时,无论通过/失败/未报备均优先,不回落旧通过状态;运营人员保存后建立明确三网任务。旧记录保留审计,跨运营商不能互相覆盖。
4. 路由、最终Gateway授权、签名卡片汇总、通道报备明细、批次目标/导出及按批次状态更新共用运营商语义。每个引流目标的通道交集按本条短信运营商计算;显式失败不得被旧carrier=null通过记录放行。批次按carrier业务键生成,旧all/legacy批次只保留原范围兼容,不把单运营商导出/状态结果扩散到其他运营商。
5. 本次另外核验HTTP IP白名单英文逗号已受支持,补输入说明和回归;发送详情仅展示敏感词命中/明确异常,不展示正常零命中快照。数据库审计不删除,实际失败原因保持。
6. 验收:并发同值新增/修改、自身/其他签名/已删除值、混合三网状态与历史覆盖、材料修改全部状态失效、真实PG/API与浏览器三尺寸、API/前端定向与全量、类型构建/质量门禁。无发送、重投、线上配置修改、推送或部署授权;本地隔离真实后端可验证配置及只读路由判断,物理短信链路不冒称通过。
### 11.1 实际数据库约束补核
真实隔离库复现原ChannelSignatureReportTask_drainage_target_key是签名+引流+通道部分唯一索引(Prisma模型未声明此部分约束)。必须新增20260914093000_drainage_carrier_reports,在同事务建立carrier非空四维唯一索引及carrier为空历史三维唯一索引,再移除旧三维索引;不改旧数据/状态。新索引同样防止状态保存与导出并发产生重复任务。迁移仅在独立验收库执行,发布后方能启用新代码;不能回退旧程序继续发送并把三网任务当通道级读取。应用回退需暂停发送并评估三网事实,不能删除新任务或直接重建旧唯一索引。
@@ -2293,3 +2293,12 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
### HTTP公共接口整改验收状态(2026-09-14)
上述Webhook DNS、IPv6 URL、严格日历与正文错误边界已在测试版本97d1334完成真实验收;不改变既定业务规则、计费或租户边界。高精度小数秒输入保留兼容,上行详情仅客户业务字段,禁止通道和内部匹配字段。详见 [验收报告](http-api-full-acceptance-20260914.md),生产环境状态不可由测试结论替代。
## 2026-09-14 引流信息与运营商报备补充
- HTTP 接口 IP 白名单支持英文逗号、中文逗号和空白分隔多个 IP/CIDR,编辑说明必须明确。
- 同一签名下未删除引流资料的登记值(去除首尾空白)唯一;新增、编辑及状态恢复均不可绕过,并发请求最多一条成功。不同签名可使用相同值,自身原值可保留,既有重复不自动清理。
- 引流报备按引流资料、通道、运营商独立配置,只有本运营商通过的通道可进入对应短信路由;显式未通过不能继承旧通道级通过状态。材料变化使旧审批失效。
- 发送详情保留敏感词命中及明确异常,不显示正常的零命中检查;审计数据保留。
- 兼容、迁移和验收以 [引流门禁方案第 11 节](drainage-send-gating-plan-20260910.md#11-2026-09-14-引流唯一性与通道运营商报备设计) 为准。
+18
View File
@@ -5488,3 +5488,21 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
新增覆盖:TC-HTTP-FULL-DATE(非法日历/query/cursor与时区、高精度兼容)、TC-HTTP-FULL-BODY(畸形/非对象/超限/字符集/编码的400/413/415及关联ID)、TC-HTTP-FULL-IPV6(回环/ULA/link-local/mapped私网拒绝且配置不变)、TC-HTTP-FULL-ROTATE(新密钥真实签名成功);记录与断言名称逐项见报告附录。
## 2026-09-14 引流唯一性、三网报备及详情用例
| 编号 | 场景与预期 |
|---|---|
| DRN-CARRIER-01 | HTTP 白名单混合英文逗号、中文逗号、换行及空格分隔 IP/CIDR,保存后逐项正确回读。 |
| DRN-CARRIER-02 | 同签名重复新增及编辑撞值返回 400;前后空白不绕过;跨签名同值、自身原值和已删除值可使用。 |
| DRN-CARRIER-03 | 同签名五请求并发新增同值只有一条成功;两条资料并发改为同值只有一条成功;恢复已删除资料不得造成重复。 |
| DRN-CARRIER-04 | 单个三网通道分别保存移动通过、联通失败、电信未报备,刷新/报备明细/汇总维度一致;仅移动路由放行。 |
| DRN-CARRIER-05 | 有旧 carrier=null 通过记录时,明确运营商失败仍拒绝;无明确任务的运营商继承历史状态并标注来源。 |
| DRN-CARRIER-06 | 材料更新立即失效所有旧审批,各适用运营商回到未报备;旧通道级审批不可继续授权。 |
| DRN-CARRIER-07 | 批次目标、导出任务及批次状态更新保留运营商,单运营商结果不得覆盖其他运营商;旧 all/legacy 批次仅作用原范围。 |
| DRN-CARRIER-08 | 实际旧索引迁移保留旧审批所有字段,允许三网独立记录,拒绝同运营商和旧通道级重复;不自动改线上数据。 |
| DRN-CARRIER-09 | 发送详情正常零命中快照不可见,真实命中和明确失败仍可见,数据库快照数量不变。 |
| DRN-CARRIER-10 | 三尺寸首次打开、保存、刷新、切换详情;检查真实响应及数据库,不把隔离服务适配器视为完整认证/Gateway 验收。 |
执行结果与未执行边界见 testing-progress.md 本日记录。
+26
View File
@@ -4971,3 +4971,29 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
## 2026-09-14 四项配置与引流运营商报备修复(本地提交范围)
### 范围与只读证据
- 起点本地 main 与实际远端 main 均为 ac6449028c4ab072ef16a219f61d592e2d453a7e,起始暂存为空。保护既有 metrics、tools/release、HTTP 接入及需求/测试/发布草稿,不推送、不部署、不发送/补发/重投/入队,不修改线上配置或业务数据。
- 2026-09-14 17:11:47(北京时间)只读核验预生产应用 d13ca0713abd6afbea5a62af39bcbb876b8bb186。号码 188****3795 的 MSG-b3b529de-602c-44a8-9cc9-bce3a5a8360f 在 15:39:30 至 15:41:51 有 10 条路由敏感词快照,全部 hits=0、reason=null;多轮候选选择均保存快照,详情逐条显示正常结果导致噪声。只过滤展示,审计/短信状态保持。首次只读查询使用不存在的 createdAt 后改用 queuedAt;未进行数据修复或发送。
- HTTP 白名单原解析已支持英文逗号,本轮明确输入说明并补混合分隔符回归。引流新增/编辑此前缺少同签名重复校验;通过同签名 advisory 事务锁将检查与写入串行化,状态恢复亦校验。
- 原引流任务按通道级 carrier=null 管理,现按通道×运营商管理,并更新路由、签名汇总、报备明细、批次/导出和状态更新。明确运营商结果优先于旧通道级状态,材料变化使旧审批失效。设计先更新于 drainage-send-gating-plan-20260910.md 第 11 节。
- 真实 PostgreSQL 首轮创建暴露旧部分唯一索引仍限制三维键,新增 20260914093000_drainage_carrier_reports,保留全部旧记录,改为明确运营商与历史通道级两个部分唯一索引。新迁移仅在本轮独立本地库执行。
### 已执行验证
- API 全量:74 suites / 811 testsAPI TypeScript 生产构建通过。
- 前端全量最终:30 files / 144 testsnpx vitest run --maxWorkers=2);TypeScript 与 production 构建通过。最初新增三网测试发现 Select 未传递 aria 名称,修复公共组件并删除关闭时无用的 portalStyle 状态重置,覆盖三网选择与重新打开。另一轮并行构建/测试发生 17 项超时及关联断言失败,保留原日志;限制 worker 后及最终稳定代码两次全量通过,未提高超时或删除断言。
- npm run lint、format:check、quality:verify、style:check、css:verify15 tests)、security:verify、bundle:verify 通过;lint 留存 3 条非阻断提示(原报备页 effect 依赖、IP 解析函数导出、测试 any)。git diff --check 通过。原未格式化测试/服务文件随当前格式门禁格式化,无业务扩展。
- tools/testing/verify-drainage-carriers.mjs:独立 loopback PostgreSQL 16414 / cmpp_qa_carriers_20260914,实际服务 HTTP 适配器 16416,25 项通过。覆盖迁移旧行完全保留及两类唯一约束、trim 重复、跨签名、自身修改、5 请求并发新增、并发改值、删除值复用及恢复防绕过、三网保存/汇总/路由、旧审批不覆盖明确失败、材料修改审批失效、批次目标与列表及 HTTP 白名单落库回读。一次新增数据扩充后列表断言受默认 10 条分页影响,限定验收签名并读取 100 条后通过。
- 浏览器连接器本轮仍为 nodeRepl.fetch request failed;使用已安装 Playwright + Chrome。本地 Vite dev 入口加载超时后,改用 production 构建 + preview 16418,真实业务组件连接上述服务与 PG1600×1000、1366×768、390×844 无横向溢出。三网分别保存(移动通过、联通失败、电信未报备)后刷新读取一致,重复值 HTTP 400,发送详情不显示零命中快照,切换弹窗与重新打开选择器通过。无框架错误;控制台仅验收入口 favicon 404 和故意触发的重复值 400。
- 证据目录:%TEMP%/cmpp-drainage-carriers-20260914preprod-records.json、api-full.log、frontend-final-stable.log、real-http-final2.log、real-fixture-final.json、ui-evidence.json、carrier-1600.png / carrier-1366.png / carrier-final-390.png 及质量日志)。保留初次失败和最终结果。
### 交付边界与遗留
- 本轮仅本地修改、文档和本地提交;未推送、未部署测试、未部署预生产。迁移和代码未在两套线上环境生效。提交号见本条记录所在提交;最终汇报提供精确 SHA。
- 隔离 HTTP 适配器直接调用真实业务服务与 PG,不包含完整 Nest 全局认证、生产反向代理和 worker;不得将其当作在线全功能验收。测试环境/预生产完整登录页面、权限与租户隔离在线回归、MinIO 报备文件导出/导入实物、Redis/Gateway 实际短信发送与计费闭环本轮未执行。原链路单元回归通过不替代物理发送专项验收。
- 53 项已有保护文件在最终核对中保持摘要(仅本轮文档采用追加并精确暂存);其余脏文件和草稿不纳入提交。历史重复资料不自动清理,历史通道级审批不批量重写。上线须先按标准发布流程执行新索引迁移,回退不可直接删除三网任务或重建旧索引。
+8 -1
View File
@@ -297,7 +297,14 @@ export type ClientSmsSignature = {
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
drainageReportTargets?: Record<
string,
Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>
Array<{
channel: AdminChannel;
channelId: string;
carrier: 'mobile' | 'unicom' | 'telecom';
status: string;
taskId?: string;
approvalScope?: string;
}>
>;
drainageCarrierReportSummary?: Record<
string,
+3 -5
View File
@@ -347,11 +347,9 @@ export function AdminReportTasksPage() {
render: (record) => (
<div>
<strong>{record.channel?.name ?? record.channelId}</strong>
{record.reportType !== 'drainage' ? (
<div className="muted">
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
</div>
) : null}
<div className="muted">
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
</div>
</div>
),
},
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage';
import { AdminSmsApplicationFormPage, parseIpAllowlist } from './AdminSmsApplicationFormPage';
vi.mock('@/api/adminApi', () => ({
adminApi: {
@@ -44,3 +44,12 @@ describe('application form feedback', () => {
expect(dialog).toBeInTheDocument();
});
});
it('accepts comma separated HTTP IP entries including IPv6 and CIDR', () => {
expect(parseIpAllowlist('203.0.113.1, 203.0.113.0/242001:db8::1\n2001:db8::/64')).toEqual([
'203.0.113.1',
'203.0.113.0/24',
'2001:db8::1',
'2001:db8::/64',
]);
});
@@ -595,7 +595,7 @@ export function AdminSmsApplicationFormPage() {
<Input
label="HTTP IP 白名单"
onChange={(event) => setHttpIpAddress(event.target.value)}
placeholder="多个 IP/CIDR 可换行填写,留空表示不限制"
placeholder="多个 IP/CIDR 可用英文逗号、中文逗号或空白分隔,留空表示不限制"
value={httpIpAddress}
/>
<Input
@@ -779,7 +779,7 @@ function getRouteGroupId(routeRules: DictionaryItem[], carrier: Carrier) {
return typeof rule?.groupId === 'string' ? rule.groupId : '';
}
function parseIpAllowlist(value: string) {
export function parseIpAllowlist(value: string) {
return value
.split(/[\s,]+/)
.map((item) => item.trim())
@@ -0,0 +1,57 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { DrainageReportStatusModal } from './SignatureReportModals';
import type { DrainageInfo } from './signature.types';
vi.mock('@/api/adminApi', () => ({ adminApi: { changeReportTaskStatuses: vi.fn().mockResolvedValue([]) } }));
describe('drainage report carrier form', () => {
it('submits three separate carrier decisions and keeps failures visible', async () => {
const item = { id: 'd', url: 'example.com' } as DrainageInfo;
const signature = {
id: 's',
name: '【测试】',
drainageReportTargets: {
d: ['mobile', 'unicom', 'telecom'].map((carrier) => ({
channelId: 'c',
channel: { id: 'c', name: '三网通道' },
carrier,
status: 'pending',
})),
},
} as unknown as ClientSmsSignature;
const saved = vi.fn();
render(<DrainageReportStatusModal item={item} signature={signature} onClose={() => {}} onSaved={saved} />);
fireEvent.click(screen.getByLabelText('三网通道移动报备状态'));
fireEvent.click(screen.getByRole('option', { name: '报备通过' }));
fireEvent.click(screen.getByLabelText('三网通道联通报备状态'));
fireEvent.click(screen.getByRole('option', { name: '报备失败' }));
fireEvent.click(screen.getByRole('button', { name: '保存状态' }));
await waitFor(() => expect(saved).toHaveBeenCalled());
expect(vi.mocked(adminApi.changeReportTaskStatuses).mock.calls[0][0].items).toEqual([
{
signatureId: 's',
drainageItemId: 'd',
reportType: 'drainage',
channelId: 'c',
carrier: 'mobile',
status: 'approved',
},
{
signatureId: 's',
drainageItemId: 'd',
reportType: 'drainage',
channelId: 'c',
carrier: 'unicom',
status: 'failed',
},
{
signatureId: 's',
drainageItemId: 'd',
reportType: 'drainage',
channelId: 'c',
carrier: 'telecom',
status: 'pending',
},
]);
});
});
@@ -2,69 +2,280 @@ import { useState } from 'react';
import { Info } from 'lucide-react';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
import { carrierLabel, CarrierReportTag } from './signature.helpers';
import { carrierLabel } from './signature.helpers';
import type { DrainageInfo } from './signature.types';
const reportStatusOptions = [
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
];
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
export function ChannelReportStatusModal({
item,
onClose,
onSaved,
}: {
item: ClientSmsSignature;
onClose: () => void;
onSaved: () => void;
}) {
const targets = item.reportTargets ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])));
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
);
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, carrier: target.carrier, status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
await adminApi.changeReportTaskStatuses({
items: targets.map((target) => ({
signatureId: item.id,
channelId: target.channelId,
carrier: target.carrier,
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
})),
reason,
sourceEntry: 'enterprise_signature',
});
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
} finally {
setSaving(false);
}
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="修改签名报备状态">
<div className="signature-report-status"><div className="signature-report-status__context"><strong>{item.name}</strong><span>{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}</span></div><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? <div className="signature-report-status__carriers">{carriers.map((carrier) => { const carrierTargets = targets.filter((target) => target.carrier === carrier); return <section className="signature-report-status__carrier" key={carrier}><header><CarrierTag carrier={carrier} /><span>{carrierTargets.length} </span></header><div className="signature-report-status__list">{carrierTargets.length ? carrierTargets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="signature-report-status__row" key={key}><strong title={target.channel.name}>{target.channel.name}</strong><Select aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`} onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="signature-report-status__empty">{carrierLabel(carrier)}</div>}</div></section>; })}</div> : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
</div>
</Modal>;
}
export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
const targets = signature.drainageReportTargets?.[item.id] ?? [];
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
</div>
</Modal>;
}
export function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
footer={
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
<Button onClick={onClose} variant="ghost">
</Button>
<Button disabled={!targets.length || saving} onClick={() => void save()}>
{saving ? '保存中...' : '保存状态'}
</Button>
</>
)}
}
onClose={onClose}
open
size="xl"
title="修改签名报备状态"
>
<div className="signature-report-status">
<div className="signature-report-status__context">
<strong>{item.name}</strong>
<span>
{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}
</span>
</div>
<div className="signature-alert">
<Info size={18} />
<span></span>
</div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? (
<div className="signature-report-status__carriers">
{carriers.map((carrier) => {
const carrierTargets = targets.filter((target) => target.carrier === carrier);
return (
<section className="signature-report-status__carrier" key={carrier}>
<header>
<CarrierTag carrier={carrier} />
<span>{carrierTargets.length} </span>
</header>
<div className="signature-report-status__list">
{carrierTargets.length ? (
carrierTargets.map((target) => {
const key = `${target.channelId}:${target.carrier}`;
return (
<div className="signature-report-status__row" key={key}>
<strong title={target.channel.name}>{target.channel.name}</strong>
<Select
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
onChange={(event) =>
setStatuses((current) => ({ ...current, [key]: event.target.value }))
}
options={reportStatusOptions}
value={statuses[key] ?? target.status}
/>
</div>
);
})
) : (
<div className="signature-report-status__empty">{carrierLabel(carrier)}</div>
)}
</div>
</section>
);
})}
</div>
) : (
<div className="empty-state"></div>
)}
<Textarea
label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="请输入运营商工单、确认依据或人工处理说明"
rows={3}
value={reason}
/>
</div>
</Modal>
);
}
export function DrainageReportStatusModal({
item,
onClose,
onSaved,
signature,
}: {
item: DrainageInfo;
signature: ClientSmsSignature;
onClose: () => void;
onSaved: () => void;
}) {
const targets = signature.drainageReportTargets?.[item.id] ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
);
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({
items: targets.map((target) => ({
signatureId: signature.id,
reportType: 'drainage',
drainageItemId: item.id,
channelId: target.channelId,
carrier: target.carrier,
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
})),
reason,
sourceEntry: 'enterprise_signature',
});
onSaved();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
} finally {
setSaving(false);
}
}
return (
<Modal
footer={
<>
<Button onClick={onClose} variant="ghost">
</Button>
<Button disabled={!targets.length || saving} onClick={() => void save()}>
{saving ? '保存中...' : '保存状态'}
</Button>
</>
}
onClose={onClose}
open
size="xl"
title="修改引流报备状态"
>
<div className="signature-report-status">
<div className="signature-report-status__context">
<strong>{item.url}</strong>
<span>
{signature.name} · {signature.application?.name ?? '-'}
</span>
</div>
<div className="signature-alert">
<Info size={18} />
<span></span>
</div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? (
<div className="signature-report-status__carriers">
{carriers.map((carrier) => {
const carrierTargets = targets.filter((target) => target.carrier === carrier);
return (
<section className="signature-report-status__carrier" key={carrier}>
<header>
<CarrierTag carrier={carrier} />
<span>{carrierTargets.length} </span>
</header>
<div className="signature-report-status__list">
{carrierTargets.length ? (
carrierTargets.map((target) => {
const key = `${target.channelId}:${target.carrier}`;
return (
<div className="signature-report-status__row" key={key}>
<strong title={target.channel.name}>
{target.channel.name}
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
</strong>
<Select
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
onChange={(event) =>
setStatuses((current) => ({ ...current, [key]: event.target.value }))
}
options={reportStatusOptions}
value={statuses[key] ?? target.status}
/>
</div>
);
})
) : (
<div className="signature-report-status__empty">{carrierLabel(carrier)}</div>
)}
</div>
</section>
);
})}
</div>
) : (
<div className="empty-state"></div>
)}
<Textarea
label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="请输入运营商工单、确认依据或人工处理说明"
rows={3}
value={reason}
/>
</div>
</Modal>
);
}
export 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="删除确认"
+11 -12
View File
@@ -19,6 +19,9 @@ type SendDetailModalProps = {
};
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
const visibleWordDecisions = record.channelWordDecisions?.filter(
(decision) => decision.snapshot.hits.length > 0 || Boolean(decision.snapshot.reason),
);
const routeRows = buildRouteRows(record, segmentAudits);
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
@@ -47,15 +50,13 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
}
>
<div className="admin-sms-send-detail">
<section aria-label="通道筛选原因">
<h3></h3>
{record.channelWordDecisions?.length ? (
record.channelWordDecisions.map((decision) => (
{visibleWordDecisions?.length ? (
<section aria-label="通道筛选原因">
<h3></h3>
{visibleWordDecisions.map((decision) => (
<div key={decision.id}>
<p>
{getTime(decision.decidedAt)} ·{' '}
{decision.snapshot.reason ||
(decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')}
{getTime(decision.decidedAt)} · {decision.snapshot.reason || '已排除命中通道,按剩余候选选路'}
</p>
{decision.snapshot.hits.map((hit) => (
<p key={hit.channelId}>
@@ -65,11 +66,9 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
</p>
))}
</div>
))
) : (
<p className="muted">{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}</p>
)}
</section>
))}
</section>
) : null}
<section aria-label="引流发送资格">
<h3></h3>
{record.drainageGate ? (
@@ -5,17 +5,39 @@ import { SmsRecordList } from './SmsRecordList';
import { SendDetailModal } from './SendDetailModal';
const record = {
id: 'record-1', messageId: 'message-1', content: '请访问example.com查询', hasDrainageContent: true,
id: 'record-1',
messageId: 'message-1',
content: '请访问example.com查询',
hasDrainageContent: true,
drainageDetection: { matches: [{ start: 3, end: 14, value: 'example.com' }] },
queuedAt: '2026-08-31T01:00:00Z', deliveredAt: '2026-08-31T01:00:05Z', status: 'delivered',
amountCents: 5, billingUnits: 1, phoneNumber: '13800138000', submitRecords: [], receiptRecords: [],
queuedAt: '2026-08-31T01:00:00Z',
deliveredAt: '2026-08-31T01:00:05Z',
status: 'delivered',
amountCents: 5,
billingUnits: 1,
phoneNumber: '13800138000',
submitRecords: [],
receiptRecords: [],
} as unknown as SmsMessageRecord;
describe('SMS drainage and final receipt presentation', () => {
it('shows only a positive drainage badge under status and removes receipt time from list', () => {
const { container } = render(<SmsRecordList currentPage={1} loading={false} records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]} total={2} totalPages={1} onExport={() => {}} onOpenDetail={() => {}} onPageChange={() => {}} />);
const { container } = render(
<SmsRecordList
currentPage={1}
loading={false}
records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]}
total={2}
totalPages={1}
onExport={() => {}}
onOpenDetail={() => {}}
onPageChange={() => {}}
/>,
);
expect(screen.getAllByText('含引流')).toHaveLength(1);
expect(container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge')).toHaveTextContent('含引流');
expect(
container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge'),
).toHaveTextContent('含引流');
expect(screen.queryByText('不含引流')).not.toBeInTheDocument();
expect(screen.queryByText('回执时间')).not.toBeInTheDocument();
expect(screen.queryByText('09:00:05')).not.toBeInTheDocument();
@@ -30,10 +52,49 @@ describe('SMS drainage and final receipt presentation', () => {
});
it('shows negative drainage only in details and does not mislabel untested historical records', () => {
const { rerender } = render(<SendDetailModal record={{ ...record, hasDrainageContent: false }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
const { rerender } = render(
<SendDetailModal
record={{ ...record, hasDrainageContent: false }}
segmentAudits={[]}
segmentLoading={false}
onClose={() => {}}
/>,
);
expect(screen.getByText('不含引流')).toBeVisible();
expect(document.querySelector('mark')).toBeNull();
rerender(<SendDetailModal record={{ ...record, hasDrainageContent: undefined }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
rerender(
<SendDetailModal
record={{ ...record, hasDrainageContent: undefined }}
segmentAudits={[]}
segmentLoading={false}
onClose={() => {}}
/>,
);
expect(screen.getByText('未检测')).toBeVisible();
});
});
it('hides no-hit routing snapshots while retaining sensitive-word failures', () => {
const clean = { id: 'clean', decidedAt: '2026-09-14T07:41:51Z', snapshot: { hits: [], reason: null } };
const props = { segmentAudits: [], segmentLoading: false, onClose: () => {} };
const { rerender } = render(
<SendDetailModal {...props} record={{ ...record, channelWordDecisions: [clean] } as unknown as SmsMessageRecord} />,
);
expect(screen.queryByRole('region', { name: '通道筛选原因' })).not.toBeInTheDocument();
expect(screen.queryByText(/候选通道未命中/)).not.toBeInTheDocument();
rerender(
<SendDetailModal
{...props}
record={
{
...record,
channelWordDecisions: [
clean,
{ ...clean, id: 'blocked', snapshot: { hits: [], reason: '可用通道均命中通道敏感词' } },
],
} as unknown as SmsMessageRecord
}
/>,
);
expect(screen.getByText(/可用通道均命中通道敏感词/)).toBeVisible();
});
+39 -19
View File
@@ -66,7 +66,11 @@ export function Select({
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node) && !dropdownRef.current?.contains(event.target as Node)) {
if (
rootRef.current &&
!rootRef.current.contains(event.target as Node) &&
!dropdownRef.current?.contains(event.target as Node)
) {
setOpen(false);
setSearchKeyword('');
}
@@ -77,10 +81,8 @@ export function Select({
}, []);
useLayoutEffect(() => {
if (!open || !dropdownPortal) {
setPortalStyle(null);
return;
}
// Closed/non-portal menus do not consume portalStyle; opening measures it before paint.
if (!open || !dropdownPortal) return;
function updatePosition() {
const trigger = rootRef.current?.querySelector<HTMLElement>('.ui-select');
@@ -122,16 +124,28 @@ export function Select({
className={['ui-select__dropdown', dropdownPortal ? 'ui-select__dropdown--portal' : ''].filter(Boolean).join(' ')}
ref={dropdownRef}
role="listbox"
style={dropdownPortal ? portalStyle ?? { visibility: 'hidden' } : undefined}
style={dropdownPortal ? (portalStyle ?? { visibility: 'hidden' }) : undefined}
>
{searchEnabled ? (
<label className="ui-select__search">
<Search size={15} />
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
<input
autoFocus
onChange={(event) => setSearchKeyword(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder={searchPlaceholder ?? '输入名称搜索'}
value={searchKeyword}
/>
</label>
) : null}
{visibleOptions.map((option) => (
<button aria-selected={option.value === selectedValue} key={option.value} onClick={() => selectOption(option.value)} role="option" type="button">
<button
aria-selected={option.value === selectedValue}
key={option.value}
onClick={() => selectOption(option.value)}
role="option"
type="button"
>
{option.label}
</button>
))}
@@ -140,15 +154,15 @@ export function Select({
);
return (
<label
className={['ui-field', className].filter(Boolean).join(' ')}
htmlFor={selectId}
ref={rootRef}
>
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={selectId} ref={rootRef}>
{label ? (
<span className="ui-field__label">
{label}
{required ? <span aria-label="必填" className="ui-field__required">*</span> : null}
{required ? (
<span aria-label="必填" className="ui-field__required">
*
</span>
) : null}
</span>
) : null}
<span
@@ -157,17 +171,23 @@ export function Select({
open ? 'ui-select--open' : '',
error ? 'ui-select--error' : '',
disabled ? 'ui-select--disabled' : '',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
>
<button
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-expanded={open}
aria-haspopup="listbox"
disabled={disabled}
id={selectId}
onClick={() => setOpen((current) => {
if (current) setSearchKeyword('');
return !current;
})}
onClick={() =>
setOpen((current) => {
if (current) setSearchKeyword('');
return !current;
})
}
type="button"
>
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
+393
View File
@@ -0,0 +1,393 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
const url = new URL(process.env.DRAINAGE_TEST_DATABASE_URL || '');
assert(
['localhost', '127.0.0.1'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'),
'Dedicated loopback QA database required',
);
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
const { PrismaService } = require('./dist/prisma/prisma.service.js');
const { SmsConfigService } = require('./dist/sms-config/sms-config.service.js');
const { ChannelReportingService } = require('./dist/channels/channel-reporting.service.js');
const { OpenApiService } = require('./dist/open-api/open-api.service.js');
const { ReportBatchGenerationService } = require('./dist/report-materials/batch-generation.service.js');
const { OperationsMessageQueries } = require('./dist/operations/queries/messages.queries.js');
const { assessDrainage } = require('./dist/send-chain/drainage-authorization.js');
const express = require('express');
const db = new PrismaService();
const sms = new SmsConfigService(db);
const reporting = new ChannelReportingService(db);
// No onModuleInit: transport, workers, reconciliation and authentication are outside this loopback service harness.
const httpConfig = new OpenApiService(db, undefined);
const batch = new ReportBatchGenerationService(db, undefined, sms, undefined, undefined);
const messages = new OperationsMessageQueries(db);
const app = express();
app.use(express.json());
app.set('json replacer', (_, value) => (typeof value === 'bigint' ? value.toString() : value));
const wrap = (fn) => async (req, res) => {
try {
res.json(await fn(req));
} catch (e) {
res.status(e.getStatus?.() || 500).json({ message: e.message });
}
};
app.get(
'/api/admin/enterprise-signatures/:id',
wrap((r) => sms.getSignature(r.params.id)),
);
app.post(
'/api/admin/enterprise-signatures/:id/drainage-infos',
wrap((r) => sms.createDrainageInfo(r.params.id, r.body, { initialAuditStatus: 'approved' })),
);
app.put(
'/api/admin/drainage-infos/:id',
wrap((r) => sms.updateDrainageInfo(r.params.id, r.body, { initialAuditStatus: 'approved' })),
);
app.get(
'/api/admin/enterprise-applications/:id/report-fields',
wrap((r) => sms.getApplicationReportFields(r.params.id)),
);
app.post(
'/api/admin/drainage-infos/:id/status',
wrap((r) => sms.changeDrainageInfoStatus(r.params.id, r.body)),
);
app.get(
'/api/admin/drainage-infos/:id/report-targets',
wrap((r) => sms.getDrainageReportTargets(r.params.id)),
);
app.post(
'/api/admin/report-tasks/status-change',
wrap((r) => reporting.changeReportTaskStatuses(r.body)),
);
app.get(
'/api/admin/enterprise-applications/:id/http-api',
wrap((r) => httpConfig.getConfig(r.params.id)),
);
app.put(
'/api/admin/enterprise-applications/:id/http-api',
wrap((r) => httpConfig.updateConfig(r.params.id, r.body)),
);
app.get(
'/api/admin/messages/:id',
wrap((r) => messages.getMessage(r.params.id)),
);
const port = Number(process.env.DRAINAGE_TEST_PORT || 16416);
const server = await new Promise((resolve) => {
const listener = app.listen(port, '127.0.0.1', () => resolve(listener));
});
const base = 'http://127.0.0.1:' + port;
const checks = [];
const check = (name, value) => {
assert(value, name);
checks.push(name);
};
const request = async (method, path, body) => {
const r = await fetch(base + '/api/admin/' + path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: r.status, data: await r.json() };
};
try {
const tag = randomUUID().slice(0, 8);
const { Client } = require('pg');
const migrationDb = new Client({ connectionString: url.toString() });
await migrationDb.connect();
try {
await migrationDb.query('CREATE SCHEMA qa_migration_' + tag);
await migrationDb.query('SET search_path TO qa_migration_' + tag);
await migrationDb.query(
'CREATE TABLE "ChannelSignatureReportTask" ("id" text, "signatureId" text, "drainageItemId" text, "channelId" text, "carrier" text, "reportType" text, "status" text)',
);
await migrationDb.query(
`CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_target_key" ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId") WHERE "reportType"='drainage' AND "drainageItemId" IS NOT NULL`,
);
await migrationDb.query(
`INSERT INTO "ChannelSignatureReportTask" VALUES ('old','s','d','c',NULL,'drainage','approved')`,
);
const oldRow = (await migrationDb.query('SELECT * FROM "ChannelSignatureReportTask"')).rows;
await migrationDb.query(
fs.readFileSync(
new URL('../../api/prisma/migrations/20260914093000_drainage_carrier_reports/migration.sql', import.meta.url),
'utf8',
),
);
assert.deepEqual((await migrationDb.query('SELECT * FROM "ChannelSignatureReportTask"')).rows, oldRow);
check('migration preserves legacy approval exactly', true);
for (const carrier of ['mobile', 'unicom', 'telecom'])
await migrationDb.query(
`INSERT INTO "ChannelSignatureReportTask" VALUES ($1,'s','d','c',$1,'drainage','pending')`,
[carrier],
);
for (const carrier of ['mobile', null])
await assert.rejects(
migrationDb.query(
`INSERT INTO "ChannelSignatureReportTask" VALUES ('dup','s','d','c',$1,'drainage','pending')`,
[carrier],
),
{ code: '23505' },
);
check('migration permits independent carriers and rejects carrier and legacy duplicates', true);
} finally {
await migrationDb.end();
}
const tenant = await db.tenant.create({ data: { name: '隔离引流验收', code: 'QA-' + tag } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '隔离引流应用',
cmppAccount: 'qa-' + tag,
cmppEnterpriseCode: 'QA',
secretHash: randomUUID(),
interfaceEnabled: false,
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: application.id, name: '【引流三网验收】', auditStatus: 'approved' },
});
const second = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: application.id, name: '【其他签名】', auditStatus: 'approved' },
});
const channel = await db.smsChannel.create({
data: {
code: 'QA-' + tag,
name: '隔离三网通道',
carriers: ['mobile', 'unicom', 'telecom'],
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'unused',
passwordCipher: 'unused',
srcId: '1069',
},
});
for (const carrier of ['mobile', 'unicom', 'telecom']) {
const group = await db.smsChannelGroup.create({
data: {
code: 'QA-' + tag + '-' + carrier,
name: carrier,
carrier,
items: { create: { channelId: channel.id, carrier } },
},
});
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier },
});
}
const field = await db.drainageField.create({ data: { code: 'qa_' + tag, name: '引流信息', fieldType: 'string' } });
await db.channelReportField.create({
data: {
channelId: channel.id,
drainageFieldId: field.id,
code: field.code,
name: field.name,
reportType: 'drainage',
exportName: field.name,
fieldType: 'string',
required: false,
},
});
const created = await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
url: 'example.com',
});
if (created.status !== 200) console.error('create response', created);
check('create', created.status === 200);
const drainage = created.data;
const duplicate = await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
url: ' example.com ',
});
check('duplicate trimmed value returns 400', duplicate.status === 400);
const other = await request('POST', 'enterprise-signatures/' + second.id + '/drainage-infos', { url: 'example.com' });
check('same value in another signature allowed', other.status === 200);
const parallel = await Promise.all(
Array.from({ length: 5 }, () =>
request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'parallel.example.com' }),
),
);
check(
'five concurrent creates commit once',
parallel.filter((x) => x.status === 200).length === 1 && parallel.filter((x) => x.status === 400).length === 4,
);
const parallelItem = parallel.find((x) => x.status === 200).data;
const conflicting = await request('PUT', 'drainage-infos/' + parallelItem.id, { url: 'example.com' });
check('edit cannot collide', conflicting.status === 400);
const editA = (
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'a.example.net' })
).data;
const editB = (
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'b.example.net' })
).data;
const racingEdits = await Promise.all(
[editA, editB].map((x) => request('PUT', 'drainage-infos/' + x.id, { url: 'race.example.net' })),
);
check(
'concurrent edits commit one target',
racingEdits.filter((x) => x.status === 200).length === 1 &&
racingEdits.filter((x) => x.status === 400).length === 1,
);
await db.smsDrainageInfo.update({ where: { id: parallelItem.id }, data: { auditStatus: 'deleted' } });
check(
'deleted target can be reused',
(
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
url: 'parallel.example.com',
})
).status === 200,
);
const unchanged = await request('PUT', 'drainage-infos/' + drainage.id, {
url: 'example.com',
remark: 'unchanged value',
});
check('edit self allowed', unchanged.status === 200);
check(
'restoration cannot bypass uniqueness',
(await request('POST', `drainage-infos/${parallelItem.id}/status`, { status: 'approved' })).status === 400,
);
const targets = (await request('GET', 'drainage-infos/' + drainage.id + '/report-targets')).data;
check('one channel has three targets', targets.length === 3 && new Set(targets.map((x) => x.carrier)).size === 3);
const states = { mobile: 'approved', unicom: 'failed', telecom: 'pending' };
const body = {
items: targets.map((t) => ({
signatureId: signature.id,
drainageItemId: drainage.id,
reportType: 'drainage',
channelId: channel.id,
carrier: t.carrier,
status: states[t.carrier],
})),
sourceEntry: 'enterprise_signature',
};
check('save three carrier states', (await request('POST', 'report-tasks/status-change', body)).status === 200);
const rows = await db.smsDrainageInfo.findMany({ where: { id: drainage.id }, include: { reportTasks: true } });
const target = {
key: 'url:example.com',
category: 'url',
value: 'example.com',
text: 'example.com',
start: 0,
end: 11,
};
check('mobile route approved', assessDrainage([target], rows, 'mobile').allowedChannelIds.includes(channel.id));
check('unicom route denied', assessDrainage([target], rows, 'unicom').allowedChannelIds.length === 0);
check('telecom pending denied', assessDrainage([target], rows, 'telecom').allowedChannelIds.length === 0);
await db.channelSignatureReportTask.create({
data: {
tenantId: tenant.id,
signatureId: signature.id,
drainageItemId: drainage.id,
channelId: channel.id,
reportType: 'drainage',
carrier: null,
status: 'approved',
},
});
const legacyRows = await db.smsDrainageInfo.findMany({ where: { id: drainage.id }, include: { reportTasks: true } });
check(
'legacy approval cannot override explicit rejection',
assessDrainage([target], legacyRows, 'unicom').allowedChannelIds.length === 0,
);
const view = (await request('GET', 'enterprise-signatures/' + signature.id)).data;
check(
'summary follows carrier',
view.drainageCarrierReportSummary[drainage.id].mobile.approved === 1 &&
view.drainageCarrierReportSummary[drainage.id].unicom.approved === 0,
);
const preview = await batch.inspectBatchItem({
reportType: 'drainage',
signatureId: signature.id,
drainageItemId: drainage.id,
});
check(
'batch targets preserve carriers',
preview.targets.length === 3 && preview.targets.every((t) => t.carrier !== 'all'),
);
const details = await reporting.listReportDetailsPage({
reportType: 'drainage',
channelId: channel.id,
signatureId: signature.id,
pageSize: 100,
});
check(
'report details unique carrier rows',
details.items.filter((t) => t.drainageItemId === drainage.id).length === 3,
);
check(
'material update',
(await request('PUT', 'drainage-infos/' + drainage.id, { url: 'example.com', remark: 'invalidate reports' }))
.status === 200,
);
const reset = await db.channelSignatureReportTask.findMany({ where: { drainageItemId: drainage.id } });
check(
'material update invalidates all approvals',
reset.every((t) => t.status !== 'approved'),
);
check(
'material update resets three carrier tasks',
reset.filter((t) => t.carrier && t.status === 'pending').length === 3,
);
const whitelist = ['203.0.113.1', '203.0.113.0/24', '2001:db8::1'];
check(
'HTTP whitelist persists',
(
await request('PUT', 'enterprise-applications/' + application.id + '/http-api', {
enabled: false,
ipAllowlist: whitelist,
})
).status === 200,
);
const config = (await request('GET', 'enterprise-applications/' + application.id + '/http-api')).data;
check(
'HTTP whitelist readback',
JSON.stringify([...config.ipAllowlist].sort()) === JSON.stringify([...whitelist].sort()),
);
const message = await db.smsMessageRecord.create({
data: {
messageId: 'QA-' + tag,
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
phoneNumber: '13800001000',
content: '【引流三网验收】只读展示',
status: 'failed',
},
});
await db.smsChannelSensitiveDecision.create({
data: {
messageRecordId: message.id,
routeAttemptId: randomUUID(),
snapshot: { hits: [], reason: null, candidateChannelIds: [], selectedChannelId: null },
},
});
const fixture = {
tag,
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
drainageId: drainage.id,
channelId: channel.id,
messageId: message.id,
base,
checks,
};
if (process.env.DRAINAGE_TEST_EVIDENCE)
fs.writeFileSync(process.env.DRAINAGE_TEST_EVIDENCE, JSON.stringify(fixture, null, 2));
console.log(JSON.stringify({ passed: checks.length, checks, fixture }));
if (process.env.DRAINAGE_TEST_KEEP_SERVER !== 'true') {
await new Promise((r) => server.close(r));
await db.$disconnect();
}
} catch (e) {
console.error(e);
await new Promise((r) => server.close(r));
await db.$disconnect();
process.exitCode = 1;
}
process.on('SIGINT', async () => {
await new Promise((r) => server.close(r));
await db.$disconnect();
process.exit(0);
});