feat: refine template deletion and channel group filters

This commit is contained in:
hectorzhao
2026-08-09 20:54:52 +08:00
parent 482d332f49
commit 78b839f468
11 changed files with 210 additions and 49 deletions
+13 -3
View File
@@ -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' });
+3 -3
View File
@@ -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) {