feat: add HTTP signature tools and fix independent HTTP send validation
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-15 15:58:29 +08:00
parent 18ecf8045f
commit c781313de5
23 changed files with 986 additions and 255 deletions
@@ -12,6 +12,30 @@ const auth = {
};
describe('HTTP API remediation boundaries', () => {
it.each([
{ mobile: 'abc' },
{ mobile: '1' },
{ mobile: '' },
{ mobile: '138001380001' },
{ accessNumber: '<script>' },
{ accessNumber: '' },
{ accessNumber: '1'.repeat(22) },
])('rejects malformed uplink number filters before querying: %o', async (query) => {
const findMany = jest.fn();
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
await expect(service.listUplinks(auth as never, query)).rejects.toMatchObject({ status: 400 });
expect(findMany).not.toHaveBeenCalled();
});
it('accepts numeric uplink filter boundaries without changing exact matches', async () => {
const findMany = jest.fn().mockResolvedValue([]);
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
await service.listUplinks(auth as never, { mobile: '13800138000', accessNumber: '1'.repeat(21) });
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ phoneNumber: '13800138000', destId: '1'.repeat(21) }),
}),
);
});
it('matches the published fixed GET signature vector', () => {
expect(
openApiSignature(
+4
View File
@@ -449,6 +449,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
if (value !== undefined && typeof value !== 'string')
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
}
if (query.mobile !== undefined && !/^1\d{10}$/.test(query.mobile))
throw new BadRequestException({ code: 'MOBILE_INVALID', message: 'mobile必须为1开头的11位手机号' });
if (query.accessNumber !== undefined && !/^\d{1,21}$/.test(query.accessNumber))
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'accessNumber必须为1至21位数字接入号' });
const endTime = query.endTime !== undefined ? parseOpenApiDate(query.endTime) : new Date();
const startTime =
query.startTime !== undefined ? parseOpenApiDate(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
+61
View File
@@ -0,0 +1,61 @@
import { SendBatchEntryService } from './send-batch-entry.service';
import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
function fixture() {
const application = {
id: 'app',
tenantId: 'tenant',
status: 'active',
interfaceEnabled: false,
httpConfig: { enabled: true, sendEnabled: true },
};
const prisma = {
tenant: { findUnique: jest.fn().mockResolvedValue({ status: 'active', certificationStatus: 'approved' }) },
smsApplication: { findUnique: jest.fn().mockImplementation(async () => application) },
};
const facade = { validateSendResources: jest.fn().mockRejectedValue(new Error('stop after validation')) };
const service = new SendBatchEntryService(
prisma as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
facade as never,
undefined as never,
);
return { application, service, facade };
}
describe('independent HTTP send gate', () => {
it('accepts HTTP-only applications and still rejects ordinary sends with CMPP disabled', async () => {
const { service } = fixture();
await expect(
service.validateSendResources('tenant', 'app', undefined, { httpRequest: true }),
).resolves.toBeUndefined();
await expect(service.validateSendResources('tenant', 'app')).rejects.toThrow('短信应用接口未开通');
});
it.each(['enabled', 'sendEnabled'] as const)('rejects disabled HTTP %s even if CMPP is enabled', async (key) => {
const { service, application } = fixture();
application.interfaceEnabled = true;
application.httpConfig[key] = false;
await expect(service.validateSendResources('tenant', 'app', undefined, { httpRequest: true })).rejects.toThrow(
'HTTP发送未开通',
);
});
it('uses only the internal request symbol, not a caller supplied sourceType', async () => {
const { service, facade } = fixture();
const data = {
tenantId: 'tenant',
applicationId: 'app',
content: 'test',
phones: ['13800138000'],
sourceType: 'api' as const,
};
await expect(service.createBatchTask(data)).rejects.toThrow('stop after validation');
expect(facade.validateSendResources).toHaveBeenLastCalledWith('tenant', 'app', undefined, { httpRequest: false });
await expect(
service.createBatchTask({ ...data, [HTTP_REQUEST_CONTEXT]: { id: 'req', requestId: 'req_id' } }),
).rejects.toThrow('stop after validation');
expect(facade.validateSendResources).toHaveBeenLastCalledWith('tenant', 'app', undefined, { httpRequest: true });
});
});
+11 -3
View File
@@ -87,7 +87,9 @@ export class SendBatchEntryService {
const httpRequest = data[HTTP_REQUEST_CONTEXT];
const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data);
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId, {
httpRequest: Boolean(httpRequest),
});
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
@@ -568,11 +570,17 @@ export class SendBatchEntryService {
if (!applicationId) {
return;
}
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: { httpConfig: true },
});
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
throw new BadRequestException('短信应用不存在或已停用');
}
if (!application.interfaceEnabled) {
if (options.httpRequest && (!application.httpConfig?.enabled || !application.httpConfig.sendEnabled)) {
throw new BadRequestException('短信应用HTTP发送未开通,不能发送短信');
}
if (!options.httpRequest && !application.interfaceEnabled) {
throw new BadRequestException('短信应用接口未开通,不能发送短信');
}
if (!templateId) {
+175 -71
View File
@@ -5,17 +5,32 @@ import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import { MetricsService } from '../metrics/metrics.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import type {
CreateBatchTaskDto,
CreateHttpBatchTaskDto,
GatewayInboundAuthDto,
GatewayInboundSubmitDto,
ImportPreviewDto,
ConfirmImportDto,
SendJob,
QueuePriority,
RoutedChannel,
} from './send-chain.contracts';
import { SendBatchEntryService } from './send-batch-entry.service';
import { SendGatewaySubmitService } from './send-gateway-submit.service';
import { SendInboundEntryService } from './send-inbound-entry.service';
import { SendReviewContinuationService } from './send-review-continuation.service';
import { SendScheduledDispatchService } from './send-scheduled-dispatch.service';
export type SendSubmissionCallbacks = {
releaseMessageReservation: (
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) => Promise<void>;
recordCmppFailureReceipt: (
@@ -37,6 +52,7 @@ export type SendSubmissionCallbacks = {
export type SendResourceValidationOptions = {
usePersistedTemplateSnapshot?: boolean;
httpRequest?: boolean;
};
/**
@@ -59,53 +75,92 @@ export class SendSubmissionService {
callbacks: SendSubmissionCallbacks,
metrics?: MetricsService,
) {
this.batchEntry = new SendBatchEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
this.batchEntry = new SendBatchEntryService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
);
this.inboundEntry = new SendInboundEntryService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
metrics,
);
this.reviewContinuation = new SendReviewContinuationService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
);
this.scheduledDispatch = new SendScheduledDispatchService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
);
this.gatewaySubmit = new SendGatewaySubmitService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
metrics,
);
}
onModuleDestroy() {
return Promise.all([
this.gatewaySubmit.onModuleDestroy(),
this.inboundEntry.stopInboundWorkflowWorker(),
]);
return Promise.all([this.gatewaySubmit.onModuleDestroy(), this.inboundEntry.stopInboundWorkflowWorker()]);
}
async createBatchTask(data: CreateBatchTaskDto) {
async createBatchTask(data: CreateBatchTaskDto) {
return this.batchEntry.createBatchTask(data);
}
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
return this.batchEntry.createHttpBatchTask(data);
}
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
return this.batchEntry.getBatchTask(taskId, tenantId, sourceType);
}
async previewImport(data: ImportPreviewDto) {
async previewImport(data: ImportPreviewDto) {
return this.batchEntry.previewImport(data);
}
async confirmImport(data: ConfirmImportDto) {
async confirmImport(data: ConfirmImportDto) {
return this.batchEntry.confirmImport(data);
}
async resolveUnitPrice(tenantId: string, applicationId?: string) {
async resolveUnitPrice(tenantId: string, applicationId?: string) {
return this.batchEntry.resolveUnitPrice(tenantId, applicationId);
}
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
return this.batchEntry.resolveQueuePriority(tenantId, applicationId);
}
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
return this.batchEntry.resolveApplicationAccessNumber(tenantId, applicationId);
}
async resolveTemplateMessageClassification(
async resolveTemplateMessageClassification(
tenantId: string,
applicationId: string | undefined,
templateId: string | undefined,
@@ -114,35 +169,40 @@ async resolveTemplateMessageClassification(
return this.batchEntry.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content);
}
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
}
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
options?: SendResourceValidationOptions,
) {
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options);
}
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
return this.batchEntry.reserveDailySendQuota(applicationId, requestedCount);
}
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
return this.batchEntry.tryReserveDailySendQuota(applicationId, requestedCount, reservationKey);
}
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
return this.inboundEntry.authenticateInboundApplication(data);
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
async submitInboundMessage(data: GatewayInboundSubmitDto) {
return this.inboundEntry.submitInboundMessage(data);
}
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
return this.inboundEntry.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers);
}
async submitCompleteInboundMessage(
async submitCompleteInboundMessage(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
@@ -150,10 +210,17 @@ async submitCompleteInboundMessage(
requestedMessageIds?: string[],
workflowKey?: string,
) {
return this.inboundEntry.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey);
return this.inboundEntry.submitCompleteInboundMessage(
data,
phoneNumbers,
application,
requestedGroupMessageId,
requestedMessageIds,
workflowKey,
);
}
async collectInboundLongMessageFragment(
async collectInboundLongMessageFragment(
data: GatewayInboundSubmitDto,
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
phoneNumbers: string[],
@@ -161,11 +228,11 @@ async collectInboundLongMessageFragment(
return this.inboundEntry.collectInboundLongMessageFragment(data, application, phoneNumbers);
}
async expireInboundLongMessages(now = new Date()) {
async expireInboundLongMessages(now = new Date()) {
return this.inboundEntry.expireInboundLongMessages(now);
}
async submitInboundSingleMessage(
async submitInboundSingleMessage(
data: GatewayInboundSubmitDto & { phoneNumber: string },
messageId: string,
submitGroupMessageId: string,
@@ -174,78 +241,94 @@ async submitInboundSingleMessage(
receiptRejection?: { code: string; reason: string },
workflowItemKey?: string,
) {
return this.inboundEntry.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey);
return this.inboundEntry.submitInboundSingleMessage(
data,
messageId,
submitGroupMessageId,
application,
synchronousRejection,
receiptRejection,
workflowItemKey,
);
}
async evaluateRiskWithPhoneFrequency(input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}, reservationKey?: string) {
async evaluateRiskWithPhoneFrequency(
input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
},
reservationKey?: string,
) {
return this.inboundEntry.evaluateRiskWithPhoneFrequency(input, reservationKey);
}
findInboundApplication(account: string) {
findInboundApplication(account: string) {
return this.inboundEntry.findInboundApplication(account);
}
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
return this.inboundEntry.resolveInboundTemplateCandidate(applicationId, content);
}
resolveInboundSignatureCandidate(applicationId: string, content: string) {
resolveInboundSignatureCandidate(applicationId: string, content: string) {
return this.inboundEntry.resolveInboundSignatureCandidate(applicationId, content);
}
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content);
}
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
async attachMessageToReviewTask(
reviewTaskId: string,
messageRecordId: string,
signatureId: string,
drainageInfoId?: string,
) {
return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
}
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
return this.reviewContinuation.handleReviewDecision(reviewTaskId, decision, reason);
}
async dispatchDueScheduledTasks(now = new Date()) {
async dispatchDueScheduledTasks(now = new Date()) {
return this.scheduledDispatch.dispatchDueScheduledTasks(now);
}
async runScheduledDispatchScan() {
async runScheduledDispatchScan() {
return this.scheduledDispatch.runScheduledDispatchScan();
}
async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
return this.gatewaySubmit.enqueueBatchTask(taskId, preparedMessage);
}
startWorker() {
startWorker() {
return this.gatewaySubmit.startWorker();
}
startSubmitOutboxPublisher() {
startSubmitOutboxPublisher() {
return this.gatewaySubmit.startSubmitOutboxPublisher();
}
startInboundWorkflowWorker() {
startInboundWorkflowWorker() {
return this.inboundEntry.startInboundWorkflowWorker();
}
stopInboundWorkflowWorker() {
stopInboundWorkflowWorker() {
return this.inboundEntry.stopInboundWorkflowWorker();
}
async processSendJob(job: SendJob) {
async processSendJob(job: SendJob) {
return this.gatewaySubmit.processSendJob(job);
}
async submitMessageToGateway(
async submitMessageToGateway(
message: {
id: string;
tenantId: string;
@@ -272,26 +355,42 @@ async submitMessageToGateway(
return this.gatewaySubmit.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId);
}
async selectChannelForMessage(
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
async selectChannelForMessage(
message: {
id: string;
tenantId: string;
applicationId?: string | null;
templateId?: string | null;
signatureId?: string | null;
phoneNumber: string;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
},
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> {
return this.gatewaySubmit.selectChannelForMessage(message, options);
}
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
async findApplicationRoute(
tenantId: string,
applicationId: string | undefined,
carrier: string,
signatureId?: string,
) {
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
}
async identifyCarrier(phoneNumber: string) {
async identifyCarrier(phoneNumber: string) {
return this.gatewaySubmit.identifyCarrier(phoneNumber);
}
async identifyProvince(phoneNumber: string) {
async identifyProvince(phoneNumber: string) {
return this.gatewaySubmit.identifyProvince(phoneNumber);
}
async ensureSignatureReportedForChannel(
async ensureSignatureReportedForChannel(
message: {
id: string;
templateId?: string | null;
@@ -304,31 +403,36 @@ async ensureSignatureReportedForChannel(
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId, carrier);
}
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
async resolveMessageSignatureId(message: {
templateId?: string | null;
signatureId?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
}) {
return this.gatewaySubmit.resolveMessageSignatureId(message);
}
async waitForChannelRateLimit(channelId: string, tps: number) {
async waitForChannelRateLimit(channelId: string, tps: number) {
return this.gatewaySubmit.waitForChannelRateLimit(channelId, tps);
}
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
return this.gatewaySubmit.refreshTaskProgress(batchTaskId, knownSingleMessageStatus);
}
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
return this.gatewaySubmit.getSendQueue();
}
getGatewayQueue(): Queue {
getGatewayQueue(): Queue {
return this.gatewaySubmit.getGatewayQueue();
}
getRedis() {
getRedis() {
return this.gatewaySubmit.getRedis();
}
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
return this.gatewaySubmit.publishGatewaySubmitCommand(command, idempotencyKey);
}
}