feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
+17
-8
@@ -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/.
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>`;
|
||||
}
|
||||
@@ -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(/\/+$/, '') ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -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('<script>');
|
||||
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) }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(':');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -17,4 +17,5 @@ export type OpenApiRequestLike = {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
socket?: { remoteAddress?: string };
|
||||
openApiAuth?: OpenApiAuthContext;
|
||||
openApiRequestId?: string;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user