feat: strengthen risk controls and review workflows

This commit is contained in:
hectorzhao
2026-07-26 13:08:12 +08:00
parent b461532075
commit 2ce682c3fc
34 changed files with 2167 additions and 390 deletions
@@ -0,0 +1,15 @@
DROP INDEX IF EXISTS "User_username_key";
DROP INDEX IF EXISTS "User_email_key";
DROP INDEX IF EXISTS "User_phone_key";
CREATE UNIQUE INDEX "User_active_username_key"
ON "User" ("username")
WHERE "deletedAt" IS NULL;
CREATE UNIQUE INDEX "User_active_email_key"
ON "User" ("email")
WHERE "deletedAt" IS NULL AND "email" IS NOT NULL;
CREATE UNIQUE INDEX "User_active_phone_key"
ON "User" ("phone")
WHERE "deletedAt" IS NULL AND "phone" IS NOT NULL;
@@ -0,0 +1,7 @@
ALTER TABLE "SmsApplication"
ADD COLUMN "disablingAt" TIMESTAMP(3),
ADD COLUMN "autoDisableAt" TIMESTAMP(3),
ADD COLUMN "disableReason" TEXT;
CREATE INDEX "SmsApplication_status_autoDisableAt_idx"
ON "SmsApplication"("status", "autoDisableAt");
@@ -0,0 +1,39 @@
-- Risk thresholds now inherit from global rules and may be overridden per application.
-- Existing maxPhonesPerTask values are intentionally discarded because this environment
-- contains test-only application data and the product owner explicitly declined migration.
ALTER TABLE "RiskRule" ADD COLUMN "applicationId" TEXT;
-- Retain historical rules and hit records for audit, but remove them from the
-- effective rule set and configuration page.
UPDATE "RiskRule"
SET "status" = 'deleted'
WHERE "code" IN (
'DUPLICATE_PHONE_RATIO',
'ILLEGAL_PHONE_RATIO',
'BLACKLIST_HIT_RATIO',
'TEMPLATE_VARIABLE_ANOMALY'
);
UPDATE "RiskRule"
SET "status" = 'deleted'
WHERE "tenantId" IS NOT NULL AND "applicationId" IS NULL;
DROP INDEX IF EXISTS "RiskRule_tenantId_code_key";
DROP INDEX IF EXISTS "RiskRule_tenantId_status_priority_idx";
CREATE UNIQUE INDEX "RiskRule_applicationId_code_key"
ON "RiskRule"("applicationId", "code");
CREATE UNIQUE INDEX "RiskRule_global_code_key"
ON "RiskRule"("code")
WHERE "applicationId" IS NULL AND "status" <> 'deleted';
CREATE INDEX "RiskRule_tenantId_applicationId_status_priority_idx"
ON "RiskRule"("tenantId", "applicationId", "status", "priority");
ALTER TABLE "RiskRule"
ADD CONSTRAINT "RiskRule_applicationId_fkey"
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SmsApplication" DROP COLUMN "maxPhonesPerTask";
+31 -25
View File
@@ -74,9 +74,9 @@ model EnterpriseCertification {
model User {
id String @id @default(cuid())
tenantId String?
username String @unique
email String? @unique
phone String? @unique
username String
email String?
phone String?
displayName String
passwordHash String
status String @default("active")
@@ -384,32 +384,34 @@ model SmsBillingRecord {
}
model SmsApplication {
id String @id @default(cuid())
id String @id @default(cuid())
tenantId String
name String
scene String?
callbackUrl String?
cmppAccount String @unique
cmppAccount String @unique
cmppEnterpriseCode String
cmppApplicationExtension String?
cmppAccessNumberFillEnabled Boolean @default(false)
cmppAccessNumberFillEnabled Boolean @default(false)
cmppAccessNumberFillPrefix String?
cmppClientSrcId String? @unique
cmppClientSrcId String? @unique
secretHash String
interfaceEnabled Boolean @default(true)
interfaceType String @default("cmpp20")
cmppMaxConnections Int @default(1)
cmppWindowSize Int @default(16)
dailyLimit Int @default(100000)
customerUnitPrice BigInt @default(0)
queuePriority String @default("normal")
maxPhonesPerTask Int @default(10000)
templateMismatchMode String @default("reject")
downstreamReceiptRetryEnabled Boolean @default(true)
downstreamUplinkRetryEnabled Boolean @default(true)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
interfaceEnabled Boolean @default(true)
interfaceType String @default("cmpp20")
cmppMaxConnections Int @default(1)
cmppWindowSize Int @default(16)
dailyLimit Int @default(100000)
customerUnitPrice BigInt @default(0)
queuePriority String @default("normal")
templateMismatchMode String @default("reject")
downstreamReceiptRetryEnabled Boolean @default(true)
downstreamUplinkRetryEnabled Boolean @default(true)
status String @default("active")
disablingAt DateTime?
autoDisableAt DateTime?
disableReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
ipAllowlist SmsApplicationIpAllowlist[]
@@ -435,8 +437,10 @@ model SmsApplication {
httpWebhookEvents HttpWebhookEvent[]
dailyUsages SmsApplicationDailyUsage[]
inboundLongMessages CmppInboundLongMessage[]
riskRules RiskRule[]
@@index([tenantId, status])
@@index([status, autoDisableAt])
}
model SmsApplicationIpAllowlist {
@@ -1213,6 +1217,7 @@ model ReportReceiptImport {
model RiskRule {
id String @id @default(cuid())
tenantId String?
applicationId String?
code String
name String
description String?
@@ -1225,11 +1230,12 @@ model RiskRule {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id])
hits RiskHitRecord[]
tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade)
hits RiskHitRecord[]
@@unique([tenantId, code])
@@index([tenantId, status, priority])
@@unique([applicationId, code])
@@index([tenantId, applicationId, status, priority])
}
model SmsSendTask {
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { SendChainService } from '../send-chain/send-chain.service';
@@ -15,8 +15,8 @@ export class AdminRiskReviewController {
constructor(private readonly riskReview: RiskReviewService, private readonly sendChain: SendChainService) {}
@Get('rules')
listRules(@Query('tenantId') tenantId?: string) {
return this.riskReview.listRules(tenantId);
listRules(@Query('applicationId') applicationId?: string) {
return this.riskReview.listRules(applicationId);
}
@Post('rules')
@@ -24,6 +24,11 @@ export class AdminRiskReviewController {
return this.riskReview.createRule(body);
}
@Put('rules/:id')
updateRule(@Param('id') ruleId: string, @Body() body: Partial<CreateRiskRuleDto>) {
return this.riskReview.updateRule(ruleId, body);
}
@Get('hits')
listHits(@Query('tenantId') tenantId?: string, @Query('taskId') taskId?: string) {
return this.riskReview.listHits(tenantId, taskId);
@@ -39,6 +44,16 @@ export class AdminRiskReviewController {
return this.riskReview.listPendingTasks();
}
@Get('tasks/:id/messages')
listTaskMessages(
@Param('id') taskId: string,
@Query('phone') phone?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.riskReview.listTaskMessages(taskId, phone, Number(page ?? 1), Number(pageSize ?? 20));
}
@Post('tasks/:id/approve')
async approveTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto, @CurrentSessionUserId() reviewerId?: string) {
const review = { ...body, reviewerId };
+124 -64
View File
@@ -4,7 +4,9 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
return {
riskRule: {
findFirst: jest.fn().mockResolvedValue({ id: 'default-rule' }),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
},
globalBlacklist: {
@@ -26,7 +28,6 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
findUnique: jest.fn().mockResolvedValue(null),
},
smsSendTask: {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'risk-task-1', ...data }),
),
@@ -37,6 +38,11 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
},
smsMessageRecord: {
update: jest.fn().mockResolvedValue({ id: 'message-1' }),
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
smsBatchTask: {
count: jest.fn().mockResolvedValue(0),
},
riskHitRecord: {
createMany: jest.fn(),
@@ -120,16 +126,15 @@ describe('RiskReviewService', () => {
await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required');
});
it('rejects tasks over the application max phone threshold', async () => {
it('rejects tasks over the effective max phone rule threshold', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 });
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-max',
code: 'MAX_PHONES_PER_TASK',
name: '单任务最大号码数',
metric: 'phoneTotal',
thresholdValue: 100000,
thresholdValue: 2,
action: 'block',
priority: 10,
},
@@ -155,30 +160,78 @@ describe('RiskReviewService', () => {
});
});
it('routes duplicate and blacklist ratio hits to manual review', async () => {
it('uses an application rule with the same code instead of the global default', async () => {
const prisma = createPrismaMock();
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000001' }]);
prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002' }]);
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-dup',
code: 'DUPLICATE_PHONE_RATIO',
name: '重复号码比例',
metric: 'duplicateRatio',
thresholdValue: 0.2,
action: 'manual_review',
priority: 20,
id: 'rule-global',
applicationId: null,
code: 'MAX_PHONES_PER_TASK',
name: '单任务最大号码数',
metric: 'phoneTotal',
thresholdValue: 100000,
action: 'block',
priority: 10,
},
{
id: 'rule-black',
code: 'BLACKLIST_HIT_RATIO',
name: '黑名单命中比例',
metric: 'blacklistHitRatio',
thresholdValue: 0.2,
action: 'manual_review',
priority: 40,
id: 'rule-app',
applicationId: 'app-1',
code: 'MAX_PHONES_PER_TASK',
name: '单任务最大号码数',
metric: 'phoneTotal',
thresholdValue: 1,
action: 'block',
priority: 10,
},
]);
const service = new RiskReviewService(prisma as never);
await expect(service.evaluateTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
content: 'hello',
phones: ['13800000001', '13800000002'],
})).resolves.toEqual(expect.objectContaining({ status: 'rejected' }));
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ ruleId: 'rule-app', thresholdValue: 1 })],
});
});
it('paginates real phone records through both direct and batch review-task relations', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1' });
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'message-1',
phoneNumber: '13800000001',
province: '上海',
carrier: 'mobile',
status: 'pending_review',
}]);
prisma.smsMessageRecord.count.mockResolvedValue(1);
const service = new RiskReviewService(prisma as never);
await expect(service.listTaskMessages('review-task-1', '138', 1, 20)).resolves.toEqual({
items: [expect.objectContaining({ phoneNumber: '13800000001' })],
total: 1,
page: 1,
pageSize: 20,
});
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: {
OR: [
{ reviewTaskId: 'review-task-1' },
{ batchTask: { riskTaskId: 'review-task-1' } },
],
phoneNumber: { contains: '138' },
},
skip: 0,
take: 20,
}));
});
it('does not create an audit task for automatic approval', async () => {
const prisma = createPrismaMock();
prisma.riskRule.findMany.mockResolvedValue([]);
const service = new RiskReviewService(prisma as never);
const result = await service.evaluateTask({
@@ -188,54 +241,20 @@ describe('RiskReviewService', () => {
phones: ['13800000001', '13800000001', '13800000002'],
});
expect(result.status).toBe('pending_review');
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
duplicateRatio: 0.3333,
blacklistHitRatio: 0.6667,
status: 'pending_review',
riskDecision: 'manual_review',
}),
});
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: { in: ['13800000001', '13800000002'] }, status: 'active' },
select: { phoneNumber: true },
});
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ ruleCode: 'DUPLICATE_PHONE_RATIO' }),
expect.objectContaining({ ruleCode: 'BLACKLIST_HIT_RATIO' }),
]),
});
expect(result).toEqual(expect.objectContaining({ status: 'approved', canSubmit: true, task: null }));
expect(prisma.smsSendTask.create).not.toHaveBeenCalled();
expect(prisma.globalBlacklist.findMany).not.toHaveBeenCalled();
expect(prisma.enterpriseBlacklist.findMany).not.toHaveBeenCalled();
});
it('rejects illegal phone and template variable anomalies', async () => {
it('keeps template variable validation as deterministic rejection instead of a configurable rule', async () => {
const prisma = createPrismaMock();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1',
category: 'notice',
variables: [{ name: 'code', required: true }],
});
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-illegal',
code: 'ILLEGAL_PHONE_RATIO',
name: '非法号码比例',
metric: 'illegalRatio',
thresholdValue: 0.1,
action: 'block',
priority: 30,
},
{
id: 'rule-var',
code: 'TEMPLATE_VARIABLE_ANOMALY',
name: '模板变量异常',
metric: 'variableIssueCount',
thresholdValue: 0,
action: 'block',
priority: 70,
},
]);
prisma.riskRule.findMany.mockResolvedValue([]);
const service = new RiskReviewService(prisma as never);
const result = await service.evaluateTask({
@@ -255,7 +274,9 @@ describe('RiskReviewService', () => {
{ type: 'missing_required_variable', name: 'code' },
{ type: 'unexpected_variable', name: 'extra' },
]),
content: [],
content: expect.arrayContaining([
expect.objectContaining({ ruleCode: 'TEMPLATE_VARIABLE_INVALID', action: 'block' }),
]),
},
}),
});
@@ -263,7 +284,7 @@ describe('RiskReviewService', () => {
it('marks non-working marketing bulk and frequent task creation for manual review', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.count.mockResolvedValue(11);
prisma.smsBatchTask.count.mockResolvedValue(10);
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-night',
@@ -292,15 +313,54 @@ describe('RiskReviewService', () => {
content: 'promo',
phones: ['13800000001', '13800000002', '13800000003'],
requestedAt: '2026-07-01T22:00:00+08:00',
applicationId: 'app-1',
sourceType: 'client',
});
expect(result.status).toBe('pending_review');
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ ruleCode: 'NON_WORKING_MARKETING_BULK', actualValue: 3 }),
expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 11 }),
expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 10 }),
]),
});
expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
where: {
applicationId: 'app-1',
sourceType: 'client',
createdAt: { gte: expect.any(Date) },
},
});
});
it('does not include CMPP or HTTP tasks in client task frequency control', async () => {
const prisma = createPrismaMock();
prisma.riskRule.findMany.mockResolvedValue([{
id: 'rule-frequency',
code: 'TASK_CREATE_FREQUENCY',
name: '短时间任务创建频控',
metric: 'recentTaskCount',
thresholdValue: 1,
action: 'manual_review',
priority: 30,
}]);
const service = new RiskReviewService(prisma as never);
await expect(service.evaluateTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
content: 'hello',
phones: ['10000000000'],
sourceType: 'cmpp',
})).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null }));
await expect(service.evaluateTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
content: 'hello',
phones: ['10000000000'],
sourceType: 'api',
})).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null }));
expect(prisma.smsBatchTask.count).not.toHaveBeenCalled();
});
it('rejects sensitive words and illegal control characters before sending', async () => {
+254 -100
View File
@@ -5,10 +5,11 @@ import { PrismaService } from '../prisma/prisma.service';
export interface CreateRiskRuleDto {
tenantId?: string;
applicationId?: string;
code: string;
name: string;
name?: string;
description?: string;
metric: string;
metric?: string;
thresholdValue: number;
action?: string;
status?: string;
@@ -26,6 +27,7 @@ export interface EvaluateSmsTaskDto {
variables?: Record<string, unknown>;
createdById?: string;
requestedAt?: string;
sourceType?: 'client' | 'api' | 'cmpp';
}
export interface ReviewSmsTaskDto {
@@ -66,33 +68,6 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [
action: 'block',
priority: 10,
},
{
code: 'DUPLICATE_PHONE_RATIO',
name: '重复号码比例',
description: '重复号码比例过高时进入人工审核。',
metric: 'duplicateRatio',
thresholdValue: 0.2,
action: 'manual_review',
priority: 20,
},
{
code: 'ILLEGAL_PHONE_RATIO',
name: '非法号码比例',
description: '非法手机号比例超过阈值时直接拒绝。',
metric: 'illegalRatio',
thresholdValue: 0.05,
action: 'block',
priority: 30,
},
{
code: 'BLACKLIST_HIT_RATIO',
name: '黑名单命中比例',
description: '命中平台或企业黑名单比例过高时进入人工审核。',
metric: 'blacklistHitRatio',
thresholdValue: 0.01,
action: 'manual_review',
priority: 40,
},
{
code: 'NON_WORKING_MARKETING_BULK',
name: '非工作时间大批量营销发送',
@@ -100,7 +75,8 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [
metric: 'nonWorkingMarketingPhones',
thresholdValue: 5000,
action: 'manual_review',
priority: 50,
priority: 20,
config: { startTime: '21:00', endTime: '08:00', timeZone: 'Asia/Shanghai' },
},
{
code: 'TASK_CREATE_FREQUENCY',
@@ -109,44 +85,86 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [
metric: 'recentTaskCount',
thresholdValue: 10,
action: 'manual_review',
priority: 60,
},
{
code: 'TEMPLATE_VARIABLE_ANOMALY',
name: '模板变量异常',
description: '模板变量缺失或多传时直接拒绝。',
metric: 'variableIssueCount',
thresholdValue: 0,
action: 'block',
priority: 70,
priority: 30,
},
];
const RULE_DEFINITIONS = new Map(DEFAULT_RULES.map((rule) => [rule.code, rule]));
@Injectable()
export class RiskReviewService {
constructor(private readonly prisma: PrismaService) {}
async listRules(tenantId?: string) {
async listRules(applicationId?: string) {
await this.ensureDefaultRules();
return this.prisma.riskRule.findMany({
where: tenantId ? { OR: [{ tenantId: null }, { tenantId }] } : undefined,
where: {
code: { in: [...RULE_DEFINITIONS.keys()] },
status: { not: 'deleted' },
...(applicationId ? { OR: [{ applicationId: null }, { applicationId }] } : {}),
},
include: {
application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } },
},
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
}
createRule(data: CreateRiskRuleDto) {
async createRule(data: CreateRiskRuleDto) {
const definition = this.validateRuleInput(data);
const scope = await this.resolveRuleScope(data.applicationId);
const existing = await this.prisma.riskRule.findFirst({
where: { applicationId: data.applicationId ?? null, code: data.code, status: { not: 'deleted' } },
select: { id: true },
});
if (existing) {
throw new BadRequestException('该适用范围已存在同名风控规则');
}
return this.prisma.riskRule.create({
data: {
tenantId: data.tenantId,
tenantId: scope.tenantId,
applicationId: data.applicationId,
code: data.code,
name: data.name,
description: data.description,
metric: data.metric,
name: definition.name!,
description: definition.description,
metric: definition.metric!,
thresholdValue: data.thresholdValue,
action: data.action ?? 'manual_review',
status: data.status ?? 'active',
priority: data.priority ?? 100,
config: data.config as Prisma.InputJsonValue | undefined,
priority: data.priority ?? definition.priority ?? 100,
config: this.normalizeRuleConfig(data.code, data.config ?? definition.config) as Prisma.InputJsonValue | undefined,
},
include: {
application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } },
},
});
}
async updateRule(ruleId: string, data: Partial<CreateRiskRuleDto>) {
const rule = await this.prisma.riskRule.findUnique({ where: { id: ruleId } });
if (!rule) {
throw new NotFoundException('Risk rule not found');
}
this.validateRuleInput({
code: rule.code,
thresholdValue: data.thresholdValue ?? rule.thresholdValue,
action: data.action ?? rule.action,
status: data.status ?? rule.status,
config: data.config ?? jsonObject(rule.config),
});
return this.prisma.riskRule.update({
where: { id: ruleId },
data: {
thresholdValue: data.thresholdValue,
action: data.action,
status: data.status,
priority: data.priority,
config: data.config === undefined
? undefined
: this.normalizeRuleConfig(rule.code, data.config) as Prisma.InputJsonValue,
},
include: {
application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } },
},
});
}
@@ -166,6 +184,13 @@ export class RiskReviewService {
where: {
tenantId,
status,
...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}),
...(!status ? {
OR: [
{ status: 'pending_review' },
{ reviewedById: { not: null } },
],
} : {}),
...(status === 'pending_review' ? {
OR: [
{ sourceType: { not: 'cmpp_template_mismatch' } },
@@ -186,6 +211,39 @@ export class RiskReviewService {
return this.listTasks(undefined, 'pending_review');
}
async listTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) {
const task = await this.prisma.smsSendTask.findUnique({ where: { id: taskId }, select: { id: true } });
if (!task) {
throw new NotFoundException('SMS send task not found');
}
const normalizedPage = Math.max(1, Math.floor(page || 1));
const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(pageSize || 20)));
const where: Prisma.SmsMessageRecordWhereInput = {
OR: [
{ reviewTaskId: taskId },
{ batchTask: { riskTaskId: taskId } },
],
...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}),
};
const [items, total] = await Promise.all([
this.prisma.smsMessageRecord.findMany({
where,
select: {
id: true,
phoneNumber: true,
province: true,
carrier: true,
status: true,
},
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
skip: (normalizedPage - 1) * normalizedPageSize,
take: normalizedPageSize,
}),
this.prisma.smsMessageRecord.count({ where }),
]);
return { items, total, page: normalizedPage, pageSize: normalizedPageSize };
}
async aggregateTemplateMismatch(data: AggregateTemplateMismatchDto) {
const normalizedContent = data.content.replace(/\r\n/g, '\n').trim();
const contentHash = createHash('sha256').update(normalizedContent, 'utf8').digest('hex');
@@ -254,37 +312,52 @@ export class RiskReviewService {
const phoneTotal = phones.length;
const uniquePhoneTotal = uniquePhones.length;
const duplicateRatio = ratio(phoneTotal - uniquePhoneTotal, phoneTotal);
const illegalCount = phones.filter((phone) => !isMainlandMobile(phone)).length;
const illegalCount = phones.filter((phone) => !isBasicMobileNumber(phone)).length;
const illegalRatio = ratio(illegalCount, phoneTotal);
const blacklistHitCount = await this.countBlacklistHits(data.tenantId, data.applicationId, uniquePhones);
const blacklistHitRatio = ratio(blacklistHitCount, phoneTotal);
const [application, template, rules, recentTaskCount, sensitiveWords] = await Promise.all([
data.applicationId ? this.prisma.smsApplication.findUnique({ where: { id: data.applicationId } }) : null,
const [template, rules, recentTaskCount, sensitiveWords] = await Promise.all([
data.templateId
? this.prisma.smsTemplate.findUnique({ where: { id: data.templateId }, include: { variables: true } })
: null,
this.effectiveRules(data.tenantId),
this.countRecentTasks(data.tenantId),
this.effectiveRules(data.applicationId),
this.countRecentClientTasks(data.applicationId, data.sourceType),
this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }),
]);
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {});
const contentIssues = evaluateContent(data.content, sensitiveWords);
if (variableIssues.length > 0) {
contentIssues.push({
ruleCode: 'TEMPLATE_VARIABLE_INVALID',
ruleName: '模板变量校验失败',
thresholdValue: 0,
actualValue: variableIssues.length,
action: 'block',
reason: formatTemplateVariableIssueReason(variableIssues),
});
}
const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date();
const nonWorkingRule = rules.find((rule) => rule.code === 'NON_WORKING_MARKETING_BULK');
const nonWorkingMarketingPhones =
isMarketing(data.category ?? template?.category) && isNonWorkingTime(requestedAt) ? phoneTotal : 0;
isMarketing(data.category ?? template?.category)
&& isNonWorkingTime(requestedAt, readNonWorkingConfig(nonWorkingRule?.config))
? phoneTotal
: 0;
const hits = this.evaluateRules(rules, {
phoneTotal,
applicationMaxPhones: application?.maxPhonesPerTask,
duplicateRatio,
illegalRatio,
blacklistHitRatio,
nonWorkingMarketingPhones,
recentTaskCount,
variableIssueCount: variableIssues.length,
});
hits.push(...contentIssues.map(contentIssueToHit));
const decision = decideRiskAction(hits);
const reason = hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null;
if (decision.status === 'approved') {
return {
canSubmit: true,
status: decision.status,
riskDecision: decision.riskDecision,
reason,
task: null,
};
}
const task = await this.prisma.smsSendTask.create({
data: {
tenantId: data.tenantId,
@@ -297,7 +370,7 @@ export class RiskReviewService {
uniquePhoneTotal,
duplicateRatio,
illegalRatio,
blacklistHitRatio,
blacklistHitRatio: 0,
variableIssues: { variables: variableIssues, content: contentIssues } as unknown as Prisma.InputJsonValue,
status: decision.status,
riskDecision: decision.riskDecision,
@@ -396,7 +469,7 @@ export class RiskReviewService {
private async ensureDefaultRules() {
for (const rule of DEFAULT_RULES) {
const exists = await this.prisma.riskRule.findFirst({
where: { tenantId: null, code: rule.code },
where: { applicationId: null, code: rule.code, status: { not: 'deleted' } },
select: { id: true },
});
if (!exists) {
@@ -411,43 +484,32 @@ export class RiskReviewService {
}
}
private async effectiveRules(tenantId: string) {
private async effectiveRules(applicationId?: string) {
const rules = await this.prisma.riskRule.findMany({
where: {
status: 'active',
OR: [{ tenantId: null }, { tenantId }],
OR: [{ applicationId: null }, ...(applicationId ? [{ applicationId }] : [])],
},
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
const byCode = new Map<string, (typeof rules)[number]>();
for (const rule of rules) {
byCode.set(rule.code, rule);
if (rule.applicationId || !byCode.has(rule.code)) {
byCode.set(rule.code, rule);
}
}
return [...byCode.values()].sort((a, b) => a.priority - b.priority);
}
private async countBlacklistHits(tenantId: string, applicationId: string | undefined, phones: string[]) {
if (phones.length === 0) {
private countRecentClientTasks(applicationId?: string, sourceType?: string) {
if (!applicationId || sourceType !== 'client') {
return 0;
}
const [globalHits, enterpriseHits] = await Promise.all([
this.prisma.globalBlacklist.findMany({
where: { phoneNumber: { in: phones }, status: 'active' },
select: { phoneNumber: true },
}),
applicationId ? this.prisma.enterpriseBlacklist.findMany({
where: { tenantId, applicationId, phoneNumber: { in: phones }, status: 'active' },
select: { phoneNumber: true },
}) : Promise.resolve([]),
]);
return new Set([...globalHits, ...enterpriseHits].map((hit) => hit.phoneNumber)).size;
}
private countRecentTasks(tenantId: string) {
const since = new Date(Date.now() - 10 * 60 * 1000);
return this.prisma.smsSendTask.count({
return this.prisma.smsBatchTask.count({
where: {
tenantId,
applicationId,
sourceType: 'client',
createdAt: { gte: since },
},
});
@@ -457,23 +519,17 @@ export class RiskReviewService {
rules: Awaited<ReturnType<RiskReviewService['effectiveRules']>>,
metrics: {
phoneTotal: number;
applicationMaxPhones?: number | null;
duplicateRatio: number;
illegalRatio: number;
blacklistHitRatio: number;
nonWorkingMarketingPhones: number;
recentTaskCount: number;
variableIssueCount: number;
},
): RuleEvaluation[] {
const hits: RuleEvaluation[] = [];
for (const rule of rules) {
const threshold =
rule.code === 'MAX_PHONES_PER_TASK' && metrics.applicationMaxPhones
? Math.min(rule.thresholdValue, metrics.applicationMaxPhones)
: rule.thresholdValue;
const threshold = rule.thresholdValue;
const actualValue = metricValue(rule.metric, metrics);
const shouldHit = rule.code === 'TEMPLATE_VARIABLE_ANOMALY' ? actualValue > threshold : actualValue > threshold;
const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY'
? actualValue >= threshold
: actualValue > threshold;
if (!shouldHit) {
continue;
}
@@ -489,6 +545,56 @@ export class RiskReviewService {
}
return hits;
}
private validateRuleInput(data: Pick<CreateRiskRuleDto, 'code' | 'thresholdValue' | 'action' | 'status' | 'config'>) {
const definition = RULE_DEFINITIONS.get(data.code);
if (!definition) {
throw new BadRequestException('不支持的风控规则编码');
}
if (!Number.isFinite(data.thresholdValue) || data.thresholdValue < 0) {
throw new BadRequestException('风控阈值必须是大于等于0的有效数字');
}
if (data.action && !['block', 'manual_review'].includes(data.action)) {
throw new BadRequestException('风控处理动作无效');
}
if (data.status && !['active', 'inactive'].includes(data.status)) {
throw new BadRequestException('风控规则状态无效');
}
this.normalizeRuleConfig(data.code, data.config);
return definition;
}
private async resolveRuleScope(applicationId?: string) {
if (!applicationId) {
return { tenantId: undefined };
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true },
});
if (!application) {
throw new BadRequestException('企业应用不存在');
}
return { tenantId: application.tenantId };
}
private normalizeRuleConfig(code: string, config?: Record<string, unknown> | null) {
if (code !== 'NON_WORKING_MARKETING_BULK') {
return config ?? undefined;
}
const startTime = String(config?.startTime ?? '21:00');
const endTime = String(config?.endTime ?? '08:00');
const timeZone = String(config?.timeZone ?? 'Asia/Shanghai');
if (!isClockTime(startTime) || !isClockTime(endTime) || startTime === endTime) {
throw new BadRequestException('非工作时间必须是两个不同的 HH:mm 时间');
}
try {
new Intl.DateTimeFormat('zh-CN', { timeZone }).format(new Date());
} catch {
throw new BadRequestException('非工作时间时区无效');
}
return { startTime, endTime, timeZone };
}
}
function ratio(count: number, total: number) {
@@ -498,17 +604,55 @@ function ratio(count: number, total: number) {
return Number((count / total).toFixed(4));
}
function isMainlandMobile(phone: string) {
return /^1[3-9]\d{9}$/.test(phone);
function isBasicMobileNumber(phone: string) {
return /^1\d{10}$/.test(phone);
}
function isMarketing(category?: string | null) {
return ['marketing', 'promo', 'promotion', '营销'].includes((category ?? '').toLowerCase());
}
function isNonWorkingTime(date: Date) {
const hour = date.getHours();
return hour < 8 || hour >= 21;
function readNonWorkingConfig(config: Prisma.JsonValue | null | undefined) {
const value = config && typeof config === 'object' && !Array.isArray(config)
? config as Record<string, Prisma.JsonValue>
: {};
return {
startTime: typeof value.startTime === 'string' ? value.startTime : '21:00',
endTime: typeof value.endTime === 'string' ? value.endTime : '08:00',
timeZone: typeof value.timeZone === 'string' ? value.timeZone : 'Asia/Shanghai',
};
}
function jsonObject(config: Prisma.JsonValue | null | undefined) {
return config && typeof config === 'object' && !Array.isArray(config)
? config as Record<string, unknown>
: undefined;
}
function isNonWorkingTime(date: Date, config: { startTime: string; endTime: string; timeZone: string }) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: config.timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? 0);
const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? 0);
const current = hour * 60 + minute;
const start = clockMinutes(config.startTime);
const end = clockMinutes(config.endTime);
return start < end
? current >= start && current < end
: current >= start || current < end;
}
function isClockTime(value: string) {
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value);
}
function clockMinutes(value: string) {
const [hour, minute] = value.split(':').map(Number);
return hour * 60 + minute;
}
function evaluateTemplateVariables(
@@ -529,6 +673,16 @@ function evaluateTemplateVariables(
];
}
function formatTemplateVariableIssueReason(issues: Array<{ type: string; name: string }>) {
const missing = issues.filter((item) => item.type === 'missing_required_variable').map((item) => item.name);
const extra = issues.filter((item) => item.type === 'unexpected_variable').map((item) => item.name);
const details = [
missing.length > 0 ? `缺少必填变量:${missing.join('、')}` : '',
extra.length > 0 ? `包含模板未定义变量:${extra.join('、')}` : '',
].filter(Boolean).join('');
return `模板变量校验失败(${details}),本次提交拒绝`;
}
function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) {
const issues: RuleEvaluation[] = [];
const controlMatches = [...content].filter((char) => /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(char));
+147 -17
View File
@@ -402,6 +402,73 @@ describe('SendChainService', () => {
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('marks invalid and blacklisted client numbers as submit failures while sending valid numbers', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002', reason: '平台拒收' }]);
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
billingUnitsPerMessage: 1,
totalBillingUnits: 1,
unitPrice: 3,
amountCents: 3,
});
await service.createBatchTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
templateId: 'tpl-1',
content: 'hello',
phones: ['13800000001', '23800000002', '13800000002'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
expect.objectContaining({
phoneNumber: '23800000002',
status: 'submit_failed',
submitStatus: 'rejected',
errorCode: 'INVALID_PHONE',
amountCents: 0,
}),
expect.objectContaining({
phoneNumber: '13800000002',
status: 'submit_failed',
submitStatus: 'rejected',
errorCode: 'GLOBAL_BLACKLIST',
amountCents: 0,
}),
]),
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('persists the review task id on every message waiting for manual review', async () => {
const { service, prisma, riskReview } = createService();
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
status: 'pending_review',
reason: '命中人工审核规则',
task: { id: 'review-task-1' },
});
await service.createBatchTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
templateId: 'tpl-1',
content: 'hello',
phones: ['13800000001'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
phoneNumber: '13800000001',
status: 'pending_review',
reviewTaskId: 'review-task-1',
})],
});
});
it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => {
const { service, prisma, billing } = createService();
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
@@ -807,7 +874,7 @@ describe('SendChainService', () => {
})).rejects.toThrow('CMPP interface is disabled for this application');
});
it('does not create a CMPP downstream delivery when the application interface was disabled after bind', async () => {
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
@@ -835,12 +902,10 @@ describe('SendChainService', () => {
phoneNumber: '13800000001',
content: 'hello',
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
})).rejects.toThrow('CMPP account is disabled for new submissions');
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'validating' }) });
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }),
});
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
@@ -852,6 +917,7 @@ describe('SendChainService', () => {
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'active',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
@@ -882,7 +948,7 @@ describe('SendChainService', () => {
cmppAccount: '100001',
secretHash: 'secret-hash',
status: 'active',
interfaceEnabled: false,
interfaceEnabled: true,
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
@@ -919,8 +985,8 @@ describe('SendChainService', () => {
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => {
@@ -1292,19 +1358,37 @@ describe('SendChainService', () => {
expect(billing.freeze).not.toHaveBeenCalled();
});
it('rejects a multi-destination CMPP Submit before persistence when any destination is invalid', async () => {
it('returns a failure receipt for an invalid destination while other CMPP destinations continue', async () => {
const { service, prisma } = createService();
let messageIndex = 0;
prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({
id: `record-${++messageIndex}`,
...data,
}));
await expect(service.submitInboundMessage({
account: '100001',
phoneNumbers: ['13800000001', 'invalid'],
content: 'hello',
remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP submit phone number is invalid');
})).resolves.toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 }));
expect(prisma.smsApplication.findFirst).not.toHaveBeenCalled();
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
where: { id: 'record-2' },
data: expect.objectContaining({
status: 'failed',
receiptStatus: 'undelivered',
errorCode: 'INVALID_PHONE',
}),
});
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
messageRecordId: 'record-2',
receiptStatus: 'undelivered',
errorCode: 'INVALID_PHONE',
}),
});
});
it('accepts only the filled client Src_Id and snapshots the real application extension', async () => {
@@ -1571,7 +1655,8 @@ describe('SendChainService', () => {
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsSendTask.findUnique.mockResolvedValue({
id: 'review-task-1',
messageRecords: [{
});
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
@@ -1581,8 +1666,7 @@ describe('SendChainService', () => {
amountCents: 3,
billingUnits: 1,
batchTask: { id: 'task-1', sourceType: 'cmpp' },
}],
});
}]);
await expect(service.handleReviewDecision('review-task-1', 'approved', '审核通过')).resolves.toEqual({
reviewTaskId: 'review-task-1',
@@ -2697,6 +2781,52 @@ describe('SendChainService', () => {
}));
});
it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
cmppEnterpriseCode: 'SP0001',
secretHash: 'secret-hash',
status: 'disabling',
interfaceEnabled: true,
cmppMaxConnections: 2,
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: 'hello',
remoteIp: '127.0.0.1',
})).rejects.toThrow('disabled for new submissions');
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
});
it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'deleted',
interfaceEnabled: true,
tenant: { id: 'tenant-1', status: 'deleted', certificationStatus: 'approved' },
});
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([]);
await expect(service.listPendingDownstreamDeliveries({ account: '100001', limit: 100 }))
.resolves.toEqual([]);
});
it('persists each upstream SubmitResp segment before the aggregate result arrives', async () => {
const { service, prisma } = createService();
+136 -49
View File
@@ -402,6 +402,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data);
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const phoneRejections = await this.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
const sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
this.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
this.resolveUnitPrice(data.tenantId, data.applicationId),
@@ -419,16 +421,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
phones,
variables: messageClassification.variables ?? data.variables,
createdById: data.createdById,
sourceType: data.sourceType ?? 'client',
});
const billing = this.billing.estimateSmsCost({
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: risk.task?.id,
content: data.content,
phoneCount: phones.length,
phoneCount: sendablePhones.length,
unitPrice,
});
const batchStatus = statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
const batchStatus = risk.status === 'approved' && sendablePhones.length === 0
? 'failed'
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
const shouldReserveBalance = batchStatus === 'ready';
if (risk.status === 'approved') {
const accountCheck = await this.billing.checkAccount({
@@ -439,8 +444,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException('企业账户余额不足');
}
}
if (data.applicationId && risk.status !== 'rejected') {
await this.reserveDailySendQuota(data.applicationId, phones.length);
if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) {
await this.reserveDailySendQuota(data.applicationId, sendablePhones.length);
}
const task = await this.prisma.smsBatchTask.create({
data: {
@@ -485,35 +490,50 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: schedule.scheduledAt?.toISOString(),
},
status: batchStatus === 'rejected' ? 'rejected' : 'accepted',
status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted',
},
});
if (phones.length > 0) {
await this.prisma.smsMessageRecord.createMany({
data: phones.map((phone) => ({
data: phones.map((phone) => {
const rejection = phoneRejections.get(phone);
const status = rejection
? 'submit_failed'
: batchStatus === 'ready'
? 'queued'
: batchStatus === 'scheduled'
? 'scheduled'
: batchStatus;
return {
tenantId: data.tenantId,
batchTaskId: task.id,
applicationId: data.applicationId,
templateId: data.templateId,
signatureId: messageClassification.signatureId,
drainageInfoId: messageClassification.drainageInfoId,
reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
messageId: `MSG-${randomUUID()}`,
clientMessageId: data.clientMessageId,
phoneNumber: phone,
content: data.content,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: billing.unitPrice,
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
unitPrice: rejection ? 0 : billing.unitPrice,
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
queuePriority,
clientSrcId: accessNumber.clientSrcId,
applicationExtension: accessNumber.applicationExtension,
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
})),
status,
submitStatus: rejection ? 'rejected' : undefined,
errorCode: rejection?.code,
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined),
};
}),
});
}
if (batchStatus === 'ready') {
if (batchStatus === 'ready' && sendablePhones.length > 0) {
await this.enqueueBatchTask(task.id);
} else if (batchStatus === 'failed') {
await this.refreshTaskProgress(task.id);
}
return this.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
}
@@ -743,18 +763,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
const reviewTask = await this.prisma.smsSendTask.findUnique({
where: { id: reviewTaskId },
include: {
messageRecords: {
where: { status: 'pending_review' },
include: { batchTask: true },
},
},
});
if (!reviewTask || reviewTask.messageRecords.length === 0) {
if (!reviewTask) {
return { reviewTaskId, decision, affected: 0 };
}
const messageRecords = await this.prisma.smsMessageRecord.findMany({
where: {
status: 'pending_review',
OR: [
{ reviewTaskId },
{ batchTask: { riskTaskId: reviewTaskId } },
],
},
include: { batchTask: true },
});
if (messageRecords.length === 0) {
return { reviewTaskId, decision, affected: 0 };
}
const batchTaskIds = new Set<string>();
for (const message of reviewTask.messageRecords) {
for (const message of messageRecords) {
if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue;
if (decision === 'approved') {
await this.prisma.smsMessageRecord.update({
@@ -781,7 +808,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
for (const batchTaskId of batchTaskIds) {
await this.enqueueBatchTask(batchTaskId);
}
return { reviewTaskId, decision, affected: reviewTask.messageRecords.length };
return { reviewTaskId, decision, affected: messageRecords.length };
}
async terminateBatchTask(taskId: string) {
@@ -1440,8 +1467,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
const application = await this.findInboundApplication(data.account);
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({
where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
@@ -2244,23 +2271,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
select: {
cmppAccount: true,
interfaceEnabled: true,
status: true,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
httpConfig: true,
},
});
try {
await this.openApi?.queueWebhookEvent({
tenantId: data.tenantId,
applicationId: data.applicationId,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
eventType: data.deliveryType,
payload: data.payload,
});
} catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
if (deliveryAllowed) {
try {
await this.openApi?.queueWebhookEvent({
tenantId: data.tenantId,
applicationId: data.applicationId,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
eventType: data.deliveryType,
payload: data.payload,
});
} catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (application?.interfaceEnabled !== true) {
return null;
@@ -2274,12 +2305,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
messageId: data.messageId,
deliveryType: data.deliveryType,
payload,
retryEnabled: data.deliveryType === 'uplink'
retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink'
? application?.downstreamUplinkRetryEnabled ?? true
: application?.downstreamReceiptRetryEnabled ?? true,
status: 'pending',
: application?.downstreamReceiptRetryEnabled ?? true),
status: deliveryAllowed ? 'pending' : 'abandoned',
lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
},
});
if (!deliveryAllowed) {
return delivery;
}
try {
const result = await this.postGatewayControl(
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
@@ -2413,7 +2448,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
const application = await this.findInboundApplication(data.account);
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (!application.interfaceEnabled) {
@@ -2445,7 +2480,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
: data.phoneNumber
? [data.phoneNumber.trim()]
: [];
if (phoneNumbers.length === 0 || phoneNumbers.some((phoneNumber) => !/^1[3-9]\d{9}$/.test(phoneNumber))) {
if (phoneNumbers.length === 0) {
throw new BadRequestException('CMPP submit phone number is invalid');
}
@@ -2453,6 +2488,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) {
throw new BadRequestException('CMPP account is disabled for new submissions');
}
if (data.longMessage) {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
@@ -2586,7 +2624,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
})
: [];
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => !persistedByPhone.has(phoneNumber)).length;
const phoneRejections = await this.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers);
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => (
!persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber)
)).length;
const dailyQuota = missingPhoneCount > 0
? await this.tryReserveDailySendQuota(application.id, missingPhoneCount)
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
@@ -2601,6 +2642,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const submissions = phoneNumbers.map((phoneNumber, index) => ({
phoneNumber,
persisted: persistedByPhone.get(phoneNumber),
receiptRejection: phoneRejections.get(phoneNumber),
messageId: persistedByPhone.get(phoneNumber)?.messageId
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
}));
@@ -2622,7 +2664,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
...data,
phoneNumber: submission.phoneNumber,
phoneNumbers: undefined,
}, submission.messageId, submitGroupMessageId, dailyLimitRejection))));
}, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection))));
}
const first = results[0];
return {
@@ -2811,6 +2853,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
messageId: string,
submitGroupMessageId: string,
synchronousRejection?: { code: string; reason: string },
receiptRejection?: { code: string; reason: string },
) {
const application = await this.findInboundApplication(data.account);
if (!application) {
@@ -2819,9 +2862,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
throw new BadRequestException('CMPP submit phone number is invalid');
}
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
@@ -2870,8 +2910,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
phoneNumber: data.phoneNumber,
content: data.content,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: billing.unitPrice,
amountCents: billing.amountCents,
unitPrice: receiptRejection ? 0 : billing.unitPrice,
amountCents: receiptRejection ? 0 : billing.amountCents,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
cmppSubmitGroupMessageId: submitGroupMessageId,
@@ -2921,6 +2961,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
content: data.content,
variables: options.templateId ? templateVariables : undefined,
phones: [data.phoneNumber],
sourceType: 'cmpp',
});
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
@@ -2929,7 +2970,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (risk.status === 'pending_review') {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'pending_review', signatureId: options.signatureId, drainageInfoId },
data: {
status: 'pending_review',
reviewTaskId: risk.task?.id,
signatureId: options.signatureId,
drainageInfoId,
},
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
@@ -2964,7 +3010,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
await this.enqueueBatchTask(task.id);
};
if (application.status !== 'active' || application.tenant.status !== 'active') {
if (receiptRejection) {
await reject(receiptRejection.code, receiptRejection.reason);
} else if (application.status !== 'active' || application.tenant.status !== 'active') {
await reject('ACCOUNT', '企业或短信应用已停用');
} else if (!application.interfaceEnabled) {
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
@@ -2998,6 +3046,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
applicationId: application.id,
content: data.content,
phones: [data.phoneNumber],
sourceType: 'cmpp',
});
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
@@ -3651,6 +3700,44 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return receipt;
}
private async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
const rejected = new Map<string, { code: string; reason: string }>();
for (const phone of phones) {
if (!/^1\d{10}$/.test(phone)) {
rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' });
}
}
const validPhones = phones.filter((phone) => !rejected.has(phone));
if (validPhones.length === 0) {
return rejected;
}
const [globalHits, enterpriseHits] = await Promise.all([
this.prisma.globalBlacklist.findMany({
where: { phoneNumber: { in: validPhones }, status: 'active' },
select: { phoneNumber: true, reason: true },
}),
applicationId
? this.prisma.enterpriseBlacklist.findMany({
where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' },
select: { phoneNumber: true, reason: true },
})
: Promise.resolve([]),
]);
for (const hit of globalHits) {
rejected.set(hit.phoneNumber, {
code: 'GLOBAL_BLACKLIST',
reason: hit.reason?.trim() || '号码命中平台黑名单',
});
}
for (const hit of enterpriseHits) {
rejected.set(hit.phoneNumber, {
code: 'ENTERPRISE_BLACKLIST',
reason: hit.reason?.trim() || '号码命中企业应用黑名单',
});
}
return rejected;
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant || tenant.status !== 'active') {
@@ -54,6 +54,11 @@ export class AdminSmsConfigController {
return this.smsConfig.listApplicationConnections(applicationId);
}
@Get('enterprise-applications/:id/deactivation-preview')
getApplicationDeactivationPreview(@Param('id') applicationId: string) {
return this.smsConfig.getApplicationDeactivationPreview(applicationId);
}
@Get('enterprise-applications/:id/cmpp-params')
getApplicationCmppParams(@Param('id') applicationId: string) {
return this.smsConfig.getApplicationCmppParams(applicationId);
+76 -1
View File
@@ -43,6 +43,7 @@ function createPrismaMock() {
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
}),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })),
},
smsApplicationIpAllowlist: {
@@ -147,12 +148,22 @@ function createPrismaMock() {
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
count: jest.fn().mockResolvedValue(1),
},
smsMessageRecord: {
groupBy: jest.fn().mockResolvedValue([
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 1 } },
{ applicationId: 'app-1', status: 'undelivered', _count: { _all: 1 } },
]),
count: jest.fn().mockResolvedValue(0),
},
cmppDownstreamDelivery: {
count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
cmppDownstreamDeliveryAttempt: {
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
smsChannel: {
findFirst: jest.fn().mockResolvedValue({
@@ -236,6 +247,71 @@ describe('SmsConfigService', () => {
}));
});
it('moves an application with outstanding receipts into disabling for 72 hours', async () => {
const prisma = createPrismaMock();
prisma.smsMessageRecord.count.mockResolvedValue(1);
const service = new SmsConfigService(prisma as never);
const result = await service.changeApplicationStatus('app-1', {
status: 'disabled',
reason: '运营端停用',
});
expect(result).toEqual(expect.objectContaining({
status: 'disabling',
autoDisableAt: expect.any(Date),
deactivation: expect.objectContaining({ awaitingSupplierReceipt: 1 }),
}));
expect(prisma.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'app-1' },
data: expect.objectContaining({
status: 'disabling',
disablingAt: expect.any(Date),
autoDisableAt: expect.any(Date),
}),
}));
});
it('automatically disables and abandons outstanding deliveries after 72 hours', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findMany.mockResolvedValue([{
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'disabling',
autoDisableAt: new Date(Date.now() - 1_000),
}]);
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'disabling',
disablingAt: new Date(Date.now() - 73 * 60 * 60 * 1_000),
autoDisableAt: new Date(Date.now() - 1_000),
disableReason: '等待清算',
});
prisma.smsMessageRecord.count.mockResolvedValue(1);
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1' }]);
prisma.cmppDownstreamDelivery.updateMany.mockResolvedValue({ count: 1 });
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: jest.fn().mockResolvedValue('{"account":"100001","disconnected":2}'),
}) as never;
const service = new SmsConfigService(prisma as never);
await service['runApplicationDisableScan']();
expect(prisma.smsApplication.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ id: 'app-1', status: 'disabling' }),
data: expect.objectContaining({ status: 'disabled' }),
}));
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'abandoned', retryEnabled: false }),
}));
global.fetch = originalFetch;
});
it('sorts enterprise applications by today send count descending with a stable name tie-breaker', async () => {
const prisma = createPrismaMock();
const baseApplication = {
@@ -324,7 +400,6 @@ describe('SmsConfigService', () => {
interfaceType: 'cmpp20',
queuePriority: 'priority',
dailyLimit: 100000,
maxPhonesPerTask: 10000,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
+256 -13
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
@@ -24,7 +24,6 @@ export interface CreateSmsApplicationDto {
dailyLimit?: number;
customerUnitPrice?: number;
queuePriority?: string;
maxPhonesPerTask?: number;
templateMismatchMode?: string;
downstreamReceiptRetryEnabled?: boolean;
downstreamUplinkRetryEnabled?: boolean;
@@ -112,6 +111,7 @@ export interface StatusChangeDto {
status?: string;
operatorId?: string;
reason?: string;
force?: boolean;
}
export interface TemplateListQuery {
@@ -159,11 +159,31 @@ type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000;
const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000;
const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const;
@Injectable()
export class SmsConfigService {
export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SmsConfigService.name);
private applicationDisableTimer?: ReturnType<typeof setInterval>;
private applicationDisableScanRunning = false;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
this.applicationDisableTimer = setInterval(
() => void this.runApplicationDisableScan(),
getPositiveIntegerEnv('APPLICATION_DISABLE_SCAN_INTERVAL_MS', DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS),
);
this.applicationDisableTimer.unref?.();
void this.runApplicationDisableScan();
}
onModuleDestroy() {
if (this.applicationDisableTimer) clearInterval(this.applicationDisableTimer);
}
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
if (query.includeConnections) {
@@ -202,6 +222,9 @@ export class SmsConfigService {
_count: { _all: true },
}),
]);
const disablingDetails = new Map((await Promise.all(applications
.filter((application) => application.status === 'disabling')
.map(async (application) => [application.id, await this.getApplicationDeactivationPreview(application.id)] as const))));
return applications.map((application) => {
const appConnections = connections.filter((connection) => connection.applicationId === application.id);
const appStats = messageStats.filter((item) => item.applicationId === application.id);
@@ -213,6 +236,7 @@ export class SmsConfigService {
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
sentToday: todayTotal,
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
deactivation: disablingDetails.get(application.id) ?? null,
};
}).sort((left, right) => right.sentToday - left.sentToday
|| left.name.localeCompare(right.name, 'zh-CN')
@@ -389,7 +413,6 @@ export class SmsConfigService {
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
customerUnitPrice: data.customerUnitPrice ?? 0,
queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask ?? 10000,
templateMismatchMode: data.templateMismatchMode ?? 'reject',
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
@@ -459,7 +482,6 @@ export class SmsConfigService {
dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
customerUnitPrice: data.customerUnitPrice,
queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask,
templateMismatchMode: data.templateMismatchMode,
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled,
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled,
@@ -567,13 +589,108 @@ export class SmsConfigService {
throw new NotFoundException('Application not found');
}
const status = data.status ?? 'disabled';
const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { status } });
await this.writeOperationLog(application.tenantId, data.operatorId, `sms_application.${status}`, 'sms_application', applicationId, {
statusBefore: application.status,
statusAfter: status,
reason: data.reason,
if (status === 'active') {
const updated = await this.prisma.smsApplication.update({
where: { id: applicationId },
data: { status: 'active', disablingAt: null, autoDisableAt: null, disableReason: null },
});
await this.writeApplicationStatusLog(application, data, 'active', {});
return updated;
}
if (!['disabled', 'disabling', 'deleted'].includes(status)) {
throw new BadRequestException(`不支持的企业应用状态:${status}`);
}
const preview = await this.getApplicationDeactivationPreview(applicationId);
if ((status === 'disabling' || status === 'disabled') && preview.totalOutstanding > 0 && !data.force) {
const disablingAt = new Date();
const autoDisableAt = new Date(disablingAt.getTime() + APPLICATION_DISABLE_GRACE_MS);
const updated = await this.prisma.smsApplication.update({
where: { id: applicationId },
data: {
status: 'disabling',
disablingAt,
autoDisableAt,
disableReason: data.reason?.trim() || '等待未完成回执清算',
},
});
await this.writeApplicationStatusLog(application, data, 'disabling', { preview, disablingAt, autoDisableAt });
return { ...updated, deactivation: { ...preview, disablingAt, autoDisableAt } };
}
const finalStatus = status === 'deleted' ? 'deleted' : 'disabled';
const abandonReason = status === 'deleted'
? '企业应用已删除,放弃剩余下游投递'
: data.force
? '运营强制停用企业应用,放弃剩余下游投递'
: '企业应用无待清算数据,完成停用';
const abandoned = await this.abandonApplicationDeliveries(applicationId, abandonReason);
const updated = await this.prisma.smsApplication.update({
where: { id: applicationId },
data: {
status: finalStatus,
disablingAt: null,
autoDisableAt: null,
disableReason: data.reason?.trim() || abandonReason,
},
});
return updated;
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, abandonReason);
await this.writeApplicationStatusLog(application, data, finalStatus, { preview, abandoned, disconnect });
return { ...updated, deactivation: null, abandoned, disconnect };
}
async getApplicationDeactivationPreview(applicationId: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: {
id: true,
status: true,
disablingAt: true,
autoDisableAt: true,
disableReason: true,
},
});
if (!application) throw new NotFoundException('Application not found');
const [
awaitingSupplierReceipt,
waitingToSend,
awaitingClientAck,
retryableFailures,
pendingUplinks,
activeConnections,
] = await Promise.all([
this.prisma.smsMessageRecord.count({
where: { applicationId, status: { in: ['submitted', 'unknown'] }, receiptStatus: null },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'receipt', status: { in: ['pending', 'manual_requeueing'] } },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'receipt', status: 'awaiting_ack' },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'receipt', status: 'failed', retryEnabled: true },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'uplink', status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
}),
this.prisma.cmppDownstreamConnection.count({
where: { applicationId, status: 'connected' },
}),
]);
return {
status: application.status,
reason: application.disableReason,
disablingAt: application.disablingAt,
autoDisableAt: application.autoDisableAt,
awaitingSupplierReceipt,
waitingToSend,
awaitingClientAck,
retryableFailures,
pendingUplinks,
activeConnections,
totalOutstanding: awaitingSupplierReceipt + waitingToSend + awaitingClientAck + retryableFailures + pendingUplinks,
};
}
async listApplicationConnections(applicationId: string) {
@@ -690,7 +807,7 @@ export class SmsConfigService {
});
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
}
if (!application.interfaceEnabled || application.status !== 'active') {
if (!application.interfaceEnabled || !['active', 'disabling'].includes(application.status)) {
throw new ForbiddenException('CMPP interface is disabled for this application');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
@@ -1658,6 +1775,132 @@ export class SmsConfigService {
return this.prisma.auditRecord.create({ data });
}
private async abandonApplicationDeliveries(applicationId: string, reason: string) {
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({
where: { applicationId, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
select: { id: true },
});
const deliveryIds = deliveries.map((delivery) => delivery.id);
if (deliveryIds.length === 0) return 0;
await this.prisma.cmppDownstreamDeliveryAttempt.updateMany({
where: { deliveryId: { in: deliveryIds }, status: { in: ['awaiting_ack', 'sent'] } },
data: {
status: 'abandoned',
ackDeadlineAt: null,
failureType: 'application_disabled',
errorMessage: reason,
},
});
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
where: { id: { in: deliveryIds }, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
data: {
status: 'abandoned',
retryEnabled: false,
nextRetryAt: null,
ackDeadlineAt: null,
lastError: reason,
},
});
return updated.count;
}
private async disconnectDownstreamAccount(account: string, reason: string) {
const baseUrl = process.env.GATEWAY_CONTROL_URL?.trim() || 'http://127.0.0.1:8090';
try {
const response = await fetch(`${baseUrl}/downstream/connections/disconnect`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account, reason }),
signal: AbortSignal.timeout(10_000),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`Gateway returned ${response.status}: ${responseText}`);
}
return responseText ? JSON.parse(responseText) as { account: string; disconnected: number } : { account, disconnected: 0 };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to disconnect downstream CMPP account ${account}: ${message}`);
return { account, disconnected: 0, error: message };
}
}
private writeApplicationStatusLog(
application: { id: string; tenantId: string; status: string },
data: StatusChangeDto,
statusAfter: string,
detail: Record<string, unknown>,
) {
return this.writeOperationLog(
application.tenantId,
data.operatorId,
`sms_application.${statusAfter}`,
'sms_application',
application.id,
{
statusBefore: application.status,
statusAfter,
reason: data.reason,
force: Boolean(data.force),
...JSON.parse(JSON.stringify(detail)) as Record<string, unknown>,
},
);
}
private async runApplicationDisableScan() {
if (this.applicationDisableScanRunning) return;
this.applicationDisableScanRunning = true;
try {
const applications = await this.prisma.smsApplication.findMany({
where: { status: 'disabling' },
select: { id: true, tenantId: true, cmppAccount: true, status: true, autoDisableAt: true },
take: 500,
});
const now = new Date();
for (const application of applications) {
const preview = await this.getApplicationDeactivationPreview(application.id);
if (preview.totalOutstanding === 0) {
await this.finalizeDisablingApplication(application, false, '待处理回执已清算完成,系统自动停用', preview);
} else if (application.autoDisableAt && application.autoDisableAt <= now) {
await this.finalizeDisablingApplication(application, true, '进入停用中状态已满72小时,系统自动放弃剩余回执', preview);
}
}
} catch (error) {
this.logger.error(`Application disabling scan failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
this.applicationDisableScanRunning = false;
}
}
private async finalizeDisablingApplication(
application: { id: string; tenantId: string; cmppAccount: string; status: string },
abandonOutstanding: boolean,
reason: string,
preview: Awaited<ReturnType<SmsConfigService['getApplicationDeactivationPreview']>>,
) {
const claimed = await this.prisma.smsApplication.updateMany({
where: { id: application.id, status: 'disabling' },
data: {
status: 'disabled',
disablingAt: null,
autoDisableAt: null,
disableReason: reason,
},
});
if (claimed.count !== 1) return false;
const abandoned = abandonOutstanding
? await this.abandonApplicationDeliveries(application.id, reason)
: 0;
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, reason);
await this.writeApplicationStatusLog(application, { reason, force: abandonOutstanding }, 'disabled', {
preview,
abandoned,
disconnect,
automatic: true,
});
return true;
}
private writeOperationLog(
tenantId: string,
userId: string | undefined,
@@ -1837,7 +2080,7 @@ function getPositiveInteger(value: number | undefined, fallback: number, fieldNa
}
function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) {
if (applicationStatus !== 'active') {
if (!['active', 'disabling'].includes(applicationStatus)) {
return 'inactive';
}
if (connections.some((connection) => connection.status === 'connected')) {
+15
View File
@@ -17,6 +17,9 @@ function createPrismaMock() {
create: jest.fn().mockResolvedValue(tenant),
update: jest.fn().mockResolvedValue(tenant),
},
smsApplication: {
count: jest.fn().mockResolvedValue(0),
},
enterpriseCertification: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'cert-1' }),
@@ -81,6 +84,18 @@ describe('TenantsService', () => {
expect(prisma.tenant.update).not.toHaveBeenCalled();
});
it('blocks enterprise deletion while applications are active or disabling', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.count.mockResolvedValue(2);
const service = new TenantsService(prisma as never);
await expect(service.delete('tenant-1')).rejects.toThrow('还有 2 个启用或停用中的企业应用');
expect(prisma.tenant.update).not.toHaveBeenCalled();
expect(prisma.smsApplication.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', status: { in: ['active', 'disabling'] } },
});
});
it('rejects enterprise credit codes containing non-alphanumeric characters', async () => {
const prisma = createPrismaMock();
const service = new TenantsService(prisma as never);
+15 -1
View File
@@ -123,7 +123,21 @@ export class TenantsService {
}
delete(id: string) {
return this.changeStatus(id, 'deleted');
return this.deleteAfterApplicationCheck(id);
}
private async deleteAfterApplicationCheck(id: string) {
await this.ensureTenant(id);
const blockingApplications = await this.prisma.smsApplication.count({
where: { tenantId: id, status: { in: ['active', 'disabling'] } },
});
if (blockingApplications > 0) {
throw new BadRequestException(`该企业还有 ${blockingApplications} 个启用或停用中的企业应用,请先完成应用停用`);
}
return this.prisma.tenant.update({
where: { id },
data: { status: 'deleted' },
});
}
private async ensureTenant(id: string) {
+15 -4
View File
@@ -23,8 +23,14 @@ export class UsersController {
@Get('admin/users')
@ApiOkResponse({ type: [AdminUserResponseDto] })
list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) {
return this.users.list(tenantId, roleCode);
list(
@Query('tenantId') tenantId?: string,
@Query('roleCode') roleCode?: string,
@Query('displayName') displayName?: string,
@Query('login') login?: string,
@Query('status') status?: string,
) {
return this.users.list({ tenantId, roleCode, displayName, login, status });
}
@Post('admin/users')
@@ -65,8 +71,13 @@ export class UsersController {
@Get('client/users')
@ApiOkResponse({ type: [ClientUserResponseDto] })
listClient(@TenantId() tenantId?: string) {
return this.users.listClientUsers(tenantId);
listClient(
@TenantId() tenantId?: string,
@Query('displayName') displayName?: string,
@Query('login') login?: string,
@Query('status') status?: string,
) {
return this.users.listClientUsers(tenantId, { displayName, login, status });
}
@Post('client/users')
+75 -4
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { hashPassword, UsersService } from './users.service';
function createPrismaMock() {
@@ -146,6 +146,63 @@ describe('UsersService', () => {
expect(user).not.toHaveProperty('failedLoginCount');
});
it('applies separate server-side user filters', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
await service.list({
displayName: ' 赵辉 ',
login: ' zhaohui ',
tenantId: 'tenant-1',
roleCode: 'enterprise_admin',
status: 'active',
});
expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: {
deletedAt: null,
tenantId: 'tenant-1',
roles: { some: { role: { code: 'enterprise_admin' } } },
status: 'active',
displayName: { contains: '赵辉', mode: 'insensitive' },
OR: [
{ username: { contains: 'zhaohui', mode: 'insensitive' } },
{ email: { contains: 'zhaohui', mode: 'insensitive' } },
{ phone: { contains: 'zhaohui' } },
],
},
}));
});
it('keeps client user filters scoped to the current tenant', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
await service.listClientUsers('tenant-1', { login: '138', status: 'disabled' });
expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
deletedAt: null,
tenantId: 'tenant-1',
roles: { some: { role: { code: 'enterprise_admin' } } },
status: 'disabled',
}),
}));
});
it('never resolves a logically deleted user by username', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
await service.findByUsername('zhaohui');
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { username: 'zhaohui', deletedAt: null },
include: { tenant: true, roles: { include: { role: true } } },
});
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it('returns a safe view after changing the current password', async () => {
const prisma = createPrismaMock();
prisma.user.findFirst.mockResolvedValue({
@@ -185,7 +242,12 @@ describe('UsersService', () => {
prisma.user.count.mockResolvedValue(1);
const service = new UsersService(prisma as never);
await expect(service.remove('user-1', 'operator-2')).rejects.toBeInstanceOf(ConflictException);
await expect(service.remove('user-1', 'operator-2')).rejects.toMatchObject({
response: expect.objectContaining({
code: 'LAST_PLATFORM_ADMIN',
message: expect.stringContaining('请先创建或启用另一名平台管理员'),
}),
});
});
it('forbids disabling the last active administrator of a tenant', async () => {
@@ -197,7 +259,12 @@ describe('UsersService', () => {
const service = new UsersService(prisma as never);
await expect(service.changeStatus('user-1', { status: 'disabled' }, 'tenant-1', 'operator-2'))
.rejects.toBeInstanceOf(ConflictException);
.rejects.toMatchObject({
response: expect.objectContaining({
code: 'LAST_ENTERPRISE_ADMIN',
message: expect.stringContaining('请先创建或启用该企业的另一名管理员'),
}),
});
});
it('maps duplicate login identifiers to HTTP 409 with the conflicting field', async () => {
@@ -208,7 +275,11 @@ describe('UsersService', () => {
await expect(service.create({
displayName: '平台管理员', email: 'admin@example.com', password: 'secret1', roleCode: 'platform_admin',
})).rejects.toMatchObject({
response: expect.objectContaining({ code: 'USER_DUPLICATE', field: 'email' }),
response: expect.objectContaining({
code: 'USER_DUPLICATE',
field: 'email',
message: '该邮箱已被其他未删除用户使用',
}),
});
});
});
+33 -8
View File
@@ -38,6 +38,14 @@ export interface ChangePasswordDto {
operatorId?: string;
}
export interface UserListFilters {
tenantId?: string;
roleCode?: string;
displayName?: string;
login?: string;
status?: string;
}
export interface CreateRoleDto {
code: string;
name: string;
@@ -70,12 +78,23 @@ const roleNames: Record<UserRoleCode, { name: string; scope: string }> = {
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId?: string, roleCode?: string) {
async list(filters: UserListFilters = {}) {
const displayName = normalizeOptional(filters.displayName);
const login = normalizeOptional(filters.login);
const users = await this.prisma.user.findMany({
where: {
deletedAt: null,
...(tenantId ? { tenantId } : {}),
...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}),
...(filters.tenantId ? { tenantId: filters.tenantId } : {}),
...(filters.roleCode ? { roles: { some: { role: { code: filters.roleCode } } } } : {}),
...(filters.status ? { status: filters.status } : {}),
...(displayName ? { displayName: { contains: displayName, mode: 'insensitive' } } : {}),
...(login ? {
OR: [
{ username: { contains: login, mode: 'insensitive' } },
{ email: { contains: login, mode: 'insensitive' } },
{ phone: { contains: login } },
],
} : {}),
},
include: { tenant: true, roles: { include: { role: true } } },
orderBy: { createdAt: 'desc' },
@@ -83,15 +102,18 @@ export class UsersService {
return users.map(publicUser);
}
listClientUsers(tenantId?: string) {
listClientUsers(tenantId: string | undefined, filters: Omit<UserListFilters, 'tenantId' | 'roleCode'> = {}) {
if (!tenantId) {
throw new BadRequestException('tenantId is required for client user management');
}
return this.list(tenantId);
return this.list({ ...filters, tenantId, roleCode: 'enterprise_admin' });
}
findByUsername(username: string) {
return this.prisma.user.findUnique({ where: { username }, include: { tenant: true, roles: { include: { role: true } } } });
return this.prisma.user.findFirst({
where: { username, deletedAt: null },
include: { tenant: true, roles: { include: { role: true } } },
});
}
findByLogin(login: string) {
@@ -374,7 +396,9 @@ export class UsersService {
if (activeCount <= 1) {
throw new ConflictException({
code: currentRole === 'platform_admin' ? 'LAST_PLATFORM_ADMIN' : 'LAST_ENTERPRISE_ADMIN',
message: currentRole === 'platform_admin' ? '不能删除、禁用或降权最后一个平台管理员' : '不能删除、禁用或降权最后一个企业管理员',
message: currentRole === 'platform_admin'
? '不能删除、禁用或降权最后一个平台管理员;请先创建或启用另一名平台管理员'
: '不能删除、禁用或降权最后一个企业管理员;请先创建或启用该企业的另一名管理员',
});
}
}
@@ -386,10 +410,11 @@ export class UsersService {
if ((error as { code?: string }).code !== 'P2002') throw error;
const target = (error as { meta?: { target?: string[] | string } }).meta?.target;
const field = Array.isArray(target) ? target[0] : target;
const fieldLabel = field === 'username' ? '用户名' : field === 'email' ? '邮箱' : field === 'phone' ? '手机号' : '登录标识';
throw new ConflictException({
code: 'USER_DUPLICATE',
field: field ?? 'login',
message: `用户${field ? `字段 ${field}` : '登录标识'}已存在;逻辑删除后仍永久保留以维持审计关联`,
message: `${fieldLabel}已被其他未删除用户使用`,
});
}
}