feat: filter SMS routes by channel sensitive words
This commit is contained in:
@@ -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;
|
||||||
@@ -266,6 +266,32 @@ model PhoneCarrierRule {
|
|||||||
@@index([status, priority])
|
@@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 {
|
model SensitiveWord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
word String @unique
|
word String @unique
|
||||||
@@ -881,6 +907,7 @@ model AuditRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsChannel {
|
model SmsChannel {
|
||||||
|
sensitiveWords ChannelSensitiveWord[]
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
@@ -1799,6 +1826,8 @@ model SmsDrainageDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsMessageRecord {
|
model SmsMessageRecord {
|
||||||
|
channelWordDecisions SmsChannelSensitiveDecision[]
|
||||||
|
channelWordFinalizationPending Boolean @default(false)
|
||||||
monitorFacts SendingMonitorFact[]
|
monitorFacts SendingMonitorFact[]
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
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('敏感词需为1~200个字符');
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { DictionariesController } from './dictionaries.controller';
|
||||||
import { DictionariesService } from './dictionaries.service';
|
import { DictionariesService } from './dictionaries.service';
|
||||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [DictionariesController],
|
controllers: [DictionariesController, ChannelSensitiveWordsController],
|
||||||
providers: [DictionariesService, PhoneRoutingLookupService],
|
providers: [DictionariesService, PhoneRoutingLookupService, ChannelSensitiveWordsService],
|
||||||
exports: [DictionariesService, PhoneRoutingLookupService],
|
exports: [DictionariesService, PhoneRoutingLookupService],
|
||||||
})
|
})
|
||||||
export class DictionariesModule {}
|
export class DictionariesModule {}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export class OperationsMessageQueries {
|
|||||||
const item = await this.prisma.smsMessageRecord.findUnique({
|
const item = await this.prisma.smsMessageRecord.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
channelWordDecisions: { orderBy: [{ decidedAt: 'desc' }, { id: 'desc' }], take: 10 },
|
||||||
tenant: { select: { id: true, name: true } },
|
tenant: { select: { id: true, name: true } },
|
||||||
application: { select: { id: true, name: true } },
|
application: { select: { id: true, name: true } },
|
||||||
channel: { select: { id: true, name: true, srcId: true } },
|
channel: { select: { id: true, name: true, srcId: true } },
|
||||||
|
|||||||
@@ -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贷款', 'Ab贷款', '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 {
|
try {
|
||||||
await this.sendChain.recoverDrainageFailureReceipts();
|
await this.sendChain.recoverDrainageFailureReceipts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`引流拦截回执恢复失败: ${String(error)}`);
|
this.logger.error(`选路拦截回执恢复失败: ${String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BillingService } from '../billing/billing.service';
|
|||||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||||
import { SendChainService } from './send-chain.service';
|
import { SendChainService } from './send-chain.service';
|
||||||
import { DrainageRejection } from './drainage-authorization';
|
import { DrainageRejection } from './drainage-authorization';
|
||||||
|
import { ChannelWordRejection } from './channel-sensitive-routing';
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
|
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
|
||||||
@@ -77,6 +78,8 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
smsChannelSensitiveDecision: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||||
tenant: {
|
tenant: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||||
},
|
},
|
||||||
@@ -498,6 +501,81 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('SendChainService', () => {
|
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 () => {
|
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
|
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
|
||||||
|
|||||||
@@ -872,10 +872,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async recoverDrainageFailureReceipts() {
|
async recoverDrainageFailureReceipts() {
|
||||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||||
where: {
|
where: {
|
||||||
drainageReceiptPending: true,
|
OR: [
|
||||||
|
{ drainageReceiptPending: true, batchTask: { sourceType: 'cmpp' } },
|
||||||
|
{ channelWordFinalizationPending: true },
|
||||||
|
],
|
||||||
status: { in: ['failed', 'submit_failed'] },
|
status: { in: ['failed', 'submit_failed'] },
|
||||||
batchTask: { sourceType: 'cmpp' },
|
|
||||||
},
|
},
|
||||||
|
include: { batchTask: { select: { sourceType: true } } },
|
||||||
orderBy: { updatedAt: 'asc' },
|
orderBy: { updatedAt: 'asc' },
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
@@ -886,9 +889,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
|
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
|
||||||
reason,
|
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(
|
await this.recordCmppFailureReceipt(
|
||||||
message,
|
message,
|
||||||
message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN',
|
message.channelWordFinalizationPending
|
||||||
|
? 'CSW'
|
||||||
|
: message.errorCode?.startsWith('DRN')
|
||||||
|
? message.errorCode
|
||||||
|
: 'DRN',
|
||||||
reason,
|
reason,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,7 +466,8 @@ export class SendDownstreamDeliveryService {
|
|||||||
const existing = await this.prisma.smsReceiptRecord.findFirst({
|
const existing = await this.prisma.smsReceiptRecord.findFirst({
|
||||||
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
|
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();
|
const deliveredAt = new Date();
|
||||||
await this.prisma.smsMessageRecord.update({
|
await this.prisma.smsMessageRecord.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
@@ -501,12 +502,12 @@ export class SendDownstreamDeliveryService {
|
|||||||
};
|
};
|
||||||
const receipt =
|
const receipt =
|
||||||
existing ??
|
existing ??
|
||||||
(errorCode.startsWith('DRN')
|
(recoverableRejection
|
||||||
? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData })
|
? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData })
|
||||||
: await this.prisma.smsReceiptRecord.create({ data: receiptData }));
|
: await this.prisma.smsReceiptRecord.create({ data: receiptData }));
|
||||||
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
|
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
|
||||||
message,
|
message,
|
||||||
propagateHttpQueueError: errorCode.startsWith('DRN'),
|
propagateHttpQueueError: recoverableRejection,
|
||||||
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
|
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
|
||||||
payload: {
|
payload: {
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
@@ -520,6 +521,11 @@ export class SendDownstreamDeliveryService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
|
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'))
|
if (errorCode.startsWith('DRN'))
|
||||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } });
|
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } });
|
||||||
return receipt;
|
return receipt;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
|
||||||
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
|
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
|
||||||
|
import { CHANNEL_WORD_NO_ROUTE, ChannelWordRejection, loadChannelWords } from './channel-sensitive-routing';
|
||||||
import { moneyToNumber } from '../common/money';
|
import { moneyToNumber } from '../common/money';
|
||||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||||
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
|
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
isNationalChannel,
|
isNationalChannel,
|
||||||
composeUpstreamSrcId,
|
composeUpstreamSrcId,
|
||||||
bullmqConnection,
|
bullmqConnection,
|
||||||
selectChannelCandidate,
|
|
||||||
} from './send-chain.helpers';
|
} from './send-chain.helpers';
|
||||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||||
|
|
||||||
@@ -462,6 +462,10 @@ export class SendGatewaySubmitService {
|
|||||||
include: { reportTasks: { where: { reportType: 'drainage' } } },
|
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 planned: Array<{ message: T; routed: RoutedChannel }> = [];
|
||||||
const failed: Array<{ message: T; reason: string; code?: string }> = [];
|
const failed: Array<{ message: T; reason: string; code?: string }> = [];
|
||||||
for (const input of routeInputs) {
|
for (const input of routeInputs) {
|
||||||
@@ -474,11 +478,13 @@ export class SendGatewaySubmitService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let gate;
|
let gate;
|
||||||
|
let content: string;
|
||||||
try {
|
try {
|
||||||
const stored =
|
const stored =
|
||||||
input.message.content === undefined
|
input.message.content === undefined
|
||||||
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
|
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
|
||||||
: input.message;
|
: input.message;
|
||||||
|
content = stored.content!;
|
||||||
gate = await evaluateMessageDrainage(
|
gate = await evaluateMessageDrainage(
|
||||||
this.prisma,
|
this.prisma,
|
||||||
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
|
{ ...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')),
|
(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,
|
carrier: input.carrier,
|
||||||
province: input.province,
|
province: input.province,
|
||||||
excludedChannelIds: new Set(),
|
excludedChannelIds: new Set(),
|
||||||
@@ -531,7 +537,11 @@ export class SendGatewaySubmitService {
|
|||||||
routingKey: input.message.id,
|
routingKey: input.message.id,
|
||||||
});
|
});
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
failed.push({ message: input.message, reason: '无已报备通过且在线的可用通道' });
|
failed.push({
|
||||||
|
message: input.message,
|
||||||
|
reason: rejected ? '可用通道均命中通道敏感词' : '无已报备通过且在线的可用通道',
|
||||||
|
...(rejected ? { code: CHANNEL_WORD_NO_ROUTE } : {}),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
planned.push({
|
planned.push({
|
||||||
@@ -546,6 +556,7 @@ export class SendGatewaySubmitService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await channelWords.persist(this.prisma);
|
||||||
return { planned, failed };
|
return { planned, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +584,8 @@ export class SendGatewaySubmitService {
|
|||||||
await this.prisma.$executeRaw(Prisma.sql`
|
await this.prisma.$executeRaw(Prisma.sql`
|
||||||
UPDATE "SmsMessageRecord" AS message
|
UPDATE "SmsMessageRecord" AS message
|
||||||
SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code,
|
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)
|
FROM (VALUES ${values}) AS failures(id, reason, code, pending)
|
||||||
WHERE message.id = failures.id AND message.status = 'queued'
|
WHERE message.id = failures.id AND message.status = 'queued'
|
||||||
`);
|
`);
|
||||||
@@ -581,8 +593,19 @@ export class SendGatewaySubmitService {
|
|||||||
failed.map(async ({ message, reason, code }) => {
|
failed.map(async ({ message, reason, code }) => {
|
||||||
await this.releaseMessageReservation(message, reason);
|
await this.releaseMessageReservation(message, reason);
|
||||||
if (message.batchTask?.sourceType === 'cmpp')
|
if (message.batchTask?.sourceType === 'cmpp')
|
||||||
await this.recordCmppFailureReceipt(message, code ? 'DRN' : 'ROUTE', reason);
|
await this.recordCmppFailureReceipt(
|
||||||
else await this.facade.refreshTaskProgress(message.batchTaskId);
|
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 });
|
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
|
||||||
this.metrics?.recordSendWorkerResult('failed');
|
this.metrics?.recordSendWorkerResult('failed');
|
||||||
}),
|
}),
|
||||||
@@ -665,7 +688,8 @@ export class SendGatewaySubmitService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500)
|
if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500)
|
||||||
throw error;
|
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 : '无可用通道组或通道';
|
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
|
||||||
await this.prisma.smsMessageRecord.update({
|
await this.prisma.smsMessageRecord.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
@@ -673,17 +697,29 @@ export class SendGatewaySubmitService {
|
|||||||
status: 'failed',
|
status: 'failed',
|
||||||
errorMessage: reason,
|
errorMessage: reason,
|
||||||
errorCode: code,
|
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);
|
await this.releaseMessageReservation(businessMessage, reason);
|
||||||
if (message.batchTask?.sourceType === 'cmpp') {
|
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 {
|
} else {
|
||||||
await this.facade.refreshTaskProgress(
|
await this.facade.refreshTaskProgress(
|
||||||
businessMessage.batchTaskId,
|
businessMessage.batchTaskId,
|
||||||
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'failed' : undefined,
|
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');
|
finish('failed');
|
||||||
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
||||||
@@ -1089,7 +1125,11 @@ return streamId`;
|
|||||||
);
|
);
|
||||||
if (gate.targets.length && approvedChannelIds.size === 0)
|
if (gate.targets.length && approvedChannelIds.size === 0)
|
||||||
throw new DrainageRejection('DRAINAGE_CHANNEL_NOT_APPROVED', '引流信息未在签名可用通道报备通过');
|
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,
|
carrier,
|
||||||
province,
|
province,
|
||||||
forceNational: options.forceNational,
|
forceNational: options.forceNational,
|
||||||
@@ -1097,6 +1137,8 @@ return streamId`;
|
|||||||
approvedChannelIds,
|
approvedChannelIds,
|
||||||
routingKey: message.id,
|
routingKey: message.id,
|
||||||
});
|
});
|
||||||
|
await channelWords.persist(this.prisma);
|
||||||
|
if (rejected) throw new ChannelWordRejection();
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
throw new NotFoundException('无已报备通过且在线的可用通道');
|
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-01~12的修订版本执行: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平台回执码CSW(3字符),状态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 引流发送资格实施确认
|
## 2026-09-10 引流发送资格实施确认
|
||||||
|
|
||||||
后续已授权修改、本地提交及测试部署。以 drainage-send-gating-plan-20260910.md 第10节为实现说明,替代此前“未授权实施”的阶段性描述。每个引流目标须匹配本企业应用/签名审核通过的资料并满足最终通道报备;NFKC及干扰清洗只用于检测,域名自身与子域按点边界匹配,纯域名不限制路径参数。非CMPP拦截不推送回执;CMPP失败回执需幂等且可恢复。旧报备配置仍由用户处理。是否线上生效及未执行发送验收见 testing-progress.md。
|
后续已授权修改、本地提交及测试部署。以 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为准。
|
||||||
|
|||||||
@@ -78,3 +78,13 @@ api/src/risk-review/
|
|||||||
## 2026-09-10 引流发送门禁实施
|
## 2026-09-10 引流发送门禁实施
|
||||||
|
|
||||||
后续用户已授权实施、本地提交及测试部署。具体数据模型、最终分片复核、并发锁、CMPP回执恢复和非CMPP无推送行为见 drainage-send-gating-plan-20260910.md 第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为准。
|
||||||
|
|||||||
@@ -5393,3 +5393,34 @@ TC-DRAINAGE-GATE-01~16的实现范围以专项方案第10节为准,不再笼
|
|||||||
### 2026-09-10 TC-DRAINAGE-GATE测试发布补充
|
### 2026-09-10 TC-DRAINAGE-GATE测试发布补充
|
||||||
|
|
||||||
0c3f820已部署测试。TC-DRAINAGE-GATE相关本地真实数据库/API/并发/统计用例及生产构建页面通过;线上历史短信详情保持“未执行引流资格校验”,三尺寸和显式关闭通过。TC-OPS0909相关六页面真实请求回归通过,不将只读打开页面记作重做全部写操作用例。迁移、13项服务、Gateway Stream与版本摘要检查通过。真实短信/供应商零Submit、长短信、CMPP回执ACK与费用对账仍待专项授权,状态不得升级为全链路通过。证据见[测试发布验收](release-20260910-test-drainage.md)。
|
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通过,实际测试环境发布后验收另记。
|
||||||
|
|||||||
@@ -4872,3 +4872,33 @@ git diff --check
|
|||||||
应用0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7已本地提交并通过标准工具部署测试,原版本809175b544f2526891ba6d2dece1a50eadaf57a0;未推送、未部署预生产。精确提交validate为API67套722项、前端27文件136项及相关门禁通过。首次preflight的安全代理运行目录缺失由用户单独授权修复,复检后正常发布。13项服务active、三个Gateway Stream pending/lag=0、消息/提交数不变、迁移/资源摘要/日志验证通过。真实测试管理员和六页面、三尺寸历史短信详情通过,线上不回填旧消息。
|
应用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和费用对账仍未执行,不能以页面通过代替。原网络超时根因与预生产历史故障未宣称关闭。
|
[本次发布验收](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 }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -118,6 +118,20 @@ export type SendQualityResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SmsMessageRecord = {
|
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;
|
id: string;
|
||||||
tenantId?: string | null;
|
tenantId?: string | null;
|
||||||
batchTaskId?: string | null;
|
batchTaskId?: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Search, Trash2 } from 'lucide-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 { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
@@ -31,6 +32,36 @@ const levelOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function AdminSensitiveWordsPage() {
|
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 [items, setItems] = useState<SensitiveWordItem[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [word, setWord] = useState('');
|
const [word, setWord] = useState('');
|
||||||
@@ -39,7 +70,8 @@ export function AdminSensitiveWordsPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
adminApi.listSensitiveWords({ keyword })
|
adminApi
|
||||||
|
.listSensitiveWords({ keyword })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setItems(data as SensitiveWordItem[]);
|
setItems(data as SensitiveWordItem[]);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -51,15 +83,37 @@ export function AdminSensitiveWordsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => items.filter((item) => {
|
const filteredItems = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter((item) => {
|
||||||
const text = [item.word, item.level, item.status].join(' ');
|
const text = [item.word, item.level, item.status].join(' ');
|
||||||
return !keyword || text.includes(keyword);
|
return !keyword || text.includes(keyword);
|
||||||
}), [items, 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: '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: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -67,15 +121,26 @@ export function AdminSensitiveWordsPage() {
|
|||||||
width: '130px',
|
width: '130px',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (record) => (
|
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>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
], []);
|
];
|
||||||
|
|
||||||
function addItem() {
|
function addItem() {
|
||||||
adminApi.createSensitiveWord({ word, level, status: 'active' })
|
adminApi
|
||||||
|
.createSensitiveWord({ word, level, status: 'active' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setWord('');
|
setWord('');
|
||||||
setLevel('medium');
|
setLevel('medium');
|
||||||
@@ -89,10 +154,11 @@ export function AdminSensitiveWordsPage() {
|
|||||||
<section className="page-stack admin-security-page">
|
<section className="page-stack admin-security-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb items={['安全控制', '敏感词管理']} />
|
<h2>平台敏感词</h2>
|
||||||
<h1>敏感词管理</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>添加敏感词</Button>
|
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>
|
||||||
|
添加敏感词
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
@@ -105,8 +171,12 @@ export function AdminSensitiveWordsPage() {
|
|||||||
value={keyword}
|
value={keyword}
|
||||||
/>
|
/>
|
||||||
<div className="admin-security-filter__actions">
|
<div className="admin-security-filter__actions">
|
||||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
<Button icon={<Search size={16} />} onClick={loadData}>
|
||||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setKeyword('')} variant="ghost">
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,19 +185,28 @@ export function AdminSensitiveWordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
<Button onClick={() => setModalOpen(false)} variant="ghost">
|
||||||
<Button disabled={!word} onClick={addItem}>确认添加</Button>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!word} onClick={addItem}>
|
||||||
|
确认添加
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
size="xl"
|
size="xl"
|
||||||
title="添加敏感词"
|
title="添加敏感词"
|
||||||
>
|
>
|
||||||
<div className="admin-security-form">
|
<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
|
<Select
|
||||||
label="风险级别"
|
label="风险级别"
|
||||||
onChange={(event) => setLevel(event.target.value)}
|
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">
|
<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="引流发送资格">
|
<section aria-label="引流发送资格">
|
||||||
<h3>引流发送资格</h3>
|
<h3>引流发送资格</h3>
|
||||||
{record.drainageGate ? (
|
{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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user