feat: filter SMS routes by channel sensitive words

This commit is contained in:
hectorzhao
2026-09-10 15:50:56 +08:00
parent 86947827cc
commit 8e4bc5a20e
26 changed files with 1615 additions and 41 deletions
@@ -0,0 +1,20 @@
CREATE TABLE "ChannelSensitiveWord" (
"id" TEXT PRIMARY KEY, "channelId" TEXT NOT NULL, "word" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active', "remark" TEXT NOT NULL DEFAULT '',
"version" INTEGER NOT NULL DEFAULT 1, "createdBy" TEXT NOT NULL, "updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChannelSensitiveWord_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChannelSensitiveWord_status_check" CHECK ("status" IN ('active','inactive','deleted')),
CONSTRAINT "ChannelSensitiveWord_word_check" CHECK (char_length("word") BETWEEN 1 AND 200)
);
CREATE UNIQUE INDEX "ChannelSensitiveWord_channelId_word_key" ON "ChannelSensitiveWord"("channelId","word");
CREATE INDEX "ChannelSensitiveWord_channelId_status_idx" ON "ChannelSensitiveWord"("channelId","status");
CREATE TABLE "SmsChannelSensitiveDecision" (
"id" TEXT PRIMARY KEY, "messageRecordId" TEXT NOT NULL, "routeAttemptId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "snapshot" JSONB NOT NULL,
CONSTRAINT "SmsChannelSensitiveDecision_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "SmsChannelSensitiveDecision_routeAttemptId_key" ON "SmsChannelSensitiveDecision"("routeAttemptId");
CREATE INDEX "SmsChannelSensitiveDecision_messageRecordId_decidedAt_idx" ON "SmsChannelSensitiveDecision"("messageRecordId","decidedAt");
ALTER TABLE "SmsMessageRecord" ADD COLUMN "channelWordFinalizationPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_channelWordFinalizationPending_idx" ON "SmsMessageRecord"("updatedAt") WHERE "channelWordFinalizationPending" = true;
+29
View File
@@ -266,6 +266,32 @@ model PhoneCarrierRule {
@@index([status, priority])
}
model ChannelSensitiveWord {
id String @id @default(cuid())
channelId String
channel SmsChannel @relation(fields: [channelId], references: [id])
word String
status String @default("active")
remark String @default("")
version Int @default(1)
createdBy String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channelId, word])
@@index([channelId, status])
}
model SmsChannelSensitiveDecision {
id String @id @default(cuid())
messageRecordId String
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id])
routeAttemptId String @unique
decidedAt DateTime @default(now())
snapshot Json
@@index([messageRecordId, decidedAt])
}
model SensitiveWord {
id String @id @default(cuid())
word String @unique
@@ -881,6 +907,7 @@ model AuditRecord {
}
model SmsChannel {
sensitiveWords ChannelSensitiveWord[]
id String @id @default(cuid())
code String @unique
name String
@@ -1799,6 +1826,8 @@ model SmsDrainageDecision {
}
model SmsMessageRecord {
channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[]
id String @id @default(cuid())
tenantId String?
@@ -0,0 +1,23 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
@Controller('admin/dictionaries/channel-sensitive-words')
export class ChannelSensitiveWordsController {
constructor(private readonly service: ChannelSensitiveWordsService) {}
@Get() list(@CurrentSessionUserId() userId: string, @Query() query: Record<string, string | undefined>) {
return this.service.list(userId, query);
}
@Post() create(@CurrentSessionUserId() userId: string, @Body() body: unknown) {
return this.service.save(userId, body);
}
@Patch(':id') update(@CurrentSessionUserId() userId: string, @Param('id') id: string, @Body() body: unknown) {
return this.service.save(userId, body, id);
}
@Delete(':id') remove(
@CurrentSessionUserId() userId: string,
@Param('id') id: string,
@Body() body: { version?: unknown },
) {
return this.service.remove(userId, id, body?.version);
}
}
@@ -0,0 +1,40 @@
import { ChannelSensitiveWordsService, validateChannelWord } from './channel-sensitive-words.service';
import { PrismaService } from '../prisma/prisma.service';
const valid = { channelId: 'a', word: ' 贷 款 ', status: 'active', remark: '' };
describe('channel word administration', () => {
it('trims only outer whitespace and requires a version for editing', () => {
expect(validateChannelWord(valid).word).toBe('贷 款');
expect(() => validateChannelWord(valid, true)).toThrow('版本');
expect(validateChannelWord({ ...valid, version: 3 }, true).version).toBe(3);
});
it.each([
null,
[],
{ ...valid, word: ' ' },
{ ...valid, word: 'a'.repeat(201) },
{ ...valid, channelId: '' },
{ ...valid, status: 'deleted' },
{ ...valid, remark: 'a'.repeat(501) },
{ ...valid, operatorId: 'spoof' },
])('rejects invalid runtime data %#', (data) => expect(() => validateChannelWord(data)).toThrow());
it('checks active platform admin permission before data access', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue(null) },
channelSensitiveWord: { findMany: jest.fn() },
};
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
await expect(service.list('client-user', {})).rejects.toMatchObject({ status: 403 });
expect(prisma.channelSensitiveWord.findMany).not.toHaveBeenCalled();
expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } }),
}),
);
});
it('rejects invalid pagination before querying rules', async () => {
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ id: 'admin' }) } };
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
for (const query of [{ page: '0' }, { pageSize: '101' }, { page: '1.5' }, { status: 'deleted' }])
await expect(service.list('admin', query)).rejects.toMatchObject({ status: 400 });
});
});
@@ -0,0 +1,157 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export function validateChannelWord(value: unknown, editing = false) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('规则参数无效');
const data = value as Record<string, unknown>;
if (Object.keys(data).some((key) => !['channelId', 'word', 'status', 'remark', 'version'].includes(key)))
throw new BadRequestException('包含不支持的字段');
if (typeof data.channelId !== 'string' || !data.channelId.trim() || data.channelId.length > 160)
throw new BadRequestException('请选择通道');
if (typeof data.word !== 'string' || !data.word.trim() || data.word.trim().length > 200)
throw new BadRequestException('敏感词需为1200个字符');
if (typeof data.status !== 'string' || !['active', 'inactive'].includes(data.status))
throw new BadRequestException('状态无效');
if (data.remark !== undefined && (typeof data.remark !== 'string' || data.remark.length > 500))
throw new BadRequestException('备注最多500个字符');
if (editing && (!Number.isSafeInteger(data.version) || Number(data.version) < 1))
throw new BadRequestException('请提供规则版本');
return {
channelId: data.channelId.trim(),
word: data.word.trim(),
status: data.status as string,
remark: (data.remark as string | undefined) ?? '',
version: editing ? Number(data.version) : undefined,
};
}
@Injectable()
export class ChannelSensitiveWordsService {
constructor(private readonly prisma: PrismaService) {}
async authorize(userId?: string) {
if (
!userId ||
!(await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } },
select: { id: true },
}))
)
throw new ForbiddenException('无敏感词管理权限');
}
async list(userId: string | undefined, query: Record<string, string | undefined>) {
await this.authorize(userId);
const page = Number(query.page ?? 1),
pageSize = Number(query.pageSize ?? 25);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
throw new BadRequestException('分页参数无效');
if (query.status && !['all', 'active', 'inactive'].includes(query.status))
throw new BadRequestException('状态无效');
if (query.keyword && (typeof query.keyword !== 'string' || query.keyword.length > 200))
throw new BadRequestException('搜索词过长');
if (query.channelId && typeof query.channelId !== 'string') throw new BadRequestException('通道参数无效');
const where: Prisma.ChannelSensitiveWordWhereInput = {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
channelId: query.channelId || undefined,
word: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [items, total] = await this.prisma.$transaction([
this.prisma.channelSensitiveWord.findMany({
where,
include: { channel: { select: { id: true, name: true, status: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSensitiveWord.count({ where }),
]);
return { items, total, page, pageSize };
}
async save(userId: string | undefined, value: unknown, id?: string) {
await this.authorize(userId);
const data = validateChannelWord(value, Boolean(id));
try {
return await this.prisma.$transaction(async (tx) => {
if (
!(await tx.smsChannel.findFirst({
where: { id: data.channelId, status: { not: 'deleted' } },
select: { id: true },
}))
)
throw new BadRequestException('通道不存在或已删除');
const current = id
? await tx.channelSensitiveWord.findUnique({ where: { id } })
: await tx.channelSensitiveWord.findUnique({
where: { channelId_word: { channelId: data.channelId, word: data.word } },
});
if (id && (!current || current.status === 'deleted')) throw new NotFoundException('规则不存在或已删除');
if (!id && current && current.status !== 'deleted') throw new ConflictException('该通道已配置相同敏感词');
const fields = {
channelId: data.channelId,
word: data.word,
status: data.status,
remark: data.remark,
updatedBy: userId!,
};
let saved;
if (current) {
const result = await tx.channelSensitiveWord.updateMany({
where: { id: current.id, version: id ? data.version : current.version },
data: { ...fields, version: { increment: 1 } },
});
if (result.count !== 1) throw new ConflictException('规则已被修改,请刷新后重试');
saved = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id: current.id } });
} else saved = await tx.channelSensitiveWord.create({ data: { ...fields, createdBy: userId! } });
await tx.operationLog.create({
data: {
userId,
action:
current?.status === 'deleted'
? 'channel_sensitive_word.restore'
: id
? 'channel_sensitive_word.update'
: 'channel_sensitive_word.create',
resource: 'channel_sensitive_word',
resourceId: saved.id,
detail: JSON.parse(JSON.stringify({ before: current, after: saved })),
},
});
return saved;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
throw new ConflictException('该通道已配置相同敏感词');
throw error;
}
}
async remove(userId: string | undefined, id: string, version: unknown) {
await this.authorize(userId);
if (!Number.isSafeInteger(version) || Number(version) < 1) throw new BadRequestException('请提供规则版本');
return this.prisma.$transaction(async (tx) => {
const before = await tx.channelSensitiveWord.findUnique({ where: { id } });
if (!before || before.status === 'deleted') throw new NotFoundException('规则不存在或已删除');
const result = await tx.channelSensitiveWord.updateMany({
where: { id, version: Number(version) },
data: { status: 'deleted', version: { increment: 1 }, updatedBy: userId! },
});
if (!result.count) throw new ConflictException('规则已被修改,请刷新后重试');
const after = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id } });
await tx.operationLog.create({
data: {
userId,
action: 'channel_sensitive_word.delete',
resource: 'channel_sensitive_word',
resourceId: id,
detail: JSON.parse(JSON.stringify({ before, after })),
},
});
return { deleted: true };
});
}
}
+4 -2
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
import { ChannelSensitiveWordsController } from './channel-sensitive-words.controller';
import { DictionariesController } from './dictionaries.controller';
import { DictionariesService } from './dictionaries.service';
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
@Module({
controllers: [DictionariesController],
providers: [DictionariesService, PhoneRoutingLookupService],
controllers: [DictionariesController, ChannelSensitiveWordsController],
providers: [DictionariesService, PhoneRoutingLookupService, ChannelSensitiveWordsService],
exports: [DictionariesService, PhoneRoutingLookupService],
})
export class DictionariesModule {}
@@ -108,6 +108,7 @@ export class OperationsMessageQueries {
const item = await this.prisma.smsMessageRecord.findUnique({
where: { id },
include: {
channelWordDecisions: { orderBy: [{ decidedAt: 'desc' }, { id: 'desc' }], take: 10 },
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } },
@@ -0,0 +1,81 @@
import { ChannelWordSnapshot, loadChannelWords } from './channel-sensitive-routing';
import { PrismaService } from '../prisma/prisma.service';
const channel = {
status: 'active',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
};
const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobile', priority: index + 1, channel }));
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
describe('channel sensitive routing snapshot', () => {
it('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
expect(snapshot.select('n', '正常业务', items, options).selected?.channelId).toBe('a');
expect(snapshot.decisions[0].snapshot).toEqual(
expect.objectContaining({ excludedChannelIds: ['a'], stage: 'route' }),
);
});
it('keeps original exclusions and distinguishes no original route from all hit', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '贷款', items, { ...options, excludedChannelIds: new Set(['b']) }).rejected).toBe(true);
expect(snapshot.select('m', '贷款', items, { ...options, approvedChannelIds: new Set() }).rejected).toBe(false);
expect(
snapshot.select(
'm',
'贷款',
items.map((item) => ({ ...item, channel: { ...channel, status: 'inactive' } })),
options,
).rejected,
).toBe(false);
});
it('allows national fallback when matching province channel is excluded', () => {
const provincial = [{ ...items[0], province: '上海', channel: { ...channel, sendRegion: '上海' } }, items[1]];
expect(
new ChannelWordSnapshot([rule]).select('m', '贷款', provincial, { ...options, province: '上海' }).selected
?.channelId,
).toBe('b');
});
it('matches original complete content, case sensitively without removing separators', () => {
const snapshot = new ChannelWordSnapshot([{ ...rule, word: 'Ab贷款' }]);
expect(snapshot.hits('【签名】Ab贷款')[0].count).toBe(1);
for (const value of ['ab贷款', 'b贷款', 'Ab贷-款']) expect(snapshot.hits(value)).toHaveLength(0);
});
it('caps sample words but never truncates excluded channels or hit counts', () => {
const rules = Array.from({ length: 21 }, (_, index) => ({ ...rule, id: String(index) }));
const snapshot = new ChannelWordSnapshot([...rules, { ...rule, channelId: 'b' }]);
expect(snapshot.hits('贷款')[0]).toEqual(expect.objectContaining({ count: 21, samples: expect.any(Array) }));
expect(snapshot.hits('贷款')[0].samples).toHaveLength(20);
expect(snapshot.select('m', '贷款', items, options).rejected).toBe(true);
});
it('reads rules once per batch and persists all decisions in one idempotent write', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([rule]) },
smsChannelSensitiveDecision: { createMany: jest.fn() },
};
const snapshot = await loadChannelWords(prisma as unknown as PrismaService, ['a', 'b', 'a']);
for (let index = 0; index < 100; index++) snapshot.select(String(index), '贷款', items, options);
expect(snapshot.hits('贷款')).toBe(snapshot.hits('贷款'));
await snapshot.persist(prisma as unknown as PrismaService);
expect(prisma.channelSensitiveWord.findMany).toHaveBeenCalledTimes(1);
expect(prisma.smsChannelSensitiveDecision.createMany).toHaveBeenCalledWith({
data: expect.any(Array),
skipDuplicates: true,
});
expect(snapshot.decisions).toHaveLength(100);
expect(new Set(snapshot.decisions.map((value) => value.routeAttemptId)).size).toBe(100);
});
it('fails closed for technical read and persistence errors, not as a word hit', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
};
await expect(loadChannelWords(prisma as unknown as PrismaService, ['a'])).rejects.toMatchObject({ status: 503 });
const snapshot = new ChannelWordSnapshot([rule]);
snapshot.select('m', '贷款', items, options);
await expect(snapshot.persist(prisma as unknown as PrismaService)).rejects.toMatchObject({ status: 503 });
});
});
@@ -0,0 +1,93 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { ChannelSensitiveWord, Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { selectChannelCandidate } from './send-chain.helpers';
export const CHANNEL_WORD_NO_ROUTE = 'CHANNEL_SENSITIVE_WORD_NO_ROUTE';
export class ChannelWordRejection extends BadRequestException {
readonly reasonCode = CHANNEL_WORD_NO_ROUTE;
constructor() {
super('可用通道均命中通道敏感词');
}
}
type Rule = Pick<ChannelSensitiveWord, 'id' | 'channelId' | 'word' | 'version'>;
type Hit = { channelId: string; count: number; samples: Array<{ id: string; word: string; version: number }> };
export class ChannelWordSnapshot {
private readonly matches = new Map<string, Hit[]>();
readonly decisions: Prisma.SmsChannelSensitiveDecisionCreateManyInput[] = [];
constructor(
private readonly rules: Rule[],
readonly readAt = new Date().toISOString(),
) {}
hits(content: string): Hit[] {
const cached = this.matches.get(content);
if (cached) return cached;
const matched = new Map<string, Hit>();
for (const rule of this.rules) {
if (!rule.word || !content.includes(rule.word)) continue;
const hit = matched.get(rule.channelId) ?? { channelId: rule.channelId, count: 0, samples: [] };
hit.count++;
if (hit.samples.length < 20) hit.samples.push({ id: rule.id, word: rule.word, version: rule.version });
matched.set(rule.channelId, hit);
}
const result = [...matched.values()];
this.matches.set(content, result);
return result;
}
select<T extends Parameters<typeof selectChannelCandidate>[0][number]>(
messageId: string,
content: string,
items: T[],
options: Parameters<typeof selectChannelCandidate>[1],
) {
const candidates = items.filter((item) => selectChannelCandidate([item], options));
const candidateIds = new Set(candidates.map((item) => item.channelId));
const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
const hits = this.hits(content)
.filter((hit) => candidateIds.has(hit.channelId))
.map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
const rejected = !selected && candidates.length > 0 && hits.length > 0;
const routeAttemptId = randomUUID();
this.decisions.push({
id: randomUUID(),
messageRecordId: messageId,
routeAttemptId,
snapshot: {
readAt: this.readAt,
stage: 'route',
contentHash: createHash('sha256').update(content).digest('hex'),
candidateChannelIds: [...candidateIds],
excludedChannelIds: hits.map((hit) => hit.channelId),
hits,
selectedChannelId: selected?.channelId ?? null,
reason: rejected ? '可用通道均命中通道敏感词' : null,
},
});
return { selected, rejected };
}
async persist(prisma: PrismaService) {
if (!this.decisions.length) return;
try {
await prisma.smsChannelSensitiveDecision.createMany({ data: this.decisions, skipDuplicates: true });
} catch {
throw new ServiceUnavailableException('通道敏感词选路记录保存失败');
}
}
}
export async function loadChannelWords(prisma: PrismaService, channelIds: string[]) {
try {
const rules = channelIds.length
? await prisma.channelSensitiveWord.findMany({
where: { channelId: { in: [...new Set(channelIds)] }, status: 'active' },
select: { id: true, channelId: true, word: true, version: true },
orderBy: { id: 'asc' },
})
: [];
return new ChannelWordSnapshot(rules);
} catch {
throw new ServiceUnavailableException('通道敏感词读取失败');
}
}
@@ -18,7 +18,7 @@ export class DrainageReceiptRecoveryService implements OnModuleInit, OnModuleDes
try {
await this.sendChain.recoverDrainageFailureReceipts();
} catch (error) {
this.logger.error(`引流拦截回执恢复失败: ${String(error)}`);
this.logger.error(`选路拦截回执恢复失败: ${String(error)}`);
} finally {
this.running = false;
}
@@ -4,6 +4,7 @@ import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service';
import { DrainageRejection } from './drainage-authorization';
import { ChannelWordRejection } from './channel-sensitive-routing';
function createPrismaMock() {
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
@@ -77,6 +78,8 @@ function createPrismaMock() {
},
};
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([]) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
tenant: {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
},
@@ -498,6 +501,81 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
}
describe('SendChainService', () => {
it('recovers non-CMPP channel-word finalization without pushing a receipt', async () => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findMany.mockResolvedValue([
{ ...message, channelWordFinalizationPending: true, batchTask: { sourceType: 'client' } },
]);
service['releaseMessageReservation'] = jest.fn();
service['recordCmppFailureReceipt'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.recoverDrainageFailureReceipts();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
expect(service['refreshTaskProgress']).toHaveBeenCalledWith('task-1');
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it.each(['cmpp', 'client', 'http'])(
'fails an all-hit ordinary route without supplier submit (%s)',
async (sourceType) => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({ ...message, batchTask: { id: 'task-1', sourceType } });
service['selectChannelForMessage'] = jest.fn().mockRejectedValue(new ChannelWordRejection());
service['recordCmppFailureReceipt'] = jest.fn();
service['releaseMessageReservation'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.processSendJob({ messageRecordId: 'record-1' });
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
if (sourceType === 'cmpp')
expect(service['recordCmppFailureReceipt']).toHaveBeenCalledWith(
expect.anything(),
'CSW',
'可用通道均命中通道敏感词',
);
else expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ channelWordFinalizationPending: true, drainageReceiptPending: false }),
}),
);
},
);
it('recovers channel-word delivery intent from an existing receipt and keeps pending on failure', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
const message = {
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppRegisteredDelivery: true,
cmppSubmitSequenceId: '101',
};
service['queueAndTryDownstreamDelivery'] = jest
.fn()
.mockRejectedValueOnce(Error('persistence failed'))
.mockResolvedValue({ id: 'delivery' });
await expect(service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词')).rejects.toThrow(
'persistence failed',
);
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { channelWordFinalizationPending: false } }),
);
await service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
+18 -3
View File
@@ -872,10 +872,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async recoverDrainageFailureReceipts() {
const messages = await this.prisma.smsMessageRecord.findMany({
where: {
drainageReceiptPending: true,
OR: [
{ drainageReceiptPending: true, batchTask: { sourceType: 'cmpp' } },
{ channelWordFinalizationPending: true },
],
status: { in: ['failed', 'submit_failed'] },
batchTask: { sourceType: 'cmpp' },
},
include: { batchTask: { select: { sourceType: true } } },
orderBy: { updatedAt: 'asc' },
take: 50,
});
@@ -886,9 +889,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
reason,
);
if (message.channelWordFinalizationPending && message.batchTask?.sourceType !== 'cmpp') {
await this.refreshTaskProgress(message.batchTaskId);
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
continue;
}
await this.recordCmppFailureReceipt(
message,
message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN',
message.channelWordFinalizationPending
? 'CSW'
: message.errorCode?.startsWith('DRN')
? message.errorCode
: 'DRN',
reason,
);
}
@@ -466,7 +466,8 @@ export class SendDownstreamDeliveryService {
const existing = await this.prisma.smsReceiptRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
});
if (existing && !errorCode.startsWith('DRN')) return existing;
const recoverableRejection = errorCode.startsWith('DRN') || errorCode === 'CSW';
if (existing && !recoverableRejection) return existing;
const deliveredAt = new Date();
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
@@ -501,12 +502,12 @@ export class SendDownstreamDeliveryService {
};
const receipt =
existing ??
(errorCode.startsWith('DRN')
(recoverableRejection
? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData })
: await this.prisma.smsReceiptRecord.create({ data: receiptData }));
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
message,
propagateHttpQueueError: errorCode.startsWith('DRN'),
propagateHttpQueueError: recoverableRejection,
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
payload: {
messageId: message.messageId,
@@ -520,6 +521,11 @@ export class SendDownstreamDeliveryService {
},
});
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
if (errorCode === 'CSW')
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
if (errorCode.startsWith('DRN'))
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } });
return receipt;
@@ -7,6 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
import { CHANNEL_WORD_NO_ROUTE, ChannelWordRejection, loadChannelWords } from './channel-sensitive-routing';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
@@ -27,7 +28,6 @@ import {
isNationalChannel,
composeUpstreamSrcId,
bullmqConnection,
selectChannelCandidate,
} from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
@@ -462,6 +462,10 @@ export class SendGatewaySubmitService {
include: { reportTasks: { where: { reportType: 'drainage' } } },
})
: [];
const channelWords = await loadChannelWords(
this.prisma,
routes.flatMap((route) => route.group.items.map((item) => item.channelId)),
);
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string; code?: string }> = [];
for (const input of routeInputs) {
@@ -474,11 +478,13 @@ export class SendGatewaySubmitService {
continue;
}
let gate;
let content: string;
try {
const stored =
input.message.content === undefined
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
: input.message;
content = stored.content!;
gate = await evaluateMessageDrainage(
this.prisma,
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
@@ -523,7 +529,7 @@ export class SendGatewaySubmitService {
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
),
);
const selected = selectChannelCandidate(approvedItems, {
const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, {
carrier: input.carrier,
province: input.province,
excludedChannelIds: new Set(),
@@ -531,7 +537,11 @@ export class SendGatewaySubmitService {
routingKey: input.message.id,
});
if (!selected) {
failed.push({ message: input.message, reason: '无已报备通过且在线的可用通道' });
failed.push({
message: input.message,
reason: rejected ? '可用通道均命中通道敏感词' : '无已报备通过且在线的可用通道',
...(rejected ? { code: CHANNEL_WORD_NO_ROUTE } : {}),
});
continue;
}
planned.push({
@@ -546,6 +556,7 @@ export class SendGatewaySubmitService {
},
});
}
await channelWords.persist(this.prisma);
return { planned, failed };
}
@@ -573,7 +584,8 @@ export class SendGatewaySubmitService {
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code,
"drainageReceiptPending" = failures.pending, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
"drainageReceiptPending" = failures.pending AND failures.code IS DISTINCT FROM ${CHANNEL_WORD_NO_ROUTE},
"channelWordFinalizationPending" = COALESCE(failures.code = ${CHANNEL_WORD_NO_ROUTE}, false), "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${values}) AS failures(id, reason, code, pending)
WHERE message.id = failures.id AND message.status = 'queued'
`);
@@ -581,8 +593,19 @@ export class SendGatewaySubmitService {
failed.map(async ({ message, reason, code }) => {
await this.releaseMessageReservation(message, reason);
if (message.batchTask?.sourceType === 'cmpp')
await this.recordCmppFailureReceipt(message, code ? 'DRN' : 'ROUTE', reason);
else await this.facade.refreshTaskProgress(message.batchTaskId);
await this.recordCmppFailureReceipt(
message,
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
reason,
);
else {
await this.facade.refreshTaskProgress(message.batchTaskId);
if (code === CHANNEL_WORD_NO_ROUTE)
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
}
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
this.metrics?.recordSendWorkerResult('failed');
}),
@@ -665,7 +688,8 @@ export class SendGatewaySubmitService {
} catch (error) {
if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500)
throw error;
const code = error instanceof DrainageRejection ? error.reasonCode : undefined;
const code =
error instanceof DrainageRejection || error instanceof ChannelWordRejection ? error.reasonCode : undefined;
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
@@ -673,17 +697,29 @@ export class SendGatewaySubmitService {
status: 'failed',
errorMessage: reason,
errorCode: code,
drainageReceiptPending: Boolean(code && message.batchTask?.sourceType === 'cmpp'),
drainageReceiptPending: Boolean(
code && code !== CHANNEL_WORD_NO_ROUTE && message.batchTask?.sourceType === 'cmpp',
),
channelWordFinalizationPending: code === CHANNEL_WORD_NO_ROUTE,
},
});
await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, code ? 'DRN' : 'ROUTE', reason);
await this.recordCmppFailureReceipt(
businessMessage,
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
reason,
);
} else {
await this.facade.refreshTaskProgress(
businessMessage.batchTaskId,
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'failed' : undefined,
);
if (code === CHANNEL_WORD_NO_ROUTE)
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
}
finish('failed');
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
@@ -1089,7 +1125,11 @@ return streamId`;
);
if (gate.targets.length && approvedChannelIds.size === 0)
throw new DrainageRejection('DRAINAGE_CHANNEL_NOT_APPROVED', '引流信息未在签名可用通道报备通过');
const selected = selectChannelCandidate(route.group.items, {
const channelWords = await loadChannelWords(
this.prisma,
route.group.items.map((item) => item.channelId),
);
const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, {
carrier,
province,
forceNational: options.forceNational,
@@ -1097,6 +1137,8 @@ return streamId`;
approvedChannelIds,
routingKey: message.id,
});
await channelWords.persist(this.prisma);
if (rejected) throw new ChannelWordRejection();
if (!selected) {
throw new NotFoundException('无已报备通过且在线的可用通道');
}