feat: filter SMS routes by channel sensitive words

This commit is contained in:
hectorzhao
2026-09-10 15:50:56 +08:00
parent 86947827cc
commit 8e4bc5a20e
26 changed files with 1615 additions and 41 deletions
@@ -0,0 +1,20 @@
CREATE TABLE "ChannelSensitiveWord" (
"id" TEXT PRIMARY KEY, "channelId" TEXT NOT NULL, "word" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active', "remark" TEXT NOT NULL DEFAULT '',
"version" INTEGER NOT NULL DEFAULT 1, "createdBy" TEXT NOT NULL, "updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChannelSensitiveWord_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChannelSensitiveWord_status_check" CHECK ("status" IN ('active','inactive','deleted')),
CONSTRAINT "ChannelSensitiveWord_word_check" CHECK (char_length("word") BETWEEN 1 AND 200)
);
CREATE UNIQUE INDEX "ChannelSensitiveWord_channelId_word_key" ON "ChannelSensitiveWord"("channelId","word");
CREATE INDEX "ChannelSensitiveWord_channelId_status_idx" ON "ChannelSensitiveWord"("channelId","status");
CREATE TABLE "SmsChannelSensitiveDecision" (
"id" TEXT PRIMARY KEY, "messageRecordId" TEXT NOT NULL, "routeAttemptId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "snapshot" JSONB NOT NULL,
CONSTRAINT "SmsChannelSensitiveDecision_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "SmsChannelSensitiveDecision_routeAttemptId_key" ON "SmsChannelSensitiveDecision"("routeAttemptId");
CREATE INDEX "SmsChannelSensitiveDecision_messageRecordId_decidedAt_idx" ON "SmsChannelSensitiveDecision"("messageRecordId","decidedAt");
ALTER TABLE "SmsMessageRecord" ADD COLUMN "channelWordFinalizationPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_channelWordFinalizationPending_idx" ON "SmsMessageRecord"("updatedAt") WHERE "channelWordFinalizationPending" = true;
+29
View File
@@ -266,6 +266,32 @@ model PhoneCarrierRule {
@@index([status, priority])
}
model ChannelSensitiveWord {
id String @id @default(cuid())
channelId String
channel SmsChannel @relation(fields: [channelId], references: [id])
word String
status String @default("active")
remark String @default("")
version Int @default(1)
createdBy String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channelId, word])
@@index([channelId, status])
}
model SmsChannelSensitiveDecision {
id String @id @default(cuid())
messageRecordId String
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id])
routeAttemptId String @unique
decidedAt DateTime @default(now())
snapshot Json
@@index([messageRecordId, decidedAt])
}
model SensitiveWord {
id String @id @default(cuid())
word String @unique
@@ -881,6 +907,7 @@ model AuditRecord {
}
model SmsChannel {
sensitiveWords ChannelSensitiveWord[]
id String @id @default(cuid())
code String @unique
name String
@@ -1799,6 +1826,8 @@ model SmsDrainageDecision {
}
model SmsMessageRecord {
channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[]
id String @id @default(cuid())
tenantId String?
@@ -0,0 +1,23 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
@Controller('admin/dictionaries/channel-sensitive-words')
export class ChannelSensitiveWordsController {
constructor(private readonly service: ChannelSensitiveWordsService) {}
@Get() list(@CurrentSessionUserId() userId: string, @Query() query: Record<string, string | undefined>) {
return this.service.list(userId, query);
}
@Post() create(@CurrentSessionUserId() userId: string, @Body() body: unknown) {
return this.service.save(userId, body);
}
@Patch(':id') update(@CurrentSessionUserId() userId: string, @Param('id') id: string, @Body() body: unknown) {
return this.service.save(userId, body, id);
}
@Delete(':id') remove(
@CurrentSessionUserId() userId: string,
@Param('id') id: string,
@Body() body: { version?: unknown },
) {
return this.service.remove(userId, id, body?.version);
}
}
@@ -0,0 +1,40 @@
import { ChannelSensitiveWordsService, validateChannelWord } from './channel-sensitive-words.service';
import { PrismaService } from '../prisma/prisma.service';
const valid = { channelId: 'a', word: ' 贷 款 ', status: 'active', remark: '' };
describe('channel word administration', () => {
it('trims only outer whitespace and requires a version for editing', () => {
expect(validateChannelWord(valid).word).toBe('贷 款');
expect(() => validateChannelWord(valid, true)).toThrow('版本');
expect(validateChannelWord({ ...valid, version: 3 }, true).version).toBe(3);
});
it.each([
null,
[],
{ ...valid, word: ' ' },
{ ...valid, word: 'a'.repeat(201) },
{ ...valid, channelId: '' },
{ ...valid, status: 'deleted' },
{ ...valid, remark: 'a'.repeat(501) },
{ ...valid, operatorId: 'spoof' },
])('rejects invalid runtime data %#', (data) => expect(() => validateChannelWord(data)).toThrow());
it('checks active platform admin permission before data access', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue(null) },
channelSensitiveWord: { findMany: jest.fn() },
};
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
await expect(service.list('client-user', {})).rejects.toMatchObject({ status: 403 });
expect(prisma.channelSensitiveWord.findMany).not.toHaveBeenCalled();
expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } }),
}),
);
});
it('rejects invalid pagination before querying rules', async () => {
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ id: 'admin' }) } };
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
for (const query of [{ page: '0' }, { pageSize: '101' }, { page: '1.5' }, { status: 'deleted' }])
await expect(service.list('admin', query)).rejects.toMatchObject({ status: 400 });
});
});
@@ -0,0 +1,157 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export function validateChannelWord(value: unknown, editing = false) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('规则参数无效');
const data = value as Record<string, unknown>;
if (Object.keys(data).some((key) => !['channelId', 'word', 'status', 'remark', 'version'].includes(key)))
throw new BadRequestException('包含不支持的字段');
if (typeof data.channelId !== 'string' || !data.channelId.trim() || data.channelId.length > 160)
throw new BadRequestException('请选择通道');
if (typeof data.word !== 'string' || !data.word.trim() || data.word.trim().length > 200)
throw new BadRequestException('敏感词需为1200个字符');
if (typeof data.status !== 'string' || !['active', 'inactive'].includes(data.status))
throw new BadRequestException('状态无效');
if (data.remark !== undefined && (typeof data.remark !== 'string' || data.remark.length > 500))
throw new BadRequestException('备注最多500个字符');
if (editing && (!Number.isSafeInteger(data.version) || Number(data.version) < 1))
throw new BadRequestException('请提供规则版本');
return {
channelId: data.channelId.trim(),
word: data.word.trim(),
status: data.status as string,
remark: (data.remark as string | undefined) ?? '',
version: editing ? Number(data.version) : undefined,
};
}
@Injectable()
export class ChannelSensitiveWordsService {
constructor(private readonly prisma: PrismaService) {}
async authorize(userId?: string) {
if (
!userId ||
!(await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } },
select: { id: true },
}))
)
throw new ForbiddenException('无敏感词管理权限');
}
async list(userId: string | undefined, query: Record<string, string | undefined>) {
await this.authorize(userId);
const page = Number(query.page ?? 1),
pageSize = Number(query.pageSize ?? 25);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
throw new BadRequestException('分页参数无效');
if (query.status && !['all', 'active', 'inactive'].includes(query.status))
throw new BadRequestException('状态无效');
if (query.keyword && (typeof query.keyword !== 'string' || query.keyword.length > 200))
throw new BadRequestException('搜索词过长');
if (query.channelId && typeof query.channelId !== 'string') throw new BadRequestException('通道参数无效');
const where: Prisma.ChannelSensitiveWordWhereInput = {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
channelId: query.channelId || undefined,
word: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [items, total] = await this.prisma.$transaction([
this.prisma.channelSensitiveWord.findMany({
where,
include: { channel: { select: { id: true, name: true, status: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSensitiveWord.count({ where }),
]);
return { items, total, page, pageSize };
}
async save(userId: string | undefined, value: unknown, id?: string) {
await this.authorize(userId);
const data = validateChannelWord(value, Boolean(id));
try {
return await this.prisma.$transaction(async (tx) => {
if (
!(await tx.smsChannel.findFirst({
where: { id: data.channelId, status: { not: 'deleted' } },
select: { id: true },
}))
)
throw new BadRequestException('通道不存在或已删除');
const current = id
? await tx.channelSensitiveWord.findUnique({ where: { id } })
: await tx.channelSensitiveWord.findUnique({
where: { channelId_word: { channelId: data.channelId, word: data.word } },
});
if (id && (!current || current.status === 'deleted')) throw new NotFoundException('规则不存在或已删除');
if (!id && current && current.status !== 'deleted') throw new ConflictException('该通道已配置相同敏感词');
const fields = {
channelId: data.channelId,
word: data.word,
status: data.status,
remark: data.remark,
updatedBy: userId!,
};
let saved;
if (current) {
const result = await tx.channelSensitiveWord.updateMany({
where: { id: current.id, version: id ? data.version : current.version },
data: { ...fields, version: { increment: 1 } },
});
if (result.count !== 1) throw new ConflictException('规则已被修改,请刷新后重试');
saved = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id: current.id } });
} else saved = await tx.channelSensitiveWord.create({ data: { ...fields, createdBy: userId! } });
await tx.operationLog.create({
data: {
userId,
action:
current?.status === 'deleted'
? 'channel_sensitive_word.restore'
: id
? 'channel_sensitive_word.update'
: 'channel_sensitive_word.create',
resource: 'channel_sensitive_word',
resourceId: saved.id,
detail: JSON.parse(JSON.stringify({ before: current, after: saved })),
},
});
return saved;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
throw new ConflictException('该通道已配置相同敏感词');
throw error;
}
}
async remove(userId: string | undefined, id: string, version: unknown) {
await this.authorize(userId);
if (!Number.isSafeInteger(version) || Number(version) < 1) throw new BadRequestException('请提供规则版本');
return this.prisma.$transaction(async (tx) => {
const before = await tx.channelSensitiveWord.findUnique({ where: { id } });
if (!before || before.status === 'deleted') throw new NotFoundException('规则不存在或已删除');
const result = await tx.channelSensitiveWord.updateMany({
where: { id, version: Number(version) },
data: { status: 'deleted', version: { increment: 1 }, updatedBy: userId! },
});
if (!result.count) throw new ConflictException('规则已被修改,请刷新后重试');
const after = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id } });
await tx.operationLog.create({
data: {
userId,
action: 'channel_sensitive_word.delete',
resource: 'channel_sensitive_word',
resourceId: id,
detail: JSON.parse(JSON.stringify({ before, after })),
},
});
return { deleted: true };
});
}
}
+4 -2
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
import { ChannelSensitiveWordsController } from './channel-sensitive-words.controller';
import { DictionariesController } from './dictionaries.controller';
import { DictionariesService } from './dictionaries.service';
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
@Module({
controllers: [DictionariesController],
providers: [DictionariesService, PhoneRoutingLookupService],
controllers: [DictionariesController, ChannelSensitiveWordsController],
providers: [DictionariesService, PhoneRoutingLookupService, ChannelSensitiveWordsService],
exports: [DictionariesService, PhoneRoutingLookupService],
})
export class DictionariesModule {}
@@ -108,6 +108,7 @@ export class OperationsMessageQueries {
const item = await this.prisma.smsMessageRecord.findUnique({
where: { id },
include: {
channelWordDecisions: { orderBy: [{ decidedAt: 'desc' }, { id: 'desc' }], take: 10 },
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } },
@@ -0,0 +1,81 @@
import { ChannelWordSnapshot, loadChannelWords } from './channel-sensitive-routing';
import { PrismaService } from '../prisma/prisma.service';
const channel = {
status: 'active',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
};
const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobile', priority: index + 1, channel }));
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
describe('channel sensitive routing snapshot', () => {
it('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
expect(snapshot.select('n', '正常业务', items, options).selected?.channelId).toBe('a');
expect(snapshot.decisions[0].snapshot).toEqual(
expect.objectContaining({ excludedChannelIds: ['a'], stage: 'route' }),
);
});
it('keeps original exclusions and distinguishes no original route from all hit', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '贷款', items, { ...options, excludedChannelIds: new Set(['b']) }).rejected).toBe(true);
expect(snapshot.select('m', '贷款', items, { ...options, approvedChannelIds: new Set() }).rejected).toBe(false);
expect(
snapshot.select(
'm',
'贷款',
items.map((item) => ({ ...item, channel: { ...channel, status: 'inactive' } })),
options,
).rejected,
).toBe(false);
});
it('allows national fallback when matching province channel is excluded', () => {
const provincial = [{ ...items[0], province: '上海', channel: { ...channel, sendRegion: '上海' } }, items[1]];
expect(
new ChannelWordSnapshot([rule]).select('m', '贷款', provincial, { ...options, province: '上海' }).selected
?.channelId,
).toBe('b');
});
it('matches original complete content, case sensitively without removing separators', () => {
const snapshot = new ChannelWordSnapshot([{ ...rule, word: 'Ab贷款' }]);
expect(snapshot.hits('【签名】Ab贷款')[0].count).toBe(1);
for (const value of ['ab贷款', 'b贷款', 'Ab贷-款']) expect(snapshot.hits(value)).toHaveLength(0);
});
it('caps sample words but never truncates excluded channels or hit counts', () => {
const rules = Array.from({ length: 21 }, (_, index) => ({ ...rule, id: String(index) }));
const snapshot = new ChannelWordSnapshot([...rules, { ...rule, channelId: 'b' }]);
expect(snapshot.hits('贷款')[0]).toEqual(expect.objectContaining({ count: 21, samples: expect.any(Array) }));
expect(snapshot.hits('贷款')[0].samples).toHaveLength(20);
expect(snapshot.select('m', '贷款', items, options).rejected).toBe(true);
});
it('reads rules once per batch and persists all decisions in one idempotent write', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([rule]) },
smsChannelSensitiveDecision: { createMany: jest.fn() },
};
const snapshot = await loadChannelWords(prisma as unknown as PrismaService, ['a', 'b', 'a']);
for (let index = 0; index < 100; index++) snapshot.select(String(index), '贷款', items, options);
expect(snapshot.hits('贷款')).toBe(snapshot.hits('贷款'));
await snapshot.persist(prisma as unknown as PrismaService);
expect(prisma.channelSensitiveWord.findMany).toHaveBeenCalledTimes(1);
expect(prisma.smsChannelSensitiveDecision.createMany).toHaveBeenCalledWith({
data: expect.any(Array),
skipDuplicates: true,
});
expect(snapshot.decisions).toHaveLength(100);
expect(new Set(snapshot.decisions.map((value) => value.routeAttemptId)).size).toBe(100);
});
it('fails closed for technical read and persistence errors, not as a word hit', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
};
await expect(loadChannelWords(prisma as unknown as PrismaService, ['a'])).rejects.toMatchObject({ status: 503 });
const snapshot = new ChannelWordSnapshot([rule]);
snapshot.select('m', '贷款', items, options);
await expect(snapshot.persist(prisma as unknown as PrismaService)).rejects.toMatchObject({ status: 503 });
});
});
@@ -0,0 +1,93 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { ChannelSensitiveWord, Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { selectChannelCandidate } from './send-chain.helpers';
export const CHANNEL_WORD_NO_ROUTE = 'CHANNEL_SENSITIVE_WORD_NO_ROUTE';
export class ChannelWordRejection extends BadRequestException {
readonly reasonCode = CHANNEL_WORD_NO_ROUTE;
constructor() {
super('可用通道均命中通道敏感词');
}
}
type Rule = Pick<ChannelSensitiveWord, 'id' | 'channelId' | 'word' | 'version'>;
type Hit = { channelId: string; count: number; samples: Array<{ id: string; word: string; version: number }> };
export class ChannelWordSnapshot {
private readonly matches = new Map<string, Hit[]>();
readonly decisions: Prisma.SmsChannelSensitiveDecisionCreateManyInput[] = [];
constructor(
private readonly rules: Rule[],
readonly readAt = new Date().toISOString(),
) {}
hits(content: string): Hit[] {
const cached = this.matches.get(content);
if (cached) return cached;
const matched = new Map<string, Hit>();
for (const rule of this.rules) {
if (!rule.word || !content.includes(rule.word)) continue;
const hit = matched.get(rule.channelId) ?? { channelId: rule.channelId, count: 0, samples: [] };
hit.count++;
if (hit.samples.length < 20) hit.samples.push({ id: rule.id, word: rule.word, version: rule.version });
matched.set(rule.channelId, hit);
}
const result = [...matched.values()];
this.matches.set(content, result);
return result;
}
select<T extends Parameters<typeof selectChannelCandidate>[0][number]>(
messageId: string,
content: string,
items: T[],
options: Parameters<typeof selectChannelCandidate>[1],
) {
const candidates = items.filter((item) => selectChannelCandidate([item], options));
const candidateIds = new Set(candidates.map((item) => item.channelId));
const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
const hits = this.hits(content)
.filter((hit) => candidateIds.has(hit.channelId))
.map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
const rejected = !selected && candidates.length > 0 && hits.length > 0;
const routeAttemptId = randomUUID();
this.decisions.push({
id: randomUUID(),
messageRecordId: messageId,
routeAttemptId,
snapshot: {
readAt: this.readAt,
stage: 'route',
contentHash: createHash('sha256').update(content).digest('hex'),
candidateChannelIds: [...candidateIds],
excludedChannelIds: hits.map((hit) => hit.channelId),
hits,
selectedChannelId: selected?.channelId ?? null,
reason: rejected ? '可用通道均命中通道敏感词' : null,
},
});
return { selected, rejected };
}
async persist(prisma: PrismaService) {
if (!this.decisions.length) return;
try {
await prisma.smsChannelSensitiveDecision.createMany({ data: this.decisions, skipDuplicates: true });
} catch {
throw new ServiceUnavailableException('通道敏感词选路记录保存失败');
}
}
}
export async function loadChannelWords(prisma: PrismaService, channelIds: string[]) {
try {
const rules = channelIds.length
? await prisma.channelSensitiveWord.findMany({
where: { channelId: { in: [...new Set(channelIds)] }, status: 'active' },
select: { id: true, channelId: true, word: true, version: true },
orderBy: { id: 'asc' },
})
: [];
return new ChannelWordSnapshot(rules);
} catch {
throw new ServiceUnavailableException('通道敏感词读取失败');
}
}
@@ -18,7 +18,7 @@ export class DrainageReceiptRecoveryService implements OnModuleInit, OnModuleDes
try {
await this.sendChain.recoverDrainageFailureReceipts();
} catch (error) {
this.logger.error(`引流拦截回执恢复失败: ${String(error)}`);
this.logger.error(`选路拦截回执恢复失败: ${String(error)}`);
} finally {
this.running = false;
}
@@ -4,6 +4,7 @@ import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service';
import { DrainageRejection } from './drainage-authorization';
import { ChannelWordRejection } from './channel-sensitive-routing';
function createPrismaMock() {
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
@@ -77,6 +78,8 @@ function createPrismaMock() {
},
};
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([]) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
tenant: {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
},
@@ -498,6 +501,81 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
}
describe('SendChainService', () => {
it('recovers non-CMPP channel-word finalization without pushing a receipt', async () => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findMany.mockResolvedValue([
{ ...message, channelWordFinalizationPending: true, batchTask: { sourceType: 'client' } },
]);
service['releaseMessageReservation'] = jest.fn();
service['recordCmppFailureReceipt'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.recoverDrainageFailureReceipts();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
expect(service['refreshTaskProgress']).toHaveBeenCalledWith('task-1');
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it.each(['cmpp', 'client', 'http'])(
'fails an all-hit ordinary route without supplier submit (%s)',
async (sourceType) => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({ ...message, batchTask: { id: 'task-1', sourceType } });
service['selectChannelForMessage'] = jest.fn().mockRejectedValue(new ChannelWordRejection());
service['recordCmppFailureReceipt'] = jest.fn();
service['releaseMessageReservation'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.processSendJob({ messageRecordId: 'record-1' });
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
if (sourceType === 'cmpp')
expect(service['recordCmppFailureReceipt']).toHaveBeenCalledWith(
expect.anything(),
'CSW',
'可用通道均命中通道敏感词',
);
else expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ channelWordFinalizationPending: true, drainageReceiptPending: false }),
}),
);
},
);
it('recovers channel-word delivery intent from an existing receipt and keeps pending on failure', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
const message = {
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppRegisteredDelivery: true,
cmppSubmitSequenceId: '101',
};
service['queueAndTryDownstreamDelivery'] = jest
.fn()
.mockRejectedValueOnce(Error('persistence failed'))
.mockResolvedValue({ id: 'delivery' });
await expect(service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词')).rejects.toThrow(
'persistence failed',
);
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { channelWordFinalizationPending: false } }),
);
await service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
+18 -3
View File
@@ -872,10 +872,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async recoverDrainageFailureReceipts() {
const messages = await this.prisma.smsMessageRecord.findMany({
where: {
drainageReceiptPending: true,
OR: [
{ drainageReceiptPending: true, batchTask: { sourceType: 'cmpp' } },
{ channelWordFinalizationPending: true },
],
status: { in: ['failed', 'submit_failed'] },
batchTask: { sourceType: 'cmpp' },
},
include: { batchTask: { select: { sourceType: true } } },
orderBy: { updatedAt: 'asc' },
take: 50,
});
@@ -886,9 +889,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
reason,
);
if (message.channelWordFinalizationPending && message.batchTask?.sourceType !== 'cmpp') {
await this.refreshTaskProgress(message.batchTaskId);
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
continue;
}
await this.recordCmppFailureReceipt(
message,
message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN',
message.channelWordFinalizationPending
? 'CSW'
: message.errorCode?.startsWith('DRN')
? message.errorCode
: 'DRN',
reason,
);
}
@@ -466,7 +466,8 @@ export class SendDownstreamDeliveryService {
const existing = await this.prisma.smsReceiptRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
});
if (existing && !errorCode.startsWith('DRN')) return existing;
const recoverableRejection = errorCode.startsWith('DRN') || errorCode === 'CSW';
if (existing && !recoverableRejection) return existing;
const deliveredAt = new Date();
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
@@ -501,12 +502,12 @@ export class SendDownstreamDeliveryService {
};
const receipt =
existing ??
(errorCode.startsWith('DRN')
(recoverableRejection
? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData })
: await this.prisma.smsReceiptRecord.create({ data: receiptData }));
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
message,
propagateHttpQueueError: errorCode.startsWith('DRN'),
propagateHttpQueueError: recoverableRejection,
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
payload: {
messageId: message.messageId,
@@ -520,6 +521,11 @@ export class SendDownstreamDeliveryService {
},
});
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
if (errorCode === 'CSW')
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
if (errorCode.startsWith('DRN'))
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } });
return receipt;
@@ -7,6 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
import { CHANNEL_WORD_NO_ROUTE, ChannelWordRejection, loadChannelWords } from './channel-sensitive-routing';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
@@ -27,7 +28,6 @@ import {
isNationalChannel,
composeUpstreamSrcId,
bullmqConnection,
selectChannelCandidate,
} from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
@@ -462,6 +462,10 @@ export class SendGatewaySubmitService {
include: { reportTasks: { where: { reportType: 'drainage' } } },
})
: [];
const channelWords = await loadChannelWords(
this.prisma,
routes.flatMap((route) => route.group.items.map((item) => item.channelId)),
);
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string; code?: string }> = [];
for (const input of routeInputs) {
@@ -474,11 +478,13 @@ export class SendGatewaySubmitService {
continue;
}
let gate;
let content: string;
try {
const stored =
input.message.content === undefined
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
: input.message;
content = stored.content!;
gate = await evaluateMessageDrainage(
this.prisma,
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
@@ -523,7 +529,7 @@ export class SendGatewaySubmitService {
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
),
);
const selected = selectChannelCandidate(approvedItems, {
const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, {
carrier: input.carrier,
province: input.province,
excludedChannelIds: new Set(),
@@ -531,7 +537,11 @@ export class SendGatewaySubmitService {
routingKey: input.message.id,
});
if (!selected) {
failed.push({ message: input.message, reason: '无已报备通过且在线的可用通道' });
failed.push({
message: input.message,
reason: rejected ? '可用通道均命中通道敏感词' : '无已报备通过且在线的可用通道',
...(rejected ? { code: CHANNEL_WORD_NO_ROUTE } : {}),
});
continue;
}
planned.push({
@@ -546,6 +556,7 @@ export class SendGatewaySubmitService {
},
});
}
await channelWords.persist(this.prisma);
return { planned, failed };
}
@@ -573,7 +584,8 @@ export class SendGatewaySubmitService {
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code,
"drainageReceiptPending" = failures.pending, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
"drainageReceiptPending" = failures.pending AND failures.code IS DISTINCT FROM ${CHANNEL_WORD_NO_ROUTE},
"channelWordFinalizationPending" = COALESCE(failures.code = ${CHANNEL_WORD_NO_ROUTE}, false), "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${values}) AS failures(id, reason, code, pending)
WHERE message.id = failures.id AND message.status = 'queued'
`);
@@ -581,8 +593,19 @@ export class SendGatewaySubmitService {
failed.map(async ({ message, reason, code }) => {
await this.releaseMessageReservation(message, reason);
if (message.batchTask?.sourceType === 'cmpp')
await this.recordCmppFailureReceipt(message, code ? 'DRN' : 'ROUTE', reason);
else await this.facade.refreshTaskProgress(message.batchTaskId);
await this.recordCmppFailureReceipt(
message,
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
reason,
);
else {
await this.facade.refreshTaskProgress(message.batchTaskId);
if (code === CHANNEL_WORD_NO_ROUTE)
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
}
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
this.metrics?.recordSendWorkerResult('failed');
}),
@@ -665,7 +688,8 @@ export class SendGatewaySubmitService {
} catch (error) {
if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500)
throw error;
const code = error instanceof DrainageRejection ? error.reasonCode : undefined;
const code =
error instanceof DrainageRejection || error instanceof ChannelWordRejection ? error.reasonCode : undefined;
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
@@ -673,17 +697,29 @@ export class SendGatewaySubmitService {
status: 'failed',
errorMessage: reason,
errorCode: code,
drainageReceiptPending: Boolean(code && message.batchTask?.sourceType === 'cmpp'),
drainageReceiptPending: Boolean(
code && code !== CHANNEL_WORD_NO_ROUTE && message.batchTask?.sourceType === 'cmpp',
),
channelWordFinalizationPending: code === CHANNEL_WORD_NO_ROUTE,
},
});
await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, code ? 'DRN' : 'ROUTE', reason);
await this.recordCmppFailureReceipt(
businessMessage,
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
reason,
);
} else {
await this.facade.refreshTaskProgress(
businessMessage.batchTaskId,
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'failed' : undefined,
);
if (code === CHANNEL_WORD_NO_ROUTE)
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
}
finish('failed');
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
@@ -1089,7 +1125,11 @@ return streamId`;
);
if (gate.targets.length && approvedChannelIds.size === 0)
throw new DrainageRejection('DRAINAGE_CHANNEL_NOT_APPROVED', '引流信息未在签名可用通道报备通过');
const selected = selectChannelCandidate(route.group.items, {
const channelWords = await loadChannelWords(
this.prisma,
route.group.items.map((item) => item.channelId),
);
const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, {
carrier,
province,
forceNational: options.forceNational,
@@ -1097,6 +1137,8 @@ return streamId`;
approvedChannelIds,
routingKey: message.id,
});
await channelWords.persist(this.prisma);
if (rejected) throw new ChannelWordRejection();
if (!selected) {
throw new NotFoundException('无已报备通过且在线的可用通道');
}
@@ -0,0 +1,93 @@
# 通道敏感词需求评估与实施方案
日期:2026-09-10。状态:已按用户授权完成本地实现及验证,待本地提交与测试部署;线上结果见测试进度。用户要求在敏感词页面新增“通道敏感词”Tab,按通道配置,短信命中时不走该通道。本方案补充[风控设计](phase-6-risk-review-plan.md)和[发送链路设计](phase-4-send-pipeline-redesign.md),不替代平台全局敏感词或[引流门禁](drainage-send-gating-plan-20260910.md)。
用户已明确基于性能考虑,第一版不做入队后的通道敏感词复核。以下方案已按“仅在选路时过滤”修订,替代初稿的逐片复核、发送授权锁及配置变化触发重选设计;既有引流门禁保持原有行为。
## 1. 结论与当前证据
可实现,属于中等规模的跨前后端、数据库和发送链路改造,不能仅增加页面筛选。核心是通道候选排除规则,不是新增全局拒绝词库。
- 当前main为8694782,应用实现提交0c3f820;实际远端main为6d63eb5452ffc7c802960d044bf598cc8646564d。已有metrics、发布工具、治理/网络/HTTP评估文档修改须保护。
- `src/apps/admin/AdminSensitiveWordsPage.tsx`仅有单列表和新增/删除,通过`/api/admin/dictionaries/sensitive-words`访问真实后端;尚无通道字段或Tab。当前页面无编辑/启停按钮,不能把后端状态接口当作已有完整页面能力。
- Prisma `SensitiveWord`仅有word、level、status等字段,word全局唯一。`risk-review.service.ts/evaluateContent`对active词使用原文`content.includes(word)`;命中统一block,当前不是按低/中/高等级选择不同动作。此事实不代表原级别设计已经正确落地,本轮不顺带改动其语义。
- `send-gateway-submit.service.ts`分别实现微批和普通选路,均先满足企业应用通道组、运营商、签名及引流资格,再调用`selectChannelCandidate`;普通路径已有排除通道集合。必须覆盖两条路径及其降级/重选调用者。
- Gateway `internal/upstream/submit.go``drainage_guard.go`已有引流资格复核,API入口为`drainage-submit-guard.controller.ts`。本需求不扩展该复核,不新增通道词Gateway请求或拒绝码;通道词排除在创建提交意图之前完成。
- 以上是本轮源码核验;未连接目标环境API/数据库验证通道敏感词功能,未启动发送、创建规则或发送短信。历史测试部署不能证明新需求已经实现。
## 2. 业务规则(建议第一版)
1. 现有列表放在“平台敏感词”Tab,保留原搜索、级别、状态和操作语义;新增“通道敏感词”Tab。两份词库独立,禁止把通道词写入全局SensitiveWord,否则会误拦其他通道。
2. 一条通道词绑定一个真实通道。A配置“贷款”、B未配置时,包含“贷款”的短信排除A,B仍需满足原有全部发送资格;剩余候选继续按原优先级、地域、权重等规则选取。不是无条件改走B,也不能越过应用绑定的通道组找其他通道。
3. 同一词可在多个通道独立配置;单通道命中任意一个启用词即排除该通道。三网通道按整个channelId生效,对移动/联通/电信都适用;本需求未要求运营商细分,不增加该配置维度。
4. 平台敏感词仍优先执行原有全局拦截。通道词命中不进入人工审核,不影响其他消息、企业配置、余额规则和报备状态;人工审核通过不豁免通道词过滤。
5. 第一版建议沿用现有敏感词的原文连续包含匹配,区分英文大小写,不支持正则、通配符、分词或自动删除间隔字符。输入词去首尾空白,禁止空词;中间字符原样保留。NFKC、忽略大小写或抗干扰匹配属于可选增强,不能直接沿用引流清洗规则而扩大拦截范围。用户已授权执行本修订方案,第一版按此规则实施。
6. 判断完整最终短信内容(含签名、变量替换后的文本、长短信重组内容),不按单片分别匹配,避免词跨片绕过。不改原文、编码、分片或计费长度。
7. 无配置/全部停用的通道不受该新增规则影响。以本次选路读取的规则快照为准:配置更新影响之后开始读取规则的选路,当前微批已经读取的快照可继续使用。已完成选路并进入提交队列的消息不再复核,即使尚未实际发出,也不因后来新增/编辑/启停/删除词而取消或换通道;已发和历史终态不重新处理。仅进入入口队列、尚未选路的消息仍在选路时检查,不能把“已入队”一概当成免检查。
8. 只有候选被本规则全部排除时,明确失败原因为“可用通道均命中通道敏感词”。原先就无有效通道时仍保留原原因,不能把离线/未报备失败归为敏感词。
9. 无可用通道时不发出,按既有路由失败处理记录、任务进度和费用预留释放;CMPP沿用原请求回执语义生成适用失败回执,非CMPP不新增拒绝回执推送。建议内部原因码`CHANNEL_SENSITIVE_WORD_NO_ROUTE`,客户协议短码独立定义并验证长度,不冒充供应商回执。
## 3. 页面与API
通道Tab独立保持通道、敏感词、启用状态三个搜索条件,查询/重置及服务端分页(默认25)。切换Tab不串筛选或请求结果;通道下拉可搜索,用真实通道ID。列表列为通道名称、敏感词、状态、备注、更新时间、操作;未知/停用通道保留历史名称及状态说明。
新增/编辑表单:通道必选、敏感词必填(建议1~200字符)、备注可选(最多500字符)、启用状态;支持新增、编辑、启用/停用、删除,第一版一次配置一个通道,不扩展导入/导出或批量多通道覆盖。删除为软删除并二次确认。停用或删除后不参与后续匹配,但历史命中快照保留。
复用公共Tab/Select/Table/Pagination/Modal;创建和编辑弹窗只能显式关闭,保留dirty提示;保存失败在弹窗内显示,不能假成功关闭。覆盖加载、空数据、失败、权限不足、停用通道、刷新和跨路由;三尺寸1600×1000、1366×768、390×844。若实施涉及CSS,先完整阅读CSS规范,页面样式归所有者,不扩大全局样式。
建议新增管理API,保持旧接口兼容:
| 接口 | 作用 |
|---|---|
| GET /api/admin/dictionaries/channel-sensitive-words | channelId/keyword/status/page/pageSize查询,返回items/total |
| POST 同路径 | 创建单条规则 |
| PATCH 同路径/:id | 修改词、通道、备注或状态,带期望version |
| DELETE 同路径/:id | 软删除,带期望version |
后端必须使用可运行时验证的DTO,不只依赖TypeScript接口。校验通道存在、状态枚举、非空词、长度、重复和版本冲突;分页上限100。沿用运营端敏感词管理入口权限,并核对全局认证/角色/权限链,客户端和无管理权限管理员不得写入,不能只隐藏按钮。配置属于平台通道,不接受客户端自报tenantId扩大访问;消息处理中的企业/应用/通道资格仍从真实记录取得。操作人从会话取,创建/修改/启停/删除记录变更前后及审计ID,不信任body中的operatorId。
## 4. 数据模型与兼容
建议新增`ChannelSensitiveWord`id、channelId外键、word、status(active/inactive/deleted)、remark、version、createdAt/updatedAt、createdBy/updatedBy。使用(channelId,word)唯一约束;删除记录重加通过受审计的恢复/更新实现,不因软删除绕过唯一约束生成含糊重复。加(channelId,status)索引;通道删除须遵守既有生命周期,不级联抹掉审计快照。
新增追加式`SmsChannelSensitiveDecision`保存messageRecordId、routeAttemptId、contentHash、使用的规则ID/version快照、候选/排除通道及命中词、判定时间、选中通道或失败原因;只记录选路阶段,不建final阶段记录。微批批量落库,同一次选路用唯一routeAttemptId保证重复持久化幂等。短信详情仅增加运营端“通道筛选原因”;成功走B也能看出A为何被排除,不依赖原规则仍存在。分页/详情限制快照规模:保存命中总数和有上限的样例,完整排除channelId集合参与算法不得截断。客户端不得泄露供应商通道词库及路由细节,仅给适用的业务失败原因。
配置变更与审计同事务,version乐观锁防止相互覆盖;数据库异常返回失败。新增表初始为空,不迁移全局敏感词、不改旧规则、不回填历史消息、不复制词到所有通道。应用回退会失去新排除规则,需评估是否允许继续发送;回退不删除决策或自动重投短信。
## 5. 仅选路过滤与性能边界
流程:原有全局风控 → 企业应用/运营商/地域/在线状态/签名/引流资格候选 → 按本次规则快照排除命中通道 → 原选路算法 → 创建提交意图 → 沿用既有发送流程。本需求不在Gateway或实际写供应商前增加通道词检查。
- 建立统一通道词评估服务,普通选路、微批及原有重选/全国通道降级共用;排除集合与原excludeChannelIds取并集。不能只检查最终选中的一个通道后直接终止,也不能仅在客户端入口检查。
- 微批按涉及channelId集合一次取词,作为本批选路快照;同内容和同规则快照复用匹配结果,决策批量持久化,不能每个号码×每个词单独查库。普通选路一次读取其候选通道词。不增加逐片数据库查询、Gateway HTTP请求或通道配置与物理发送之间的锁。第一版不引入跨批长期缓存,不承诺任意词库规模下固定TPS;按实际批大小和词库规模验证SQL次数与吞吐,必要时再优化匹配算法。
- 配置写入仍保留管理端乐观锁和审计事务,防止多人编辑相互覆盖;它不参与发送授权,也不阻塞已取得规则快照的选路。无需新增按channelId的发送共享/独占锁或配置变更触发器。
- 已选A后新增A敏感词:消息继续按原选路结果处理,不触发取消、扫描队列、逐片检查或自动重选。当前批处理期间发生变更,也不重跑本批匹配。这是用户接受的第一版生效边界,不列作绕过缺陷。
- 若现有业务因原有原因进入新的选路尝试(例如原有降级/允许的重试),该次选路使用新读取的快照并执行过滤;如果只是消费同一已确定通道的提交意图,不重复检查。保持原重试资格、次数和部分已发保护,不新增因配置变化产生的发送/补发/重新入队。
- 原有绕过业务选路的直连诊断路径不新增通道词Gateway门禁;实施时列明此覆盖边界,不能宣称所有物理提交都受新规则复核。
- 选路读取规则失败或超时:不得当空词库放行,也不能伪装业务命中;走现有技术异常有限重试/死信与告警。选路命中导致排除不创建对应通道的发送尝试,不计为供应商发送失败。
- 费用幂等释放、终态/任务进度、CMPP适用失败回执和投递意图必须可恢复;当前drainageReceiptPending仅服务引流拒绝,不可不加审查复用于所有原因。实施需为新原因扩展通用恢复标记或独立持久标记、补已有回执缺失投递的恢复测试。终态回执只生成一次,中途排除A后B成功不得提前给客户失败回执。
## 6. 范围、验证成本与实施顺序
范围为一页及其API/types、词库/选路决策迁移、统一评估服务、普通/微批选路及原有重选调用者、无路由失败恢复、运营端详情和相关测试文档。移除通道词最终Gateway门禁、逐片复核、配置与发送并发锁,以及由最终拒绝触发的结果协议/重选改造。没有新增报备、审核、客户自助词库、批量文件导入、通道组级词、运营商细分或正则编辑器;这些须另提需求。
建议先实现配置/迁移及隔离数据库用例,再实现选路过滤/无路由失败处理,最后接UI和真实页面回归。用户已选择仅选路检查,按该边界即可作为第一版完整交付。验证重点为普通/微批选路一致性、规则快照生效边界、批量性能、决策落库和全候选排除时的回执/费用幂等;未做环境及数据量基准,不给固定工时/TPS承诺。
实施验收按[系统用例](system-functional-test-cases.md)TC-CHANNEL-WORD-0112的修订版本执行:API/前端定向和相关全量、类型、production构建及质量门禁;真实PostgreSQL/Redis及生产构建浏览器验收。本方案无Gateway代码变更,若实际改动范围不涉及Go,不为本需求新增Go改造或强制重跑Go门禁。mock仅隔离测试,不能证明供应商零Submit或客户收到回执。实际发送、客户配置写入、提交/推送/两环境部署均另按明确授权,方案和用例存在不构成执行授权。
## 7. 实施前确认建议
已明确需求是独立Tab、按通道配置、命中排除通道,并且第一版仅选路检查,不做入队后复核。建议第一版采用原文连续包含匹配、单channelId覆盖三网、全部候选排除时失败而非待人工审核。若用户要求抗干扰匹配、按运营商区分或无通道时等待恢复,应先修改这些规则。用户随后已授权按本方案实施、提交代码并部署测试环境;未授权推送或预生产部署。网络绕过任务的未完成状态不因本需求实施改变。
## 8. 2026-09-10 实施与验收
已实现独立ChannelSensitiveWord管理API与Tab,运行时校验、平台管理员权限复核、服务端分页、版本乐观锁、软删除/恢复及同事务审计。普通选路和微批共用ChannelWordSnapshot;每批一次读取候选通道active词、按完整原文复用命中结果、一次批量写决策,再使用原通道选择算法。未改Gateway、逐片授权或既有引流最终门禁。
实际数据字段:SmsChannelSensitiveDecision的snapshot JSON保存contentHash、readAt、候选/排除ID、命中总数及每通道最多20个词样例(ID/word/version/name),routeAttemptId唯一防止同次重复落库。运营端详情返回最近10次选路记录,客户端白名单映射不返回本字段。词库为空也记录选路快照;不承诺无限词库规模的固定TPS。
全候选排除内部码CHANNEL_SENSITIVE_WORD_NO_ROUTE,适用CMPP平台回执码CSW3字符),状态REJECTD/undelivered,沿用原Registered_Delivery、长短信和去重规则。新增channelWordFinalizationPending默认false和部分索引,在失败状态持久化时置true;复用已有10秒恢复扫描,保证费用预留释放、任务进度、CMPP适用回执/意图完成后才清除。非CMPP仅恢复释放/进度,不新增拒绝回执推送。迁移新增两表和一列,不转换既有业务配置;回退应用不删数据或重投,回退后新通道词规则不再生效。
本地证据:API全量69套745项通过,后追加非CMPP恢复测试后对应发送链138项通过;前端28套139项通过。API类型/构建、前端production构建、lint(既有28警告,无错误)、格式、CSS、依赖安全、bundle门禁通过。新增tools/testing/verify-channel-sensitive-words.mjs在隔离本地PostgreSQL克隆中验证迁移、真实管理服务并发、普通/100条微批选路、失败SQL耐久标记、原文不变和客户数据隔离;微批通道词SQL读取1次、决策写1次,样本390~441ms包含既有引流处理,不是物理发送TPS。
生产构建+真实Nest API+隔离PostgreSQL/Redis的Edge浏览器通过三尺寸新增/编辑/启停/软删除、Tab筛选保持、显式关闭、刷新/跨路由及历史选路原因;pageerror=0。浏览器鉴权使用真实Redis7隔离前缀,未启用短信Worker或Gateway传输;fixture的在线状态仅用于隔离路由验证,不作为真实供应商连接证据。实际短信发送、供应商零Submit、客户回执ACK、费用流水闭环及实际吞吐未执行,不以隔离结果替代。
@@ -2262,3 +2262,13 @@
## 2026-09-10 引流发送资格实施确认
后续已授权修改、本地提交及测试部署。以 drainage-send-gating-plan-20260910.md 第10节为实现说明,替代此前“未授权实施”的阶段性描述。每个引流目标须匹配本企业应用/签名审核通过的资料并满足最终通道报备;NFKC及干扰清洗只用于检测,域名自身与子域按点边界匹配,纯域名不限制路径参数。非CMPP拦截不推送回执;CMPP失败回执需幂等且可恢复。旧报备配置仍由用户处理。是否线上生效及未执行发送验收见 testing-progress.md。
## 2026-09-10 通道敏感词需求评估(待实现)
敏感词页面新增“通道敏感词”Tab,按真实channelId独立配置;完整短信内容命中启用词时仅排除对应通道,剩余通道继续遵守原应用通道组、签名/引流报备、运营商、地域和优先级。用户明确第一版仅在普通/微批选路及原有新选路尝试时过滤,不增加入队后或逐片复核;已选路消息按原结果继续处理,配置变化不主动取消或重选,已读取快照的微批可继续使用。尚未选路的入口队列消息仍在选路时检查。现有平台敏感词保持全局拒绝,两份词库不混用。全部候选被排除时记录原因并沿用适用失败处理,不跨通道组强行发送。建议首版原文连续包含、单通道覆盖三网、无通道时失败;匹配增强等建议待实施确认。完整页面/API/数据模型、选路快照与性能边界、回执及验收见[专项方案](channel-sensitive-words-plan-20260910.md)。仅评估,不构成代码、配置写入或发送/发布授权。
### 通道敏感词实施更新(2026-09-10)
用户已授权执行修订方案、本地提交及测试部署。本地已实现独立Tab/词库、管理审计和版本冲突、普通/微批仅选路过滤、运营端历史解释及独立失败完成恢复标记。无Gateway/逐片复核;配置仅影响后续读取规则的选路,非CMPP不新增拒绝推送。实际实现与验证见[channel-sensitive-words方案第8节](channel-sensitive-words-plan-20260910.md),提交/环境状态以testing-progress.md为准。
+10
View File
@@ -78,3 +78,13 @@ api/src/risk-review/
## 2026-09-10 引流发送门禁实施
后续用户已授权实施、本地提交及测试部署。具体数据模型、最终分片复核、并发锁、CMPP回执恢复和非CMPP无推送行为见 drainage-send-gating-plan-20260910.md 第10节;取代此前本主题仅处于设计阶段的状态。旧批准配置不迁移;线上状态与未执行项以测试进度为准。
## 2026-09-10 通道敏感词候选排除(评估阶段)
[通道敏感词方案](channel-sensitive-words-plan-20260910.md)补充独立通道词库及选路排除,保留原平台全局敏感词语义。用户基于性能明确取消第一版入队后复核:只覆盖微批/普通选路及原有新选路尝试,不增加通道词Gateway/逐片检查、发送授权锁或配置变化触发重选。已选路消息及已读取规则快照的微批不追溯更新;尚未选路的消息照常过滤。管理端编辑乐观锁、原重试与部分已发保护保持。无可用通道和技术读取失败分别处理,不能给选路中排除A但最终B可用的消息提前发送失败回执。既有引流门禁不受本次方案调整影响。当前仅完成源码评估与设计,未迁移或实现。
### 通道敏感词实施更新(2026-09-10)
用户已授权执行修订方案、本地提交及测试部署。本地已实现独立Tab/词库、管理审计和版本冲突、普通/微批仅选路过滤、运营端历史解释及独立失败完成恢复标记。无Gateway/逐片复核;配置仅影响后续读取规则的选路,非CMPP不新增拒绝推送。实际实现与验证见[channel-sensitive-words方案第8节](channel-sensitive-words-plan-20260910.md),提交/环境状态以testing-progress.md为准。
+31
View File
@@ -5393,3 +5393,34 @@ TC-DRAINAGE-GATE-0116的实现范围以专项方案第10节为准,不再笼
### 2026-09-10 TC-DRAINAGE-GATE测试发布补充
0c3f820已部署测试。TC-DRAINAGE-GATE相关本地真实数据库/API/并发/统计用例及生产构建页面通过;线上历史短信详情保持“未执行引流资格校验”,三尺寸和显式关闭通过。TC-OPS0909相关六页面真实请求回归通过,不将只读打开页面记作重做全部写操作用例。迁移、13项服务、Gateway Stream与版本摘要检查通过。真实短信/供应商零Submit、长短信、CMPP回执ACK与费用对账仍待专项授权,状态不得升级为全链路通过。证据见[测试发布验收](release-20260910-test-drainage.md)。
## 2026-09-10 通道敏感词待执行用例
方案:[通道敏感词](channel-sensitive-words-plan-20260910.md)。以下按用户“第一版仅选路过滤、不做入队后复核”要求修订,取代初稿的最终授权/逐片检查用例;全部未实现/未执行。测试夹具、脚本不构成实际短信发送或配置改动授权。
| 编号 | 场景 | 预期 |
|---|---|---|
| TC-CHANNEL-WORD-01 | 两Tab与独立查询、刷新、跨路由、三尺寸 | 保留平台词语义;通道/词/状态独立筛选,分页与真实API一致,无假成功 |
| TC-CHANNEL-WORD-02 | 新增、编辑、启停、删除、重复词、失效通道和版本冲突 | 真实持久化/审计;空词/非法参数拒绝;同词跨通道允许;同通道重复受控,软删除历史保留 |
| TC-CHANNEL-WORD-03 | A命中而B未命中,A原优先级更高 | 排除A,从仍满足全部资格的B等候选按原规则选;不自动跨组 |
| TC-CHANNEL-WORD-04 | 所有合格候选命中;原本无合格候选 | 前者明确通道敏感词原因,后者保留离线/未报备等原原因;均不发送 |
| TC-CHANNEL-WORD-05 | 空库/停用词/不同通道/平台全局词 | 空库保持原路由,停用不匹配,配置互不串用,全局命中仍全局拒绝 |
| TC-CHANNEL-WORD-06 | 连续包含、英文大小写、全半角、间隔符、签名/变量及跨长短信分片词 | 按最终确认的匹配规则一致处理;匹配完整最终内容,短信原文/分片/计费长度不变 |
| TC-CHANNEL-WORD-07 | 普通/微批、多入口、全国通道降级、原有重选、三网同通道 | 均执行排除;不绕过运营商/地域/签名/引流限制;同channelId规则覆盖三网 |
| TC-CHANNEL-WORD-08 | 选路快照前/后加词、编辑、启停/删除;入口队列尚未选路 | 后续新读取的选路使用新配置;已读取快照的微批和已选路消息不追溯,不取消或重选;未选路消息仍需检查 |
| TC-CHANNEL-WORD-09 | 初次选路A命中、B可用;重复决策持久化;原有原因进入新选路 | 初次不创建A提交尝试,B按原规则选取且无提前失败回执;同次选路决策幂等,新选路重新读取词库,不新增重试机会 |
| TC-CHANNEL-WORD-10 | 已确定通道意图消费、跨多分片发送过程中配置变更 | 不新增通道词HTTP请求/逐片数据库查询/授权锁;继续原发送流程,保持原部分已发/不确定状态处理,不因词变更自动重发 |
| TC-CHANNEL-WORD-11 | 最终无路由的费用/任务/CMPP回执;非CMPP;崩溃恢复 | 费用释放/进度幂等,适用CMPP失败回执及意图耐久恢复;非CMPP不新增拒绝推送,无重复终态 |
| TC-CHANNEL-WORD-12 | 选路读库失败、多实例/大批量、越权、伪造客户端已检查标记、历史数据 | 技术故障不当空库放行;微批按候选通道集合读词/复用匹配/批量记决策,无N+1;后端真实选路不信客户端标记,权限和历史快照隔离,真实性能留证 |
验收分层:隔离单元/真实PostgreSQL与Redis/真实浏览器/供应商及客户回执全链路分别记录。03/04/09/10/11中的真实物理发送、供应商零Submit、回执ACK和费用闭环需专项授权,不能由mock或静态页面代替。第一版验收不得再要求通道词入队后复核;既有引流校验照常,不将其已有查询计为新增通道词开销。
### TC-CHANNEL-WORD 2026-09-10 实施验证
01/02/05/08/12:真实PostgreSQL管理服务验证权限、参数、筛选、重复、并发冲突、启停/删除恢复、旧快照不追溯;真实Nest浏览器验证无登录401、CRUD落库、独立筛选和分页请求。保存冲突/失败、删除确认及关闭行为补隔离组件测试。
03/04/06/07/09:真实隔离数据库中普通和100条微批均排除A选B,全命中返回专用原因;保留完整原文、历史管理员原因及客户端字段隔离。连续包含/大小写/全半角/分隔符/地域降级/离线资格和样例限额补单元测试。完整短信样本覆盖长文本,不宣称已进行物理分片发送。
10:源码确认未改Gateway,不增加最终通道词复核;规则快照更新边界通过真实数据库检查。物理发送中途配置变更未执行。
11:普通全排除的CMPP/非CMPP分支、既有回执意图恢复失败后保留标记、非CMPP恢复不推送通过隔离单测;真实批量失败SQL在注入释放失败后仍持久保存完成标记。未执行真实余额变更或客户回执投递。
12性能样本:100条完整同文消息,候选词一次读取、决策一次写入,390~441ms含原引流处理;不代表完整短信链路TPS。真实页面三尺寸1600×1000、1366×768、390×844通过,实际测试环境发布后验收另记。
+30
View File
@@ -4872,3 +4872,33 @@ git diff --check
应用0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7已本地提交并通过标准工具部署测试,原版本809175b544f2526891ba6d2dece1a50eadaf57a0;未推送、未部署预生产。精确提交validate为API67套722项、前端27文件136项及相关门禁通过。首次preflight的安全代理运行目录缺失由用户单独授权修复,复检后正常发布。13项服务active、三个Gateway Stream pending/lag=0、消息/提交数不变、迁移/资源摘要/日志验证通过。真实测试管理员和六页面、三尺寸历史短信详情通过,线上不回填旧消息。
[本次发布验收](release-20260910-test-drainage.md)记录完整版本、工具未提交摘要、备份、容量清单与阶段时间。候选77.9秒、备份35.7秒,停服务至恢复约14秒;发布后系统盘可用10.37GB,比发布前减少约1.48GB,旧版本/候选/备份未清理。真实发送、供应商Submit、客户回执ACK和费用对账仍未执行,不能以页面通过代替。原网络超时根因与预生产历史故障未宣称关闭。
## 2026-09-10 通道敏感词需求评估
授权仅评估。main/HEAD为8694782,实际远端main为6d63eb5452ffc7c802960d044bf598cc8646564d,暂存区空;保护全部已有源码、工具、网络及HTTP评估文档。本轮完整阅读UI规范及当前敏感词页、词库/风控和普通/微批路由、最终Gateway门禁与结果处理,新增[channel-sensitive-words方案](channel-sensitive-words-plan-20260910.md),同步需求/风控索引及12项待执行用例。
现状为全局SensitiveWord原文includes命中统一block,无通道模型或Tab。建议独立库、候选排除、原选路算法、最终复核;零写入方可有限重选,部分已发不整体重发,全候选排除才形成适用失败闭环。普通包含/三网统一/全排除失败为建议首版规则,不将未经确认的抗干扰或等待恢复加入实现。中等规模跨模块改造,不能仅改UI;无固定工时或TPS承诺。
本轮未连接真实API/PG/Redis/MinIO/Gateway进行业务验收,未启动或发送短信、未改规则/网络配置。仅文档与源码评估,功能和12项用例均未执行;无代码修改、无提交、无推送、无测试/预生产部署。文档保护副本及核验位于%TEMP%/cmpp-channel-sensitive-assessment-20260910。此前绕过OpenWrt任务仍未通过候选网关出口验证,本评估不宣称该任务完成。
本轮文档核验:四份既有文档的原字节前缀保留,其他开工保护文件摘要未变;专项方案链接和diff检查通过,暂存区为空。
## 2026-09-10 通道敏感词方案缩减为仅选路过滤
用户明确因性能考虑,暂不做入队后通道敏感词复核。已修订专项方案、需求和风控索引,以及TC-CHANNEL-WORD-08/09/10/12等用例:普通/微批及原有新选路时按快照过滤;已读取快照的微批与已选路消息不追溯配置变化。移除新增Gateway/逐片检查、发送授权锁、final决策和配置变化触发重选,保留管理端编辑并发保护及既有引流门禁。前一节初稿的最终复核/零写入新增重选描述由本记录取代,不再作为第一版验收要求。
仅文档修改,未修改代码或网络/业务配置,未提交、未推送、未测试或预生产部署。保护副本位于%TEMP%/cmpp-channel-sensitive-routing-only-20260910;执行文档一致性、链接及diff核验,不重跑业务测试。
## 2026-09-10 通道敏感词实施与本地验收
授权:按修订方案修改、本地提交、部署测试环境;不推送、不部署预生产,不发送/重投短信或改现有线上业务配置。开工main=8694782,真实远端main=6d63eb5;测试版本0c3f820。原metrics、工具、规范、网络/HTTP评估文档受保护,提交只纳入本轮代码及主题文档精确部分。
实现:独立通道词Tab/API、运行时参数/平台管理员校验、version冲突、软删除恢复和事务审计;普通/微批选路快照排除候选,运营端最近10次解释;CSW失败与channelWordFinalizationPending耐久恢复,非CMPP不新增回执。Gateway/逐片检查未改。新增迁移不改历史配置。设计和边界见channel-sensitive-words-plan-20260910.md第8节。
验证:API全量69套745项通过;后追加恢复用例,发送链定向138项通过。前端全量28套139项通过,API构建/类型、production前端构建、lint/格式/CSS/安全/bundle通过(lint既有28警告)。初轮测试选择器及测试类型错误已修正;原页面空依赖列缓存随组件拆分取消,避免旧查询闭包。隔离真实PostgreSQL迁移与并发/路由/失败SQL验证通过;100条微批通道词读写各1次,390~441ms包含既有引流,不是发送TPS。
真实浏览器:production构建、Nest API、隔离PG、Redis7认证前缀,三尺寸CRUD/软删除/独立筛选/关闭/刷新/跨路由/详情通过,pageerror=0;匿名API401。未启动短信发送Worker/Gateway transport。证据%TEMP%/cmpp-channel-sensitive-implementation-20260910,包括real-api-failure.log、browser-1789026246272、各质量门禁日志。实际短信发送/客户回执ACK/费用流水与完整吞吐未执行。
本地实现和验收完成;本地提交、测试部署正在执行,推送/预生产未授权。测试部署成功后另补精确版本、恢复资产、磁盘增量及真实页面验收。
@@ -0,0 +1,29 @@
import { request, withQuery } from '../core/httpClient';
import type { PagedResult } from '../types';
export type ChannelWord = {
id: string;
channelId: string;
word: string;
status: string;
remark: string;
version: number;
updatedAt: string;
channel: { id: string; name: string; status: string };
};
export type ChannelWordForm = { channelId: string; word: string; status: string; remark: string };
export type ChannelWordQuery = { channelId: string; keyword: string; status: string; page: number; pageSize: number };
const path = '/admin/dictionaries/channel-sensitive-words';
export const channelWordsApi = {
list: (query: ChannelWordQuery) => request<PagedResult<ChannelWord>>(withQuery(path, query)),
save: (form: ChannelWordForm, item?: ChannelWord) =>
request<ChannelWord>(item ? `${path}/${encodeURIComponent(item.id)}` : path, {
method: item ? 'PATCH' : 'POST',
body: JSON.stringify({ ...form, ...(item ? { version: item.version } : {}) }),
}),
remove: (item: ChannelWord) =>
request<{ deleted: boolean }>(`${path}/${encodeURIComponent(item.id)}`, {
method: 'DELETE',
body: JSON.stringify({ version: item.version }),
}),
};
+14
View File
@@ -118,6 +118,20 @@ export type SendQualityResponse = {
};
export type SmsMessageRecord = {
channelWordDecisions?: Array<{
id: string;
decidedAt: string;
snapshot: {
selectedChannelId: string | null;
reason: string | null;
hits: Array<{
channelId: string;
channelName?: string;
count: number;
samples: Array<{ id: string; word: string; version: number }>;
}>;
};
}>;
id: string;
tenantId?: string | null;
batchTaskId?: string | null;
+101 -22
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { ChannelSensitiveWordsPanel } from './sensitive-words/ChannelSensitiveWordsPanel';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -31,6 +32,36 @@ const levelOptions = [
];
export function AdminSensitiveWordsPage() {
const [tab, setTab] = useState('platform');
const [channelVisited, setChannelVisited] = useState(false);
return (
<section className="page-stack admin-security-page">
<div className="page-heading">
<div>
<Breadcrumb items={['安全控制', '敏感词管理']} />
<h1></h1>
</div>
</div>
<Tabs
value={tab}
onChange={(value) => {
setTab(value);
if (value === 'channel') setChannelVisited(true);
}}
items={[
{ label: '平台敏感词', value: 'platform', content: null },
{ label: '通道敏感词', value: 'channel', content: null },
]}
/>
<div hidden={tab !== 'platform'}>
<PlatformSensitiveWordsPanel />
</div>
<div hidden={tab !== 'channel'}>{channelVisited ? <ChannelSensitiveWordsPanel /> : null}</div>
</section>
);
}
function PlatformSensitiveWordsPanel() {
const [items, setItems] = useState<SensitiveWordItem[]>([]);
const [keyword, setKeyword] = useState('');
const [word, setWord] = useState('');
@@ -39,7 +70,8 @@ export function AdminSensitiveWordsPage() {
const [error, setError] = useState('');
function loadData() {
adminApi.listSensitiveWords({ keyword })
adminApi
.listSensitiveWords({ keyword })
.then((data) => {
setItems(data as SensitiveWordItem[]);
setError('');
@@ -51,15 +83,37 @@ export function AdminSensitiveWordsPage() {
loadData();
}, []);
const filteredItems = useMemo(() => items.filter((item) => {
const text = [item.word, item.level, item.status].join(' ');
return !keyword || text.includes(keyword);
}), [items, keyword]);
const filteredItems = useMemo(
() =>
items.filter((item) => {
const text = [item.word, item.level, item.status].join(' ');
return !keyword || text.includes(keyword);
}),
[items, keyword],
);
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
const columns: Array<TableColumn<SensitiveWordItem>> = [
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> },
{
key: 'level',
title: '级别',
width: '120px',
render: (record) => (
<Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>
{levelLabelMap[record.level ?? 'medium'] ?? record.level}
</Tag>
),
},
{
key: 'status',
title: '状态',
width: '120px',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'neutral'}>
{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}
</Tag>
),
},
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{
key: 'actions',
@@ -67,15 +121,26 @@ export function AdminSensitiveWordsPage() {
width: '130px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteSensitiveWord(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
<Button
icon={<Trash2 size={15} />}
onClick={() =>
adminApi
.deleteSensitiveWord(record.id)
.then(loadData)
.catch((failure: Error) => setError(failure.message))
}
size="sm"
variant="danger"
>
</Button>
),
},
], []);
];
function addItem() {
adminApi.createSensitiveWord({ word, level, status: 'active' })
adminApi
.createSensitiveWord({ word, level, status: 'active' })
.then(() => {
setWord('');
setLevel('medium');
@@ -89,10 +154,11 @@ export function AdminSensitiveWordsPage() {
<section className="page-stack admin-security-page">
<div className="page-heading">
<div>
<Breadcrumb items={['安全控制', '敏感词管理']} />
<h1></h1>
<h2></h2>
</div>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -105,8 +171,12 @@ export function AdminSensitiveWordsPage() {
value={keyword}
/>
<div className="admin-security-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setKeyword('')} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={loadData}>
</Button>
<Button onClick={() => setKeyword('')} variant="ghost">
</Button>
</div>
</div>
@@ -115,19 +185,28 @@ export function AdminSensitiveWordsPage() {
</div>
<Modal
footer={(
footer={
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!word} onClick={addItem}></Button>
<Button onClick={() => setModalOpen(false)} variant="ghost">
</Button>
<Button disabled={!word} onClick={addItem}>
</Button>
</>
)}
}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title="添加敏感词"
>
<div className="admin-security-form">
<Input label="敏感词" onChange={(event) => setWord(event.target.value)} placeholder="请输入敏感词" value={word} />
<Input
label="敏感词"
onChange={(event) => setWord(event.target.value)}
placeholder="请输入敏感词"
value={word}
/>
<Select
label="风险级别"
onChange={(event) => setLevel(event.target.value)}
@@ -0,0 +1,66 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelSensitiveWordsPanel } from './ChannelSensitiveWordsPanel';
const { api, channels } = vi.hoisted(() => ({
api: { list: vi.fn(), save: vi.fn(), remove: vi.fn() },
channels: vi.fn(),
}));
vi.mock('@/api/admin/channel-sensitive-words.api', () => ({ channelWordsApi: api }));
vi.mock('@/api/adminApi', () => ({ adminApi: { listChannels: channels } }));
const item = {
id: 'rule',
channelId: 'a',
word: '贷款',
status: 'active',
remark: '',
version: 4,
updatedAt: '2026-09-10T00:00:00Z',
channel: { id: 'a', name: '通道A', status: 'active' },
};
describe('channel word panel', () => {
beforeEach(() => {
vi.resetAllMocks();
channels.mockResolvedValue([{ id: 'a', name: '通道A', status: 'active' }]);
api.list.mockResolvedValue({ items: [item], total: 1 });
});
it('keeps save conflicts inside the dialog and does not dismiss on Escape or backdrop', async () => {
api.save.mockRejectedValue(Error('规则已被修改,请刷新后重试'));
render(<ChannelSensitiveWordsPanel />);
fireEvent.click(await screen.findByRole('button', { name: '编辑' }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByRole('textbox', { name: /敏感词/ }), { target: { value: '理财' } });
fireEvent.keyDown(dialog, { key: 'Escape' });
fireEvent.mouseDown(document.querySelector('.ui-modal-backdrop') ?? document.body);
expect(dialog).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('规则已被修改');
expect(api.save).toHaveBeenCalledWith({ channelId: 'a', word: '理财', status: 'active', remark: '' }, item);
expect(api.list).toHaveBeenCalledTimes(1);
});
it('only deletes after confirmation and keeps failures visible', async () => {
api.remove.mockRejectedValue(Error('删除失败'));
render(<ChannelSensitiveWordsPanel />);
fireEvent.click(await screen.findByRole('button', { name: '删除' }));
expect(api.remove).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('删除失败');
expect(api.remove).toHaveBeenCalledWith(item);
});
it('keeps keyword and status separate and uses server pagination', async () => {
render(<ChannelSensitiveWordsPanel />);
await screen.findByRole('button', { name: '编辑' });
fireEvent.change(screen.getByLabelText('敏感词'), { target: { value: '理财' } });
fireEvent.click(screen.getByRole('button', { name: '启用状态' }));
fireEvent.click(screen.getByRole('option', { name: '停用' }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(api.list).toHaveBeenLastCalledWith({
channelId: '',
keyword: '理财',
status: 'inactive',
page: 1,
pageSize: 25,
}),
);
});
});
@@ -0,0 +1,352 @@
import { useEffect, useRef, useState } from 'react';
import { adminApi, type AdminChannel } from '@/api/adminApi';
import {
channelWordsApi,
type ChannelWord,
type ChannelWordForm,
type ChannelWordQuery,
} from '@/api/admin/channel-sensitive-words.api';
import { Button, Input, Modal, Pagination, QueryPanel, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const emptyForm: ChannelWordForm = { channelId: '', word: '', status: 'active', remark: '' };
const initialQuery: ChannelWordQuery = { channelId: '', keyword: '', status: 'all', page: 1, pageSize: 25 };
const statuses = [
{ label: '启用', value: 'active' },
{ label: '停用', value: 'inactive' },
];
export function ChannelSensitiveWordsPanel() {
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [filters, setFilters] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [items, setItems] = useState<ChannelWord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [channelError, setChannelError] = useState('');
const [reload, setReload] = useState(0);
const [editor, setEditor] = useState<{ item?: ChannelWord; initial: ChannelWordForm } | null>(null);
const [form, setForm] = useState(emptyForm);
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
const [removing, setRemoving] = useState<ChannelWord | null>(null);
const [removeError, setRemoveError] = useState('');
const busy = useRef(false);
useEffect(() => {
let alive = true;
adminApi
.listChannels()
.then((data) => {
if (alive) {
setChannels(data);
setChannelError('');
}
})
.catch((failure: Error) => {
if (alive) setChannelError(failure.message || '通道加载失败');
});
return () => {
alive = false;
};
}, [reload]);
useEffect(() => {
let alive = true;
setLoading(true);
setError('');
channelWordsApi
.list(query)
.then((data) => {
if (!alive) return;
if (query.page > 1 && !data.items.length) {
setQuery({ ...query, page: Math.max(1, Math.ceil(data.total / query.pageSize)) });
return;
}
setItems(data.items);
setTotal(data.total);
})
.catch((failure: Error) => {
if (alive) {
setError(failure.message || '通道敏感词加载失败');
setItems([]);
setTotal(0);
}
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [query, reload]);
const channelOptions = channels
.filter((channel) => channel.status !== 'deleted')
.map((channel) => ({
value: channel.id,
label: `${channel.name}${channel.status === 'active' ? '' : '(已停用)'}`,
}));
if (editor?.item && !channelOptions.some((option) => option.value === editor.item!.channelId))
channelOptions.push({ value: editor.item.channelId, label: `${editor.item.channel.name}(历史通道)` });
function edit(item?: ChannelWord) {
const initial = item
? { channelId: item.channelId, word: item.word, status: item.status, remark: item.remark }
: { ...emptyForm };
setEditor({ item, initial });
setForm(initial);
setFormError('');
}
async function save() {
if (busy.current || !editor) return;
if (!form.channelId || !form.word.trim()) {
setFormError('请选择通道并填写敏感词');
return;
}
busy.current = true;
setSaving(true);
setFormError('');
try {
await channelWordsApi.save(form, editor.item);
setEditor(null);
setReload((value) => value + 1);
} catch (failure) {
setFormError(failure instanceof Error ? failure.message : '保存失败');
} finally {
busy.current = false;
setSaving(false);
}
}
async function toggle(item: ChannelWord) {
if (busy.current) return;
busy.current = true;
setSaving(true);
setError('');
try {
await channelWordsApi.save(
{
channelId: item.channelId,
word: item.word,
remark: item.remark,
status: item.status === 'active' ? 'inactive' : 'active',
},
item,
);
setReload((value) => value + 1);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '操作失败');
} finally {
busy.current = false;
setSaving(false);
}
}
async function remove() {
if (busy.current || !removing) return;
busy.current = true;
setSaving(true);
setRemoveError('');
try {
await channelWordsApi.remove(removing);
setRemoving(null);
setReload((value) => value + 1);
} catch (failure) {
setRemoveError(failure instanceof Error ? failure.message : '删除失败');
} finally {
busy.current = false;
setSaving(false);
}
}
const columns: Array<TableColumn<ChannelWord>> = [
{
key: 'channel',
title: '通道',
render: (item) => `${item.channel.name}${item.channel.status === 'active' ? '' : '(已停用或删除)'}`,
},
{ key: 'word', title: '敏感词', render: (item) => item.word },
{
key: 'status',
title: '状态',
render: (item) => (
<Tag tone={item.status === 'active' ? 'success' : 'neutral'}>{item.status === 'active' ? '启用' : '停用'}</Tag>
),
},
{ key: 'remark', title: '备注', render: (item) => item.remark || '-' },
{ key: 'updatedAt', title: '更新时间', render: (item) => formatDateTime(item.updatedAt) },
{
key: 'actions',
title: '操作',
render: (item) => (
<div className="admin-security-filter__actions">
<Button size="sm" disabled={saving} onClick={() => edit(item)}>
</Button>
<Button size="sm" variant="ghost" disabled={saving} onClick={() => void toggle(item)}>
{item.status === 'active' ? '停用' : '启用'}
</Button>
<Button
size="sm"
variant="danger"
disabled={saving}
onClick={() => {
setRemoving(item);
setRemoveError('');
}}
>
</Button>
</div>
),
},
];
return (
<div className="page-stack">
<div className="page-heading">
<p></p>
<Button onClick={() => edit()} disabled={Boolean(channelError)}>
</Button>
</div>
<QueryPanel title="筛选通道敏感词">
<Select
label="通道"
searchable
options={[{ label: '全部通道', value: '' }, ...channelOptions]}
value={filters.channelId}
onChange={(event) => setFilters({ ...filters, channelId: event.target.value })}
/>
<Input
label="敏感词"
value={filters.keyword}
maxLength={200}
onChange={(event) => setFilters({ ...filters, keyword: event.target.value })}
/>
<Select
label="启用状态"
options={[{ label: '全部状态', value: 'all' }, ...statuses]}
value={filters.status}
onChange={(event) => setFilters({ ...filters, status: event.target.value })}
/>
<div className="admin-security-filter__actions">
<Button onClick={() => setQuery({ ...filters, page: 1, pageSize: query.pageSize })}></Button>
<Button
variant="ghost"
onClick={() => {
setFilters(initialQuery);
setQuery({ ...initialQuery });
}}
>
</Button>
<Button variant="ghost" onClick={() => setReload((value) => value + 1)}>
</Button>
</div>
</QueryPanel>
{error || channelError ? (
<p className="form-error" role="alert">
{error || channelError}
</p>
) : null}
<div className="surface admin-security-table-card" aria-busy={loading}>
<Table
columns={columns}
data={loading ? [] : items}
rowKey="id"
emptyText={loading ? '加载中…' : error ? '加载失败,请重试' : '暂无通道敏感词'}
/>
<Pagination
total={total}
page={query.page}
pageSize={query.pageSize}
previousDisabled={loading || query.page <= 1}
nextDisabled={loading || query.page * query.pageSize >= total}
onPrevious={() => setQuery({ ...query, page: query.page - 1 })}
onNext={() => setQuery({ ...query, page: query.page + 1 })}
onPageChange={(page) => setQuery({ ...query, page })}
onPageSizeChange={(pageSize) => setQuery({ ...query, pageSize, page: 1 })}
/>
</div>
<Modal
open={Boolean(editor)}
title={editor?.item ? '编辑通道敏感词' : '新增通道敏感词'}
dirty={Boolean(editor && JSON.stringify(form) !== JSON.stringify(editor.initial))}
onClose={() => {
if (!saving) setEditor(null);
}}
footer={({ requestClose }) => (
<>
<Button variant="ghost" disabled={saving} onClick={requestClose}>
</Button>
<Button disabled={saving} onClick={() => void save()}>
{saving ? '保存中…' : '保存'}
</Button>
</>
)}
>
<div className="admin-security-form">
{formError ? (
<p className="form-error" role="alert">
{formError}
</p>
) : null}
<Select
label="通道"
searchable
required
value={form.channelId}
disabled={saving}
options={[{ label: '请选择通道', value: '' }, ...channelOptions]}
onChange={(event) => setForm({ ...form, channelId: event.target.value })}
/>
<Input
label="敏感词"
required
maxLength={200}
disabled={saving}
value={form.word}
hint="按原文连续匹配,区分英文大小写"
onChange={(event) => setForm({ ...form, word: event.target.value })}
/>
<Select
label="启用状态"
options={statuses}
disabled={saving}
value={form.status}
onChange={(event) => setForm({ ...form, status: event.target.value })}
/>
<Input
label="备注"
maxLength={500}
disabled={saving}
value={form.remark}
onChange={(event) => setForm({ ...form, remark: event.target.value })}
/>
</div>
</Modal>
<Modal
open={Boolean(removing)}
title="删除通道敏感词"
onClose={() => {
if (!saving) setRemoving(null);
}}
footer={
<>
<Button variant="ghost" disabled={saving} onClick={() => setRemoving(null)}>
</Button>
<Button variant="danger" disabled={saving} onClick={() => void remove()}>
</Button>
</>
}
>
<p>
{removing?.channel.name} {removing?.word}
</p>
{removeError ? (
<p className="form-error" role="alert">
{removeError}
</p>
) : null}
</Modal>
</div>
);
}
@@ -47,6 +47,29 @@ 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) => (
<div key={decision.id}>
<p>
{getTime(decision.decidedAt)} ·{' '}
{decision.snapshot.reason ||
(decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')}
</p>
{decision.snapshot.hits.map((hit) => (
<p key={hit.channelId}>
{hit.channelName || hit.channelId} {hit.count}
{hit.samples.map((sample) => `${sample.word}`).join('、')}
{hit.count > hit.samples.length ? '(仅展示部分)' : ''}
</p>
))}
</div>
))
) : (
<p className="muted">{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}</p>
)}
</section>
<section aria-label="引流发送资格">
<h3></h3>
{record.drainageGate ? (
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
// Only isolated local QA data. Never starts workers, publishes SMS, changes
// live configuration, or fabricates supplier/customer delivery evidence.
const require = createRequire(resolve('api/package.json'));
for (const file of ['api/.env', '.env']) if (existsSync(file)) process.loadEnvFile(file);
const name = process.env.CMPP_CHANNEL_WORD_QA_DATABASE;
assert.match(name ?? '', /^cmpp_qa_channel_words_\d+$/);
const url = new URL(process.env.DATABASE_URL);
assert(['127.0.0.1', 'localhost'].includes(url.hostname));
url.pathname = `/${name}`;
process.env.DATABASE_URL = url.toString();
process.env.CMPP_PROCESS_ROLE = 'api';
const { PrismaService } = require('./dist/prisma/prisma.service.js');
const { ChannelSensitiveWordsService } = require('./dist/dictionaries/channel-sensitive-words.service.js');
const { SendChainService } = require('./dist/send-chain/send-chain.service.js');
const { loadChannelWords } = require('./dist/send-chain/channel-sensitive-routing.js');
const { OperationsMessageQueries } = require('./dist/operations/queries/messages.queries.js');
const { clientMessageView } = require('./dist/operations/operations.helpers.js');
const db = new PrismaService(),
prefix = `qa-channel-word-${randomUUID()}`,
checks = [];
try {
const role = await db.role.findUniqueOrThrow({ where: { code: 'platform_admin' } });
const admin = await db.user.create({
data: {
username: prefix,
displayName: '隔离验收管理员',
passwordHash: 'isolated-no-login',
roles: { create: { roleId: role.id } },
},
});
const unauthorized = await db.user.create({
data: { username: `${prefix}-unauthorized`, displayName: '隔离无权限用户', passwordHash: 'isolated-no-login' },
});
const tenant = await db.tenant.create({ data: { code: prefix, name: prefix } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: prefix,
cmppAccount: prefix,
cmppEnterpriseCode: 'QA',
secretHash: 'isolated-no-login',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: application.id, name: prefix, auditStatus: 'approved' },
});
const group = await db.smsChannelGroup.create({ data: { code: prefix, name: prefix, carrier: 'mobile' } });
const channels = [];
for (const index of [0, 1]) {
const channel = await db.smsChannel.create({
data: {
code: `${prefix}-${index}`,
name: `验收通道${index ? 'B' : 'A'}`,
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: prefix,
passwordCipher: 'isolated-no-transport',
srcId: '1069',
},
});
channels.push(channel);
await db.cmppConnectionState.create({
data: {
channelId: channel.id,
connectionId: prefix,
status: 'connected',
currentConnections: 1,
desiredConnections: 1,
},
});
await db.smsChannelGroupItem.create({
data: { groupId: group.id, channelId: channel.id, carrier: 'mobile', priority: index + 1 },
});
await db.channelSignatureReportTask.create({
data: {
channelId: channel.id,
signatureId: signature.id,
tenantId: tenant.id,
carrier: 'mobile',
approvalScope: 'carrier_specific',
reportType: 'signature',
status: 'approved',
},
});
}
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile' },
});
const service = new ChannelSensitiveWordsService(db);
await assert.rejects(service.list(unauthorized.id, {}), /权限/);
await assert.rejects(service.save(admin.id, { channelId: channels[0].id, word: ' ', status: 'active' }), /字符/);
const data = { channelId: channels[0].id, word: ' 贷款 ', status: 'active', remark: 'QA' };
let word = await service.save(admin.id, data);
assert.equal(word.word, '贷款');
await assert.rejects(service.save(admin.id, data), /相同/);
const concurrent = await Promise.allSettled([
service.save(admin.id, { ...data, version: word.version, remark: 'first' }, word.id),
service.save(admin.id, { ...data, version: word.version, remark: 'second' }, word.id),
]);
assert.equal(concurrent.filter((result) => result.status === 'fulfilled').length, 1);
assert.equal(concurrent.filter((result) => result.status === 'rejected').length, 1);
word = await db.channelSensitiveWord.findUniqueOrThrow({ where: { id: word.id } });
assert.equal(word.version, 2);
checks.push('real PostgreSQL validation, permission, unique word, concurrent optimistic version conflict');
const list = await service.list(admin.id, {
channelId: channels[0].id,
keyword: '贷',
page: '1',
pageSize: '25',
status: 'active',
});
assert.equal(list.total, 1);
assert.equal(list.items[0].id, word.id);
const before = await loadChannelWords(
db,
channels.map((channel) => channel.id),
);
word = await service.save(admin.id, { ...data, version: word.version, status: 'inactive' }, word.id);
assert.equal(before.hits('贷款').length, 1);
assert.equal(
(
await loadChannelWords(
db,
channels.map((channel) => channel.id),
)
).hits('贷款').length,
0,
);
await service.remove(admin.id, word.id, word.version);
assert.equal((await service.list(admin.id, { channelId: channels[0].id })).total, 0);
const restored = await service.save(admin.id, data);
assert.equal(restored.id, word.id);
assert(restored.version > word.version);
assert.equal(await db.operationLog.count({ where: { resourceId: word.id, resource: 'channel_sensitive_word' } }), 5);
checks.push('filter, disable, soft delete, audited restore retain ID; old snapshot unaffected by config edits');
const messages = [];
for (let index = 0; index < 100; index++)
messages.push(
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
messageId: `${prefix}-${index}`,
content: `【验收】贷款咨询${'字'.repeat(80)}`,
phoneNumber: '13800000000',
carrier: 'mobile',
},
}),
);
const sendChain = new SendChainService(db, {}, {}, {});
const ordinary = await sendChain.selectChannelForMessage(messages[0]);
assert.equal(ordinary.channel.id, channels[1].id);
const started = performance.now();
let reads = 0,
writes = 0;
const originalRead = db.channelSensitiveWord.findMany.bind(db.channelSensitiveWord);
const originalWrite = db.smsChannelSensitiveDecision.createMany.bind(db.smsChannelSensitiveDecision);
db.channelSensitiveWord.findMany = (...args) => {
reads++;
return originalRead(...args);
};
db.smsChannelSensitiveDecision.createMany = (...args) => {
writes++;
return originalWrite(...args);
};
const batch = await sendChain.submission.gatewaySubmit.planRoutesBatch(messages);
assert.equal(batch.planned.length, 100);
assert.equal(batch.failed.length, 0);
assert(batch.planned.every((item) => item.routed.channel.id === channels[1].id));
assert.equal(reads, 1);
assert.equal(writes, 1);
const durationMs = Math.round(performance.now() - started);
checks.push(
`ordinary and 100-message batch choose B, one word SQL and one decision SQL; ${durationMs}ms includes existing drainage work`,
);
await service.save(admin.id, { ...data, channelId: channels[1].id });
const failed = await sendChain.submission.gatewaySubmit.planRoutesBatch([messages[1]]);
assert.equal(failed.failed[0].code, 'CHANNEL_SENSITIVE_WORD_NO_ROUTE');
await assert.rejects(sendChain.selectChannelForMessage(messages[2]), /均命中/);
const detail = await new OperationsMessageQueries(db).getMessage(messages[0].id);
assert(
detail.channelWordDecisions.some((decision) =>
decision.snapshot.hits.some((hit) => hit.channelName === '验收通道A'),
),
);
assert.equal(clientMessageView(detail).channelWordDecisions, undefined);
assert.equal(
(await db.smsMessageRecord.findUniqueOrThrow({ where: { id: messages[0].id } })).content,
messages[0].content,
);
await before.persist(db);
assert.equal(
await db.gatewaySubmitOutbox.count({ where: { messageRecordId: { in: messages.map((message) => message.id) } } }),
0,
);
assert.equal(
await db.smsSubmitRecord.count({ where: { messageRecordId: { in: messages.map((message) => message.id) } } }),
0,
);
checks.push(
'all candidates rejected in ordinary/batch, historical admin explanation, client redaction, original text unchanged, no submit intents',
);
// Exercise the actual failure SQL before an isolated release failure.
const task = await db.smsBatchTask.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
taskNo: prefix,
sourceType: 'client',
content: '贷款',
phoneTotal: 1,
},
});
const pending = await db.smsMessageRecord.update({
where: { id: messages[3].id },
data: { batchTaskId: task.id },
include: { batchTask: true },
});
sendChain.releaseMessageReservation = async () => {
throw Error('isolated release failure');
};
await assert.rejects(
sendChain.submission.gatewaySubmit.failRouteBatch(
[{ message: pending, reason: '可用通道均命中通道敏感词', code: 'CHANNEL_SENSITIVE_WORD_NO_ROUTE' }],
new Map(),
),
/isolated release failure/,
);
const persisted = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: pending.id } });
assert.equal(persisted.status, 'failed');
assert.equal(persisted.channelWordFinalizationPending, true);
assert.equal(persisted.drainageReceiptPending, false);
checks.push(
'real batch failure SQL marks non-CMPP finalization pending before injected release failure; no callback or SMS',
);
console.log(
JSON.stringify({ success: true, database: name, fixturePrefix: prefix, messageId: messages[0].id, checks }),
);
} finally {
await db.$disconnect();
}