From 8e4bc5a20e4d98e96fc8572fabbac149ba12c8e6 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Thu, 10 Sep 2026 15:50:56 +0800 Subject: [PATCH] feat: filter SMS routes by channel sensitive words --- .../migration.sql | 20 + api/prisma/schema.prisma | 29 ++ .../channel-sensitive-words.controller.ts | 23 ++ .../channel-sensitive-words.service.spec.ts | 40 ++ .../channel-sensitive-words.service.ts | 157 ++++++++ api/src/dictionaries/dictionaries.module.ts | 6 +- .../operations/queries/messages.queries.ts | 1 + .../channel-sensitive-routing.spec.ts | 81 ++++ .../send-chain/channel-sensitive-routing.ts | 93 +++++ .../drainage-receipt-recovery.service.ts | 2 +- api/src/send-chain/send-chain.service.spec.ts | 78 ++++ api/src/send-chain/send-chain.service.ts | 21 +- .../send-downstream-delivery.service.ts | 12 +- .../send-chain/send-gateway-submit.service.ts | 62 ++- docs/channel-sensitive-words-plan-20260910.md | 93 +++++ .../first-version-development-requirements.md | 10 + docs/phase-6-risk-review-plan.md | 10 + docs/system-functional-test-cases.md | 31 ++ docs/testing-progress.md | 30 ++ src/api/admin/channel-sensitive-words.api.ts | 29 ++ src/api/types/operations.ts | 14 + src/apps/admin/AdminSensitiveWordsPage.tsx | 123 ++++-- .../ChannelSensitiveWordsPanel.test.tsx | 66 ++++ .../ChannelSensitiveWordsPanel.tsx | 352 ++++++++++++++++++ .../admin/sms-records/SendDetailModal.tsx | 23 ++ .../verify-channel-sensitive-words.mjs | 250 +++++++++++++ 26 files changed, 1615 insertions(+), 41 deletions(-) create mode 100644 api/prisma/migrations/20260910160000_channel_sensitive_words/migration.sql create mode 100644 api/src/dictionaries/channel-sensitive-words.controller.ts create mode 100644 api/src/dictionaries/channel-sensitive-words.service.spec.ts create mode 100644 api/src/dictionaries/channel-sensitive-words.service.ts create mode 100644 api/src/send-chain/channel-sensitive-routing.spec.ts create mode 100644 api/src/send-chain/channel-sensitive-routing.ts create mode 100644 docs/channel-sensitive-words-plan-20260910.md create mode 100644 src/api/admin/channel-sensitive-words.api.ts create mode 100644 src/apps/admin/sensitive-words/ChannelSensitiveWordsPanel.test.tsx create mode 100644 src/apps/admin/sensitive-words/ChannelSensitiveWordsPanel.tsx create mode 100644 tools/testing/verify-channel-sensitive-words.mjs diff --git a/api/prisma/migrations/20260910160000_channel_sensitive_words/migration.sql b/api/prisma/migrations/20260910160000_channel_sensitive_words/migration.sql new file mode 100644 index 0000000..b856a32 --- /dev/null +++ b/api/prisma/migrations/20260910160000_channel_sensitive_words/migration.sql @@ -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; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d96a157..ce4a305 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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? diff --git a/api/src/dictionaries/channel-sensitive-words.controller.ts b/api/src/dictionaries/channel-sensitive-words.controller.ts new file mode 100644 index 0000000..bae1c9b --- /dev/null +++ b/api/src/dictionaries/channel-sensitive-words.controller.ts @@ -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) { + 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); + } +} diff --git a/api/src/dictionaries/channel-sensitive-words.service.spec.ts b/api/src/dictionaries/channel-sensitive-words.service.spec.ts new file mode 100644 index 0000000..41a748a --- /dev/null +++ b/api/src/dictionaries/channel-sensitive-words.service.spec.ts @@ -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 }); + }); +}); diff --git a/api/src/dictionaries/channel-sensitive-words.service.ts b/api/src/dictionaries/channel-sensitive-words.service.ts new file mode 100644 index 0000000..e94b2bb --- /dev/null +++ b/api/src/dictionaries/channel-sensitive-words.service.ts @@ -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; + 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) { + 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 }; + }); + } +} diff --git a/api/src/dictionaries/dictionaries.module.ts b/api/src/dictionaries/dictionaries.module.ts index 1f71f0e..6baaa92 100644 --- a/api/src/dictionaries/dictionaries.module.ts +++ b/api/src/dictionaries/dictionaries.module.ts @@ -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 {} diff --git a/api/src/operations/queries/messages.queries.ts b/api/src/operations/queries/messages.queries.ts index 948c818..840b6b9 100644 --- a/api/src/operations/queries/messages.queries.ts +++ b/api/src/operations/queries/messages.queries.ts @@ -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 } }, diff --git a/api/src/send-chain/channel-sensitive-routing.spec.ts b/api/src/send-chain/channel-sensitive-routing.spec.ts new file mode 100644 index 0000000..3ffba67 --- /dev/null +++ b/api/src/send-chain/channel-sensitive-routing.spec.ts @@ -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(), 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 }); + }); +}); diff --git a/api/src/send-chain/channel-sensitive-routing.ts b/api/src/send-chain/channel-sensitive-routing.ts new file mode 100644 index 0000000..255e7ae --- /dev/null +++ b/api/src/send-chain/channel-sensitive-routing.ts @@ -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; +type Hit = { channelId: string; count: number; samples: Array<{ id: string; word: string; version: number }> }; +export class ChannelWordSnapshot { + private readonly matches = new Map(); + 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(); + 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[0][number]>( + messageId: string, + content: string, + items: T[], + options: Parameters[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('通道敏感词读取失败'); + } +} diff --git a/api/src/send-chain/drainage-receipt-recovery.service.ts b/api/src/send-chain/drainage-receipt-recovery.service.ts index 31e291f..b6fded8 100644 --- a/api/src/send-chain/drainage-receipt-recovery.service.ts +++ b/api/src/send-chain/drainage-receipt-recovery.service.ts @@ -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; } diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 0e06c21..98aa700 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -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' }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index c69af41..4f3b3e9 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -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, ); } diff --git a/api/src/send-chain/send-downstream-delivery.service.ts b/api/src/send-chain/send-downstream-delivery.service.ts index 511c95e..810df8a 100644 --- a/api/src/send-chain/send-downstream-delivery.service.ts +++ b/api/src/send-chain/send-downstream-delivery.service.ts @@ -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; diff --git a/api/src/send-chain/send-gateway-submit.service.ts b/api/src/send-chain/send-gateway-submit.service.ts index 0ecd734..f465c0b 100644 --- a/api/src/send-chain/send-gateway-submit.service.ts +++ b/api/src/send-chain/send-gateway-submit.service.ts @@ -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('无已报备通过且在线的可用通道'); } diff --git a/docs/channel-sensitive-words-plan-20260910.md b/docs/channel-sensitive-words-plan-20260910.md new file mode 100644 index 0000000..f16e136 --- /dev/null +++ b/docs/channel-sensitive-words-plan-20260910.md @@ -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、费用流水闭环及实际吞吐未执行,不以隔离结果替代。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index b26f9d3..16dad61 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2262,3 +2262,13 @@ ## 2026-09-10 引流发送资格实施确认 后续已授权修改、本地提交及测试部署。以 drainage-send-gating-plan-20260910.md 第10节为实现说明,替代此前“未授权实施”的阶段性描述。每个引流目标须匹配本企业应用/签名审核通过的资料并满足最终通道报备;NFKC及干扰清洗只用于检测,域名自身与子域按点边界匹配,纯域名不限制路径参数。非CMPP拦截不推送回执;CMPP失败回执需幂等且可恢复。旧报备配置仍由用户处理。是否线上生效及未执行发送验收见 testing-progress.md。 + + +## 2026-09-10 通道敏感词需求评估(待实现) + +敏感词页面新增“通道敏感词”Tab,按真实channelId独立配置;完整短信内容命中启用词时仅排除对应通道,剩余通道继续遵守原应用通道组、签名/引流报备、运营商、地域和优先级。用户明确第一版仅在普通/微批选路及原有新选路尝试时过滤,不增加入队后或逐片复核;已选路消息按原结果继续处理,配置变化不主动取消或重选,已读取快照的微批可继续使用。尚未选路的入口队列消息仍在选路时检查。现有平台敏感词保持全局拒绝,两份词库不混用。全部候选被排除时记录原因并沿用适用失败处理,不跨通道组强行发送。建议首版原文连续包含、单通道覆盖三网、无通道时失败;匹配增强等建议待实施确认。完整页面/API/数据模型、选路快照与性能边界、回执及验收见[专项方案](channel-sensitive-words-plan-20260910.md)。仅评估,不构成代码、配置写入或发送/发布授权。 + + +### 通道敏感词实施更新(2026-09-10) + +用户已授权执行修订方案、本地提交及测试部署。本地已实现独立Tab/词库、管理审计和版本冲突、普通/微批仅选路过滤、运营端历史解释及独立失败完成恢复标记。无Gateway/逐片复核;配置仅影响后续读取规则的选路,非CMPP不新增拒绝推送。实际实现与验证见[channel-sensitive-words方案第8节](channel-sensitive-words-plan-20260910.md),提交/环境状态以testing-progress.md为准。 diff --git a/docs/phase-6-risk-review-plan.md b/docs/phase-6-risk-review-plan.md index 4aa6919..d38b55e 100644 --- a/docs/phase-6-risk-review-plan.md +++ b/docs/phase-6-risk-review-plan.md @@ -78,3 +78,13 @@ api/src/risk-review/ ## 2026-09-10 引流发送门禁实施 后续用户已授权实施、本地提交及测试部署。具体数据模型、最终分片复核、并发锁、CMPP回执恢复和非CMPP无推送行为见 drainage-send-gating-plan-20260910.md 第10节;取代此前本主题仅处于设计阶段的状态。旧批准配置不迁移;线上状态与未执行项以测试进度为准。 + + +## 2026-09-10 通道敏感词候选排除(评估阶段) + +[通道敏感词方案](channel-sensitive-words-plan-20260910.md)补充独立通道词库及选路排除,保留原平台全局敏感词语义。用户基于性能明确取消第一版入队后复核:只覆盖微批/普通选路及原有新选路尝试,不增加通道词Gateway/逐片检查、发送授权锁或配置变化触发重选。已选路消息及已读取规则快照的微批不追溯更新;尚未选路的消息照常过滤。管理端编辑乐观锁、原重试与部分已发保护保持。无可用通道和技术读取失败分别处理,不能给选路中排除A但最终B可用的消息提前发送失败回执。既有引流门禁不受本次方案调整影响。当前仅完成源码评估与设计,未迁移或实现。 + + +### 通道敏感词实施更新(2026-09-10) + +用户已授权执行修订方案、本地提交及测试部署。本地已实现独立Tab/词库、管理审计和版本冲突、普通/微批仅选路过滤、运营端历史解释及独立失败完成恢复标记。无Gateway/逐片复核;配置仅影响后续读取规则的选路,非CMPP不新增拒绝推送。实际实现与验证见[channel-sensitive-words方案第8节](channel-sensitive-words-plan-20260910.md),提交/环境状态以testing-progress.md为准。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index dbad8a4..9c9179b 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5393,3 +5393,34 @@ TC-DRAINAGE-GATE-01~16的实现范围以专项方案第10节为准,不再笼 ### 2026-09-10 TC-DRAINAGE-GATE测试发布补充 0c3f820已部署测试。TC-DRAINAGE-GATE相关本地真实数据库/API/并发/统计用例及生产构建页面通过;线上历史短信详情保持“未执行引流资格校验”,三尺寸和显式关闭通过。TC-OPS0909相关六页面真实请求回归通过,不将只读打开页面记作重做全部写操作用例。迁移、13项服务、Gateway Stream与版本摘要检查通过。真实短信/供应商零Submit、长短信、CMPP回执ACK与费用对账仍待专项授权,状态不得升级为全链路通过。证据见[测试发布验收](release-20260910-test-drainage.md)。 + + +## 2026-09-10 通道敏感词待执行用例 + +方案:[通道敏感词](channel-sensitive-words-plan-20260910.md)。以下按用户“第一版仅选路过滤、不做入队后复核”要求修订,取代初稿的最终授权/逐片检查用例;全部未实现/未执行。测试夹具、脚本不构成实际短信发送或配置改动授权。 + +| 编号 | 场景 | 预期 | +|---|---|---| +| TC-CHANNEL-WORD-01 | 两Tab与独立查询、刷新、跨路由、三尺寸 | 保留平台词语义;通道/词/状态独立筛选,分页与真实API一致,无假成功 | +| TC-CHANNEL-WORD-02 | 新增、编辑、启停、删除、重复词、失效通道和版本冲突 | 真实持久化/审计;空词/非法参数拒绝;同词跨通道允许;同通道重复受控,软删除历史保留 | +| TC-CHANNEL-WORD-03 | A命中而B未命中,A原优先级更高 | 排除A,从仍满足全部资格的B等候选按原规则选;不自动跨组 | +| TC-CHANNEL-WORD-04 | 所有合格候选命中;原本无合格候选 | 前者明确通道敏感词原因,后者保留离线/未报备等原原因;均不发送 | +| TC-CHANNEL-WORD-05 | 空库/停用词/不同通道/平台全局词 | 空库保持原路由,停用不匹配,配置互不串用,全局命中仍全局拒绝 | +| TC-CHANNEL-WORD-06 | 连续包含、英文大小写、全半角、间隔符、签名/变量及跨长短信分片词 | 按最终确认的匹配规则一致处理;匹配完整最终内容,短信原文/分片/计费长度不变 | +| TC-CHANNEL-WORD-07 | 普通/微批、多入口、全国通道降级、原有重选、三网同通道 | 均执行排除;不绕过运营商/地域/签名/引流限制;同channelId规则覆盖三网 | +| TC-CHANNEL-WORD-08 | 选路快照前/后加词、编辑、启停/删除;入口队列尚未选路 | 后续新读取的选路使用新配置;已读取快照的微批和已选路消息不追溯,不取消或重选;未选路消息仍需检查 | +| TC-CHANNEL-WORD-09 | 初次选路A命中、B可用;重复决策持久化;原有原因进入新选路 | 初次不创建A提交尝试,B按原规则选取且无提前失败回执;同次选路决策幂等,新选路重新读取词库,不新增重试机会 | +| TC-CHANNEL-WORD-10 | 已确定通道意图消费、跨多分片发送过程中配置变更 | 不新增通道词HTTP请求/逐片数据库查询/授权锁;继续原发送流程,保持原部分已发/不确定状态处理,不因词变更自动重发 | +| TC-CHANNEL-WORD-11 | 最终无路由的费用/任务/CMPP回执;非CMPP;崩溃恢复 | 费用释放/进度幂等,适用CMPP失败回执及意图耐久恢复;非CMPP不新增拒绝推送,无重复终态 | +| TC-CHANNEL-WORD-12 | 选路读库失败、多实例/大批量、越权、伪造客户端已检查标记、历史数据 | 技术故障不当空库放行;微批按候选通道集合读词/复用匹配/批量记决策,无N+1;后端真实选路不信客户端标记,权限和历史快照隔离,真实性能留证 | + +验收分层:隔离单元/真实PostgreSQL与Redis/真实浏览器/供应商及客户回执全链路分别记录。03/04/09/10/11中的真实物理发送、供应商零Submit、回执ACK和费用闭环需专项授权,不能由mock或静态页面代替。第一版验收不得再要求通道词入队后复核;既有引流校验照常,不将其已有查询计为新增通道词开销。 + + +### TC-CHANNEL-WORD 2026-09-10 实施验证 + +01/02/05/08/12:真实PostgreSQL管理服务验证权限、参数、筛选、重复、并发冲突、启停/删除恢复、旧快照不追溯;真实Nest浏览器验证无登录401、CRUD落库、独立筛选和分页请求。保存冲突/失败、删除确认及关闭行为补隔离组件测试。 +03/04/06/07/09:真实隔离数据库中普通和100条微批均排除A选B,全命中返回专用原因;保留完整原文、历史管理员原因及客户端字段隔离。连续包含/大小写/全半角/分隔符/地域降级/离线资格和样例限额补单元测试。完整短信样本覆盖长文本,不宣称已进行物理分片发送。 +10:源码确认未改Gateway,不增加最终通道词复核;规则快照更新边界通过真实数据库检查。物理发送中途配置变更未执行。 +11:普通全排除的CMPP/非CMPP分支、既有回执意图恢复失败后保留标记、非CMPP恢复不推送通过隔离单测;真实批量失败SQL在注入释放失败后仍持久保存完成标记。未执行真实余额变更或客户回执投递。 +12性能样本:100条完整同文消息,候选词一次读取、决策一次写入,390~441ms含原引流处理;不代表完整短信链路TPS。真实页面三尺寸1600×1000、1366×768、390×844通过,实际测试环境发布后验收另记。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 7a95c7b..1738a1b 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4872,3 +4872,33 @@ git diff --check 应用0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7已本地提交并通过标准工具部署测试,原版本809175b544f2526891ba6d2dece1a50eadaf57a0;未推送、未部署预生产。精确提交validate为API67套722项、前端27文件136项及相关门禁通过。首次preflight的安全代理运行目录缺失由用户单独授权修复,复检后正常发布。13项服务active、三个Gateway Stream pending/lag=0、消息/提交数不变、迁移/资源摘要/日志验证通过。真实测试管理员和六页面、三尺寸历史短信详情通过,线上不回填旧消息。 [本次发布验收](release-20260910-test-drainage.md)记录完整版本、工具未提交摘要、备份、容量清单与阶段时间。候选77.9秒、备份35.7秒,停服务至恢复约14秒;发布后系统盘可用10.37GB,比发布前减少约1.48GB,旧版本/候选/备份未清理。真实发送、供应商Submit、客户回执ACK和费用对账仍未执行,不能以页面通过代替。原网络超时根因与预生产历史故障未宣称关闭。 + + +## 2026-09-10 通道敏感词需求评估 + +授权仅评估。main/HEAD为8694782,实际远端main为6d63eb5452ffc7c802960d044bf598cc8646564d,暂存区空;保护全部已有源码、工具、网络及HTTP评估文档。本轮完整阅读UI规范及当前敏感词页、词库/风控和普通/微批路由、最终Gateway门禁与结果处理,新增[channel-sensitive-words方案](channel-sensitive-words-plan-20260910.md),同步需求/风控索引及12项待执行用例。 + +现状为全局SensitiveWord原文includes命中统一block,无通道模型或Tab。建议独立库、候选排除、原选路算法、最终复核;零写入方可有限重选,部分已发不整体重发,全候选排除才形成适用失败闭环。普通包含/三网统一/全排除失败为建议首版规则,不将未经确认的抗干扰或等待恢复加入实现。中等规模跨模块改造,不能仅改UI;无固定工时或TPS承诺。 + +本轮未连接真实API/PG/Redis/MinIO/Gateway进行业务验收,未启动或发送短信、未改规则/网络配置。仅文档与源码评估,功能和12项用例均未执行;无代码修改、无提交、无推送、无测试/预生产部署。文档保护副本及核验位于%TEMP%/cmpp-channel-sensitive-assessment-20260910。此前绕过OpenWrt任务仍未通过候选网关出口验证,本评估不宣称该任务完成。 + +本轮文档核验:四份既有文档的原字节前缀保留,其他开工保护文件摘要未变;专项方案链接和diff检查通过,暂存区为空。 + +## 2026-09-10 通道敏感词方案缩减为仅选路过滤 + +用户明确因性能考虑,暂不做入队后通道敏感词复核。已修订专项方案、需求和风控索引,以及TC-CHANNEL-WORD-08/09/10/12等用例:普通/微批及原有新选路时按快照过滤;已读取快照的微批与已选路消息不追溯配置变化。移除新增Gateway/逐片检查、发送授权锁、final决策和配置变化触发重选,保留管理端编辑并发保护及既有引流门禁。前一节初稿的最终复核/零写入新增重选描述由本记录取代,不再作为第一版验收要求。 + +仅文档修改,未修改代码或网络/业务配置,未提交、未推送、未测试或预生产部署。保护副本位于%TEMP%/cmpp-channel-sensitive-routing-only-20260910;执行文档一致性、链接及diff核验,不重跑业务测试。 + + +## 2026-09-10 通道敏感词实施与本地验收 + +授权:按修订方案修改、本地提交、部署测试环境;不推送、不部署预生产,不发送/重投短信或改现有线上业务配置。开工main=8694782,真实远端main=6d63eb5;测试版本0c3f820。原metrics、工具、规范、网络/HTTP评估文档受保护,提交只纳入本轮代码及主题文档精确部分。 + +实现:独立通道词Tab/API、运行时参数/平台管理员校验、version冲突、软删除恢复和事务审计;普通/微批选路快照排除候选,运营端最近10次解释;CSW失败与channelWordFinalizationPending耐久恢复,非CMPP不新增回执。Gateway/逐片检查未改。新增迁移不改历史配置。设计和边界见channel-sensitive-words-plan-20260910.md第8节。 + +验证:API全量69套745项通过;后追加恢复用例,发送链定向138项通过。前端全量28套139项通过,API构建/类型、production前端构建、lint/格式/CSS/安全/bundle通过(lint既有28警告)。初轮测试选择器及测试类型错误已修正;原页面空依赖列缓存随组件拆分取消,避免旧查询闭包。隔离真实PostgreSQL迁移与并发/路由/失败SQL验证通过;100条微批通道词读写各1次,390~441ms包含既有引流,不是发送TPS。 + +真实浏览器:production构建、Nest API、隔离PG、Redis7认证前缀,三尺寸CRUD/软删除/独立筛选/关闭/刷新/跨路由/详情通过,pageerror=0;匿名API401。未启动短信发送Worker/Gateway transport。证据%TEMP%/cmpp-channel-sensitive-implementation-20260910,包括real-api-failure.log、browser-1789026246272、各质量门禁日志。实际短信发送/客户回执ACK/费用流水与完整吞吐未执行。 + +本地实现和验收完成;本地提交、测试部署正在执行,推送/预生产未授权。测试部署成功后另补精确版本、恢复资产、磁盘增量及真实页面验收。 diff --git a/src/api/admin/channel-sensitive-words.api.ts b/src/api/admin/channel-sensitive-words.api.ts new file mode 100644 index 0000000..54d57ba --- /dev/null +++ b/src/api/admin/channel-sensitive-words.api.ts @@ -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>(withQuery(path, query)), + save: (form: ChannelWordForm, item?: ChannelWord) => + request(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 }), + }), +}; diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index db06722..0930a9d 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -118,6 +118,20 @@ export type SendQualityResponse = { }; export type SmsMessageRecord = { + channelWordDecisions?: Array<{ + id: string; + decidedAt: string; + snapshot: { + selectedChannelId: string | null; + reason: string | null; + hits: Array<{ + channelId: string; + channelName?: string; + count: number; + samples: Array<{ id: string; word: string; version: number }>; + }>; + }; + }>; id: string; tenantId?: string | null; batchTaskId?: string | null; diff --git a/src/apps/admin/AdminSensitiveWordsPage.tsx b/src/apps/admin/AdminSensitiveWordsPage.tsx index 79b62e7..48f4f2b 100644 --- a/src/apps/admin/AdminSensitiveWordsPage.tsx +++ b/src/apps/admin/AdminSensitiveWordsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Plus, Search, Trash2 } from 'lucide-react'; -import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; +import { ChannelSensitiveWordsPanel } from './sensitive-words/ChannelSensitiveWordsPanel'; import { adminApi, type DictionaryItem } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; @@ -31,6 +32,36 @@ const levelOptions = [ ]; export function AdminSensitiveWordsPage() { + const [tab, setTab] = useState('platform'); + const [channelVisited, setChannelVisited] = useState(false); + return ( +
+
+
+ +

敏感词管理

+
+
+ { + setTab(value); + if (value === 'channel') setChannelVisited(true); + }} + items={[ + { label: '平台敏感词', value: 'platform', content: null }, + { label: '通道敏感词', value: 'channel', content: null }, + ]} + /> + + +
+ ); +} + +function PlatformSensitiveWordsPanel() { const [items, setItems] = useState([]); const [keyword, setKeyword] = useState(''); const [word, setWord] = useState(''); @@ -39,7 +70,8 @@ export function AdminSensitiveWordsPage() { const [error, setError] = useState(''); function loadData() { - adminApi.listSensitiveWords({ keyword }) + adminApi + .listSensitiveWords({ keyword }) .then((data) => { setItems(data as SensitiveWordItem[]); setError(''); @@ -51,15 +83,37 @@ export function AdminSensitiveWordsPage() { loadData(); }, []); - const filteredItems = useMemo(() => items.filter((item) => { - const text = [item.word, item.level, item.status].join(' '); - return !keyword || text.includes(keyword); - }), [items, keyword]); + const filteredItems = useMemo( + () => + items.filter((item) => { + const text = [item.word, item.level, item.status].join(' '); + return !keyword || text.includes(keyword); + }), + [items, keyword], + ); - const columns = useMemo>>(() => [ + const columns: Array> = [ { key: 'word', title: '敏感词', width: '180px', render: (record) => {record.word} }, - { key: 'level', title: '级别', width: '120px', render: (record) => {levelLabelMap[record.level ?? 'medium'] ?? record.level} }, - { key: 'status', title: '状态', width: '120px', render: (record) => {record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'} }, + { + key: 'level', + title: '级别', + width: '120px', + render: (record) => ( + + {levelLabelMap[record.level ?? 'medium'] ?? record.level} + + ), + }, + { + key: 'status', + title: '状态', + width: '120px', + render: (record) => ( + + {record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'} + + ), + }, { key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) }, { key: 'actions', @@ -67,15 +121,26 @@ export function AdminSensitiveWordsPage() { width: '130px', align: 'right', render: (record) => ( - ), }, - ], []); + ]; function addItem() { - adminApi.createSensitiveWord({ word, level, status: 'active' }) + adminApi + .createSensitiveWord({ word, level, status: 'active' }) .then(() => { setWord(''); setLevel('medium'); @@ -89,10 +154,11 @@ export function AdminSensitiveWordsPage() {
- -

敏感词管理

+

平台敏感词

- +
{error ?

{error}

: null} @@ -105,8 +171,12 @@ export function AdminSensitiveWordsPage() { value={keyword} />
- - + +
@@ -115,19 +185,28 @@ export function AdminSensitiveWordsPage() { - - + + - )} + } onClose={() => setModalOpen(false)} open={modalOpen} size="xl" title="添加敏感词" >
- setWord(event.target.value)} placeholder="请输入敏感词" value={word} /> + setWord(event.target.value)} + placeholder="请输入敏感词" + value={word} + /> setFilters({ ...filters, channelId: event.target.value })} + /> + setFilters({ ...filters, keyword: event.target.value })} + /> + setForm({ ...form, channelId: event.target.value })} + /> + setForm({ ...form, word: event.target.value })} + /> + setForm({ ...form, remark: event.target.value })} + /> +
+
+ { + if (!saving) setRemoving(null); + }} + footer={ + <> + + + + } + > +

+ 确认删除 {removing?.channel.name} 的敏感词“{removing?.word}”?历史筛选记录会保留。 +

+ {removeError ? ( +

+ {removeError} +

+ ) : null} +
+ + ); +} diff --git a/src/apps/admin/sms-records/SendDetailModal.tsx b/src/apps/admin/sms-records/SendDetailModal.tsx index 1db9d05..0cc7c31 100644 --- a/src/apps/admin/sms-records/SendDetailModal.tsx +++ b/src/apps/admin/sms-records/SendDetailModal.tsx @@ -47,6 +47,29 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose } >
+
+

通道筛选原因

+ {record.channelWordDecisions?.length ? ( + record.channelWordDecisions.map((decision) => ( +
+

+ {getTime(decision.decidedAt)} ·{' '} + {decision.snapshot.reason || + (decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')} +

+ {decision.snapshot.hits.map((hit) => ( +

+ 通道 {hit.channelName || hit.channelId}:命中 {hit.count} 个词, + {hit.samples.map((sample) => `“${sample.word}”`).join('、')} + {hit.count > hit.samples.length ? '(仅展示部分)' : ''} +

+ ))} +
+ )) + ) : ( +

{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}

+ )} +

引流发送资格

{record.drainageGate ? ( diff --git a/tools/testing/verify-channel-sensitive-words.mjs b/tools/testing/verify-channel-sensitive-words.mjs new file mode 100644 index 0000000..05b66b3 --- /dev/null +++ b/tools/testing/verify-channel-sensitive-words.mjs @@ -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(); +}