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])
|
||||
}
|
||||
|
||||
model ChannelSensitiveWord {
|
||||
id String @id @default(cuid())
|
||||
channelId String
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
word String
|
||||
status String @default("active")
|
||||
remark String @default("")
|
||||
version Int @default(1)
|
||||
createdBy String
|
||||
updatedBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@unique([channelId, word])
|
||||
@@index([channelId, status])
|
||||
}
|
||||
|
||||
model SmsChannelSensitiveDecision {
|
||||
id String @id @default(cuid())
|
||||
messageRecordId String
|
||||
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id])
|
||||
routeAttemptId String @unique
|
||||
decidedAt DateTime @default(now())
|
||||
snapshot Json
|
||||
@@index([messageRecordId, decidedAt])
|
||||
}
|
||||
|
||||
model SensitiveWord {
|
||||
id String @id @default(cuid())
|
||||
word String @unique
|
||||
@@ -881,6 +907,7 @@ model AuditRecord {
|
||||
}
|
||||
|
||||
model SmsChannel {
|
||||
sensitiveWords ChannelSensitiveWord[]
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
@@ -1799,6 +1826,8 @@ model SmsDrainageDecision {
|
||||
}
|
||||
|
||||
model SmsMessageRecord {
|
||||
channelWordDecisions SmsChannelSensitiveDecision[]
|
||||
channelWordFinalizationPending Boolean @default(false)
|
||||
monitorFacts SendingMonitorFact[]
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
|
||||
@Controller('admin/dictionaries/channel-sensitive-words')
|
||||
export class ChannelSensitiveWordsController {
|
||||
constructor(private readonly service: ChannelSensitiveWordsService) {}
|
||||
@Get() list(@CurrentSessionUserId() userId: string, @Query() query: Record<string, string | undefined>) {
|
||||
return this.service.list(userId, query);
|
||||
}
|
||||
@Post() create(@CurrentSessionUserId() userId: string, @Body() body: unknown) {
|
||||
return this.service.save(userId, body);
|
||||
}
|
||||
@Patch(':id') update(@CurrentSessionUserId() userId: string, @Param('id') id: string, @Body() body: unknown) {
|
||||
return this.service.save(userId, body, id);
|
||||
}
|
||||
@Delete(':id') remove(
|
||||
@CurrentSessionUserId() userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { version?: unknown },
|
||||
) {
|
||||
return this.service.remove(userId, id, body?.version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChannelSensitiveWordsService, validateChannelWord } from './channel-sensitive-words.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
const valid = { channelId: 'a', word: ' 贷 款 ', status: 'active', remark: '' };
|
||||
describe('channel word administration', () => {
|
||||
it('trims only outer whitespace and requires a version for editing', () => {
|
||||
expect(validateChannelWord(valid).word).toBe('贷 款');
|
||||
expect(() => validateChannelWord(valid, true)).toThrow('版本');
|
||||
expect(validateChannelWord({ ...valid, version: 3 }, true).version).toBe(3);
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{ ...valid, word: ' ' },
|
||||
{ ...valid, word: 'a'.repeat(201) },
|
||||
{ ...valid, channelId: '' },
|
||||
{ ...valid, status: 'deleted' },
|
||||
{ ...valid, remark: 'a'.repeat(501) },
|
||||
{ ...valid, operatorId: 'spoof' },
|
||||
])('rejects invalid runtime data %#', (data) => expect(() => validateChannelWord(data)).toThrow());
|
||||
it('checks active platform admin permission before data access', async () => {
|
||||
const prisma = {
|
||||
user: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
channelSensitiveWord: { findMany: jest.fn() },
|
||||
};
|
||||
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
|
||||
await expect(service.list('client-user', {})).rejects.toMatchObject({ status: 403 });
|
||||
expect(prisma.channelSensitiveWord.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
it('rejects invalid pagination before querying rules', async () => {
|
||||
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ id: 'admin' }) } };
|
||||
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
|
||||
for (const query of [{ page: '0' }, { pageSize: '101' }, { page: '1.5' }, { status: 'deleted' }])
|
||||
await expect(service.list('admin', query)).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export function validateChannelWord(value: unknown, editing = false) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('规则参数无效');
|
||||
const data = value as Record<string, unknown>;
|
||||
if (Object.keys(data).some((key) => !['channelId', 'word', 'status', 'remark', 'version'].includes(key)))
|
||||
throw new BadRequestException('包含不支持的字段');
|
||||
if (typeof data.channelId !== 'string' || !data.channelId.trim() || data.channelId.length > 160)
|
||||
throw new BadRequestException('请选择通道');
|
||||
if (typeof data.word !== 'string' || !data.word.trim() || data.word.trim().length > 200)
|
||||
throw new BadRequestException('敏感词需为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 { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
|
||||
import { ChannelSensitiveWordsController } from './channel-sensitive-words.controller';
|
||||
import { DictionariesController } from './dictionaries.controller';
|
||||
import { DictionariesService } from './dictionaries.service';
|
||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DictionariesController],
|
||||
providers: [DictionariesService, PhoneRoutingLookupService],
|
||||
controllers: [DictionariesController, ChannelSensitiveWordsController],
|
||||
providers: [DictionariesService, PhoneRoutingLookupService, ChannelSensitiveWordsService],
|
||||
exports: [DictionariesService, PhoneRoutingLookupService],
|
||||
})
|
||||
export class DictionariesModule {}
|
||||
|
||||
@@ -108,6 +108,7 @@ export class OperationsMessageQueries {
|
||||
const item = await this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
channelWordDecisions: { orderBy: [{ decidedAt: 'desc' }, { id: 'desc' }], take: 10 },
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
channel: { select: { id: true, name: true, srcId: true } },
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ChannelWordSnapshot, loadChannelWords } from './channel-sensitive-routing';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
const channel = {
|
||||
status: 'active',
|
||||
carrier: 'all',
|
||||
carriers: ['mobile', 'unicom', 'telecom'],
|
||||
sendRegion: '全国',
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
};
|
||||
const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobile', priority: index + 1, channel }));
|
||||
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
|
||||
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
|
||||
describe('channel sensitive routing snapshot', () => {
|
||||
it('removes only matching eligible channels before original priority selection', () => {
|
||||
const snapshot = new ChannelWordSnapshot([rule]);
|
||||
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
|
||||
expect(snapshot.select('n', '正常业务', items, options).selected?.channelId).toBe('a');
|
||||
expect(snapshot.decisions[0].snapshot).toEqual(
|
||||
expect.objectContaining({ excludedChannelIds: ['a'], stage: 'route' }),
|
||||
);
|
||||
});
|
||||
it('keeps original exclusions and distinguishes no original route from all hit', () => {
|
||||
const snapshot = new ChannelWordSnapshot([rule]);
|
||||
expect(snapshot.select('m', '贷款', items, { ...options, excludedChannelIds: new Set(['b']) }).rejected).toBe(true);
|
||||
expect(snapshot.select('m', '贷款', items, { ...options, approvedChannelIds: new Set() }).rejected).toBe(false);
|
||||
expect(
|
||||
snapshot.select(
|
||||
'm',
|
||||
'贷款',
|
||||
items.map((item) => ({ ...item, channel: { ...channel, status: 'inactive' } })),
|
||||
options,
|
||||
).rejected,
|
||||
).toBe(false);
|
||||
});
|
||||
it('allows national fallback when matching province channel is excluded', () => {
|
||||
const provincial = [{ ...items[0], province: '上海', channel: { ...channel, sendRegion: '上海' } }, items[1]];
|
||||
expect(
|
||||
new ChannelWordSnapshot([rule]).select('m', '贷款', provincial, { ...options, province: '上海' }).selected
|
||||
?.channelId,
|
||||
).toBe('b');
|
||||
});
|
||||
it('matches original complete content, case sensitively without removing separators', () => {
|
||||
const snapshot = new ChannelWordSnapshot([{ ...rule, word: 'Ab贷款' }]);
|
||||
expect(snapshot.hits('【签名】Ab贷款')[0].count).toBe(1);
|
||||
for (const value of ['ab贷款', '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 {
|
||||
await this.sendChain.recoverDrainageFailureReceipts();
|
||||
} catch (error) {
|
||||
this.logger.error(`引流拦截回执恢复失败: ${String(error)}`);
|
||||
this.logger.error(`选路拦截回执恢复失败: ${String(error)}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BillingService } from '../billing/billing.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
import { DrainageRejection } from './drainage-authorization';
|
||||
import { ChannelWordRejection } from './channel-sensitive-routing';
|
||||
|
||||
function createPrismaMock() {
|
||||
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
|
||||
@@ -77,6 +78,8 @@ function createPrismaMock() {
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
smsChannelSensitiveDecision: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
@@ -498,6 +501,81 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
it('recovers non-CMPP channel-word finalization without pushing a receipt', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
{ ...message, channelWordFinalizationPending: true, batchTask: { sourceType: 'client' } },
|
||||
]);
|
||||
service['releaseMessageReservation'] = jest.fn();
|
||||
service['recordCmppFailureReceipt'] = jest.fn();
|
||||
service['refreshTaskProgress'] = jest.fn();
|
||||
await service.recoverDrainageFailureReceipts();
|
||||
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
|
||||
expect(service['refreshTaskProgress']).toHaveBeenCalledWith('task-1');
|
||||
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: { channelWordFinalizationPending: false },
|
||||
});
|
||||
});
|
||||
it.each(['cmpp', 'client', 'http'])(
|
||||
'fails an all-hit ordinary route without supplier submit (%s)',
|
||||
async (sourceType) => {
|
||||
const { service, prisma } = createService();
|
||||
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({ ...message, batchTask: { id: 'task-1', sourceType } });
|
||||
service['selectChannelForMessage'] = jest.fn().mockRejectedValue(new ChannelWordRejection());
|
||||
service['recordCmppFailureReceipt'] = jest.fn();
|
||||
service['releaseMessageReservation'] = jest.fn();
|
||||
service['refreshTaskProgress'] = jest.fn();
|
||||
await service.processSendJob({ messageRecordId: 'record-1' });
|
||||
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
||||
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
|
||||
if (sourceType === 'cmpp')
|
||||
expect(service['recordCmppFailureReceipt']).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'CSW',
|
||||
'可用通道均命中通道敏感词',
|
||||
);
|
||||
else expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ channelWordFinalizationPending: true, drainageReceiptPending: false }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
it('recovers channel-word delivery intent from an existing receipt and keeps pending on failure', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
|
||||
const message = {
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
cmppRegisteredDelivery: true,
|
||||
cmppSubmitSequenceId: '101',
|
||||
};
|
||||
service['queueAndTryDownstreamDelivery'] = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Error('persistence failed'))
|
||||
.mockResolvedValue({ id: 'delivery' });
|
||||
await expect(service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词')).rejects.toThrow(
|
||||
'persistence failed',
|
||||
);
|
||||
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { channelWordFinalizationPending: false } }),
|
||||
);
|
||||
await service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词');
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: { channelWordFinalizationPending: false },
|
||||
});
|
||||
});
|
||||
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
|
||||
|
||||
@@ -872,10 +872,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
async recoverDrainageFailureReceipts() {
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
drainageReceiptPending: true,
|
||||
OR: [
|
||||
{ drainageReceiptPending: true, batchTask: { sourceType: 'cmpp' } },
|
||||
{ channelWordFinalizationPending: true },
|
||||
],
|
||||
status: { in: ['failed', 'submit_failed'] },
|
||||
batchTask: { sourceType: 'cmpp' },
|
||||
},
|
||||
include: { batchTask: { select: { sourceType: true } } },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
@@ -886,9 +889,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
|
||||
reason,
|
||||
);
|
||||
if (message.channelWordFinalizationPending && message.batchTask?.sourceType !== 'cmpp') {
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { channelWordFinalizationPending: false },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.recordCmppFailureReceipt(
|
||||
message,
|
||||
message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN',
|
||||
message.channelWordFinalizationPending
|
||||
? 'CSW'
|
||||
: message.errorCode?.startsWith('DRN')
|
||||
? message.errorCode
|
||||
: 'DRN',
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -466,7 +466,8 @@ export class SendDownstreamDeliveryService {
|
||||
const existing = await this.prisma.smsReceiptRecord.findFirst({
|
||||
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
|
||||
});
|
||||
if (existing && !errorCode.startsWith('DRN')) return existing;
|
||||
const recoverableRejection = errorCode.startsWith('DRN') || errorCode === 'CSW';
|
||||
if (existing && !recoverableRejection) return existing;
|
||||
const deliveredAt = new Date();
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
@@ -501,12 +502,12 @@ export class SendDownstreamDeliveryService {
|
||||
};
|
||||
const receipt =
|
||||
existing ??
|
||||
(errorCode.startsWith('DRN')
|
||||
(recoverableRejection
|
||||
? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData })
|
||||
: await this.prisma.smsReceiptRecord.create({ data: receiptData }));
|
||||
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
|
||||
message,
|
||||
propagateHttpQueueError: errorCode.startsWith('DRN'),
|
||||
propagateHttpQueueError: recoverableRejection,
|
||||
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
@@ -520,6 +521,11 @@ export class SendDownstreamDeliveryService {
|
||||
},
|
||||
});
|
||||
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
if (errorCode === 'CSW')
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { channelWordFinalizationPending: false },
|
||||
});
|
||||
if (errorCode.startsWith('DRN'))
|
||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } });
|
||||
return receipt;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
|
||||
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
|
||||
import { CHANNEL_WORD_NO_ROUTE, ChannelWordRejection, loadChannelWords } from './channel-sensitive-routing';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
isNationalChannel,
|
||||
composeUpstreamSrcId,
|
||||
bullmqConnection,
|
||||
selectChannelCandidate,
|
||||
} from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
@@ -462,6 +462,10 @@ export class SendGatewaySubmitService {
|
||||
include: { reportTasks: { where: { reportType: 'drainage' } } },
|
||||
})
|
||||
: [];
|
||||
const channelWords = await loadChannelWords(
|
||||
this.prisma,
|
||||
routes.flatMap((route) => route.group.items.map((item) => item.channelId)),
|
||||
);
|
||||
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
|
||||
const failed: Array<{ message: T; reason: string; code?: string }> = [];
|
||||
for (const input of routeInputs) {
|
||||
@@ -474,11 +478,13 @@ export class SendGatewaySubmitService {
|
||||
continue;
|
||||
}
|
||||
let gate;
|
||||
let content: string;
|
||||
try {
|
||||
const stored =
|
||||
input.message.content === undefined
|
||||
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
|
||||
: input.message;
|
||||
content = stored.content!;
|
||||
gate = await evaluateMessageDrainage(
|
||||
this.prisma,
|
||||
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
|
||||
@@ -523,7 +529,7 @@ export class SendGatewaySubmitService {
|
||||
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
|
||||
),
|
||||
);
|
||||
const selected = selectChannelCandidate(approvedItems, {
|
||||
const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, {
|
||||
carrier: input.carrier,
|
||||
province: input.province,
|
||||
excludedChannelIds: new Set(),
|
||||
@@ -531,7 +537,11 @@ export class SendGatewaySubmitService {
|
||||
routingKey: input.message.id,
|
||||
});
|
||||
if (!selected) {
|
||||
failed.push({ message: input.message, reason: '无已报备通过且在线的可用通道' });
|
||||
failed.push({
|
||||
message: input.message,
|
||||
reason: rejected ? '可用通道均命中通道敏感词' : '无已报备通过且在线的可用通道',
|
||||
...(rejected ? { code: CHANNEL_WORD_NO_ROUTE } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
planned.push({
|
||||
@@ -546,6 +556,7 @@ export class SendGatewaySubmitService {
|
||||
},
|
||||
});
|
||||
}
|
||||
await channelWords.persist(this.prisma);
|
||||
return { planned, failed };
|
||||
}
|
||||
|
||||
@@ -573,7 +584,8 @@ export class SendGatewaySubmitService {
|
||||
await this.prisma.$executeRaw(Prisma.sql`
|
||||
UPDATE "SmsMessageRecord" AS message
|
||||
SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code,
|
||||
"drainageReceiptPending" = failures.pending, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
|
||||
"drainageReceiptPending" = failures.pending AND failures.code IS DISTINCT FROM ${CHANNEL_WORD_NO_ROUTE},
|
||||
"channelWordFinalizationPending" = COALESCE(failures.code = ${CHANNEL_WORD_NO_ROUTE}, false), "updatedAt" = (NOW() AT TIME ZONE 'UTC')
|
||||
FROM (VALUES ${values}) AS failures(id, reason, code, pending)
|
||||
WHERE message.id = failures.id AND message.status = 'queued'
|
||||
`);
|
||||
@@ -581,8 +593,19 @@ export class SendGatewaySubmitService {
|
||||
failed.map(async ({ message, reason, code }) => {
|
||||
await this.releaseMessageReservation(message, reason);
|
||||
if (message.batchTask?.sourceType === 'cmpp')
|
||||
await this.recordCmppFailureReceipt(message, code ? 'DRN' : 'ROUTE', reason);
|
||||
else await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
await this.recordCmppFailureReceipt(
|
||||
message,
|
||||
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
|
||||
reason,
|
||||
);
|
||||
else {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
if (code === CHANNEL_WORD_NO_ROUTE)
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { channelWordFinalizationPending: false },
|
||||
});
|
||||
}
|
||||
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
|
||||
this.metrics?.recordSendWorkerResult('failed');
|
||||
}),
|
||||
@@ -665,7 +688,8 @@ export class SendGatewaySubmitService {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500)
|
||||
throw error;
|
||||
const code = error instanceof DrainageRejection ? error.reasonCode : undefined;
|
||||
const code =
|
||||
error instanceof DrainageRejection || error instanceof ChannelWordRejection ? error.reasonCode : undefined;
|
||||
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
@@ -673,17 +697,29 @@ export class SendGatewaySubmitService {
|
||||
status: 'failed',
|
||||
errorMessage: reason,
|
||||
errorCode: code,
|
||||
drainageReceiptPending: Boolean(code && message.batchTask?.sourceType === 'cmpp'),
|
||||
drainageReceiptPending: Boolean(
|
||||
code && code !== CHANNEL_WORD_NO_ROUTE && message.batchTask?.sourceType === 'cmpp',
|
||||
),
|
||||
channelWordFinalizationPending: code === CHANNEL_WORD_NO_ROUTE,
|
||||
},
|
||||
});
|
||||
await this.releaseMessageReservation(businessMessage, reason);
|
||||
if (message.batchTask?.sourceType === 'cmpp') {
|
||||
await this.recordCmppFailureReceipt(businessMessage, code ? 'DRN' : 'ROUTE', reason);
|
||||
await this.recordCmppFailureReceipt(
|
||||
businessMessage,
|
||||
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
|
||||
reason,
|
||||
);
|
||||
} else {
|
||||
await this.facade.refreshTaskProgress(
|
||||
businessMessage.batchTaskId,
|
||||
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'failed' : undefined,
|
||||
);
|
||||
if (code === CHANNEL_WORD_NO_ROUTE)
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { channelWordFinalizationPending: false },
|
||||
});
|
||||
}
|
||||
finish('failed');
|
||||
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
||||
@@ -1089,7 +1125,11 @@ return streamId`;
|
||||
);
|
||||
if (gate.targets.length && approvedChannelIds.size === 0)
|
||||
throw new DrainageRejection('DRAINAGE_CHANNEL_NOT_APPROVED', '引流信息未在签名可用通道报备通过');
|
||||
const selected = selectChannelCandidate(route.group.items, {
|
||||
const channelWords = await loadChannelWords(
|
||||
this.prisma,
|
||||
route.group.items.map((item) => item.channelId),
|
||||
);
|
||||
const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, {
|
||||
carrier,
|
||||
province,
|
||||
forceNational: options.forceNational,
|
||||
@@ -1097,6 +1137,8 @@ return streamId`;
|
||||
approvedChannelIds,
|
||||
routingKey: message.id,
|
||||
});
|
||||
await channelWords.persist(this.prisma);
|
||||
if (rejected) throw new ChannelWordRejection();
|
||||
if (!selected) {
|
||||
throw new NotFoundException('无已报备通过且在线的可用通道');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user