feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 12:48:10 +08:00
parent d13ca0713a
commit f0e843436c
33 changed files with 3332 additions and 452 deletions
@@ -0,0 +1,11 @@
ALTER TABLE "HttpWebhookDelivery" ADD COLUMN "recoveryVersion" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "leaseToken" TEXT, ADD COLUMN "leaseUntil" TIMESTAMP(3);
CREATE TABLE "OpenApiDispatchOutbox" (
"id" TEXT NOT NULL, "requestId" TEXT NOT NULL, "batchTaskId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending', "leaseToken" TEXT, "leaseUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenApiDispatchOutbox_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_requestId_key" ON "OpenApiDispatchOutbox"("requestId");
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_batchTaskId_key" ON "OpenApiDispatchOutbox"("batchTaskId");
CREATE INDEX "OpenApiDispatchOutbox_status_leaseUntil_idx" ON "OpenApiDispatchOutbox"("status", "leaseUntil");
+15
View File
@@ -686,6 +686,9 @@ model HttpWebhookEvent {
}
model HttpWebhookDelivery {
recoveryVersion Int @default(0)
leaseToken String?
leaseUntil DateTime?
id String @id @default(cuid())
eventId String
endpointId String
@@ -2825,3 +2828,15 @@ model SendingMonitorTargetVersion {
updatedBy String
@@id([channelId,version])
}
model OpenApiDispatchOutbox {
id String @id @default(cuid())
requestId String @unique
batchTaskId String @unique
status String @default("pending")
leaseToken String?
leaseUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, leaseUntil])
}
+17 -8
View File
@@ -33,13 +33,19 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
const clientDocument = SwaggerModule.createDocument(app, new DocumentBuilder()
.setTitle('CMPP短信平台 HTTP 客户接口')
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
.setVersion('1.0.0')
.build(), { include: [OpenApiModule] });
clientDocument.paths = Object.fromEntries(Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')));
SwaggerModule.setup('api/client-docs', app, clientDocument);
const clientDocument = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('CMPP短信平台 HTTP 客户接口')
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
.setVersion('1.0.0')
.build(),
{ include: [OpenApiModule] },
);
clientDocument.paths = Object.fromEntries(
Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')),
);
SwaggerModule.setup('api/client-docs', app, clientDocument, { ui: false });
const port = Number(process.env.API_PORT ?? 3000);
// 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。
@@ -55,7 +61,10 @@ async function bootstrap() {
response.writeHead(404).end();
return;
}
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
response.writeHead(200, {
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
'Cache-Control': 'no-store',
});
response.end(metrics.render());
});
// Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/.
+47
View File
@@ -0,0 +1,47 @@
.http-developer-docs { margin: 0; color: #1f2937; background: #f6f7f9; font: 14px/1.6 system-ui, sans-serif; }
.http-developer-docs * { box-sizing: border-box; }
.http-developer-docs .http-doc-header { padding: 24px; border-bottom: 1px solid #e5e7eb; background: #fff; display: flex; gap: 20px; justify-content: space-between; align-items: center; }
.http-developer-docs h1 { font-size: 24px; margin: 8px 0; }
.http-developer-docs h2 { font-size: 20px; margin: 0 0 16px; }
.http-developer-docs h3 { font-size: 16px; margin: 20px 0 12px; }
.http-developer-docs p { overflow-wrap: anywhere; }
.http-developer-docs a { color: #2563eb; text-decoration: none; overflow-wrap: anywhere; }
.http-developer-docs a:hover { text-decoration: underline; }
.http-developer-docs .http-doc-actions { display: flex; gap: 16px; flex-wrap: wrap; }
.http-developer-docs .http-doc-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); }
.http-developer-docs nav { padding: 20px; position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto; }
.http-developer-docs nav a { display: block; padding: 7px 0; font-size: 13px; }
.http-developer-docs nav label { display: block; margin-top: 20px; }
.http-developer-docs input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font: inherit; }
.http-developer-docs main { min-width: 0; }
.http-developer-docs section { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); border-bottom: 1px solid #e5e7eb; scroll-margin-top: 20px; }
.http-developer-docs section[hidden] { display: none; }
.http-developer-docs .http-doc-body { padding: 24px; background: #fff; min-width: 0; }
.http-developer-docs aside { min-width: 0; padding: 24px 16px; }
.http-developer-docs .http-doc-sample { margin-bottom: 16px; border: 1px solid #d1d5db; border-radius: 8px; overflow: hidden; background: #fff; }
.http-developer-docs .http-doc-sample-bar { padding: 10px; display: flex; gap: 12px; justify-content: space-between; align-items: center; font-size: 12px; color: #6b7280; }
.http-developer-docs button { padding: 5px 12px; border: 1px solid #d1d5db; border-radius: 6px; color: #1f2937; background: #fff; cursor: pointer; flex-shrink: 0; }
.http-developer-docs button:focus-visible, .http-developer-docs a:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.http-developer-docs pre { margin: 0; padding: 16px; overflow-x: auto; font-size: 13px; background: #f4f6f8; }
.http-developer-docs code { font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
.http-developer-docs .http-doc-table { overflow-x: auto; }
.http-developer-docs table { border-collapse: collapse; min-width: 100%; }
.http-developer-docs td { padding: 9px; border: 1px solid #e5e7eb; min-width: 100px; overflow-wrap: anywhere; }
.http-developer-docs tr:first-child { font-weight: 600; background: #f4f6f8; }
.http-developer-docs .http-doc-copy-status { position: fixed; bottom: 12px; right: 12px; max-width: 80vw; background: #fff; border-radius: 6px; padding: 8px; box-shadow: 0 2px 12px #0002; }
.http-developer-docs .http-doc-copy-status:empty { display: none; }
@media (width <= 1400px) {
.http-developer-docs section { grid-template-columns: minmax(0, 1fr); }
.http-developer-docs aside { padding: 16px 24px; }
.http-developer-docs aside:empty { display: none; }
}
@media (width <= 700px) {
.http-developer-docs .http-doc-header { padding: 16px; display: block; }
.http-developer-docs .http-doc-layout { grid-template-columns: minmax(0, 1fr); }
.http-developer-docs nav { position: static; max-height: none; padding: 16px; }
.http-developer-docs .http-doc-body, .http-developer-docs aside { padding: 16px; }
}
.http-developer-docs .http-doc-sample-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.http-developer-docs .http-doc-sample-tabs button[aria-pressed="true"] { background: #eff6ff; color: #2563eb; border-color: #2563eb; }
+38
View File
@@ -0,0 +1,38 @@
/* global document, navigator, window, Event */
const copyStatus = document.getElementById('copy-status');
document.addEventListener('click', async (event) => {
const tab = event.target.closest('button[data-show-sample]');
if (tab) {
const aside = tab.closest('aside');
aside.querySelectorAll('.http-doc-sample').forEach((sample, index) => { sample.hidden = index !== Number(tab.dataset.showSample); });
aside.querySelectorAll('button[data-show-sample]').forEach((button) => button.setAttribute('aria-pressed', String(button === tab)));
return;
}
const button = event.target.closest('button[data-copy]');
if (!button) return;
const content = document.getElementById(button.dataset.copy);
try {
if (!navigator.clipboard) throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(content.textContent);
copyStatus.textContent = '已复制示例;未执行任何请求。';
} catch {
const range = document.createRange(); range.selectNodeContents(content);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
copyStatus.textContent = '自动复制不可用,已选中示例,请手动复制。';
}
});
const search = document.getElementById('doc-search');
search.addEventListener('input', () => {
const term = search.value.trim().toLowerCase(); let visible = 0;
document.querySelectorAll('[data-doc-section]').forEach((section) => {
section.hidden = !!term && !section.textContent.toLowerCase().includes(term);
if (!section.hidden) visible++;
});
document.getElementById('no-results').hidden = visible !== 0;
document.getElementById('search-status').textContent = term ? `${visible} 个章节匹配` : '';
});
document.querySelector('nav').addEventListener('click', (event) => {
if (!event.target.closest('a')) return;
search.value = ''; search.dispatchEvent(new Event('input'));
});
if (window.innerWidth <= 700) document.querySelector('nav details').open = false;
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
export function httpDocVersion(markdown: string) {
const metadata = markdown.split(/\r?\n/).find((line) => line.startsWith('**接口版本:')) ?? '';
const version = /接口版本:([a-zA-Z0-9.-]+)/.exec(metadata)?.[1];
const revision = /\b\d{4}-\d{2}-\d{2}\b/.exec(metadata)?.[0];
if (!version || !revision) throw new Error('HTTP document metadata is missing');
return version + ' / ' + revision;
}
export function readHttpGuide() {
return readFileSync(resolve(__dirname, '../../../../docs/client-http-api-guide.md'), 'utf8');
}
function asset(name: string) {
return readFileSync(resolve(__dirname, '../../../src/open-api/docs', name), 'utf8');
}
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!);
}
function inline(value: string): string {
// Escape first; raw HTML can never execute. Only HTTP(S) and local anchors become links.
return escapeHtml(value).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, url: string) => /^(https?:\/\/|#)/i.test(url) ? `<a href="${url}" rel="noreferrer">${label}</a>` : label);
}
export function renderHttpGuide(markdown: string, origin: string) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const sections: Array<{ id: string; title: string; body: string[]; samples: string[] }> = [];
let current = { id: 'introduction', title: '接入指南', body: [] as string[], samples: [] as string[] };
sections.push(current);
let code: string[] | null = null;
let language = '';
let sampleTitle = '示例';
let table = false;
let sampleCount = 0;
const closeTable = () => { if (table) { current.body.push('</tbody></table></div>'); table = false; } };
for (const line of lines) {
if (code) {
if (/^```/.test(line)) {
current.samples.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
code = null;
} else code.push(line);
continue;
}
if (/^```/.test(line)) { closeTable(); code = []; language = line.slice(3).trim(); continue; }
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) {
closeTable();
sampleTitle = heading[2];
if (heading[1].length === 2) {
current = { id: 'section-' + sections.length, title: heading[2], body: [], samples: [] };
sections.push(current);
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
continue;
}
if (/^\s*\|/.test(line)) {
if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
if (!table) { current.body.push('<div class="http-doc-table"><table><tbody>'); table = true; }
current.body.push('<tr>' + line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => `<td>${inline(cell.trim())}</td>`).join('') + '</tr>');
continue;
}
closeTable();
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
}
closeTable();
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div><aside aria-label="${escapeHtml(section.title)} 示例">${section.samples.length > 1 ? '<div class="http-doc-sample-tabs" role="group" aria-label="切换示例">' + section.samples.map((_sample, index) => '<button type="button" data-show-sample="' + index + '" aria-pressed="' + (index === 0) + '">示例 ' + (index + 1) + '</button>').join('') + '</div>' : ''}${section.samples.map((sample, index) => sample.replace('class="http-doc-sample"', 'class="http-doc-sample"' + (index ? ' hidden' : ''))).join('')}</aside></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
}
+101 -15
View File
@@ -1,19 +1,61 @@
import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import {
CanActivate,
ExecutionContext,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
OnModuleDestroy,
Optional,
UnauthorizedException,
} from '@nestjs/common';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net';
import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis;
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
@Optional() private readonly protocolLogs?: ProtocolLogsService,
) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
request.openApiRequestId = 'req_' + randomUUID();
context
.switchToHttp()
.getResponse<{ setHeader: (name: string, value: string) => void }>()
.setHeader('X-Request-Id', request.openApiRequestId);
const startedAt = Date.now();
try {
return await this.authenticate(context);
} catch (error) {
const failure = publicOpenApiFailure(error);
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'authentication',
status: 'failed',
requestId: request.openApiRequestId,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
detail: { method: request.method, path: '/api/openapi/v1/sms' },
});
throw error;
}
}
private async authenticate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const accessKey = header(request, 'x-app-key');
const timestampText = header(request, 'x-timestamp');
@@ -40,26 +82,46 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' });
}
const timestamp = Number(timestampText);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
if (
!Number.isFinite(timestamp) ||
Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000
) {
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
}
const sourceIp = requestIp(request);
if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) {
if (
credential.application.httpIpAllowlist.length > 0 &&
(!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))
) {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
}
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex');
const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n');
const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex');
const bodyHash = openApiBodyHash(request.rawBody, request.body);
const expected = openApiSignature(
decryptSecret(credential.secretEncrypted),
request.method,
path,
timestampText,
nonce,
bodyHash,
);
const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
? Buffer.from(suppliedSignature, 'hex')
: Buffer.alloc(0);
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
}
const redis = this.getRedis();
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
const nonceAccepted = await redis.set(
`openapi:nonce:${credential.id}:${nonce}`,
'1',
'EX',
config.timestampToleranceSeconds * 2,
'NX',
);
if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
@@ -78,22 +140,41 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
accessKey,
sourceIp,
};
await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } });
await this.prisma.httpApiCredential.update({
where: { id: credential.id },
data: { lastUsedAt: new Date(), lastUsedIp: sourceIp },
});
return true;
}
onModuleDestroy() { this.redis?.disconnect(); }
onModuleDestroy() {
this.redis?.disconnect();
}
private getRedis() {
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
return this.redis;
}
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
private async recordFailure(
ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt',
request: OpenApiRequestLike,
account: string | undefined,
resultCode: string,
) {
const sourceIp = requestIp(request);
if (!sourceIp) return;
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
await this.security
.recordEvent({
ruleCode,
sourceIp,
account,
resultCode,
protocol: 'http',
path: (request.originalUrl ?? request.url ?? '').split('?')[0],
})
.catch(() => undefined);
}
}
@@ -105,7 +186,12 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
const trustedProxies = new Set(
(process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
);
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
}
@@ -0,0 +1,66 @@
import { PrismaService } from '../prisma/prisma.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { OpenApiController } from './open-api.controller';
import { OpenApiService } from './open-api.service';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
@Module({
controllers: [OpenApiController],
providers: [
{ provide: PrismaService, useValue: {} },
{ provide: SecurityDetectionService, useValue: {} },
{ provide: OpenApiService, useValue: {} },
{ provide: OpenApiAuthGuard, useValue: {} },
{ provide: OpenApiTraceInterceptor, useValue: {} },
],
})
class ContractModule {}
describe('generated public OpenAPI contract', () => {
it('describes exactly four operations, seven query fields, nullable IDs and both callbacks', async () => {
const app = await NestFactory.create(ContractModule, { logger: false, abortOnError: false });
try {
app.setGlobalPrefix('api');
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder().setTitle('contract').setVersion('v1').build(),
);
expect(
Object.values(document.paths).reduce(
(count, path) => count + Object.keys(path).filter((key) => ['get', 'post'].includes(key)).length,
0,
),
).toBe(4);
const post = document.paths['/api/openapi/v1/sms/messages'].post!;
const headers = post.parameters as Array<{ name: string; in: string; required?: boolean }>;
expect(headers.filter((field) => field.name.toLowerCase() === 'idempotency-key')).toHaveLength(1);
expect(headers.some((field) => field.name.toLowerCase() === 'user-agent' && field.required)).toBe(false);
const query = document.paths['/api/openapi/v1/sms/uplinks'].get!.parameters as Array<{
name: string;
in: string;
}>;
expect(
query
.filter((field) => field.in === 'query')
.map((field) => field.name)
.sort(),
).toEqual(['accessNumber', 'cursor', 'endTime', 'keyword', 'limit', 'mobile', 'startTime']);
for (const path of Object.values(document.paths)) {
if (path.get) expect(path.get.responses['200']).toHaveProperty('content.application/json.schema');
}
expect(document.components!.schemas!.OpenApiSendMessageResponseDto).toMatchObject({
properties: { clientMessageId: { type: 'string', nullable: true } },
});
expect(document.components!.schemas).toHaveProperty('OpenApiReceiptEventDto');
expect(document.components!.schemas).toHaveProperty('OpenApiUplinkEventDto');
const detail = JSON.stringify(document.components!.schemas!.OpenApiUplinkDetailDto);
expect(detail).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,33 @@
import { Controller, Get, Header, Query, Res } from '@nestjs/common';
import { httpDocVersion, readHttpGuide, renderHttpGuide } from './docs/reader';
@Controller('client-docs')
export class OpenApiDocsController {
@Get()
@Header('Cache-Control', 'no-cache')
getGuide(
@Query('format') format: string | undefined,
@Res()
response: {
type: (value: string) => void;
setHeader: (name: string, value: string) => void;
send: (value: string) => void;
},
) {
const markdown = readHttpGuide();
response.setHeader('X-Document-Version', httpDocVersion(markdown));
response.setHeader('X-Content-Type-Options', 'nosniff');
if (format === 'md') {
response.type('text/markdown; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="client-http-api-guide.md"');
response.send(markdown);
return;
}
response.type('text/html; charset=utf-8');
response.setHeader(
'Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
response.send(renderHttpGuide(markdown, process.env.HTTP_API_PUBLIC_ORIGIN?.replace(/\/+$/, '') ?? ''));
}
}
+41 -14
View File
@@ -1,20 +1,47 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { publicOpenApiFailure } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Catch()
export class OpenApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(OpenApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<{ status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } } }>();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const value = exception instanceof HttpException ? exception.getResponse() : {};
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {};
const rawMessage = object.message ?? (exception instanceof Error ? exception.message : 'Internal server error');
const detail = Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage);
response.status(status).type('application/problem+json').send({
type: `https://cmpp-platform.local/problems/${String(object.code ?? 'REQUEST_FAILED').toLowerCase()}`,
title: String(object.error ?? HttpStatus[status] ?? 'Request failed'),
status,
code: String(object.code ?? 'REQUEST_FAILED'),
detail,
});
const http = host.switchToHttp();
const request = http.getRequest<OpenApiRequestLike>();
const response = http.getResponse<{
setHeader: (name: string, value: string) => void;
status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } };
}>();
const requestId = (request.openApiRequestId ??= `req_${randomUUID()}`);
const failure = publicOpenApiFailure(exception);
// Dependency messages may include SQL values or credentials; keep safe correlation only.
if (failure.status >= 500)
this.logger.error({
requestId,
code: failure.code,
errorType: exception instanceof Error ? exception.name : 'UnknownError',
stack:
exception instanceof Error
? exception.stack
?.split('\n')
.filter((line) => /^\s*at /.test(line))
.slice(0, 8)
.join('\n')
: undefined,
});
response.setHeader('X-Request-Id', requestId);
response
.status(failure.status)
.type('application/problem+json')
.send({
type: `https://cmpp-platform.local/problems/${failure.code.toLowerCase()}`,
title: failure.status >= 500 ? 'Internal Server Error' : 'Request failed',
status: failure.status,
code: failure.code,
detail: failure.message,
requestId,
});
}
}
@@ -0,0 +1,181 @@
import { BadRequestException, HttpException } from '@nestjs/common';
import { Job } from 'bullmq';
import { createHash, createHmac } from 'node:crypto';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { OpenApiService } from './open-api.service';
import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { renderHttpGuide } from './docs/reader';
const auth = {
application: { id: 'own-app', tenantId: 'own-tenant' },
config: { sendEnabled: true, uplinkQueryEnabled: true, maxQueryRangeDays: 31, maxPageSize: 100 },
};
describe('HTTP API remediation boundaries', () => {
it('matches the published fixed GET signature vector', () => {
expect(
openApiSignature(
'doc-example-secret',
'GET',
'/api/openapi/v1/sms/uplinks',
'1789344000',
'550e8400-e29b-41d4-a716-446655440000',
openApiBodyHash(undefined, undefined),
),
).toBe('f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65');
});
it('keeps GET absent-body compatibility and signs exact POST UTF8 bytes', () => {
expect(openApiBodyHash(undefined, undefined)).toBe(
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
);
const raw = Buffer.from('{ "content": "中文\\n正文" }');
expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex'));
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', openApiBodyHash(raw, {})].join('\n');
expect(
openApiSignature(
'offline-secret',
'post',
'/api/openapi/v1/sms/messages?ignored=1',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex'));
for (const separator of ['\r\n', '\\n'])
expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe(
openApiSignature(
'offline-secret',
'POST',
'/api/openapi/v1/sms/messages',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
);
});
it('uses stable colon-free job IDs accepted by the actual BullMQ validator', () => {
const validate = (jobId: string) =>
(Job.prototype as unknown as { validateOptions: (data: unknown) => void }).validateOptions.call(
{ opts: { jobId } },
{ data: '{}' },
);
expect(() => validate('delivery:2')).toThrow('Custom Id cannot contain :');
expect(() => validate(webhookJobId('delivery:legacy', 2))).not.toThrow();
expect(webhookJobId('delivery:legacy', 2)).toBe(webhookJobId('delivery:legacy', 2));
expect(webhookJobId('delivery:legacy', 2)).not.toBe(webhookJobId('delivery:legacy', 3));
});
it.each([new Error('postgres://private:secret@host/secret'), new HttpException('private-secret', 503)])(
'does not expose dependency failures',
(error) => {
expect(publicOpenApiFailure(error)).toEqual({
status: 500,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
},
);
it('keeps first and replayed failure bodies consistent', () => {
const first = publicOpenApiFailure(new Error('private'));
const replay = publicOpenApiFailure(new HttpException({ code: first.code, message: first.message }, first.status));
expect(replay).toEqual(first);
expect(publicOpenApiFailure(new BadRequestException({ code: 'LIMIT_INVALID', message: 'bad limit' })).code).toBe(
'LIMIT_INVALID',
);
});
it('returns a safe request ID with the problem response', () => {
const send = jest.fn();
const setHeader = jest.fn();
const response = { setHeader, status: jest.fn(() => ({ type: () => ({ send }) })) };
new OpenApiExceptionFilter().catch(new Error('secret'), {
switchToHttp: () => ({ getRequest: () => ({ openApiRequestId: 'req-test' }), getResponse: () => response }),
} as never);
expect(setHeader).toHaveBeenCalledWith('X-Request-Id', 'req-test');
expect(send).toHaveBeenCalledWith(expect.objectContaining({ code: 'INTERNAL_ERROR', requestId: 'req-test' }));
expect(JSON.stringify(send.mock.calls)).not.toContain('secret');
});
it.each([1, {}, [], 'x'.repeat(129)])(
'rejects invalid clientMessageId before persistence',
async (clientMessageId) => {
const service = new OpenApiService({} as never, {} as never);
await expect(
service.sendMessage(auth as never, { mobile: '13800138000', content: '示例', clientMessageId } as never, {
idempotencyKey: 'offline-0001',
bodyHash: 'hash',
}),
).rejects.toMatchObject({ status: 400 });
},
);
it.each(['1.5', '0', '-1', 'NaN', 'Infinity', '', '9999999999999999999'])(
'rejects invalid limit %s before Prisma',
async (limit) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { limit })).rejects.toMatchObject({ status: 400 });
},
);
it.each([
'not-base64!',
Buffer.from(JSON.stringify(['2026-09-14', {}])).toString('base64url'),
Buffer.from(JSON.stringify(['bad-date', 'row'])).toString('base64url'),
])('rejects malformed cursor', async (cursor) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { cursor })).rejects.toMatchObject({ status: 400 });
});
it('projects only public detail fields and the authenticated tenant/application', async () => {
const findFirst = jest.fn().mockResolvedValue({ id: 'uplink' });
const service = new OpenApiService({ smsUplinkMessage: { findFirst } } as never, {} as never);
await service.getUplink(auth as never, 'uplink');
expect(findFirst).toHaveBeenCalledWith({
where: { id: 'uplink', applicationId: 'own-app', tenantId: 'own-tenant', matchStatus: 'matched' },
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
});
expect(JSON.stringify(findFirst.mock.calls)).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
});
it('retains application page-size clipping and a real empty response', async () => {
const findMany = jest.fn().mockResolvedValue([]);
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, {} as never);
await expect(service.listUplinks(auth as never, { limit: '1000' })).resolves.toEqual({
items: [],
nextCursor: null,
});
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 101 }));
});
it('never rebuilds an interrupted or uncertain request', async () => {
const send = jest.fn();
const service = new OpenApiService(
{
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'hash', status: 'requires_review' }) },
} as never,
{ createHttpBatchTask: send } as never,
);
await expect(
service.sendMessage(
auth as never,
{ mobile: '13800138000', content: '示例' },
{ idempotencyKey: 'offline-0001', bodyHash: 'hash' },
),
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
expect(send).not.toHaveBeenCalled();
});
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 接入\n<script>alert(1)</script>\n[bad](javascript:alert)\n```html\n<img src=x onerror=alert(1)>\n```',
'https://example.test',
);
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('href="javascript:');
expect(html).not.toContain('<img src=x');
expect(html).toContain('data-copy="sample-1"');
});
});
@@ -0,0 +1,28 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Optional } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { publicOpenApiFailure } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Injectable()
export class OpenApiTraceInterceptor implements NestInterceptor {
constructor(@Optional() private readonly logs?: ProtocolLogsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const startedAt = Date.now();
const record = (error?: unknown) =>
this.logs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: request.method === 'GET' ? 'query_request' : 'send_request',
status: error ? 'failed' : 'success',
requestId: request.openApiRequestId,
tenantId: request.openApiAuth?.application.tenantId,
applicationId: request.openApiAuth?.application.id,
resultCode: error ? publicOpenApiFailure(error).code : 'OK',
durationMs: Date.now() - startedAt,
detail: { method: request.method, operation: context.getHandler().name },
});
return next.handle().pipe(tap({ next: () => record(), error: (error: unknown) => record(error) }));
}
}
+76 -6
View File
@@ -1,19 +1,54 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common';
import { ApiBody, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { createHash } from 'node:crypto';
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
ValidationPipe,
BadRequestException,
} from '@nestjs/common';
import { ApiExtraModels, ApiBody, ApiHeader, ApiQuery, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { openApiBodyHash } from './open-api.protocol';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiService } from './open-api.service';
import type { OpenApiRequestLike } from './open-api.types';
import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { OpenApiSendMessageDto, OpenApiSendMessageResponseDto } from './open-api.dto';
import {
OpenApiSendMessageDto,
OpenApiSendMessageResponseDto,
OpenApiMessageDto,
OpenApiUplinksDto,
OpenApiUplinkDetailDto,
OpenApiProblemDto,
OpenApiReceiptEventDto,
OpenApiUplinkEventDto,
} from './open-api.dto';
@ApiExtraModels(OpenApiReceiptEventDto, OpenApiUplinkEventDto)
@ApiTags('client-open-api-v1')
@ApiHeader({ name: 'X-App-Key', required: true })
@ApiHeader({ name: 'X-Timestamp', required: true })
@ApiHeader({ name: 'X-Nonce', required: true })
@ApiHeader({ name: 'X-Signature', required: true })
@ApiResponse({ status: 400, type: OpenApiProblemDto })
@ApiResponse({ status: 401, type: OpenApiProblemDto })
@ApiResponse({ status: 403, type: OpenApiProblemDto })
@ApiResponse({ status: 404, type: OpenApiProblemDto })
@ApiResponse({ status: 409, type: OpenApiProblemDto })
@ApiResponse({ status: 422, type: OpenApiProblemDto })
@ApiResponse({ status: 429, type: OpenApiProblemDto })
@ApiResponse({ status: 500, type: OpenApiProblemDto })
@UseGuards(OpenApiAuthGuard)
@UseFilters(OpenApiExceptionFilter)
@UseInterceptors(OpenApiTraceInterceptor)
@Controller('openapi/v1/sms')
export class OpenApiController {
constructor(private readonly service: OpenApiService) {}
@@ -22,27 +57,62 @@ export class OpenApiController {
@HttpCode(202)
@ApiHeader({ name: 'Idempotency-Key', required: true })
@ApiOperation({ summary: '发送单条短信' })
@UsePipes(
new ValidationPipe({
transform: true,
exceptionFactory: () => new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' }),
}),
)
@ApiBody({ type: OpenApiSendMessageDto })
@ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto })
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) {
return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent });
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto) {
return this.service.sendMessage(request.openApiAuth!, body, {
idempotencyKey: scalarHeader(request, 'idempotency-key'),
bodyHash: openApiBodyHash(request.rawBody, body),
userAgent: scalarHeader(request, 'user-agent'),
});
}
@ApiResponse({ status: 200, type: OpenApiMessageDto })
@Get('messages/:messageId')
@ApiOperation({ summary: '查询短信状态' })
getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) {
return this.service.getMessage(request.openApiAuth!, messageId);
}
@ApiResponse({ status: 200, type: OpenApiUplinksDto })
@ApiQuery({ name: 'startTime', required: false, type: String, description: 'ISO8601时间,默认endTime前24小时' })
@ApiQuery({
name: 'endTime',
required: false,
type: String,
description: 'ISO8601时间,默认当前时间;翻页固定时间范围',
})
@ApiQuery({ name: 'mobile', required: false, type: String })
@ApiQuery({ name: 'accessNumber', required: false, type: String })
@ApiQuery({ name: 'keyword', required: false, type: String })
@ApiQuery({
name: 'limit',
required: false,
schema: { type: 'integer', minimum: 1, default: 50 },
description: '按当前应用maxPageSize裁剪',
})
@ApiQuery({ name: 'cursor', required: false, type: String })
@Get('uplinks')
@ApiOperation({ summary: '游标分页查询上行短信' })
listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record<string, string | undefined>) {
return this.service.listUplinks(request.openApiAuth!, query);
}
@ApiResponse({ status: 200, type: OpenApiUplinkDetailDto })
@Get('uplinks/:uplinkId')
@ApiOperation({ summary: '查询上行短信详情' })
getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) {
return this.service.getUplink(request.openApiAuth!, uplinkId);
}
}
function scalarHeader(request: OpenApiRequestLike, name: string) {
const value = request.headers[name];
return Array.isArray(value) ? value[0] : value;
}
+89 -2
View File
@@ -1,16 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength, Matches, IsNotEmpty } from 'class-validator';
export class OpenApiSendMessageDto {
@ApiProperty({ example: '13800138000', description: '中国大陆手机号' })
@IsString()
@Matches(/^1\d{10}$/)
mobile!: string;
@ApiProperty({
example: '【示例签名】您的验证码是123456,5分钟内有效。',
description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID',
})
@IsString()
@IsNotEmpty()
content!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', maxLength: 128 })
@ApiPropertyOptional({ type: String, nullable: true, example: 'order-20260720-0001', maxLength: 128 })
@IsOptional()
@IsString()
@MaxLength(128)
clientMessageId?: string;
}
@@ -24,7 +32,7 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' })
messageId!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', nullable: true })
@ApiProperty({ type: String, example: 'order-20260720-0001', nullable: true })
clientMessageId!: string | null;
@ApiProperty({ example: 'queued' })
@@ -33,3 +41,82 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: '2026-07-20T08:00:00.000Z' })
acceptedAt!: string;
}
export class OpenApiProblemDto {
@ApiProperty() type!: string;
@ApiProperty() title!: string;
@ApiProperty() status!: number;
@ApiProperty() code!: string;
@ApiProperty() detail!: string;
@ApiProperty() requestId!: string;
}
export class OpenApiMessageDto {
@ApiProperty() messageId!: string;
@ApiProperty({ type: String, nullable: true }) clientMessageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() status!: string;
@ApiProperty() submitStatus!: string;
@ApiProperty() receiptStatus!: string;
@ApiProperty({ type: String, nullable: true }) errorCode!: string | null;
@ApiProperty({ type: String, nullable: true }) errorMessage!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) queuedAt!: string;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) submittedAt!: string | null;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) deliveredAt!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) updatedAt!: string;
}
export class OpenApiUplinkDto {
@ApiProperty() id!: string;
@ApiProperty({ type: String, nullable: true }) messageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
}
export class OpenApiUplinkDetailDto extends OpenApiUplinkDto {
@ApiProperty() tenantId!: string;
@ApiProperty() applicationId!: string;
}
export class OpenApiUplinksDto {
@ApiProperty({ type: [OpenApiUplinkDto] }) items!: OpenApiUplinkDto[];
@ApiProperty({ type: String, nullable: true }) nextCursor!: string | null;
}
export class OpenApiReceiptDataDto {
@ApiProperty() messageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) gatewayMessageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() receiptStatus!: string;
@ApiPropertyOptional({ type: String, nullable: true }) rawStatus?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorCode?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorMessage?: string | null;
@ApiPropertyOptional({ type: String, format: 'date-time', nullable: true }) deliveredAt?: string | null;
}
export class OpenApiUplinkDataDto {
@ApiProperty() applicationId!: string;
@ApiProperty() uplinkMessageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) messageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
@ApiPropertyOptional() manualClaim?: boolean;
}
export class OpenApiReceiptEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['receipt'] }) eventType!: 'receipt';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiReceiptDataDto }) data!: OpenApiReceiptDataDto;
}
export class OpenApiUplinkEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['uplink'] }) eventType!: 'uplink';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiUplinkDataDto }) data!: OpenApiUplinkDataDto;
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import { OpenApiDocsController } from './open-api-docs.controller';
import { forwardRef, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { SendChainModule } from '../send-chain/send-chain.module';
@@ -10,8 +12,8 @@ import { SecurityDetectionModule } from '../security-detection/security-detectio
@Module({
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiService, OpenApiAuthGuard],
controllers: [OpenApiDocsController, OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiTraceInterceptor, OpenApiService, OpenApiAuthGuard],
exports: [OpenApiService],
})
export class OpenApiModule {}
+40
View File
@@ -0,0 +1,40 @@
import { createHash, createHmac } from 'node:crypto';
import { HttpException } from '@nestjs/common';
/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
return createHash('sha256')
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
.digest('hex');
}
export function openApiSignature(
secret: string,
method: string,
path: string,
timestamp: string,
nonce: string,
bodyHash: string,
) {
return createHmac('sha256', secret)
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n'))
.digest('hex');
}
export function publicOpenApiFailure(error: unknown) {
if (!(error instanceof HttpException) || error.getStatus() >= 500) {
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
}
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const message = object.message ?? value;
return {
status: error.getStatus(),
code: String(object.code ?? 'REQUEST_FAILED'),
message: Array.isArray(message) ? message.join('') : String(message),
};
}
export function webhookJobId(deliveryId: string, attemptNo: number) {
return `webhook-${createHash('sha256').update(deliveryId).digest('hex')}-${attemptNo}`;
}
@@ -0,0 +1,70 @@
import { OpenApiRecovery } from './open-api.recovery';
describe('OpenApiRecovery', () => {
function setup() {
const prisma = {
openApiDispatchOutbox: {
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsBatchTask: { findUnique: jest.fn().mockResolvedValue({ status: 'queued' }) },
httpWebhookDelivery: { findMany: jest.fn().mockResolvedValue([]) },
};
const send = { enqueueBatchTask: jest.fn().mockResolvedValue({}) };
const queue = { getJob: jest.fn().mockResolvedValue(undefined), add: jest.fn().mockResolvedValue({}) };
return { prisma, send, queue, recovery: new OpenApiRecovery(prisma as never, send as never, queue as never) };
}
it('keeps publication failures durable without marking them dispatched', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
send.enqueueBatchTask.mockRejectedValue(new Error('controlled failure'));
await recovery.tick();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'pending' }),
data: expect.objectContaining({ leaseToken: expect.any(String), leaseUntil: expect.any(Date) }),
}),
);
});
it('does not dispatch a batch whose lease was taken by another instance', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.openApiDispatchOutbox.updateMany.mockResolvedValue({ count: 0 });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
});
it.each(['canceled', 'sending', 'finished', 'rejected', 'pending_review'])(
'does not enqueue non-eligible batch %s',
async (status) => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.smsBatchTask.findUnique.mockResolvedValue({ status });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'closed' }) }),
);
},
);
it('scans only versioned webhook deliveries and preserves active Redis jobs', async () => {
const { prisma, queue, recovery } = setup();
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'active' });
await recovery.tick();
expect(queue.add).not.toHaveBeenCalled();
expect(prisma.httpWebhookDelivery.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ recoveryVersion: 1 }), take: 50 }),
);
});
it('recovers a failed Redis job only when its durable PG row still needs delivery', async () => {
const { prisma, queue, recovery } = setup();
const remove = jest.fn().mockResolvedValue(undefined);
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'failed', remove });
await recovery.tick();
expect(remove).toHaveBeenCalledTimes(1);
expect(queue.add).toHaveBeenCalledTimes(1);
expect(queue.add.mock.calls[0][2].jobId).not.toContain(':');
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { PrismaService } from '../prisma/prisma.service';
import type { SendChainService } from '../send-chain/send-chain.service';
import type { Queue } from 'bullmq';
import { webhookJobId } from './open-api.protocol';
/** Only versioned/new durable work is eligible; never infer or replay historical work. */
export class OpenApiRecovery {
private readonly logger = new Logger(OpenApiRecovery.name);
private pending?: Promise<void>;
private stopped = false;
constructor(
private readonly prisma: PrismaService,
private readonly sendChain: SendChainService,
private readonly queue: Queue<{ deliveryId: string }>,
) {}
tick(): Promise<void> {
if (this.stopped) return Promise.resolve();
if (this.pending) return this.pending;
this.pending = this.run().finally(() => {
this.pending = undefined;
});
return this.pending;
}
async close() {
this.stopped = true;
await this.pending;
}
private async run() {
try {
await this.dispatchMessages();
await this.dispatchWebhooks();
} catch (error) {
this.logger.error({
code: 'OPENAPI_RECOVERY_FAILED',
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
private async dispatchMessages() {
const now = new Date();
const rows = await this.prisma.openApiDispatchOutbox.findMany({
where: { status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
orderBy: { createdAt: 'asc' },
take: 50,
});
for (const row of rows) {
if (this.stopped) return;
const leaseToken = randomUUID();
const claimed = await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
data: { leaseToken, leaseUntil: new Date(Date.now() + 120_000) },
});
if (!claimed.count) continue;
try {
const task = await this.prisma.smsBatchTask.findUnique({
where: { id: row.batchTaskId },
select: { status: true },
});
if (!task || !['ready', 'queued'].includes(task.status)) {
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'closed', leaseToken: null, leaseUntil: null },
});
continue;
}
await this.sendChain.enqueueBatchTask(row.batchTaskId);
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'dispatched', leaseToken: null, leaseUntil: null },
});
} catch (error) {
this.logger.error({
code: 'OPENAPI_DISPATCH_PENDING',
outboxId: row.id,
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
}
private async dispatchWebhooks() {
const now = new Date();
const rows = await this.prisma.httpWebhookDelivery.findMany({
where: {
recoveryVersion: 1,
status: { in: ['pending', 'retrying', 'delivering'] },
AND: [
{ OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }] },
{ OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
],
},
orderBy: { nextRetryAt: 'asc' },
take: 50,
});
for (const row of rows) {
const jobId = webhookJobId(row.id, row.attemptCount + 1);
const job = await this.queue.getJob(jobId);
if (job) {
const state = await job.getState();
if (!['failed', 'completed'].includes(state)) continue;
// Failed/finished jobs are no longer executing; DB state is the durable authority.
await job.remove();
}
await this.queue.add('deliver', { deliveryId: row.id }, { jobId, removeOnComplete: 1000, removeOnFail: 1000 });
}
}
}
+46 -63
View File
@@ -60,13 +60,11 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => {
const prisma = {
openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
bodyHash: 'same',
status: 'completed',
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
}),
findUnique: jest.fn().mockResolvedValue({
bodyHash: 'same',
status: 'completed',
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
}),
},
};
const sendChain = { createHttpBatchTask: jest.fn() };
@@ -97,14 +95,12 @@ describe('OpenApiService', () => {
it('replays the same persisted business rejection', async () => {
const prisma = {
openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
bodyHash: 'same',
status: 'failed',
httpStatus: 422,
responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
}),
findUnique: jest.fn().mockResolvedValue({
bodyHash: 'same',
status: 'failed',
httpStatus: 422,
responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
}),
},
};
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
@@ -117,37 +113,29 @@ describe('OpenApiService', () => {
).rejects.toMatchObject({ status: 422 });
});
it('uses the real send chain and persists the accepted response', async () => {
it('returns only the response snapshot committed by the send chain', async () => {
const response = { code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' };
const prisma = {
openApiRequest: {
findUnique: jest.fn().mockResolvedValue(null),
findUnique: jest
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue({ status: 'completed', responseBody: response }),
create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
update: jest.fn().mockResolvedValue({}),
update: jest.fn(),
},
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
};
const sendChain = {
createHttpBatchTask: jest
.fn()
.mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
};
const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({}) };
const service = new OpenApiService(prisma as never, sendChain as never);
const result = await service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
);
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(
expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }),
);
expect(result).toEqual(
expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
);
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
}),
);
await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '示例', clientMessageId: 'client-1' },
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
),
).resolves.toEqual(response);
expect(prisma.openApiRequest.update).not.toHaveBeenCalled();
});
it('persists a 422 result when the real send chain rejects the business request', async () => {
@@ -180,13 +168,12 @@ describe('OpenApiService', () => {
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
const prisma = {
smsApplication: {
findUnique: jest
.fn()
.mockResolvedValue({
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
}),
findUnique: jest.fn().mockResolvedValue({
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
}),
},
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
$transaction: jest.fn().mockImplementation(async (callback) => callback(prisma)),
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
};
@@ -209,7 +196,7 @@ describe('OpenApiService', () => {
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
create: { eventId: 'event-row-1', endpointId: 'endpoint-1', recoveryVersion: 1 },
}),
);
});
@@ -217,15 +204,13 @@ describe('OpenApiService', () => {
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
const prisma = {
smsApplication: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: null,
httpIpAllowlist: [],
}),
findFirst: jest.fn().mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: null,
httpIpAllowlist: [],
}),
},
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
@@ -280,15 +265,13 @@ describe('OpenApiService', () => {
it('rejects an already expired credential before writing a secret', async () => {
const prisma = {
smsApplication: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
httpIpAllowlist: [],
}),
findFirst: jest.fn().mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
httpIpAllowlist: [],
}),
},
httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
};
+187 -162
View File
@@ -1,3 +1,5 @@
import { OpenApiRecovery } from './open-api.recovery';
import { HTTP_REQUEST_CONTEXT } from '../send-chain/send-chain.contracts';
import {
BadRequestException,
ConflictException,
@@ -14,7 +16,7 @@ import {
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
import { createHmac, randomBytes, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { request as httpRequest } from 'node:http';
@@ -24,8 +26,18 @@ import { SendChainService } from '../send-chain/send-chain.service';
import { decryptSecret, encryptSecret } from './open-api.crypto';
import type { OpenApiAuthContext } from './open-api.types';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { automaticDeliveryMode } from './delivery-mode';
export const OPEN_API_WEBHOOK_TRANSPORT = Symbol('open-api-webhook-transport');
export type OpenApiWebhookTransport = (
url: string,
body: string,
headers: Record<string, string>,
timeoutMs: number,
requireHttps: boolean,
) => Promise<{ status: number; body: string }>;
const WEBHOOK_QUEUE = 'http-webhook-delivery';
const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400];
@@ -58,11 +70,14 @@ export type HttpConfigInput = {
export class OpenApiService implements OnModuleInit, OnModuleDestroy {
private queue?: Queue<{ deliveryId: string }>;
private worker?: Worker<{ deliveryId: string }>;
private recovery?: OpenApiRecovery;
private recoveryTimer?: ReturnType<typeof setInterval>;
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService,
@Optional() private readonly protocolLogs?: ProtocolLogsService,
@Optional() @Inject(OPEN_API_WEBHOOK_TRANSPORT) private readonly webhookTransport?: OpenApiWebhookTransport,
) {}
onModuleInit() {
@@ -72,6 +87,9 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
// Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
this.recovery = new OpenApiRecovery(this.prisma, this.sendChain, this.queue);
this.recoveryTimer = setInterval(() => void this.recovery?.tick(), 15_000);
this.recoveryTimer.unref?.();
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
connection,
concurrency: 10,
@@ -79,6 +97,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}
async onModuleDestroy() {
if (this.recoveryTimer) clearInterval(this.recoveryTimer);
await this.recovery?.close();
await this.worker?.close();
await this.queue?.close();
}
@@ -262,10 +282,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
) {
if (!auth.config.sendEnabled)
throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
const mobile = String(input.mobile ?? '').trim();
if (
typeof input.mobile !== 'string' ||
typeof input.content !== 'string' ||
(input.clientMessageId != null &&
(typeof input.clientMessageId !== 'string' || Array.from(input.clientMessageId).length > 128))
) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' });
}
const mobile = input.mobile.trim();
const content = String(input.content ?? '');
if (!/^1[3-9]\d{9}$/.test(mobile))
throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!/^1\d{10}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
@@ -283,8 +310,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus)
if (['failed', 'requires_review'].includes(existing.status) && existing.responseBody && existing.httpStatus)
throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
if (
existing.status === 'requires_review' ||
(existing.createdAt && Date.now() - existing.createdAt.getTime() > 600_000)
)
throw new ConflictException({
code: 'REQUEST_REQUIRES_REVIEW',
message: '请求结果待核对,请提供requestId联系支持,勿更换幂等键重发',
requestId: existing.requestId,
});
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
if (input.clientMessageId) {
@@ -326,14 +362,15 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus)
if (raced && ['failed', 'requires_review'].includes(raced.status) && raced.responseBody && raced.httpStatus)
throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
throw error;
}
try {
const task = await this.sendChain.createHttpBatchTask({
await this.sendChain.createHttpBatchTask({
[HTTP_REQUEST_CONTEXT]: { id: request.id, requestId },
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
content,
@@ -342,50 +379,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
userAgent: meta.userAgent,
clientMessageId: input.clientMessageId,
});
const message = task.messages?.[0];
if (task.status === 'rejected' || message?.status === 'rejected') {
throw new UnprocessableEntityException({
code: 'SEND_REJECTED',
message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验',
});
const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (frozen?.status === 'completed' && frozen.responseBody) {
void this.recovery?.tick();
return frozen.responseBody;
}
const response = {
code: 'ACCEPTED',
requestId,
messageId: message?.messageId,
clientMessageId: input.clientMessageId ?? null,
status: message?.status ?? task.status,
acceptedAt: new Date().toISOString(),
};
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'completed',
httpStatus: 202,
businessCode: 'ACCEPTED',
responseBody: response,
messageRecordId: message?.id,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'send_request',
status: 'accepted',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
messageId: message?.messageId,
requestId,
phone: mobile,
resultCode: 'ACCEPTED',
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
detail: { clientMessageId: input.clientMessageId },
});
return response;
if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
throw new Error('HTTP acceptance snapshot was not committed');
} catch (error) {
const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (frozen?.status === 'completed' && frozen.responseBody) return frozen.responseBody;
if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
let outwardError = error;
if (error instanceof HttpException && error.getStatus() === 400) {
const response = error.getResponse();
@@ -399,7 +405,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'failed',
status: failure.httpStatus >= 500 ? 'requires_review' : 'failed',
httpStatus: failure.httpStatus,
businessCode: failure.code,
responseBody: failure.responseBody,
@@ -407,20 +413,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'send_request',
status: 'failed',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
requestId,
phone: mobile,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
});
throw outwardError;
throw new HttpException(failure.responseBody as Record<string, unknown>, failure.httpStatus);
}
}
@@ -451,6 +445,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) {
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
for (const value of Object.values(query)) {
if (value !== undefined && typeof value !== 'string')
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
}
const endTime = query.endTime ? new Date(query.endTime) : new Date();
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
@@ -460,7 +458,12 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
code: 'TIME_RANGE_TOO_LARGE',
message: `单次查询不能超过${auth.config.maxQueryRangeDays}`,
});
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize);
if (
query.limit !== undefined &&
(!/^\d+$/.test(query.limit) || !Number.isSafeInteger(Number(query.limit)) || Number(query.limit) < 1)
)
throw new BadRequestException({ code: 'LIMIT_INVALID', message: 'limit必须为正整数' });
const limit = Math.min(Number(query.limit ?? 50), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({
where: {
@@ -482,8 +485,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: true,
destId: true,
content: true,
matchStatus: true,
matchReason: true,
receivedAt: true,
},
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
@@ -499,7 +500,22 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({
where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' },
where: {
id: uplinkId,
applicationId: auth.application.id,
tenantId: auth.application.tenantId,
matchStatus: 'matched',
},
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
});
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row;
@@ -532,30 +548,32 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
: data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`;
const event = await this.prisma.httpWebhookEvent.upsert({
where: { eventId },
update: {},
create: {
eventId,
tenantId: data.tenantId,
applicationId: data.applicationId,
eventType: data.eventType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId: data.uplinkMessageId,
payload: data.payload as Prisma.InputJsonValue,
},
const delivery = await this.prisma.$transaction(async (tx) => {
const event = await tx.httpWebhookEvent.upsert({
where: { eventId },
update: {},
create: {
eventId,
tenantId: data.tenantId,
applicationId: data.applicationId!,
eventType: data.eventType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId: data.uplinkMessageId,
payload: data.payload as Prisma.InputJsonValue,
},
});
return tx.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
update: {},
create: { eventId: event.id, endpointId: endpoint.id, recoveryVersion: 1 },
});
});
const delivery = await this.prisma.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
update: {},
create: { eventId: event.id, endpointId: endpoint.id },
});
if (delivery.status === 'delivered') return delivery;
if (delivery.status !== 'pending' || delivery.recoveryVersion !== 1) return delivery;
await this.queue?.add(
'deliver',
{ deliveryId: delivery.id },
{ jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 },
{ jobId: webhookJobId(delivery.id, 1), removeOnComplete: 1000, removeOnFail: 1000 },
);
return delivery;
}
@@ -603,14 +621,27 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId, event: { applicationId } },
});
if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
await this.prisma.httpWebhookDelivery.update({
where: { id: delivery.id },
data: { status: 'pending', nextRetryAt: null, lastError: null },
const reset = await this.prisma.httpWebhookDelivery.updateMany({
where: {
id: delivery.id,
status: { in: ['pending', 'retrying', 'failed'] },
attemptCount: delivery.attemptCount,
OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
},
data: {
status: 'pending',
nextRetryAt: null,
lastError: null,
recoveryVersion: 1,
leaseToken: null,
leaseUntil: null,
},
});
if (!reset.count) throw new ConflictException('回调正在投递或已成功,不能重投');
await this.queue?.add(
'deliver',
{ deliveryId },
{ jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 },
{ jobId: webhookJobId(deliveryId, Date.now()), removeOnComplete: 1000, removeOnFail: 1000 },
);
return { id: deliveryId, status: 'pending' };
}
@@ -620,11 +651,32 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId },
include: { event: true, endpoint: true },
});
if (!delivery || delivery.status === 'delivered') return;
if (!delivery || !['pending', 'retrying', 'delivering'].includes(delivery.status)) return;
if (delivery.nextRetryAt && delivery.nextRetryAt.getTime() > Date.now()) return;
const config = await this.prisma.smsApplicationHttpConfig.findUnique({
where: { applicationId: delivery.event.applicationId },
});
if (!config) return;
if (
!config?.enabled ||
delivery.endpoint.status !== 'active' ||
!(delivery.event.eventType === 'receipt' ? config.receiptWebhookEnabled : config.uplinkWebhookEnabled)
)
return;
const leaseToken = randomUUID();
const claimed = await this.prisma.httpWebhookDelivery.updateMany({
where: {
id: deliveryId,
status: delivery.status,
attemptCount: delivery.attemptCount,
OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
},
data: {
status: 'delivering',
leaseToken,
leaseUntil: new Date(Date.now() + config.webhookTimeoutSeconds * 1000 + 60_000),
},
});
if (!claimed.count) return;
const attemptNo = delivery.attemptCount + 1;
const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({
@@ -641,7 +693,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
let responseSummary: string | undefined;
let errorMessage: string | undefined;
try {
const response = await postWebhook(
const response = await (this.webhookTransport ?? postWebhook)(
delivery.endpoint.url,
body,
{
@@ -665,22 +717,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
responseStatus === 408 ||
responseStatus === 429 ||
(responseStatus !== undefined && responseStatus >= 500);
await this.prisma.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'platform_to_client',
@@ -696,56 +732,53 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
retryCount: attemptNo - 1,
detail: { deliveryId, attemptNo, error: errorMessage },
});
if (success) {
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'delivered',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: null,
deliveredAt: new Date(),
nextRetryAt: null,
},
});
return;
}
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) {
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000);
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
const willRetry = !success && config.webhookRetryEnabled && retryable && attemptNo < maxAttempts;
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
const nextRetryAt = willRetry ? new Date(Date.now() + delaySeconds * 1000) : null;
await this.prisma.$transaction(async (tx) => {
const updated = await tx.httpWebhookDelivery.updateMany({
where: { id: deliveryId, leaseToken },
data: {
status: 'retrying',
status: success ? 'delivered' : willRetry ? 'retrying' : 'failed',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
lastError: success ? null : (errorMessage ?? 'HTTP ' + responseStatus),
deliveredAt: success ? new Date() : null,
nextRetryAt,
leaseToken: null,
leaseUntil: null,
},
});
if (!updated.count) return;
await tx.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
});
if (willRetry)
await this.queue?.add(
'deliver',
{ deliveryId },
{
jobId: `${deliveryId}:${attemptNo + 1}`,
jobId: webhookJobId(deliveryId, attemptNo + 1),
delay: delaySeconds * 1000,
removeOnComplete: 1000,
removeOnFail: 1000,
},
);
return;
}
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'failed',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
nextRetryAt: null,
},
});
}
private async requireApplication(applicationId: string, tenantId?: string) {
@@ -783,23 +816,11 @@ function httpApiPublicOrigin() {
}
function normalizeOpenApiFailure(error: unknown) {
if (error instanceof HttpException) {
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const rawMessage = object.message ?? error.message;
return {
httpStatus: error.getStatus(),
code: String(object.code ?? 'SEND_REJECTED'),
responseBody: {
code: String(object.code ?? 'SEND_REJECTED'),
message: Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage),
} as Prisma.InputJsonValue,
};
}
const failure = publicOpenApiFailure(error);
return {
httpStatus: 500,
code: 'INTERNAL_ERROR',
responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
httpStatus: failure.status,
code: failure.code,
responseBody: { code: failure.code, message: failure.message } as Prisma.InputJsonValue,
};
}
@@ -968,7 +989,11 @@ function encodeCursor(receivedAt: Date, id: string) {
function decodeCursor(value?: string) {
if (!value) return null;
try {
const [date, id] = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as [string, string];
if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error();
const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string')
throw new Error();
const [date, id] = parsed;
const receivedAt = new Date(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id };
+1
View File
@@ -17,4 +17,5 @@ export type OpenApiRequestLike = {
headers: Record<string, string | string[] | undefined>;
socket?: { remoteAddress?: string };
openApiAuth?: OpenApiAuthContext;
openApiRequestId?: string;
};
+217 -127
View File
@@ -1,20 +1,41 @@
import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
import {
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
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 type {
CreateBatchTaskDto,
CreateHttpBatchTaskDto,
ImportPreviewDto,
ConfirmImportDto,
QueuePriority,
} from './send-chain.contracts';
import {
statusFromRisk,
parseSchedule,
parseImportRows,
normalizeQueuePriority,
matchTemplateContent,
shanghaiDateKey,
} from './send-chain.helpers';
import { detectDrainageContent } from './drainage-content-detection';
import type { SendResourceValidationOptions, 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.
@@ -33,7 +54,13 @@ export class SendBatchEntryService {
) {}
private 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,
) {
return this.callbacks.releaseMessageReservation(message, remark);
@@ -56,15 +83,20 @@ export class SendBatchEntryService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async createBatchTask(data: CreateBatchTaskDto) {
async createBatchTask(data: CreateBatchTaskDto) {
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);
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([
this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
this.facade.resolveTemplateMessageClassification(
data.tenantId,
data.applicationId,
data.templateId,
data.content,
),
this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
@@ -73,16 +105,16 @@ async createBatchTask(data: CreateBatchTaskDto) {
const risk = messageClassification.rejectionReason
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
: await this.riskReview.evaluateTask({
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
content: data.content,
category: data.category,
phones,
variables: messageClassification.variables ?? data.variables,
createdById: data.createdById,
sourceType: data.sourceType ?? 'client',
});
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
content: data.content,
category: data.category,
phones,
variables: messageClassification.variables ?? data.variables,
createdById: data.createdById,
sourceType: data.sourceType ?? 'client',
});
let frequencyRejectedAll = false;
let frequencyBatchReason: string | undefined;
if (risk.status !== 'rejected' && sendablePhones.length > 0) {
@@ -97,9 +129,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
}
sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone));
frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0;
frequencyBatchReason = frequencyRejectedAll
? [...frequencyRejections.values()][0]?.reason
: undefined;
frequencyBatchReason = frequencyRejectedAll ? [...frequencyRejections.values()][0]?.reason : undefined;
}
if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) {
await this.prisma.smsSendTask.update({
@@ -124,7 +154,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
? 'rejected'
: risk.status === 'approved' && sendablePhones.length === 0
? 'failed'
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
const shouldReserveBalance = batchStatus === 'ready';
if (risk.status === 'approved') {
const accountCheck = await this.billing.checkAccount({
@@ -140,6 +170,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
}
const task = await this.prisma.smsBatchTask.create({
data: {
...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
@@ -150,7 +181,12 @@ async createBatchTask(data: CreateBatchTaskDto) {
phoneTotal: phones.length,
status: batchStatus,
riskTaskId: risk.task?.id,
auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved',
auditStatus:
frequencyRejectedAll || risk.status === 'rejected'
? 'rejected'
: risk.status === 'pending_review'
? 'pending'
: 'approved',
reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null,
rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null,
progressTotal: phones.length,
@@ -185,44 +221,80 @@ async createBatchTask(data: CreateBatchTaskDto) {
},
});
if (phones.length > 0) {
await this.prisma.smsMessageRecord.createMany({
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,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: rejection ? 0 : billing.unitPrice,
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
queuePriority,
clientSrcId: accessNumber.clientSrcId,
applicationExtension: accessNumber.applicationExtension,
status,
submitStatus: rejection ? 'rejected' : undefined,
errorCode: rejection?.code,
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined),
};
}),
});
const persistMessages = async (tx: Prisma.TransactionClient) => {
await tx.smsMessageRecord.createMany({
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,
...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
messageId: httpRequest ? `MSG-http-${httpRequest.id}` : `MSG-${randomUUID()}`,
clientMessageId: data.clientMessageId,
phoneNumber: phone,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: rejection ? 0 : billing.unitPrice,
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
queuePriority,
clientSrcId: accessNumber.clientSrcId,
applicationExtension: accessNumber.applicationExtension,
status,
submitStatus: rejection ? 'rejected' : undefined,
errorCode: rejection?.code,
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? (risk.reason ?? undefined) : undefined),
};
}),
});
if (httpRequest) {
const rejected = batchStatus === 'rejected';
const responseBody = rejected
? { code: 'SEND_REJECTED', message: task.rejectReason ?? '短信未通过业务校验' }
: {
code: 'ACCEPTED',
requestId: httpRequest.requestId,
messageId: 'MSG-http-' + httpRequest.id,
clientMessageId: data.clientMessageId ?? null,
status: phoneRejections.has(phones[0])
? 'submit_failed'
: batchStatus === 'ready'
? 'queued'
: batchStatus,
acceptedAt: new Date().toISOString(),
};
const frozen = await tx.openApiRequest.updateMany({
where: { id: httpRequest.id, status: 'processing' },
data: {
status: rejected ? 'failed' : 'completed',
httpStatus: rejected ? 422 : 202,
businessCode: responseBody.code,
responseBody,
messageRecordId: 'http-' + httpRequest.id,
completedAt: new Date(),
},
});
if (frozen.count !== 1) throw new ConflictException('HTTP request is no longer processing');
if (batchStatus === 'ready' && sendablePhones.length > 0)
await tx.openApiDispatchOutbox.create({ data: { requestId: httpRequest.id, batchTaskId: task.id } });
}
};
if (httpRequest) await this.prisma.$transaction(persistMessages);
else await persistMessages(this.prisma);
}
if (batchStatus === 'ready' && sendablePhones.length > 0) {
if (!httpRequest && batchStatus === 'ready' && sendablePhones.length > 0) {
await this.facade.enqueueBatchTask(task.id);
} else if (batchStatus === 'failed') {
await this.facade.refreshTaskProgress(task.id);
@@ -230,7 +302,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
}
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
if (!data.applicationId) {
throw new BadRequestException('公开 HTTP 发送必须关联企业应用');
}
@@ -250,7 +322,7 @@ async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
});
}
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
const task = await this.prisma.smsBatchTask.findFirst({
where: { id: taskId, tenantId, sourceType },
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
@@ -261,7 +333,7 @@ async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
return task;
}
async previewImport(data: ImportPreviewDto) {
async previewImport(data: ImportPreviewDto) {
const sizeBytes = Buffer.byteLength(data.content, 'utf8');
if (sizeBytes > 20 * 1024 * 1024) {
throw new BadRequestException('导入文件不能超过 20MB');
@@ -270,10 +342,12 @@ async previewImport(data: ImportPreviewDto) {
const phones: string[] = [];
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
const requiredVariables = data.requiredVariables ?? [];
const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({
where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
select: { phoneNumber: true },
}) : [];
const enterpriseBlacklist = data.applicationId
? await this.prisma.enterpriseBlacklist.findMany({
where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
select: { phoneNumber: true },
})
: [];
const globalBlacklist = await this.prisma.globalBlacklist.findMany({
where: { status: 'active' },
select: { phoneNumber: true },
@@ -299,7 +373,11 @@ async previewImport(data: ImportPreviewDto) {
}
const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
if (missingVariables.length > 0) {
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` });
errors.push({
rowNumber: row.rowNumber,
phoneNumber: row.phoneNumber,
reason: `变量列缺失:${missingVariables.join(',')}`,
});
continue;
}
seen.add(row.phoneNumber);
@@ -316,7 +394,7 @@ async previewImport(data: ImportPreviewDto) {
};
}
async confirmImport(data: ConfirmImportDto) {
async confirmImport(data: ConfirmImportDto) {
const preview = await this.facade.previewImport({
tenantId: data.tenantId,
applicationId: data.applicationId,
@@ -329,7 +407,7 @@ async confirmImport(data: ConfirmImportDto) {
return this.facade.createBatchTask({ ...data, phones: preview.phones });
}
async resolveUnitPrice(tenantId: string, applicationId?: string) {
async resolveUnitPrice(tenantId: string, applicationId?: string) {
if (!applicationId) {
return 0;
}
@@ -343,7 +421,7 @@ async resolveUnitPrice(tenantId: string, applicationId?: string) {
return moneyToNumber(application.customerUnitPrice);
}
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
if (!applicationId) {
return 'normal';
}
@@ -357,7 +435,7 @@ async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<Qu
return normalizeQueuePriority(application.queuePriority);
}
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
if (!applicationId) {
return { clientSrcId: null, applicationExtension: null };
}
@@ -374,7 +452,7 @@ async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
};
}
async resolveTemplateMessageClassification(
async resolveTemplateMessageClassification(
tenantId: string,
applicationId: string | undefined,
templateId: string | undefined,
@@ -385,8 +463,13 @@ async resolveTemplateMessageClassification(
where: { id: templateId },
include: { signature: true },
});
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId
|| template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
if (
!template ||
template.tenantId !== tenantId ||
template.applicationId !== applicationId ||
template.auditStatus !== 'approved' ||
template.signature?.auditStatus !== 'approved'
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
const variables = matchTemplateContent(template.content, content);
@@ -431,7 +514,7 @@ async resolveTemplateMessageClassification(
};
}
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
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)) {
@@ -469,7 +552,7 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
return rejected;
}
async validateSendResources(
async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
@@ -499,12 +582,14 @@ async validateSendResources(
where: { id: templateId },
include: { signature: true },
});
const templateBelongsToApplication = template
&& template.tenantId === tenantId
&& template.applicationId === applicationId;
const templateBelongsToApplication =
template && template.tenantId === tenantId && template.applicationId === applicationId;
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) {
if (
!templateBelongsToApplication ||
(!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
if (!template.signature || template.signature.auditStatus !== 'approved') {
@@ -512,20 +597,23 @@ async validateSendResources(
}
}
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount);
if (!result.reserved) {
throw new HttpException({
code: 'DAILY_SEND_LIMIT_EXCEEDED',
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
dailyLimit: result.dailyLimit,
requestedCount,
}, HttpStatus.TOO_MANY_REQUESTS);
throw new HttpException(
{
code: 'DAILY_SEND_LIMIT_EXCEEDED',
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
dailyLimit: result.dailyLimit,
requestedCount,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
return result;
}
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
throw new BadRequestException('发送号码数量必须为正整数');
}
@@ -560,40 +648,42 @@ async tryReserveDailySendQuota(applicationId: string, requestedCount: number, re
const normalizedReservationKey = reservationKey?.trim();
const rows = normalizedReservationKey
? await this.prisma.$transaction(async (tx) => {
// The quota increment and its idempotency record share one short transaction. A worker
// crash can therefore neither lose a successful reservation nor increment it twice.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'daily-quota:' + normalizedReservationKey}, 0))`;
const existing = await tx.smsApplicationDailyReservation.findUnique({
where: { reservationKey: normalizedReservationKey },
});
if (existing) {
if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
throw new ConflictException('日发送配额幂等键已用于另一笔预留');
}
return [{
tenantId: existing.tenantId,
dailyLimit: existing.dailyLimit,
usedCount: existing.usedCount,
}];
}
const reservedRows = await reserve(tx);
if (reservedRows.length > 0) {
const row = reservedRows[0];
await tx.smsApplicationDailyReservation.create({
data: {
reservationKey: normalizedReservationKey,
tenantId: row.tenantId,
applicationId,
usageDate: usageDateValue,
requestedCount,
dailyLimit: Number(row.dailyLimit),
usedCount: row.usedCount == null ? null : Number(row.usedCount),
reserved: row.usedCount != null,
},
// The quota increment and its idempotency record share one short transaction. A worker
// crash can therefore neither lose a successful reservation nor increment it twice.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'daily-quota:' + normalizedReservationKey}, 0))`;
const existing = await tx.smsApplicationDailyReservation.findUnique({
where: { reservationKey: normalizedReservationKey },
});
}
return reservedRows;
})
if (existing) {
if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
throw new ConflictException('日发送配额幂等键已用于另一笔预留');
}
return [
{
tenantId: existing.tenantId,
dailyLimit: existing.dailyLimit,
usedCount: existing.usedCount,
},
];
}
const reservedRows = await reserve(tx);
if (reservedRows.length > 0) {
const row = reservedRows[0];
await tx.smsApplicationDailyReservation.create({
data: {
reservationKey: normalizedReservationKey,
tenantId: row.tenantId,
applicationId,
usageDate: usageDateValue,
requestedCount,
dailyLimit: Number(row.dailyLimit),
usedCount: row.usedCount == null ? null : Number(row.usedCount),
reserved: row.usedCount != null,
},
});
}
return reservedRows;
})
: await reserve(this.prisma);
if (rows.length === 0) {
throw new NotFoundException('短信应用不存在');
@@ -1,6 +1,9 @@
// R8 contract-only declarations. Runtime behavior remains in SendChainService.
export const HTTP_REQUEST_CONTEXT: unique symbol = Symbol('http-request-context');
export interface CreateBatchTaskDto {
[HTTP_REQUEST_CONTEXT]?: { id: string; requestId: string };
tenantId: string;
applicationId?: string;
templateId?: string;