feat: strengthen risk controls and review workflows
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }] },
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: '该邮箱已被其他未删除用户使用',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}已被其他未删除用户使用`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user