feat: refine template deletion and channel group filters
This commit is contained in:
@@ -14,7 +14,7 @@ import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import { detectDrainageContent } from './drainage-content-detection';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
@@ -469,7 +469,12 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
|
||||
return rejected;
|
||||
}
|
||||
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
async validateSendResources(
|
||||
tenantId: string,
|
||||
applicationId?: string,
|
||||
templateId?: string,
|
||||
options: SendResourceValidationOptions = {},
|
||||
) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant || tenant.status !== 'active') {
|
||||
throw new BadRequestException('企业客户不存在或已停用');
|
||||
@@ -494,7 +499,12 @@ async validateSendResources(tenantId: string, applicationId?: string, templateId
|
||||
where: { id: templateId },
|
||||
include: { signature: true },
|
||||
});
|
||||
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') {
|
||||
const templateBelongsToApplication = template
|
||||
&& template.tenantId === tenantId
|
||||
&& template.applicationId === applicationId;
|
||||
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
|
||||
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
|
||||
if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) {
|
||||
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
||||
}
|
||||
if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
|
||||
@@ -666,6 +666,22 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a new task that selects a deleted template', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
|
||||
content: 'hello', auditStatus: 'deleted',
|
||||
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.createBatchTask({
|
||||
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
||||
content: 'hello', phones: ['13800000001'],
|
||||
})).rejects.toThrow('短信模板不存在、未通过审核或不属于当前应用');
|
||||
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects free content without an approved leading signature', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
@@ -927,6 +943,47 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches an accepted scheduled task from its snapshot after the template is deleted', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
|
||||
signature: { id: 'sig-1', auditStatus: 'approved' },
|
||||
});
|
||||
prisma.smsBatchTask.findMany.mockResolvedValue([{
|
||||
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
|
||||
}]);
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
|
||||
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
||||
dispatched: 1,
|
||||
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
||||
});
|
||||
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ relatedId: 'task-1' }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('still blocks scheduled dispatch when the persisted template signature is no longer approved', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
|
||||
signature: { id: 'sig-1', auditStatus: 'deleted' },
|
||||
});
|
||||
prisma.smsBatchTask.findMany.mockResolvedValue([{
|
||||
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
|
||||
}]);
|
||||
|
||||
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
||||
dispatched: 0,
|
||||
results: [{ taskId: 'task-1', status: 'failed', reason: '短信签名未审核通过' }],
|
||||
});
|
||||
|
||||
expect(billing.freeze).not.toHaveBeenCalled();
|
||||
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto,
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
|
||||
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
|
||||
import { SendSubmissionService } from './send-submission.service';
|
||||
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
|
||||
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -722,8 +722,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||
}
|
||||
|
||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
return this.submission.validateSendResources(tenantId, applicationId, templateId);
|
||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
|
||||
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
|
||||
}
|
||||
|
||||
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
|
||||
@@ -87,7 +87,12 @@ async dispatchDueScheduledTasks(now = new Date()) {
|
||||
let reservationEstablished = false;
|
||||
let dispatchPrepared = false;
|
||||
try {
|
||||
await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
||||
await this.facade.validateSendResources(
|
||||
task.tenantId,
|
||||
task.applicationId ?? undefined,
|
||||
task.templateId ?? undefined,
|
||||
{ usePersistedTemplateSnapshot: true },
|
||||
);
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
|
||||
select: { id: true, amountCents: true, billingUnits: true },
|
||||
|
||||
@@ -34,6 +34,10 @@ export type SendSubmissionCallbacks = {
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type SendResourceValidationOptions = {
|
||||
usePersistedTemplateSnapshot?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* R9 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||
*/
|
||||
@@ -109,8 +113,8 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
|
||||
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||
}
|
||||
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId);
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
|
||||
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options);
|
||||
}
|
||||
|
||||
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
|
||||
Reference in New Issue
Block a user