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

This commit is contained in:
hectorzhao
2026-09-14 12:48:10 +08:00
parent d13ca0713a
commit f0e843436c
33 changed files with 3332 additions and 452 deletions
@@ -0,0 +1,11 @@
ALTER TABLE "HttpWebhookDelivery" ADD COLUMN "recoveryVersion" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "leaseToken" TEXT, ADD COLUMN "leaseUntil" TIMESTAMP(3);
CREATE TABLE "OpenApiDispatchOutbox" (
"id" TEXT NOT NULL, "requestId" TEXT NOT NULL, "batchTaskId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending', "leaseToken" TEXT, "leaseUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenApiDispatchOutbox_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_requestId_key" ON "OpenApiDispatchOutbox"("requestId");
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_batchTaskId_key" ON "OpenApiDispatchOutbox"("batchTaskId");
CREATE INDEX "OpenApiDispatchOutbox_status_leaseUntil_idx" ON "OpenApiDispatchOutbox"("status", "leaseUntil");
+15
View File
@@ -686,6 +686,9 @@ model HttpWebhookEvent {
}
model HttpWebhookDelivery {
recoveryVersion Int @default(0)
leaseToken String?
leaseUntil DateTime?
id String @id @default(cuid())
eventId String
endpointId String
@@ -2825,3 +2828,15 @@ model SendingMonitorTargetVersion {
updatedBy String
@@id([channelId,version])
}
model OpenApiDispatchOutbox {
id String @id @default(cuid())
requestId String @unique
batchTaskId String @unique
status String @default("pending")
leaseToken String?
leaseUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, leaseUntil])
}
+14 -5
View File
@@ -33,13 +33,19 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
const clientDocument = SwaggerModule.createDocument(app, new DocumentBuilder()
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);
.build(),
{ include: [OpenApiModule] },
);
clientDocument.paths = Object.fromEntries(
Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')),
);
SwaggerModule.setup('api/client-docs', app, clientDocument, { ui: false });
const port = Number(process.env.API_PORT ?? 3000);
// 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。
@@ -55,7 +61,10 @@ async function bootstrap() {
response.writeHead(404).end();
return;
}
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
response.writeHead(200, {
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
'Cache-Control': 'no-store',
});
response.end(metrics.render());
});
// Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/.
+47
View File
@@ -0,0 +1,47 @@
.http-developer-docs { margin: 0; color: #1f2937; background: #f6f7f9; font: 14px/1.6 system-ui, sans-serif; }
.http-developer-docs * { box-sizing: border-box; }
.http-developer-docs .http-doc-header { padding: 24px; border-bottom: 1px solid #e5e7eb; background: #fff; display: flex; gap: 20px; justify-content: space-between; align-items: center; }
.http-developer-docs h1 { font-size: 24px; margin: 8px 0; }
.http-developer-docs h2 { font-size: 20px; margin: 0 0 16px; }
.http-developer-docs h3 { font-size: 16px; margin: 20px 0 12px; }
.http-developer-docs p { overflow-wrap: anywhere; }
.http-developer-docs a { color: #2563eb; text-decoration: none; overflow-wrap: anywhere; }
.http-developer-docs a:hover { text-decoration: underline; }
.http-developer-docs .http-doc-actions { display: flex; gap: 16px; flex-wrap: wrap; }
.http-developer-docs .http-doc-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); }
.http-developer-docs nav { padding: 20px; position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto; }
.http-developer-docs nav a { display: block; padding: 7px 0; font-size: 13px; }
.http-developer-docs nav label { display: block; margin-top: 20px; }
.http-developer-docs input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font: inherit; }
.http-developer-docs main { min-width: 0; }
.http-developer-docs section { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); border-bottom: 1px solid #e5e7eb; scroll-margin-top: 20px; }
.http-developer-docs section[hidden] { display: none; }
.http-developer-docs .http-doc-body { padding: 24px; background: #fff; min-width: 0; }
.http-developer-docs aside { min-width: 0; padding: 24px 16px; }
.http-developer-docs .http-doc-sample { margin-bottom: 16px; border: 1px solid #d1d5db; border-radius: 8px; overflow: hidden; background: #fff; }
.http-developer-docs .http-doc-sample-bar { padding: 10px; display: flex; gap: 12px; justify-content: space-between; align-items: center; font-size: 12px; color: #6b7280; }
.http-developer-docs button { padding: 5px 12px; border: 1px solid #d1d5db; border-radius: 6px; color: #1f2937; background: #fff; cursor: pointer; flex-shrink: 0; }
.http-developer-docs button:focus-visible, .http-developer-docs a:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.http-developer-docs pre { margin: 0; padding: 16px; overflow-x: auto; font-size: 13px; background: #f4f6f8; }
.http-developer-docs code { font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
.http-developer-docs .http-doc-table { overflow-x: auto; }
.http-developer-docs table { border-collapse: collapse; min-width: 100%; }
.http-developer-docs td { padding: 9px; border: 1px solid #e5e7eb; min-width: 100px; overflow-wrap: anywhere; }
.http-developer-docs tr:first-child { font-weight: 600; background: #f4f6f8; }
.http-developer-docs .http-doc-copy-status { position: fixed; bottom: 12px; right: 12px; max-width: 80vw; background: #fff; border-radius: 6px; padding: 8px; box-shadow: 0 2px 12px #0002; }
.http-developer-docs .http-doc-copy-status:empty { display: none; }
@media (width <= 1400px) {
.http-developer-docs section { grid-template-columns: minmax(0, 1fr); }
.http-developer-docs aside { padding: 16px 24px; }
.http-developer-docs aside:empty { display: none; }
}
@media (width <= 700px) {
.http-developer-docs .http-doc-header { padding: 16px; display: block; }
.http-developer-docs .http-doc-layout { grid-template-columns: minmax(0, 1fr); }
.http-developer-docs nav { position: static; max-height: none; padding: 16px; }
.http-developer-docs .http-doc-body, .http-developer-docs aside { padding: 16px; }
}
.http-developer-docs .http-doc-sample-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.http-developer-docs .http-doc-sample-tabs button[aria-pressed="true"] { background: #eff6ff; color: #2563eb; border-color: #2563eb; }
+38
View File
@@ -0,0 +1,38 @@
/* global document, navigator, window, Event */
const copyStatus = document.getElementById('copy-status');
document.addEventListener('click', async (event) => {
const tab = event.target.closest('button[data-show-sample]');
if (tab) {
const aside = tab.closest('aside');
aside.querySelectorAll('.http-doc-sample').forEach((sample, index) => { sample.hidden = index !== Number(tab.dataset.showSample); });
aside.querySelectorAll('button[data-show-sample]').forEach((button) => button.setAttribute('aria-pressed', String(button === tab)));
return;
}
const button = event.target.closest('button[data-copy]');
if (!button) return;
const content = document.getElementById(button.dataset.copy);
try {
if (!navigator.clipboard) throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(content.textContent);
copyStatus.textContent = '已复制示例;未执行任何请求。';
} catch {
const range = document.createRange(); range.selectNodeContents(content);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
copyStatus.textContent = '自动复制不可用,已选中示例,请手动复制。';
}
});
const search = document.getElementById('doc-search');
search.addEventListener('input', () => {
const term = search.value.trim().toLowerCase(); let visible = 0;
document.querySelectorAll('[data-doc-section]').forEach((section) => {
section.hidden = !!term && !section.textContent.toLowerCase().includes(term);
if (!section.hidden) visible++;
});
document.getElementById('no-results').hidden = visible !== 0;
document.getElementById('search-status').textContent = term ? `${visible} 个章节匹配` : '';
});
document.querySelector('nav').addEventListener('click', (event) => {
if (!event.target.closest('a')) return;
search.value = ''; search.dispatchEvent(new Event('input'));
});
if (window.innerWidth <= 700) document.querySelector('nav details').open = false;
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
export function httpDocVersion(markdown: string) {
const metadata = markdown.split(/\r?\n/).find((line) => line.startsWith('**接口版本:')) ?? '';
const version = /接口版本:([a-zA-Z0-9.-]+)/.exec(metadata)?.[1];
const revision = /\b\d{4}-\d{2}-\d{2}\b/.exec(metadata)?.[0];
if (!version || !revision) throw new Error('HTTP document metadata is missing');
return version + ' / ' + revision;
}
export function readHttpGuide() {
return readFileSync(resolve(__dirname, '../../../../docs/client-http-api-guide.md'), 'utf8');
}
function asset(name: string) {
return readFileSync(resolve(__dirname, '../../../src/open-api/docs', name), 'utf8');
}
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!);
}
function inline(value: string): string {
// Escape first; raw HTML can never execute. Only HTTP(S) and local anchors become links.
return escapeHtml(value).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, url: string) => /^(https?:\/\/|#)/i.test(url) ? `<a href="${url}" rel="noreferrer">${label}</a>` : label);
}
export function renderHttpGuide(markdown: string, origin: string) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const sections: Array<{ id: string; title: string; body: string[]; samples: string[] }> = [];
let current = { id: 'introduction', title: '接入指南', body: [] as string[], samples: [] as string[] };
sections.push(current);
let code: string[] | null = null;
let language = '';
let sampleTitle = '示例';
let table = false;
let sampleCount = 0;
const closeTable = () => { if (table) { current.body.push('</tbody></table></div>'); table = false; } };
for (const line of lines) {
if (code) {
if (/^```/.test(line)) {
current.samples.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
code = null;
} else code.push(line);
continue;
}
if (/^```/.test(line)) { closeTable(); code = []; language = line.slice(3).trim(); continue; }
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) {
closeTable();
sampleTitle = heading[2];
if (heading[1].length === 2) {
current = { id: 'section-' + sections.length, title: heading[2], body: [], samples: [] };
sections.push(current);
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
continue;
}
if (/^\s*\|/.test(line)) {
if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
if (!table) { current.body.push('<div class="http-doc-table"><table><tbody>'); table = true; }
current.body.push('<tr>' + line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => `<td>${inline(cell.trim())}</td>`).join('') + '</tr>');
continue;
}
closeTable();
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
}
closeTable();
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div><aside aria-label="${escapeHtml(section.title)} 示例">${section.samples.length > 1 ? '<div class="http-doc-sample-tabs" role="group" aria-label="切换示例">' + section.samples.map((_sample, index) => '<button type="button" data-show-sample="' + index + '" aria-pressed="' + (index === 0) + '">示例 ' + (index + 1) + '</button>').join('') + '</div>' : ''}${section.samples.map((sample, index) => sample.replace('class="http-doc-sample"', 'class="http-doc-sample"' + (index ? ' hidden' : ''))).join('')}</aside></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
}
+101 -15
View File
@@ -1,19 +1,61 @@
import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import {
CanActivate,
ExecutionContext,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
OnModuleDestroy,
Optional,
UnauthorizedException,
} from '@nestjs/common';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net';
import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis;
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
@Optional() private readonly protocolLogs?: ProtocolLogsService,
) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
request.openApiRequestId = 'req_' + randomUUID();
context
.switchToHttp()
.getResponse<{ setHeader: (name: string, value: string) => void }>()
.setHeader('X-Request-Id', request.openApiRequestId);
const startedAt = Date.now();
try {
return await this.authenticate(context);
} catch (error) {
const failure = publicOpenApiFailure(error);
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'authentication',
status: 'failed',
requestId: request.openApiRequestId,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
detail: { method: request.method, path: '/api/openapi/v1/sms' },
});
throw error;
}
}
private async authenticate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const accessKey = header(request, 'x-app-key');
const timestampText = header(request, 'x-timestamp');
@@ -40,26 +82,46 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' });
}
const timestamp = Number(timestampText);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
if (
!Number.isFinite(timestamp) ||
Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000
) {
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
}
const sourceIp = requestIp(request);
if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) {
if (
credential.application.httpIpAllowlist.length > 0 &&
(!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))
) {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
}
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex');
const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n');
const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex');
const bodyHash = openApiBodyHash(request.rawBody, request.body);
const expected = openApiSignature(
decryptSecret(credential.secretEncrypted),
request.method,
path,
timestampText,
nonce,
bodyHash,
);
const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
? Buffer.from(suppliedSignature, 'hex')
: Buffer.alloc(0);
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
}
const redis = this.getRedis();
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
const nonceAccepted = await redis.set(
`openapi:nonce:${credential.id}:${nonce}`,
'1',
'EX',
config.timestampToleranceSeconds * 2,
'NX',
);
if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
@@ -78,22 +140,41 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
accessKey,
sourceIp,
};
await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } });
await this.prisma.httpApiCredential.update({
where: { id: credential.id },
data: { lastUsedAt: new Date(), lastUsedIp: sourceIp },
});
return true;
}
onModuleDestroy() { this.redis?.disconnect(); }
onModuleDestroy() {
this.redis?.disconnect();
}
private getRedis() {
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
return this.redis;
}
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
private async recordFailure(
ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt',
request: OpenApiRequestLike,
account: string | undefined,
resultCode: string,
) {
const sourceIp = requestIp(request);
if (!sourceIp) return;
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
await this.security
.recordEvent({
ruleCode,
sourceIp,
account,
resultCode,
protocol: 'http',
path: (request.originalUrl ?? request.url ?? '').split('?')[0],
})
.catch(() => undefined);
}
}
@@ -105,7 +186,12 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
const trustedProxies = new Set(
(process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
);
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
}
@@ -0,0 +1,66 @@
import { PrismaService } from '../prisma/prisma.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { OpenApiController } from './open-api.controller';
import { OpenApiService } from './open-api.service';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
@Module({
controllers: [OpenApiController],
providers: [
{ provide: PrismaService, useValue: {} },
{ provide: SecurityDetectionService, useValue: {} },
{ provide: OpenApiService, useValue: {} },
{ provide: OpenApiAuthGuard, useValue: {} },
{ provide: OpenApiTraceInterceptor, useValue: {} },
],
})
class ContractModule {}
describe('generated public OpenAPI contract', () => {
it('describes exactly four operations, seven query fields, nullable IDs and both callbacks', async () => {
const app = await NestFactory.create(ContractModule, { logger: false, abortOnError: false });
try {
app.setGlobalPrefix('api');
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder().setTitle('contract').setVersion('v1').build(),
);
expect(
Object.values(document.paths).reduce(
(count, path) => count + Object.keys(path).filter((key) => ['get', 'post'].includes(key)).length,
0,
),
).toBe(4);
const post = document.paths['/api/openapi/v1/sms/messages'].post!;
const headers = post.parameters as Array<{ name: string; in: string; required?: boolean }>;
expect(headers.filter((field) => field.name.toLowerCase() === 'idempotency-key')).toHaveLength(1);
expect(headers.some((field) => field.name.toLowerCase() === 'user-agent' && field.required)).toBe(false);
const query = document.paths['/api/openapi/v1/sms/uplinks'].get!.parameters as Array<{
name: string;
in: string;
}>;
expect(
query
.filter((field) => field.in === 'query')
.map((field) => field.name)
.sort(),
).toEqual(['accessNumber', 'cursor', 'endTime', 'keyword', 'limit', 'mobile', 'startTime']);
for (const path of Object.values(document.paths)) {
if (path.get) expect(path.get.responses['200']).toHaveProperty('content.application/json.schema');
}
expect(document.components!.schemas!.OpenApiSendMessageResponseDto).toMatchObject({
properties: { clientMessageId: { type: 'string', nullable: true } },
});
expect(document.components!.schemas).toHaveProperty('OpenApiReceiptEventDto');
expect(document.components!.schemas).toHaveProperty('OpenApiUplinkEventDto');
const detail = JSON.stringify(document.components!.schemas!.OpenApiUplinkDetailDto);
expect(detail).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,33 @@
import { Controller, Get, Header, Query, Res } from '@nestjs/common';
import { httpDocVersion, readHttpGuide, renderHttpGuide } from './docs/reader';
@Controller('client-docs')
export class OpenApiDocsController {
@Get()
@Header('Cache-Control', 'no-cache')
getGuide(
@Query('format') format: string | undefined,
@Res()
response: {
type: (value: string) => void;
setHeader: (name: string, value: string) => void;
send: (value: string) => void;
},
) {
const markdown = readHttpGuide();
response.setHeader('X-Document-Version', httpDocVersion(markdown));
response.setHeader('X-Content-Type-Options', 'nosniff');
if (format === 'md') {
response.type('text/markdown; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="client-http-api-guide.md"');
response.send(markdown);
return;
}
response.type('text/html; charset=utf-8');
response.setHeader(
'Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
response.send(renderHttpGuide(markdown, process.env.HTTP_API_PUBLIC_ORIGIN?.replace(/\/+$/, '') ?? ''));
}
}
+40 -13
View File
@@ -1,20 +1,47 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { publicOpenApiFailure } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Catch()
export class OpenApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(OpenApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<{ status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } } }>();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const value = exception instanceof HttpException ? exception.getResponse() : {};
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {};
const rawMessage = object.message ?? (exception instanceof Error ? exception.message : 'Internal server error');
const detail = Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage);
response.status(status).type('application/problem+json').send({
type: `https://cmpp-platform.local/problems/${String(object.code ?? 'REQUEST_FAILED').toLowerCase()}`,
title: String(object.error ?? HttpStatus[status] ?? 'Request failed'),
status,
code: String(object.code ?? 'REQUEST_FAILED'),
detail,
const http = host.switchToHttp();
const request = http.getRequest<OpenApiRequestLike>();
const response = http.getResponse<{
setHeader: (name: string, value: string) => void;
status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } };
}>();
const requestId = (request.openApiRequestId ??= `req_${randomUUID()}`);
const failure = publicOpenApiFailure(exception);
// Dependency messages may include SQL values or credentials; keep safe correlation only.
if (failure.status >= 500)
this.logger.error({
requestId,
code: failure.code,
errorType: exception instanceof Error ? exception.name : 'UnknownError',
stack:
exception instanceof Error
? exception.stack
?.split('\n')
.filter((line) => /^\s*at /.test(line))
.slice(0, 8)
.join('\n')
: undefined,
});
response.setHeader('X-Request-Id', requestId);
response
.status(failure.status)
.type('application/problem+json')
.send({
type: `https://cmpp-platform.local/problems/${failure.code.toLowerCase()}`,
title: failure.status >= 500 ? 'Internal Server Error' : 'Request failed',
status: failure.status,
code: failure.code,
detail: failure.message,
requestId,
});
}
}
@@ -0,0 +1,181 @@
import { BadRequestException, HttpException } from '@nestjs/common';
import { Job } from 'bullmq';
import { createHash, createHmac } from 'node:crypto';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { OpenApiService } from './open-api.service';
import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { renderHttpGuide } from './docs/reader';
const auth = {
application: { id: 'own-app', tenantId: 'own-tenant' },
config: { sendEnabled: true, uplinkQueryEnabled: true, maxQueryRangeDays: 31, maxPageSize: 100 },
};
describe('HTTP API remediation boundaries', () => {
it('matches the published fixed GET signature vector', () => {
expect(
openApiSignature(
'doc-example-secret',
'GET',
'/api/openapi/v1/sms/uplinks',
'1789344000',
'550e8400-e29b-41d4-a716-446655440000',
openApiBodyHash(undefined, undefined),
),
).toBe('f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65');
});
it('keeps GET absent-body compatibility and signs exact POST UTF8 bytes', () => {
expect(openApiBodyHash(undefined, undefined)).toBe(
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
);
const raw = Buffer.from('{ "content": "中文\\n正文" }');
expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex'));
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', openApiBodyHash(raw, {})].join('\n');
expect(
openApiSignature(
'offline-secret',
'post',
'/api/openapi/v1/sms/messages?ignored=1',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex'));
for (const separator of ['\r\n', '\\n'])
expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe(
openApiSignature(
'offline-secret',
'POST',
'/api/openapi/v1/sms/messages',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
);
});
it('uses stable colon-free job IDs accepted by the actual BullMQ validator', () => {
const validate = (jobId: string) =>
(Job.prototype as unknown as { validateOptions: (data: unknown) => void }).validateOptions.call(
{ opts: { jobId } },
{ data: '{}' },
);
expect(() => validate('delivery:2')).toThrow('Custom Id cannot contain :');
expect(() => validate(webhookJobId('delivery:legacy', 2))).not.toThrow();
expect(webhookJobId('delivery:legacy', 2)).toBe(webhookJobId('delivery:legacy', 2));
expect(webhookJobId('delivery:legacy', 2)).not.toBe(webhookJobId('delivery:legacy', 3));
});
it.each([new Error('postgres://private:secret@host/secret'), new HttpException('private-secret', 503)])(
'does not expose dependency failures',
(error) => {
expect(publicOpenApiFailure(error)).toEqual({
status: 500,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
},
);
it('keeps first and replayed failure bodies consistent', () => {
const first = publicOpenApiFailure(new Error('private'));
const replay = publicOpenApiFailure(new HttpException({ code: first.code, message: first.message }, first.status));
expect(replay).toEqual(first);
expect(publicOpenApiFailure(new BadRequestException({ code: 'LIMIT_INVALID', message: 'bad limit' })).code).toBe(
'LIMIT_INVALID',
);
});
it('returns a safe request ID with the problem response', () => {
const send = jest.fn();
const setHeader = jest.fn();
const response = { setHeader, status: jest.fn(() => ({ type: () => ({ send }) })) };
new OpenApiExceptionFilter().catch(new Error('secret'), {
switchToHttp: () => ({ getRequest: () => ({ openApiRequestId: 'req-test' }), getResponse: () => response }),
} as never);
expect(setHeader).toHaveBeenCalledWith('X-Request-Id', 'req-test');
expect(send).toHaveBeenCalledWith(expect.objectContaining({ code: 'INTERNAL_ERROR', requestId: 'req-test' }));
expect(JSON.stringify(send.mock.calls)).not.toContain('secret');
});
it.each([1, {}, [], 'x'.repeat(129)])(
'rejects invalid clientMessageId before persistence',
async (clientMessageId) => {
const service = new OpenApiService({} as never, {} as never);
await expect(
service.sendMessage(auth as never, { mobile: '13800138000', content: '示例', clientMessageId } as never, {
idempotencyKey: 'offline-0001',
bodyHash: 'hash',
}),
).rejects.toMatchObject({ status: 400 });
},
);
it.each(['1.5', '0', '-1', 'NaN', 'Infinity', '', '9999999999999999999'])(
'rejects invalid limit %s before Prisma',
async (limit) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { limit })).rejects.toMatchObject({ status: 400 });
},
);
it.each([
'not-base64!',
Buffer.from(JSON.stringify(['2026-09-14', {}])).toString('base64url'),
Buffer.from(JSON.stringify(['bad-date', 'row'])).toString('base64url'),
])('rejects malformed cursor', async (cursor) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { cursor })).rejects.toMatchObject({ status: 400 });
});
it('projects only public detail fields and the authenticated tenant/application', async () => {
const findFirst = jest.fn().mockResolvedValue({ id: 'uplink' });
const service = new OpenApiService({ smsUplinkMessage: { findFirst } } as never, {} as never);
await service.getUplink(auth as never, 'uplink');
expect(findFirst).toHaveBeenCalledWith({
where: { id: 'uplink', applicationId: 'own-app', tenantId: 'own-tenant', matchStatus: 'matched' },
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
});
expect(JSON.stringify(findFirst.mock.calls)).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
});
it('retains application page-size clipping and a real empty response', async () => {
const findMany = jest.fn().mockResolvedValue([]);
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, {} as never);
await expect(service.listUplinks(auth as never, { limit: '1000' })).resolves.toEqual({
items: [],
nextCursor: null,
});
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 101 }));
});
it('never rebuilds an interrupted or uncertain request', async () => {
const send = jest.fn();
const service = new OpenApiService(
{
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'hash', status: 'requires_review' }) },
} as never,
{ createHttpBatchTask: send } as never,
);
await expect(
service.sendMessage(
auth as never,
{ mobile: '13800138000', content: '示例' },
{ idempotencyKey: 'offline-0001', bodyHash: 'hash' },
),
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
expect(send).not.toHaveBeenCalled();
});
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 接入\n<script>alert(1)</script>\n[bad](javascript:alert)\n```html\n<img src=x onerror=alert(1)>\n```',
'https://example.test',
);
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('href="javascript:');
expect(html).not.toContain('<img src=x');
expect(html).toContain('data-copy="sample-1"');
});
});
@@ -0,0 +1,28 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Optional } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { publicOpenApiFailure } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Injectable()
export class OpenApiTraceInterceptor implements NestInterceptor {
constructor(@Optional() private readonly logs?: ProtocolLogsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const startedAt = Date.now();
const record = (error?: unknown) =>
this.logs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: request.method === 'GET' ? 'query_request' : 'send_request',
status: error ? 'failed' : 'success',
requestId: request.openApiRequestId,
tenantId: request.openApiAuth?.application.tenantId,
applicationId: request.openApiAuth?.application.id,
resultCode: error ? publicOpenApiFailure(error).code : 'OK',
durationMs: Date.now() - startedAt,
detail: { method: request.method, operation: context.getHandler().name },
});
return next.handle().pipe(tap({ next: () => record(), error: (error: unknown) => record(error) }));
}
}
+76 -6
View File
@@ -1,19 +1,54 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common';
import { ApiBody, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { createHash } from 'node:crypto';
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
ValidationPipe,
BadRequestException,
} from '@nestjs/common';
import { ApiExtraModels, ApiBody, ApiHeader, ApiQuery, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { openApiBodyHash } from './open-api.protocol';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiService } from './open-api.service';
import type { OpenApiRequestLike } from './open-api.types';
import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { OpenApiSendMessageDto, OpenApiSendMessageResponseDto } from './open-api.dto';
import {
OpenApiSendMessageDto,
OpenApiSendMessageResponseDto,
OpenApiMessageDto,
OpenApiUplinksDto,
OpenApiUplinkDetailDto,
OpenApiProblemDto,
OpenApiReceiptEventDto,
OpenApiUplinkEventDto,
} from './open-api.dto';
@ApiExtraModels(OpenApiReceiptEventDto, OpenApiUplinkEventDto)
@ApiTags('client-open-api-v1')
@ApiHeader({ name: 'X-App-Key', required: true })
@ApiHeader({ name: 'X-Timestamp', required: true })
@ApiHeader({ name: 'X-Nonce', required: true })
@ApiHeader({ name: 'X-Signature', required: true })
@ApiResponse({ status: 400, type: OpenApiProblemDto })
@ApiResponse({ status: 401, type: OpenApiProblemDto })
@ApiResponse({ status: 403, type: OpenApiProblemDto })
@ApiResponse({ status: 404, type: OpenApiProblemDto })
@ApiResponse({ status: 409, type: OpenApiProblemDto })
@ApiResponse({ status: 422, type: OpenApiProblemDto })
@ApiResponse({ status: 429, type: OpenApiProblemDto })
@ApiResponse({ status: 500, type: OpenApiProblemDto })
@UseGuards(OpenApiAuthGuard)
@UseFilters(OpenApiExceptionFilter)
@UseInterceptors(OpenApiTraceInterceptor)
@Controller('openapi/v1/sms')
export class OpenApiController {
constructor(private readonly service: OpenApiService) {}
@@ -22,27 +57,62 @@ export class OpenApiController {
@HttpCode(202)
@ApiHeader({ name: 'Idempotency-Key', required: true })
@ApiOperation({ summary: '发送单条短信' })
@UsePipes(
new ValidationPipe({
transform: true,
exceptionFactory: () => new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' }),
}),
)
@ApiBody({ type: OpenApiSendMessageDto })
@ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto })
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) {
return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent });
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto) {
return this.service.sendMessage(request.openApiAuth!, body, {
idempotencyKey: scalarHeader(request, 'idempotency-key'),
bodyHash: openApiBodyHash(request.rawBody, body),
userAgent: scalarHeader(request, 'user-agent'),
});
}
@ApiResponse({ status: 200, type: OpenApiMessageDto })
@Get('messages/:messageId')
@ApiOperation({ summary: '查询短信状态' })
getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) {
return this.service.getMessage(request.openApiAuth!, messageId);
}
@ApiResponse({ status: 200, type: OpenApiUplinksDto })
@ApiQuery({ name: 'startTime', required: false, type: String, description: 'ISO8601时间,默认endTime前24小时' })
@ApiQuery({
name: 'endTime',
required: false,
type: String,
description: 'ISO8601时间,默认当前时间;翻页固定时间范围',
})
@ApiQuery({ name: 'mobile', required: false, type: String })
@ApiQuery({ name: 'accessNumber', required: false, type: String })
@ApiQuery({ name: 'keyword', required: false, type: String })
@ApiQuery({
name: 'limit',
required: false,
schema: { type: 'integer', minimum: 1, default: 50 },
description: '按当前应用maxPageSize裁剪',
})
@ApiQuery({ name: 'cursor', required: false, type: String })
@Get('uplinks')
@ApiOperation({ summary: '游标分页查询上行短信' })
listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record<string, string | undefined>) {
return this.service.listUplinks(request.openApiAuth!, query);
}
@ApiResponse({ status: 200, type: OpenApiUplinkDetailDto })
@Get('uplinks/:uplinkId')
@ApiOperation({ summary: '查询上行短信详情' })
getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) {
return this.service.getUplink(request.openApiAuth!, uplinkId);
}
}
function scalarHeader(request: OpenApiRequestLike, name: string) {
const value = request.headers[name];
return Array.isArray(value) ? value[0] : value;
}
+89 -2
View File
@@ -1,16 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength, Matches, IsNotEmpty } from 'class-validator';
export class OpenApiSendMessageDto {
@ApiProperty({ example: '13800138000', description: '中国大陆手机号' })
@IsString()
@Matches(/^1\d{10}$/)
mobile!: string;
@ApiProperty({
example: '【示例签名】您的验证码是123456,5分钟内有效。',
description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID',
})
@IsString()
@IsNotEmpty()
content!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', maxLength: 128 })
@ApiPropertyOptional({ type: String, nullable: true, example: 'order-20260720-0001', maxLength: 128 })
@IsOptional()
@IsString()
@MaxLength(128)
clientMessageId?: string;
}
@@ -24,7 +32,7 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' })
messageId!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', nullable: true })
@ApiProperty({ type: String, example: 'order-20260720-0001', nullable: true })
clientMessageId!: string | null;
@ApiProperty({ example: 'queued' })
@@ -33,3 +41,82 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: '2026-07-20T08:00:00.000Z' })
acceptedAt!: string;
}
export class OpenApiProblemDto {
@ApiProperty() type!: string;
@ApiProperty() title!: string;
@ApiProperty() status!: number;
@ApiProperty() code!: string;
@ApiProperty() detail!: string;
@ApiProperty() requestId!: string;
}
export class OpenApiMessageDto {
@ApiProperty() messageId!: string;
@ApiProperty({ type: String, nullable: true }) clientMessageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() status!: string;
@ApiProperty() submitStatus!: string;
@ApiProperty() receiptStatus!: string;
@ApiProperty({ type: String, nullable: true }) errorCode!: string | null;
@ApiProperty({ type: String, nullable: true }) errorMessage!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) queuedAt!: string;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) submittedAt!: string | null;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) deliveredAt!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) updatedAt!: string;
}
export class OpenApiUplinkDto {
@ApiProperty() id!: string;
@ApiProperty({ type: String, nullable: true }) messageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
}
export class OpenApiUplinkDetailDto extends OpenApiUplinkDto {
@ApiProperty() tenantId!: string;
@ApiProperty() applicationId!: string;
}
export class OpenApiUplinksDto {
@ApiProperty({ type: [OpenApiUplinkDto] }) items!: OpenApiUplinkDto[];
@ApiProperty({ type: String, nullable: true }) nextCursor!: string | null;
}
export class OpenApiReceiptDataDto {
@ApiProperty() messageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) gatewayMessageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() receiptStatus!: string;
@ApiPropertyOptional({ type: String, nullable: true }) rawStatus?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorCode?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorMessage?: string | null;
@ApiPropertyOptional({ type: String, format: 'date-time', nullable: true }) deliveredAt?: string | null;
}
export class OpenApiUplinkDataDto {
@ApiProperty() applicationId!: string;
@ApiProperty() uplinkMessageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) messageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
@ApiPropertyOptional() manualClaim?: boolean;
}
export class OpenApiReceiptEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['receipt'] }) eventType!: 'receipt';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiReceiptDataDto }) data!: OpenApiReceiptDataDto;
}
export class OpenApiUplinkEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['uplink'] }) eventType!: 'uplink';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiUplinkDataDto }) data!: OpenApiUplinkDataDto;
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import { OpenApiDocsController } from './open-api-docs.controller';
import { forwardRef, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { SendChainModule } from '../send-chain/send-chain.module';
@@ -10,8 +12,8 @@ import { SecurityDetectionModule } from '../security-detection/security-detectio
@Module({
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiService, OpenApiAuthGuard],
controllers: [OpenApiDocsController, OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiTraceInterceptor, OpenApiService, OpenApiAuthGuard],
exports: [OpenApiService],
})
export class OpenApiModule {}
+40
View File
@@ -0,0 +1,40 @@
import { createHash, createHmac } from 'node:crypto';
import { HttpException } from '@nestjs/common';
/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
return createHash('sha256')
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
.digest('hex');
}
export function openApiSignature(
secret: string,
method: string,
path: string,
timestamp: string,
nonce: string,
bodyHash: string,
) {
return createHmac('sha256', secret)
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n'))
.digest('hex');
}
export function publicOpenApiFailure(error: unknown) {
if (!(error instanceof HttpException) || error.getStatus() >= 500) {
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
}
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const message = object.message ?? value;
return {
status: error.getStatus(),
code: String(object.code ?? 'REQUEST_FAILED'),
message: Array.isArray(message) ? message.join('') : String(message),
};
}
export function webhookJobId(deliveryId: string, attemptNo: number) {
return `webhook-${createHash('sha256').update(deliveryId).digest('hex')}-${attemptNo}`;
}
@@ -0,0 +1,70 @@
import { OpenApiRecovery } from './open-api.recovery';
describe('OpenApiRecovery', () => {
function setup() {
const prisma = {
openApiDispatchOutbox: {
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsBatchTask: { findUnique: jest.fn().mockResolvedValue({ status: 'queued' }) },
httpWebhookDelivery: { findMany: jest.fn().mockResolvedValue([]) },
};
const send = { enqueueBatchTask: jest.fn().mockResolvedValue({}) };
const queue = { getJob: jest.fn().mockResolvedValue(undefined), add: jest.fn().mockResolvedValue({}) };
return { prisma, send, queue, recovery: new OpenApiRecovery(prisma as never, send as never, queue as never) };
}
it('keeps publication failures durable without marking them dispatched', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
send.enqueueBatchTask.mockRejectedValue(new Error('controlled failure'));
await recovery.tick();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'pending' }),
data: expect.objectContaining({ leaseToken: expect.any(String), leaseUntil: expect.any(Date) }),
}),
);
});
it('does not dispatch a batch whose lease was taken by another instance', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.openApiDispatchOutbox.updateMany.mockResolvedValue({ count: 0 });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
});
it.each(['canceled', 'sending', 'finished', 'rejected', 'pending_review'])(
'does not enqueue non-eligible batch %s',
async (status) => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.smsBatchTask.findUnique.mockResolvedValue({ status });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'closed' }) }),
);
},
);
it('scans only versioned webhook deliveries and preserves active Redis jobs', async () => {
const { prisma, queue, recovery } = setup();
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'active' });
await recovery.tick();
expect(queue.add).not.toHaveBeenCalled();
expect(prisma.httpWebhookDelivery.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ recoveryVersion: 1 }), take: 50 }),
);
});
it('recovers a failed Redis job only when its durable PG row still needs delivery', async () => {
const { prisma, queue, recovery } = setup();
const remove = jest.fn().mockResolvedValue(undefined);
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'failed', remove });
await recovery.tick();
expect(remove).toHaveBeenCalledTimes(1);
expect(queue.add).toHaveBeenCalledTimes(1);
expect(queue.add.mock.calls[0][2].jobId).not.toContain(':');
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { PrismaService } from '../prisma/prisma.service';
import type { SendChainService } from '../send-chain/send-chain.service';
import type { Queue } from 'bullmq';
import { webhookJobId } from './open-api.protocol';
/** Only versioned/new durable work is eligible; never infer or replay historical work. */
export class OpenApiRecovery {
private readonly logger = new Logger(OpenApiRecovery.name);
private pending?: Promise<void>;
private stopped = false;
constructor(
private readonly prisma: PrismaService,
private readonly sendChain: SendChainService,
private readonly queue: Queue<{ deliveryId: string }>,
) {}
tick(): Promise<void> {
if (this.stopped) return Promise.resolve();
if (this.pending) return this.pending;
this.pending = this.run().finally(() => {
this.pending = undefined;
});
return this.pending;
}
async close() {
this.stopped = true;
await this.pending;
}
private async run() {
try {
await this.dispatchMessages();
await this.dispatchWebhooks();
} catch (error) {
this.logger.error({
code: 'OPENAPI_RECOVERY_FAILED',
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
private async dispatchMessages() {
const now = new Date();
const rows = await this.prisma.openApiDispatchOutbox.findMany({
where: { status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
orderBy: { createdAt: 'asc' },
take: 50,
});
for (const row of rows) {
if (this.stopped) return;
const leaseToken = randomUUID();
const claimed = await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
data: { leaseToken, leaseUntil: new Date(Date.now() + 120_000) },
});
if (!claimed.count) continue;
try {
const task = await this.prisma.smsBatchTask.findUnique({
where: { id: row.batchTaskId },
select: { status: true },
});
if (!task || !['ready', 'queued'].includes(task.status)) {
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'closed', leaseToken: null, leaseUntil: null },
});
continue;
}
await this.sendChain.enqueueBatchTask(row.batchTaskId);
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'dispatched', leaseToken: null, leaseUntil: null },
});
} catch (error) {
this.logger.error({
code: 'OPENAPI_DISPATCH_PENDING',
outboxId: row.id,
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
}
private async dispatchWebhooks() {
const now = new Date();
const rows = await this.prisma.httpWebhookDelivery.findMany({
where: {
recoveryVersion: 1,
status: { in: ['pending', 'retrying', 'delivering'] },
AND: [
{ OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }] },
{ OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
],
},
orderBy: { nextRetryAt: 'asc' },
take: 50,
});
for (const row of rows) {
const jobId = webhookJobId(row.id, row.attemptCount + 1);
const job = await this.queue.getJob(jobId);
if (job) {
const state = await job.getState();
if (!['failed', 'completed'].includes(state)) continue;
// Failed/finished jobs are no longer executing; DB state is the durable authority.
await job.remove();
}
await this.queue.add('deliver', { deliveryId: row.id }, { jobId, removeOnComplete: 1000, removeOnFail: 1000 });
}
}
}
+21 -38
View File
@@ -60,9 +60,7 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => {
const prisma = {
openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
findUnique: jest.fn().mockResolvedValue({
bodyHash: 'same',
status: 'completed',
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
@@ -97,9 +95,7 @@ describe('OpenApiService', () => {
it('replays the same persisted business rejection', async () => {
const prisma = {
openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
findUnique: jest.fn().mockResolvedValue({
bodyHash: 'same',
status: 'failed',
httpStatus: 422,
@@ -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(
await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
{ 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' }),
}),
);
),
).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({
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,9 +204,7 @@ 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({
findFirst: jest.fn().mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
@@ -280,9 +265,7 @@ describe('OpenApiService', () => {
it('rejects an already expired credential before writing a secret', async () => {
const prisma = {
smsApplication: {
findFirst: jest
.fn()
.mockResolvedValue({
findFirst: jest.fn().mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
+172 -147
View File
@@ -1,3 +1,5 @@
import { OpenApiRecovery } from './open-api.recovery';
import { HTTP_REQUEST_CONTEXT } from '../send-chain/send-chain.contracts';
import {
BadRequestException,
ConflictException,
@@ -14,7 +16,7 @@ import {
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
import { createHmac, randomBytes, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { request as httpRequest } from 'node:http';
@@ -24,8 +26,18 @@ import { SendChainService } from '../send-chain/send-chain.service';
import { decryptSecret, encryptSecret } from './open-api.crypto';
import type { OpenApiAuthContext } from './open-api.types';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { automaticDeliveryMode } from './delivery-mode';
export const OPEN_API_WEBHOOK_TRANSPORT = Symbol('open-api-webhook-transport');
export type OpenApiWebhookTransport = (
url: string,
body: string,
headers: Record<string, string>,
timeoutMs: number,
requireHttps: boolean,
) => Promise<{ status: number; body: string }>;
const WEBHOOK_QUEUE = 'http-webhook-delivery';
const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400];
@@ -58,11 +70,14 @@ export type HttpConfigInput = {
export class OpenApiService implements OnModuleInit, OnModuleDestroy {
private queue?: Queue<{ deliveryId: string }>;
private worker?: Worker<{ deliveryId: string }>;
private recovery?: OpenApiRecovery;
private recoveryTimer?: ReturnType<typeof setInterval>;
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService,
@Optional() private readonly protocolLogs?: ProtocolLogsService,
@Optional() @Inject(OPEN_API_WEBHOOK_TRANSPORT) private readonly webhookTransport?: OpenApiWebhookTransport,
) {}
onModuleInit() {
@@ -72,6 +87,9 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
// Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
this.recovery = new OpenApiRecovery(this.prisma, this.sendChain, this.queue);
this.recoveryTimer = setInterval(() => void this.recovery?.tick(), 15_000);
this.recoveryTimer.unref?.();
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
connection,
concurrency: 10,
@@ -79,6 +97,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}
async onModuleDestroy() {
if (this.recoveryTimer) clearInterval(this.recoveryTimer);
await this.recovery?.close();
await this.worker?.close();
await this.queue?.close();
}
@@ -262,10 +282,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
) {
if (!auth.config.sendEnabled)
throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
const mobile = String(input.mobile ?? '').trim();
if (
typeof input.mobile !== 'string' ||
typeof input.content !== 'string' ||
(input.clientMessageId != null &&
(typeof input.clientMessageId !== 'string' || Array.from(input.clientMessageId).length > 128))
) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' });
}
const mobile = input.mobile.trim();
const content = String(input.content ?? '');
if (!/^1[3-9]\d{9}$/.test(mobile))
throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!/^1\d{10}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
@@ -283,8 +310,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus)
if (['failed', 'requires_review'].includes(existing.status) && existing.responseBody && existing.httpStatus)
throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
if (
existing.status === 'requires_review' ||
(existing.createdAt && Date.now() - existing.createdAt.getTime() > 600_000)
)
throw new ConflictException({
code: 'REQUEST_REQUIRES_REVIEW',
message: '请求结果待核对,请提供requestId联系支持,勿更换幂等键重发',
requestId: existing.requestId,
});
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
if (input.clientMessageId) {
@@ -326,14 +362,15 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus)
if (raced && ['failed', 'requires_review'].includes(raced.status) && raced.responseBody && raced.httpStatus)
throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
throw error;
}
try {
const task = await this.sendChain.createHttpBatchTask({
await this.sendChain.createHttpBatchTask({
[HTTP_REQUEST_CONTEXT]: { id: request.id, requestId },
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
content,
@@ -342,50 +379,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
userAgent: meta.userAgent,
clientMessageId: input.clientMessageId,
});
const message = task.messages?.[0];
if (task.status === 'rejected' || message?.status === 'rejected') {
throw new UnprocessableEntityException({
code: 'SEND_REJECTED',
message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验',
});
const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (frozen?.status === 'completed' && frozen.responseBody) {
void this.recovery?.tick();
return frozen.responseBody;
}
const response = {
code: 'ACCEPTED',
requestId,
messageId: message?.messageId,
clientMessageId: input.clientMessageId ?? null,
status: message?.status ?? task.status,
acceptedAt: new Date().toISOString(),
};
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'completed',
httpStatus: 202,
businessCode: 'ACCEPTED',
responseBody: response,
messageRecordId: message?.id,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'send_request',
status: 'accepted',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
messageId: message?.messageId,
requestId,
phone: mobile,
resultCode: 'ACCEPTED',
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
detail: { clientMessageId: input.clientMessageId },
});
return response;
if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
throw new Error('HTTP acceptance snapshot was not committed');
} catch (error) {
const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (frozen?.status === 'completed' && frozen.responseBody) return frozen.responseBody;
if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
let outwardError = error;
if (error instanceof HttpException && error.getStatus() === 400) {
const response = error.getResponse();
@@ -399,7 +405,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'failed',
status: failure.httpStatus >= 500 ? 'requires_review' : 'failed',
httpStatus: failure.httpStatus,
businessCode: failure.code,
responseBody: failure.responseBody,
@@ -407,20 +413,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'send_request',
status: 'failed',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
requestId,
phone: mobile,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
});
throw outwardError;
throw new HttpException(failure.responseBody as Record<string, unknown>, failure.httpStatus);
}
}
@@ -451,6 +445,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) {
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
for (const value of Object.values(query)) {
if (value !== undefined && typeof value !== 'string')
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
}
const endTime = query.endTime ? new Date(query.endTime) : new Date();
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
@@ -460,7 +458,12 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
code: 'TIME_RANGE_TOO_LARGE',
message: `单次查询不能超过${auth.config.maxQueryRangeDays}`,
});
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize);
if (
query.limit !== undefined &&
(!/^\d+$/.test(query.limit) || !Number.isSafeInteger(Number(query.limit)) || Number(query.limit) < 1)
)
throw new BadRequestException({ code: 'LIMIT_INVALID', message: 'limit必须为正整数' });
const limit = Math.min(Number(query.limit ?? 50), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({
where: {
@@ -482,8 +485,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: true,
destId: true,
content: true,
matchStatus: true,
matchReason: true,
receivedAt: true,
},
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
@@ -499,7 +500,22 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({
where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' },
where: {
id: uplinkId,
applicationId: auth.application.id,
tenantId: auth.application.tenantId,
matchStatus: 'matched',
},
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
});
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row;
@@ -532,13 +548,14 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
: data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`;
const event = await this.prisma.httpWebhookEvent.upsert({
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,
applicationId: data.applicationId!,
eventType: data.eventType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
@@ -546,16 +563,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
payload: data.payload as Prisma.InputJsonValue,
},
});
const delivery = await this.prisma.httpWebhookDelivery.upsert({
return tx.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
update: {},
create: { eventId: event.id, endpointId: endpoint.id },
create: { eventId: event.id, endpointId: endpoint.id, recoveryVersion: 1 },
});
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 willRetry = !success && 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 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;
const failure = publicOpenApiFailure(error);
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,
};
}
return {
httpStatus: 500,
code: 'INTERNAL_ERROR',
responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
httpStatus: failure.status,
code: failure.code,
responseBody: { code: failure.code, message: failure.message } as Prisma.InputJsonValue,
};
}
@@ -968,7 +989,11 @@ function encodeCursor(receivedAt: Date, id: string) {
function decodeCursor(value?: string) {
if (!value) return null;
try {
const [date, id] = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as [string, string];
if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error();
const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string')
throw new Error();
const [date, id] = parsed;
const receivedAt = new Date(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id };
+1
View File
@@ -17,4 +17,5 @@ export type OpenApiRequestLike = {
headers: Record<string, string | string[] | undefined>;
socket?: { remoteAddress?: string };
openApiAuth?: OpenApiAuthContext;
openApiRequestId?: string;
};
+123 -33
View File
@@ -1,20 +1,41 @@
import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
import {
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import type {
CreateBatchTaskDto,
CreateHttpBatchTaskDto,
ImportPreviewDto,
ConfirmImportDto,
QueuePriority,
} from './send-chain.contracts';
import {
statusFromRisk,
parseSchedule,
parseImportRows,
normalizeQueuePriority,
matchTemplateContent,
shanghaiDateKey,
} from './send-chain.helpers';
import { detectDrainageContent } from './drainage-content-detection';
import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
import type {
SendResourceValidationOptions,
SendSubmissionCallbacks,
SendSubmissionService,
} from './send-submission.service';
/**
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
@@ -33,7 +54,13 @@ export class SendBatchEntryService {
) {}
private releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.callbacks.releaseMessageReservation(message, remark);
@@ -56,15 +83,20 @@ export class SendBatchEntryService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async createBatchTask(data: CreateBatchTaskDto) {
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),
@@ -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({
@@ -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,7 +221,8 @@ async createBatchTask(data: CreateBatchTaskDto) {
},
});
if (phones.length > 0) {
await this.prisma.smsMessageRecord.createMany({
const persistMessages = async (tx: Prisma.TransactionClient) => {
await tx.smsMessageRecord.createMany({
data: phones.map((phone) => {
const rejection = phoneRejections.get(phone);
const status = rejection
@@ -203,7 +240,8 @@ async createBatchTask(data: CreateBatchTaskDto) {
signatureId: messageClassification.signatureId,
drainageInfoId: messageClassification.drainageInfoId,
reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
messageId: `MSG-${randomUUID()}`,
...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
messageId: httpRequest ? `MSG-http-${httpRequest.id}` : `MSG-${randomUUID()}`,
clientMessageId: data.clientMessageId,
phoneNumber: phone,
content: data.content,
@@ -217,12 +255,46 @@ async createBatchTask(data: CreateBatchTaskDto) {
status,
submitStatus: rejection ? 'rejected' : undefined,
errorCode: rejection?.code,
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined),
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 (batchStatus === 'ready' && sendablePhones.length > 0) {
};
if (httpRequest) await this.prisma.$transaction(persistMessages);
else await persistMessages(this.prisma);
}
if (!httpRequest && batchStatus === 'ready' && sendablePhones.length > 0) {
await this.facade.enqueueBatchTask(task.id);
} else if (batchStatus === 'failed') {
await this.facade.refreshTaskProgress(task.id);
@@ -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({
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);
@@ -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);
@@ -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') {
@@ -515,12 +600,15 @@ async validateSendResources(
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount);
if (!result.reserved) {
throw new HttpException({
throw new HttpException(
{
code: 'DAILY_SEND_LIMIT_EXCEEDED',
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
dailyLimit: result.dailyLimit,
requestedCount,
}, HttpStatus.TOO_MANY_REQUESTS);
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
return result;
}
@@ -570,11 +658,13 @@ async tryReserveDailySendQuota(applicationId: string, requestedCount: number, re
if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
throw new ConflictException('日发送配额幂等键已用于另一笔预留');
}
return [{
return [
{
tenantId: existing.tenantId,
dailyLimit: existing.dailyLimit,
usedCount: existing.usedCount,
}];
},
];
}
const reservedRows = await reserve(tx);
if (reservedRows.length > 0) {
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -2272,3 +2272,9 @@
### 通道敏感词实施更新(2026-09-10)
用户已授权执行修订方案、本地提交及测试部署。本地已实现独立Tab/词库、管理审计和版本冲突、普通/微批仅选路过滤、运营端历史解释及独立失败完成恢复标记。无Gateway/逐片复核;配置仅影响后续读取规则的选路,非CMPP不新增拒绝推送。实际实现与验证见[channel-sensitive-words方案第8节](channel-sensitive-words-plan-20260910.md),提交/环境状态以testing-progress.md为准。
## 2026-09-14 HTTP整改实施要求
按[HTTP整改方案](http-api-assessment-20260910.md)本轮实施章节执行R01~R09。保持v1四接口和GET兼容摘要;补参数/响应契约、公共错误和交互关联ID。新请求以确定性业务关联及消息/响应/发送待办原子落库避免永久processing;未知结果必须核对,不自动新建短信。新回调耐久恢复采用安全任务编号、租约和原子尝试记录,旧回调不自动回填或重投。
用户本轮明确:上行不得暴露通道、供应商编号及内部匹配诊断,仅输出客户自己的公共业务字段和所属企业/应用ID。公共文档与客户端Tab同源复用,未开通/无应用/配置失败可读通用文档,不能用默认值冒充真实应用配置。发送正向链路和历史回调处理须另有专项授权。本轮提交、推送、测试环境部署已授权;预生产未授权。
+288
View File
@@ -0,0 +1,288 @@
# 客户 HTTP 接口与接口文档整改方案
修订日期:2026-09-14。状态:**整改代码实施中,发布与验收见本轮记录**。本文件由原《客户 HTTP 接口与对接文档评估》转为整改方案,保留原文件名 `http-api-assessment-20260910.md`,避免既有链接失效;末尾原评估作为2026-09-10历史证据保留,历史版本/计数不代表当前现场。
原文档讨论轮授权仅修改接口手册、整改方案及相关需求/用例/进度文档,不修改运行代码、页面、数据库、Redis、业务配置或发布工具,不提交、推送或部署,不发送或重投短信/回调。用户仍将继续提出问题,不能把讨论稿视为已定稿并自动开始实施。
## 2026-09-14 本轮实施设计(覆盖上述旧文档阶段授权说明)
本次用户已明确授权按本方案整改、提交、推送及测试环境发布;不含预生产发布、实际短信发送、历史短信/回调重投及业务配置变更。原讨论记录保留历史含义。
- R01:本次维持无体GET的`{}`摘要、五行LF、PATH不含query和旧合法nonce。抽取唯一签名实现并用手册固定向量验证,不切换空字节协议。
- R02/R03回调:仅新事件进入版本化耐久投递记录;事件和投递同一PG事务。PG保存下一尝试时间、租约和尝试号,扫描器每15秒最多认领50条;Redis任务编号由deliveryId/attemptNo组成且不含冒号。投递前条件认领,尝试和最终/退避状态同事务;崩溃后租约到期恢复。HTTP可能已到达但ACK丢失时属于至少一次投递,客户按eventId幂等。旧记录不自动升级或补投,手工重投继续要求原权限和明确操作。
- R03发送:新请求的批次、消息采用确定性关联;消息创建、HTTP响应快照及发送待办在同一PG事务提交,之后才投Redis。已冻结的202快照不因后续Redis失败变成500/failed;待办仅认领本轮新增的耐久记录,使用现有消息ID去重与发送状态检查。业务前置风控/余额操作仍在既有服务执行,若在消息事务前中断,请求进入`requires_review`,禁止超时自动重建/重发。历史processing只报告核对状态,不猜测关联或补投。追加迁移,不回填历史。
- R04/R05:保持四业务路径,显式DTO/schema/queryclientMessageId只接受string/null且最长128,limit必须正整数并保持应用上限裁剪。历史数字/对象clientMessageId、小数limit原属未承诺输入,本轮改为稳定400。未知异常和幂等重放统一INTERNAL_ERROR与固定公共文案;每次HTTP交互返回独立X-Request-Id,业务requestId保持幂等稳定。
- R06:用户明确禁止暴露通道字段,详情和列表直接采用公共投影,移除通道/供应商编号及内部事件、匹配诊断;详情允许返回当前客户自己的tenantId/applicationId。属于已确认的字段收紧,旧接入需移除内部字段依赖,不保留可选泄露通道的兼容视图。
- R07:复用现有有界协议日志,记录查询/鉴权失败/限流的关联ID、路径模板、结果和耗时,不保存正文、密钥、签名或完整鉴权头;不新增无界日志表。
- R08:公开页与客户端Tab复用阅读组件及单一MD源。API独立阅读器服务文档HTML并内嵌精确CSS/JS,客户端以同源iframe复用该阅读器与单一MD正文,不生成另一套长文案;无需新增Nginx资源白名单。独立文档入口只服务文档HTML/精确资源,不放开管理路由;OpenAPI JSON原地址保留。文档资源打包进入候选,页面支持目录、示例复制、MD/JSON下载、错误检索和三尺寸。配置失败/无应用仍可读通用正文,真实参数不得用默认值冒充。
- R09:维持默认24小时、应用级跨度与分页上限;不新增留存清理或扩大查询窗口。
验收按R01~R09分别记录:故障隔离用真实PG/Redis及受控接收端;禁止向现存业务队列创建探针。短信正向链路未获专项发送授权时明确未执行;测试环境发布与真实页面仍需独立核验。追加字段/表允许应用回退,回退不删除待办;保留恢复资产。需要测试机sudo时使用标准工具掩码入口。
## 一、目标与文档分工
- [客户接口手册](client-http-api-guide.md):面向客户开发人员,说明当前可用调用方式、参数/响应、报文、接入步骤和限制;拟变更行为明确标注,不能伪装成已上线。
- 本方案:维护当前问题、已采纳原则、实施范围、兼容影响、页面整改和验收条件。历史HTTP-A01~A08作为问题来源,均未因文档修改而关闭。
- [需求](first-version-development-requirements.md):维护业务及页面要求;[用例](system-functional-test-cases.md)维护验收;[进度](testing-progress.md)维护实际执行证据。
- 顺序固定为:继续讨论并确定手册 → 完善/确定整改方案 → 用户授权后修改代码与验收 → 按独立授权提交/发布。文档可先完善,但不预先宣称业务功能通过。
## 二、本次已采纳的接口与表述原则
| 主题 | 已采纳原则 | 兼容与现状 |
| --- | --- | --- |
| 上行时间 | 分开说明默认24小时、单次跨度默认31天、历史留存、游标有效期 | 单次跨度按应用配置;当前没有“只查最近31天”硬限制;90天保留配置未发现执行闭环,不承诺已生效 |
| 请求标识 | `X-Nonce` 中文名改为“请求唯一标识”,推荐标准库UUID v4 | 保留字段名和原8~128字符规则,不强制淘汰旧合法随机值;每次调用/重试生成新值 |
| 业务标识 | nonce、Idempotency-Key、clientMessageId分开讲解 | 同一发送的网络重试更新nonce/时间戳/签名,保持幂等键、客户编号和body字节 |
| 签名结构 | 保留现有五项固定顺序和LF,不改成`&`拼接协议 | 公式明确`\n`为0x0A,非字面反斜杠n、非CRLF、无结尾换行;提供固定向量 |
| query与空体 | 当前PATH不含queryGET空体按`{}`摘要是兼容现状,不是整改完成 | 不在本轮偷偷加入query签名;标准空体摘要的迁移策略需结合既有客户再确定 |
| 示例 | 四接口逐一提供cURL、成功/失败HTTP响应;上行含空数据/两页,回调含POST与ACK;单发另提供完整独立生成脚本和已计算演示向量 | 区分结构占位、动态生成和固定离线校对;演示凭据无业务权限,生成命令不等于执行或发送成功 |
| 协议能力 | 保留四公开接口、现有应用隔离和HMAC安全基础 | 不新增匿名试发、批量发送、余额/模板管理等范围 |
nonce表述和UUID示例不需要修改服务端鉴权规则。接口响应默认值、枚举、错误码等须以最终设计约定落实,不能把偶然实现行为长期固化为正确设计。
## 三、整改工作项与依赖
所有“目标”均为待实施,表中“文档已补”不表示线上已修复。
| 编号 | 来源 | 目标与最小范围 | 验收与依赖 |
| --- | --- | --- | --- |
| HTTP-R01 | A01 | 明确空体摘要、UTF-8/JSON原字节、LF及路径规则;签名函数与服务端共享测试向量;保留nonce头兼容 | 定向鉴权/重放/篡改测试和真实认证查询;先查既有客户兼容,当前手册暂保留`{}` |
| HTTP-R02 | A02 | 回调自动/手工任务编号采用安全稳定格式;禁止依赖BullMQ冒号兼容分支 | 真实隔离Redis/PG和受控回调端验证首次失败后确有后续任务、尝试/退避/上限一致 |
| HTTP-R03 | A03 | 请求与业务记录建立确定性关联,补处理中断和DB到Redis入队恢复机制 | 实施前补详细数据模型/事务和状态设计;不得超时后直接重新发送;未知结果进入核对状态 |
| HTTP-R04 | A04/A05 | 明确请求与响应DTO、类型/可空性、参数校验和公共错误;修Swagger重复/误必填及缺失schema/query | 生成契约检查、边界/权限/租户隔离真实验收;有历史兼容影响的字段收紧先列清单 |
| HTTP-R05 | A06 | 未知异常对外固定公共信息,内部保留关联诊断;首次/幂等重放错误一致 | 不泄露依赖异常,错误码/响应头/日志关联可核对 |
| HTTP-R06 | A07 | 上行详情改为公共响应映射,列表/详情字段语义一致 | 现有客户内部字段依赖先盘点;删字段需兼容策略,不直接破坏旧接入 |
| HTTP-R07 | A08 | 补查询、验签/限流失败的可定位性,区分请求唯一标识、平台requestId、业务编号和回调eventId | 控制日志量与隐私;增加的持久化/索引需单列设计,不将全部请求正文入日志 |
| HTTP-R08 | 页面/文档 | 定制公开开发者文档页,客户端文档Tab与公开页共用正文/组件,MD及OpenAPI形成一致的阅读与参考体系 | 采用第三种做法,详见第四节;保留其他四个Tab及企业应用切换 |
| HTTP-R09 | 时间边界 | 澄清查询跨度与保留策略;查询默认/游标行为覆盖测试 | 本轮只纠正说明;真正实现清理、归档或改变历史查询窗口须另外确定数据治理范围和保留授权 |
### 3.1 请求与回调恢复设计约束
R03不能仅通过“增加重试”关闭。后续详细设计至少说明:请求幂等记录、短信业务记录和投递待办的关联键;哪些在同一事务中提交;跨DB/Redis失败如何扫描并认领;多实例租约/唯一约束如何防重复;哪些状态能自动恢复、哪些必须人工核对;响应快照何时冻结。
区分业务重发和耐久任务恢复。原请求结果无法确定时,不自动新建短信;历史pending/retrying回调需先统计核对并取得专项处理授权,不在升级时批量补投。迁移应可追加、历史记录按可证明关联程度处理,不猜测旧记录关联关系。索引/迁移、账号权限、运行角色和扫描负载在设计细化后才实施。
### 3.2 签名兼容门槛
保留五行LF结构,不将签名方案整体重做。整改空体前核对客户是否使用`{}`,明确新旧签名的识别方式、适用请求、过渡期限、日志观测和回退条件;不得无限制尝试不同body摘要或改变POST原始字节校验。兼容策略尚未在本稿定死,讨论完成前代码不得切换。
当前query未参与签名写清楚即可;如后续决定保护query,必须说明编码、排序、重复/空参数和签名版本,是单独协议变更,不能夹带在LF说明修订内。
## 四、定制开发者文档页与客户端文档整改(HTTP-R08)
### 4.0 已选方案:定制开发者文档页(2026-09-14补充)
用户选择第三种做法:按客户接入手册定制独立开发者文档页面,采用平台统一品牌和UI规范。此选择确立页面整改方向,当前仍仅修改文档,未开始页面开发。
- 公开入口 `https://api.lisglo.com/api/client-docs` 后续展示定制页面,保持既有客户链接可用;不将仅换Swagger配色或换一个默认文档组件作为交付完成。
- `/api/client-docs-json` 继续提供机器可读OpenAPI,业务路径和鉴权边界保持不变。公开文档允许未开户用户阅读,不显示私有应用配置,不提供匿名试发。
- 已登录客户端的“接口文档”Tab与公开页复用正文、代码示例和文档组件;客户端外层保留应用上下文及真实参数,公开页仅展示环境公共信息和明确标注的默认值。MD下载来自同一文档源。
- OpenAPI继续作为接口定义来源,提供下载入口并与叙述文档做契约校验;默认Swagger UI不再作为客户主要阅读入口。若仍需保留原调试UI,实施设计应先说明用途、地址与访问边界,不能顺带新增公开管理接口或自动执行请求。
- 入口切换可能涉及静态资源服务、路由和现有Nginx白名单,实施前核对实际部署方式。仅允许文档所需资源,不放开整个前端或admin/client业务路径;加载资源、深链接和原JSON地址均需独立验收。
桌面端采用“左侧目录+中间正文+右侧示例”的阅读结构:目录包括快速开始、鉴权、四个接口、回调、错误码;正文说明用途、参数和注意事项;示例区切换cURL、请求报文、成功与失败响应并支持复制。1366宽度或客户端内容区不足时将示例并入正文,保持内容完整;手机端目录可收起,内容单列,代码块独立滚动。顶部展示环境、版本、MD下载与OpenAPI下载。
验收必须同时覆盖公开页和客户端Tab:统一内容/版本、相同接口示例、无密钥或客户数据泄露、公开入口与旧链接兼容、三尺寸阅读、复制/下载/目录定位、资源与控制台正常。采用现有公共组件实现阅读体验,不改变本方案其他API修复和兼容边界。
### 4.1 问题和目标
当前文档Tab只显示签名原文、四条路径和简短回调说明,公开Swagger也不适合作为完整入门手册。整改为上述定制页面和客户端共用的文档内容,支持按步骤阅读、定位接口、复制示例;OpenAPI提供结构化参考,不让客户必须跳转后再自行猜字段。
### 4.2 页面结构
保留当前“接口概览 / 访问凭据 / 回调配置 / 接口文档 / 调用与回调记录”五个Tab、企业应用选择器、原鉴权/配置操作和日志入口。客户端仅重构文档Tab内容,并新增公开定制阅读外层,不借机调整其他业务流程。
1. 顶部显示“HTTP接口接入文档”、接口版本、手册修订日期、当前环境/接口基础地址;入口为“下载MD”“OpenAPI文档”。版本来自发布的文档元数据,不用浏览器当天日期伪造更新。
2. 页内目录:接入准备、鉴权、单条发送、短信状态、上行列表/详情、回调、错误码、限制与变更。桌面提供目录定位,窄屏改为可展开目录;目录不遮挡应用切换或内容。
3. 每个接口采用相同顺序:用途 → 方法/路径 → 前置能力 → 参数表 → cURL/HTTP示例 → 成功/空数据/失败响应 → 易错点。四个接口分别可定位,不只放一个总清单。
4. 鉴权区显示三种编号对照、明确拼接公式、可复制签名函数、固定离线校对向量;区分“签名LF”和“HTTP报文换行”,区分GET当前兼容与待整改事项。单发示例同时提供“结构示例 / 完整生成脚本 / 已计算示例”三个明确入口,完整脚本单独复制即可离线生成演示命令,不要求读者猜测头变量来源。
5. 回调区分别展示回执/上行POST、验签函数、成功ACK、失败/重试规则和现存限制;不能将“平台交付成功”与“客户业务处理完成”混为一谈。
6. 错误码可按code/关键词在本地文档内检索;检索只过滤文档内容,不查询客户短信。无结果显示真实空状态。
### 4.3 复制、下载及数据来源
- 复用现有Button等公共组件;代码块独立滚动或折行,不导致整页横向溢出。复制按钮支持键盘和可读名称,剪贴板失败提示可手动选择,不报假成功。仅文档规划,CSS实现前另按仓库规范完整阅读并验证。
- 复制示例不会执行请求,不增加“点击即发送”功能。示例保留虚构值/明确占位符,不自动填入Secret、真实短信正文或完整鉴权头;凭据只在原凭据流程展示。
- 上述限制针对真实客户鉴权信息;允许展示和复制明确标注为未开户演示凭据的完整计算样例,包括演示Secret、实际生成的UUID、时间戳、摘要和签名。页面须在示例旁持续标注“仅离线演示,未执行业务调用”,固定时间戳会过期;不能标为“测试发送成功”。示例脚本可以输出演示鉴权头,正式接入代码不得把真实完整鉴权信息输出到共享日志。
- 正文采用同一份受版本管理的文档内容生成页面和MD下载,禁止手工在TSX复制另一套长文案。若Markdown渲染支持HTML,必须禁用原始HTML或严格净化,校验链接协议,不能执行代码块。
- 参数、响应和错误契约以最终明确DTO及生成OpenAPI为准;叙述文档仍需交叉验证,并用契约检查防漂移,不宣称仅渲染同一个MD就保证与API一致。
- 环境地址/应用QPS/时间容差/跨度/分页等个性化参数从真实配置API读取;静态手册中的默认值必须标明是系统默认。文档通用内容可在配置失败时继续阅读,但不得把默认值显示为该应用真实配置。
- MD下载应包含接口/文档版本和范围,不夹带密钥、会话或客户私有配置。未开户可阅读公开文档;接口业务鉴权继续独立执行,不提供匿名发送能力。
### 4.4 页面状态与验收
覆盖已开通/未开通HTTP、子能力关闭、无可选应用、配置加载中/失败/重试、应用切换、首次进入、刷新、跨路由返回、深链接定位、复制成功/失败、MD下载及文档检索无结果。未开通仍能阅读通用文档,真实业务操作按权限受限;账户或角色无权访问客户端则沿用现有认证边界。
必须以1600×1000、1366×768、390×844三个尺寸核对参数表、长路径、代码块、目录和按钮;原其他Tab行为不回归。真实API参数、控制台、失败网络请求和MD/页面版本一致性均列为验收,不以隔离截图、mock或构建代替页面通过。
## 五、Swagger与报文示例整改
公开路径只包含四个客户业务接口。补齐三个GET响应schema、七个上行参数、可空字段、应用级限制说明和所有明确公共错误;删除重复幂等头,User-Agent可选,clientMessageId类型为string/null。回调两事件结构与独立签名规则形成完整文档定义。
每个接口必须有可见cURL请求、成功HTTP响应和典型失败响应;列表含空数据和连贯分页。公共动态签名辅助函数被各示例引用,不用过期固定签名冒充可运行示例。HTTP示例可以明确省略传输层自动生成头,但必须保留方法/路径、鉴权/内容类型、状态行和body。回调须展示平台请求及客户ACK。
验证至少包括:JSON/Python语法、Bash命令结构、GET/POST跨语言签名固定向量、LF/CRLF/字面转义差异、UUID每次变化、原body变化和重放、回调篡改、分页前后参数一致、字段/类型与生成契约一致。执行示例中的发送需专项授权,文档验证默认离线,不读取真实密钥。
### 5.1 最新手册完整生成示例的同步要求(2026-09-14)
本次核对工作区手册第11.2.1、11.2.2节:新增独立`generate_sms_curl.py`说明及演示凭据生成的完整cURL。只新增讲解与离线生成能力,没有改变签名协议、接口参数或发送授权。手册已有内容原样保护;本方案补齐其实施和验收要求。
1. 结构示例可用Bash变量,但必须就近说明来源;完整生成脚本应自行产生当前时间、UUID v4、原始JSON摘要和HMAC,生成的演示命令不遗留未解释的头变量。它只构造并输出命令,不调用网络或启动curl。
2. 固定已计算样例同时保存演示Secret、timestamp、nonce、原始body、SHA256和HMAC;能从公开的虚构输入完全复算。不得用真实账户凭据做公开测试向量,也不将旧时间戳当成实时可用值。
3. 完整脚本与公共签名函数用同一组向量交叉验证;正文只序列化一次。校验shlex引用后cURL携带的body与签名字节一致,涵盖中文、引号、反斜杠和内容中的换行;注明Bash/UTF-8,不能冒称PowerShell可直接执行。
4. 页面、MD下载和示例区共用这些版本化示例。复制脚本不得截掉import、演示值、生成逻辑或用途说明;固定样例不能被页面“刷新”成伪造成功结果,不要求在浏览器输入真实Secret生成签名。
5. 离线验收应验证脚本UUID v4、HMAC、两个语言实现复算、Bash语法、原字节一致与无自动业务调用。真实客户鉴权和发送/回调闭环另列授权验收,不能用演示密钥无权限的结果代替。
本次离线复算确认手册新增固定摘要和HMAC一致,独立Node计算吻合,生成/固定cURL均通过Bash语法检查;没有执行curl或发送请求。对应TC-HTTP-REMED-DOC-0809;页面实现及扩展特殊字符验收仍待执行。
## 六、分阶段交付与完成判定
| 阶段 | 交付物 | 完成标准 |
| --- | --- | --- |
| D:文档讨论(当前) | 手册、整改方案、需求/用例/进度同步 | 四点建议及页面整改纳入,示例离线可核对;未确认的兼容/数据设计显式登记 |
| I:设计细化 | R01兼容清单、R03数据/恢复设计、R04/R06兼容映射、页面实施设计 | 业务规则和重大范围变化由用户决定;不自动按草稿实施 |
| C:代码实施 | 定向最小修复及页面/契约更新 | 后续明确授权后才执行;先复现后修改,不夹带旧工作区内容 |
| V:真实验收 | API/PG/Redis/受控回调及浏览器证据 | 定向/回归/类型/构建/质量门禁按范围通过,未执行项明确;真实短信发送另授权 |
| R:交付发布 | 精确提交与两环境独立验收 | 提交、推送、测试/预生产分别授权,使用标准发布工具;不因文档完成就发布 |
本轮验收用例新增TC-HTTP-REMED-DOC-0106,原HTTP-A01A08、TC-HTTP-ASSESS-*与TC-HTTP-GUIDE-*继续作为历史和后续验收来源。以下历史评估保留其原始时点结论,不是本次重新执行结果。
---
## 历史附录:2026-09-10评估原文(保留证据,非当前实施状态)
# 客户 HTTP 接口与对接文档评估(2026-09-10)
本轮为评估与只读核验,不实施运行代码修复、不修改客户配置、不发送短信或回调、不提交或发布。本报告补充现状和待办,不替代[第一版需求](first-version-development-requirements.md)的“HTTP 客户接口第一版”及后续自动投递规则;非 CMPP 拒绝分支的业务选择仍遵循最新引流方案,不在本评估中重新扩大回执范围。
## 1. 客户从哪里看
- 公网文档:[客户 HTTP 接口 Swagger](https://api.lisglo.com/api/client-docs)。2026-09-10 本轮直接 HTTPS GET 返回 200HTML 3168 字节。
- 机器可读契约:[OpenAPI JSON](https://api.lisglo.com/api/client-docs-json)。直接 HTTPS GET 返回 200JSON 4142 字节,公开路径只有四个客户接口。
- 登录客户平台后:左侧“接口对接”→选择企业应用→“接口文档”。前端路由为 `#/client/http-api`,另有接口概览、访问凭据、回调配置、调用与回调记录四个页签。该入口由当前源码确认,本轮未做登录页面浏览器验收。
- 客户接口基础地址:`https://api.lisglo.com/api/openapi/v1`。管理站域名不能替代客户接口域名。
公开 Swagger 当前主要是接口清单;客户登录页另有签名原文和回调验签简述。两者均不等于一份可以让新客户独立完成对接的完整手册。
## 2. 版本和现场证据
| 项目 | 本轮事实 |
| --- | --- |
| 本地 | `main`HEAD `86947827cc80dbc363514b3b52c2576546f5337b` |
| 真实远端 | `git ls-remote` 返回 `6d63eb5452ffc7c802960d044bf598cc8646564d`,本地领先 3 个提交 |
| 预生产 | 2026-09-10 14:31:27 CST 读取 `.deployed-commit``809175b544f2526891ba6d2dece1a50eadaf57a0` |
| 代码对照 | 预生产与本地的 service、guard、controller、exception filter、body parser、main、客户 HTTP 页面共 7 个源文件逐行内容一致;文件原始字节摘要不同,未将其冒称字节一致 |
| 队列库 | 本地与预生产 BullMQ 均为 `5.79.2`,现场校验逻辑与隔离验证使用版本一致 |
| PostgreSQL | 只读事务:HTTP 配置 16 条,其中 enabled 为 true 的 3 条;OpenApiRequest completed=1、failed=2,超过 10 分钟 processing=0HttpWebhookDelivery 与 HttpWebhookAttempt 均无记录 |
| Redis | 只读取 HTTP 回调队列计数:wait=0、active=0、delayed=0、failed=0、completed=3。历史完成任务数不能代替数据库业务投递记录或客户 ACK 证据 |
| 工作区保护 | 暂存区为空;保留原有发布工具、治理文档、metrics 等修改;评估期间发现另一会话更新发布记录,同样保留。未切换分支 |
结论依据是当前公开契约、上述源码、真实数据库和队列只读证据。现有业务样本极少,不能据此宣称客户生产接入稳定、重试成功或容量达标。
## 3. 能力评价
当前范围明确为单号码提交、短信状态查询、上行列表和上行详情四个接口,另有回执/上行 Webhook 的配置与异步投递实现。
已经具备有价值的基础:应用凭据确定身份,应用范围查询,HMAC-SHA256、时间容差、Redis nonce 防重放和应用级 QPS、独立 IP/CIDR 白名单、密钥加密存储、数据库幂等唯一约束与响应快照。单发接入已有签名/模板和发送链路,而不是直接伪造成功。回调目标有 HTTPS、DNS/IP 安全校验、禁止重定向,事件与投递有数据库唯一约束。
总体判断:基础接入框架已形成,但在正式扩大客户自助接入前,应先处理下面的签名一致性、回调重试和故障恢复问题,并补齐客户文档。批量发送、余额查询、模板管理或多语言 SDK 属于可选后续范围,缺少这些不自动构成本期 Bug。配置中的 QPS 数字也不是实测吞吐能力。
## 4. 问题与优先级
### HTTP-A01 / P1:无请求体 GET 的签名规则与文档不一致
位置:`api/src/open-api/open-api-auth.guard.ts:51-53``api/src/http-body-limits.ts``src/apps/client/ClientHttpApiPage.tsx:120-126`
公开接口鉴权计算 `SHA256(request.rawBody ?? JSON.stringify(request.body ?? {}))`。使用实际 Nest 初始化参数和项目 body parser,在仅含探针控制器的本地隔离应用中发起真实无 body GET:无论是否带 JSON Content-Type`rawBody``body` 均不存在,最终摘要是 `{}``44136f…aff8a`,不是空字节的 `e3b0c4…b855`。文档却只写 `SHA256(rawBody)`
客户按通常的空请求体签名方式实现 GET,会与服务端验签计算不一致。应明确 UTF-8 原始字节、空体、路径和 query 的规范,并统一实现与测试;变更前调查现有客户是否已使用 `{}` 兼容规则,不能直接无提示切换协议。当前代码的 PATH 不含 query,也须明确说明,不将未签 query 单独断言为已发生安全事件。
证据等级:实际解析器隔离 HTTP 运行 + 鉴权源码;未用真实客户凭据执行线上认证查询。
### HTTP-A02 / P1:自动回调重试任务无法正常建立
位置:`api/src/open-api/open-api.service.ts:714-736`
首次可重试失败后,代码先把数据库投递状态改为 `retrying`,再使用 `${deliveryId}:${attemptNo + 1}` 添加延迟任务。BullMQ 5.79.2 的实际校验对这种带一个冒号的编号抛出 `Custom Id cannot contain :`。隔离调用实际依赖校验器已复现;首次编号 `delivery-example` 通过,重试编号 `delivery-example:2` 被拒绝。
因此在走到该分支时,预期重试任务建不起来,数据库还可能停在 `retrying`。应使用不含冒号的稳定编号,并覆盖数据库状态与 Redis 入队失败后的恢复。官方也要求自定义编号避免冒号,见 [BullMQ Job IDs](https://docs.bullmq.io/guide/jobs/job-ids)。
手工重试当前编号有两个冒号,在该版本兼容分支下通过校验;不能据此误报“手工重试必然同样失败”,但后续宜一并统一编号规则。预生产本次无投递记录,未证明已经造成实际客户回调故障;没有触发、补发或重投回调。
### HTTP-A03 / P1:幂等请求与回调入队存在故障恢复窗口
位置:`api/src/open-api/open-api.service.ts:272-370,532-559,714-736`
发送请求先落 `processing`,业务创建和响应快照更新是后续独立操作;进程在中间终止可能留下永久 `REQUEST_PROCESSING`,而实际业务是否已创建需要核对。回调事件/投递落库与 Redis 入队也分步执行。在本次检索的 OpenAPI 路径中没有找到清理或重建过期 processing/pending/retrying 的耐久恢复机制。
建议先设计关联请求与业务记录的确定性标识、状态恢复和持久化待办,再补故障注入验收;不能简单把超时请求重新发送,也不能自动重投历史回调。预生产当前无过期 processing/retrying,该项是代码窗口风险,不是现场已发生故障。
### HTTP-A04 / P1:客户接口契约不完整且部分标注错误
位置:公开 `client-docs-json``api/src/open-api/open-api.controller.ts:21-45``open-api.dto.ts:32-33`
- 三个 GET 的 200 响应均无 schema、字段定义和示例。
- 上行列表的 `startTime/endTime/mobile/accessNumber/keyword/limit/cursor` 七项查询参数全部未出现在 Swagger。
- POST 的 Idempotency-Key 被以不同大小写重复标注,User-Agent 被标成必填,而实现参数可选。
- 响应 `clientMessageId` 被生成成 object,可实际为 string/null。
- 错误响应、业务码、回调请求体、验签范例、ACK 与重试规则没有形成完整公开契约。
客户难以独立构造请求、解析响应或生成可靠客户端。应以显式请求/响应 DTO 和实际错误为唯一契约来源,自动检查生成的 OpenAPI,不手工维护另一份漂移 JSON。
### HTTP-A05 / P2:公共入参约束需要完整落实
位置:`api/src/open-api/open-api.dto.ts:3-15``open-api.service.ts:260-313,464`
公开 DTO 只有 Swagger 注解,未施加运行时 class-validator 约束。服务层校验手机号、正文非空和幂等键,但 `clientMessageId` 的类型和文档所写 128 长度未完整校验;上行 `limit` 用 Number/clamp,未拒绝小数,可能把非整数 take 传给 Prisma。应补明确 DTO,覆盖类型、长度、整型边界、非法日期/cursor,并返回稳定 4xx。
证据为源码审查;未向预生产发畸形请求,也未把推断的 Prisma 报错作为现场复现。
### HTTP-A06 / P2:未知异常会向客户返回内部错误正文
位置:`api/src/open-api/open-api-exception.filter.ts:8-17`
非 HttpException 直接取 `exception.message` 作为 detail。隔离运行传入标记异常后,返回的 500 正文原样包含该内部标记;数据库或其他依赖异常因此有泄露实现细节的风险。应向客户固定返回公共错误文案与关联 ID,内部日志保留诊断信息;同时统一首次失败与幂等重放的错误码。
未发现或导出真实客户秘密;该结论不等于已发生凭据泄露。
### HTTP-A07 / P2:上行详情直接返回数据库模型
位置:`api/src/open-api/open-api.service.ts:503-512`
查询有 applicationId 与 matched 限定,这是正确隔离基础;但详情不设 select/响应映射,直接返回模型,公开了内部租户、应用、通道、事件和匹配字段,并让客户契约跟着模型变化。应只输出客户需要的字段,并保持列表/详情命名一致。本轮没有跨租户泄露证据。
### HTTP-A08 / P2:对接故障排查和测试覆盖不足
当前调用记录主要从 OpenApiRequest 读取发送请求,不代表全部查询请求、验签失败和限流日志。客户文档还缺少“哪个编号提供给客服”、时间偏差、白名单、nonce 重用、429 与 409 的处理方法。
本次现有 OpenAPI service 与管理 DTO 定向测试为 2 套 14 项通过,但没有因此发现 GET 空体签名、自动重试 jobId 等问题,说明测试没有覆盖完整协议和真实队列失败路径。应增加契约/鉴权/失败恢复测试,不以增加普通 mock 用例数量代替。
## 5. 客户文档最小补齐范围
1. **快速开始**:申请开通、基础地址、凭据与 Secret 的区别、权限/白名单、可复制的完整签名示例。先给安全的查询示例;发送示例明确 202 只是受理。
2. **签名规范**:五行原文、UTF-8、原始 JSON 字节、秒级时间戳、nonce 格式、大小写、PATH 与 query 边界、空体规则;每次网络重试更新 timestamp/nonce,保持同一业务 Idempotency-Key 和相同原始 body。
3. **四个接口**:全部参数、默认值/上限、类型/可空性、成功和失败示例、状态枚举;说明 messageId 与 clientMessageId 的查询关系、上行游标和时间窗口。
4. **回调协议**:两类事件完整 payload、独立回调密钥、原始体验签、eventId 去重、2xx ACK、超时和重试策略、乱序/重复及人工处理流程。写明当前实现缺陷修复前的限制,不能把预期重试写成已验证能力。
5. **排错与兼容**:公共错误码及处理动作、关联 ID、QPS、时间与编码、密钥轮换、版本和变更记录、支持范围。不宣传未验收的峰值容量。
建议先修 A01/A02,并同步文档;A03 单独形成跨持久化边界的设计与故障恢复验收;随后完成公共 DTO/契约和客户手册。此建议不构成本轮运行代码修改或发布授权。
## 6. 验证记录及交付状态
- 已执行:公开 Swagger/JSON HTTPS GET;预生产版本及 7 文件只读对照;PostgreSQL 只读聚合;Redis 队列计数;真实 Nest body parser 的本地隔离 GET;当前 BullMQ 校验器和异常过滤器隔离复现;现有定向 Jest 2 套 14 项通过(36.39 秒)。
- 未执行:真实客户凭据认证、短信发送、Gateway/供应商链路、真实回调与重试、客户页面浏览器交互、容量与故障注入。未启动完整业务 AppModule,不创建发送/回调 Worker;不使用隔离结果冒称真实业务闭环。
- 本轮只有本报告与测试用例/进度文档追加,无运行代码修改;未提交、未推送、未部署测试、未部署预生产。纯评估文档无需重跑全量构建或 CSS 门禁。
- 证据目录:`%TEMP%/cmpp-http-api-assessment-20260910/`,包括 `openapi.json``swagger.html``preproduction-readonly.json``preproduction-sources.json``isolated-probes.cjs``isolated-results.json`。只读现场脚本只输出计数、版本和源码,不导出凭据或业务正文。
- 后续验收见[系统用例](system-functional-test-cases.md)的 `TC-HTTP-ASSESS-*`;执行状态见[测试进度](testing-progress.md)。
### 原手册维护附录归档(内部实施记录,不进入公开阅读正文)
## 附录:本稿范围与维护依据
本稿是客户阅读版,供审阅后继续完善;尚未替换客户端页面或上线 Swagger。它解释当前接口,不修改鉴权、计费、发送、投递业务规则,也不替代[HTTP 接口整改方案(含历史评估)](http-api-assessment-20260910.md)。其中缺陷说明保留到实际修复及验收后再更新。
2026-09-14 核验:本地、真实 Git 远端、预生产部署标记均为 `d13ca0713abd6afbea5a62af39bcbb876b8bb186`。预生产鉴权 guard、OpenApiService、controller 经换行规范化的文本摘要与本地一致;公开 Swagger/JSON 均 HTTP 200。文档依据当前 controller、DTO、guard、service、异常过滤器、回执/上行事件生产代码和 Prisma 字段;未把历史数据库样本当作本次业务验收。
内部追踪:[测试用例](system-functional-test-cases.md)、[测试与实施进度](testing-progress.md)。后续实现发生变化时,签名函数、字段表、错误码、回调示例和在线契约须一起更新。
+18
View File
@@ -5427,3 +5427,21 @@ TC-DRAINAGE-GATE-0116的实现范围以专项方案第10节为准,不再笼
TC-CHANNEL-WORD测试部署验收:应用8e4bc5a;新Tab/API200真实空词库、已有管理员登录、三尺寸弹窗显式关闭、刷新/跨路由和历史短信详情通过。线上未保存规则,写操作/并发/路由组合仍以隔离PG证据为准;真实供应商Submit/客户回执ACK/费用流水用例未执行。详见release-20260910-test-channel-sensitive-words.md。
## TC-HTTP-REMED-IMPL-20260914
| 编号 | 场景与判定 | 已取得证据 / 边界 |
|---|---|---|
| R01 | 五行LF/GET{}、原UTF8字节、固定向量、nonce重放和篡改 | 定向单测及隔离真实HTTP/Redis通过;不切换协议 |
| R02 | 自动重试任务编号、真实首次500后60秒再200、尝试/退避落库 | 独立PG15439/Redis16389及受控HTTP接收端通过;传输DI仅隔离接收端,不改生产SSRF限制 |
| R03-A | Redis入队失败后DB事件/投递仍存在,扫描恢复;legacy版本0不自动投递 | 隔离真实PG/Redis通过;非历史业务维护 |
| R03-B | 消息/响应快照/待办同事务;待办写入故障回滚,未知结果重放不重建 | 真实PG事务集成通过,前置分类/计费以隔离依赖替代;未作真实发送/计费验收,未入队短信 |
| R03-C | 多实例认领失败不派发;已取消/发送中/终态/待审不再入队 | 定向恢复单测通过;不冒充生产并发容量结论 |
| R04 | 四接口schema、七query、幂等头不重复、User-Agent可选、clientMessageId string/null、回调schema、非法字段/limit/cursor/date | 生成契约测试及隔离真实HTTP边界通过 |
| R05/R07 | 未知依赖错误固定公共文案,首次/重放一致,X-Request-Id与有界安全日志定位 | 定向测试通过;故障日志无正文/Secret/完整签名;新查询日志复用原协议日志 |
| R06 | 上行详情与列表公共字段一致;跨企业应用404,不含通道/供应商/内部诊断 | 真实HTTP/PG及字段投影测试通过;按用户要求直接收紧旧输出 |
| R08-A | 共用阅读器、三尺寸、检索无结果、复制成功/失败、示例切换、MD同字节、刷新 | 本地实际文档HTTP阅读器与隔离Edge 1600×1000/1366×768/390×844通过,无整页溢出、pageerror=[] |
| R08-B | 客户端无应用/配置失败通用正文、应用私有参数不进入公开URL | 组件测试通过;现有已登录客户端/其余Tab的真实目标环境验收未完成 |
| R09 | 默认24小时、应用跨度与分页上限;不实现留存清理 | 查询定向与真实隔离分页通过;历史留存清理未执行 |
候选完整门禁、精确版本及测试发布另见testing-progress.md。浏览器连接器本轮实际返回nodeRepl.fetch request failed;隔离浏览器使用已有Edge二进制,不操作用户浏览器配置或会话。外部证据在本机TEMP/cmpp-http-remediation-20260914和测试机独立/tmp/cmpp-http-remediation-inqN9k,不提交测试凭据、快照或客户数据。
+13
View File
@@ -4909,3 +4909,16 @@ git diff --check
应用`8e4bc5a20e4d98e96fc8572fabbac149ba12c8e6`已本地提交,并通过标准工具部署测试;精确归档validate为API69套745项、前端28套139项及门禁通过。13项服务active,三个Stream pending/lag=0,消息119509/提交130769前后未变,新增迁移/HTTP产物摘要/日志验证通过。已有管理员正常登录、新Tab真实空词库API200、三尺寸显式关闭/刷新/跨路由/历史详情通过;没有保存线上词库或发送短信。观察器同名关闭按钮修正后通过,原失败保留。
[发布验收与容量清单](release-20260910-test-channel-sensitive-words.md)记录精确版本、工具未提交摘要、独立备份、47目录盘点和时间。prepare56.5秒、备份35.2秒、停止至恢复17.0秒;系统盘可用10.35GB→8.81GB、使用率92%,增量约1.54GB,旧版本/候选/备份均未清理,容量治理未完成。未推送、未部署预生产,实际发送/客户回执ACK/费用流水与完整吞吐未执行。
## 2026-09-14 HTTP整改代码与隔离验收(提交前)
- 授权:按http-api-assessment-20260910.md整改,提交、推送、部署测试;不操作预生产,不发送/补发/重投/入队短信,不改现有余额、通道、客户配置或恢复账号。
- 开工本地main/实际远端d13ca0713abd6afbea5a62af39bcbb876b8bb186、0/0、staged为空;保护metrics、tools/release及全部旧文档/工具草稿。测试SSH已实查,应用8e4bc5a20e4d98e96fc8572fabbac149ba12c8e6、系统盘可用24,643,088,384字节、76%;没有/data目录,sudo -n需要密码。仅测试环境,预生产版本未在本轮重新核验。
- 实现:共享签名/固定GET兼容、DTO/schema/query、未知错误公共化和交互ID;上行仅公共字段,按用户决定移除通道/供应商/内部匹配信息。确定性HTTP消息与受理快照/发送待办同事务;不确定结果requires_review,不自动重建。回调事件/投递同事务,新版本待办恢复、租约、尝试/退避同事务和无冒号任务编号,历史记录不回填。新迁移只追加,应用回退不会删除待办,但旧应用不识别新待办,回退前必须核对排空或保留后续恢复安排。
- 文档阅读器:公开/api/client-docs及原JSON地址保留;客户端同源iframe复用单一MD和阅读组件,文档HTML内嵌所需样式/脚本,无需扩大Nginx资源白名单。版本从MD元数据读取。补应用切换过期响应保护及无配置阅读状态;原业务Tab不主动改变操作语义。CSS所有权已登记。
- 本地验证:首次API生成缺少隔离NODE_ENV/DATABASE_URL失败,补明确隔离环境后Prisma generate/API构建通过。HTTP定向最终4套47项通过;工作区API全量此前71套779项、前端29套141项通过(含保护中的旧metrics修改,不作为精确发布候选证据)。前端生产构建、类型检查、入口gzip107.17KiB/250KiB门禁、部署/安全检查通过。CSS首次漏登记所有者失败,登记后CSS治理15项和stylelint通过。旧文件未用import造成lint失败,限定本轮触及文件清理无用import后通过;未改保护文件以消除失败。
- 隔离真实验证:测试机新建PG15439与Redis16389,仅127.0.0.1及/tmp/cmpp-http-remediation-inqN9k。实际Nest控制器+鉴权+Prisma完成GET签名、分页、跨租户404、nonce/篡改、参数拒绝和上行字段核对;合成回调在受控Redis发布失败后恢复,首次HTTP500、60秒后HTTP200PG尝试[500,200],历史version0未重投。该轮0短信/0供应商Submit。另真实PG事务测试用隔离前置依赖核对待办故障回滚及202原子快照,保留1条合成消息fixture,未冻结/扣费、未入队短信,不能当发送业务验收。
- 迁移:隔离数据库从HEAD schema执行追加迁移通过,历史写法兼容进一步核验单列证据。恢复资产不删除;不做生产数据回填。
- 页面:cua.getState本轮仍返回nodeRepl.fetch request failed,不能推断未登录。使用已有Playwright+Edge独立无头上下文验收本地实际文档页,三尺寸、复制/失败降级、检索/空结果、示例切换、MD下载同字节与刷新通过,pageerror=[]。未用隔离文档页冒称测试环境已登录客户端及其他Tab全部通过。
- 证据:本机TEMP/cmpp-http-remediation-20260914(保护摘要、targeted/api-full/frontend-full/http-final、browser-result、截图、integration-result、事务及迁移脚本);独立测试进程已按确切cwd/PID停止,PG/Redis临时实例与目录保留待本轮收尾。只读测试环境SSH有一次超时,成功与失败分别记录,不归因于Git或密码。
- 当前状态:本地代码/设计/手册/用例完成本阶段;尚未本轮提交/推送/测试部署,未部署预生产。下一步仅暂存本轮代码、两份HTTP专属文档及三个台账新增段落,从精确提交跑标准release validate,再推送与测试preflight/prepare/deploy/verify。测试sudo需标准掩码入口,真实短信正向/计费与已登录客户端最终验收仍未执行。
+271 -47
View File
@@ -1,11 +1,21 @@
import { useEffect, useState } from 'react';
import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi';
import { HttpDeveloperDocs } from './http-docs/HttpDeveloperDocs';
import { useEffect, useRef, useState } from 'react';
import { Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
import {
clientApi,
type ClientSmsApplication,
type HttpApiConfigResponse,
type HttpApiCredential,
type HttpApiRequestLog,
type HttpWebhookDelivery,
type HttpWebhookEndpoint,
} from '@/api/adminApi';
import { Button, Input, Select, Tabs, Tag } from '@/components/ui';
import { copyText } from '@/utils/clipboard';
import { formatHttpApiParams, httpApiPublicOrigin } from '@/utils/interfaceParams';
export function ClientHttpApiPage() {
const loadSequence = useRef(0);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [applicationId, setApplicationId] = useState('');
const [config, setConfig] = useState<HttpApiConfigResponse | null>(null);
@@ -15,7 +25,9 @@ export function ClientHttpApiPage() {
const [deliveries, setDeliveries] = useState<HttpWebhookDelivery[]>([]);
const [receiptUrl, setReceiptUrl] = useState('');
const [uplinkUrl, setUplinkUrl] = useState('');
const [revealedSecret, setRevealedSecret] = useState<{ title: string; accessKey?: string; secret: string } | null>(null);
const [revealedSecret, setRevealedSecret] = useState<{ title: string; accessKey?: string; secret: string } | null>(
null,
);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [paramsCopied, setParamsCopied] = useState(false);
@@ -33,15 +45,20 @@ export function ClientHttpApiPage() {
}
useEffect(() => {
clientApi.listApplications().then((items) => {
clientApi
.listApplications()
.then((items) => {
const active = items.filter((item) => item.status !== 'deleted');
setApplications(active);
setApplicationId((current) => current || active[0]?.id || '');
}).catch((failure: Error) => setError(failure.message)).finally(() => setLoading(false));
})
.catch((failure: Error) => setError(failure.message))
.finally(() => setLoading(false));
}, []);
async function loadApplication(id: string) {
if (!id) return;
const sequence = ++loadSequence.current;
setLoading(true);
setError('');
try {
@@ -51,9 +68,13 @@ export function ClientHttpApiPage() {
setRequests([]);
setDeliveries([]);
const results = await Promise.allSettled([
clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id),
clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id),
clientApi.getApplicationHttpApiConfig(id),
clientApi.listHttpApiCredentials(id),
clientApi.listHttpWebhooks(id),
clientApi.listHttpApiRequests(id),
clientApi.listHttpWebhookDeliveries(id),
]);
if (sequence !== loadSequence.current) return;
const [configResult, credentialsResult, webhooksResult, requestsResult, deliveriesResult] = results;
if (configResult.status === 'rejected') throw configResult.reason;
const nextConfig = configResult.value;
@@ -68,75 +89,278 @@ export function ClientHttpApiPage() {
setDeliveries(nextDeliveries);
setReceiptUrl(nextWebhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
setUplinkUrl(nextWebhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
if (results.some((result) => result.status === 'rejected')) setError('部分接口记录暂时无法加载,请稍后刷新;接口概览仍可正常使用。');
if (results.some((result) => result.status === 'rejected'))
setError('部分接口记录暂时无法加载,请稍后刷新;接口概览仍可正常使用。');
} catch (failure) {
if (sequence !== loadSequence.current) return;
const message = failure instanceof Error ? failure.message : '';
setError(message.toLowerCase().includes('internal server error') ? '接口资料暂时加载失败,请稍后重试。' : message || 'HTTP接口资料加载失败');
setError(
message.toLowerCase().includes('internal server error')
? '接口资料暂时加载失败,请稍后重试。'
: message || 'HTTP接口资料加载失败',
);
} finally {
if (sequence === loadSequence.current) setLoading(false);
}
finally { setLoading(false); }
}
useEffect(() => { void loadApplication(applicationId); }, [applicationId]);
useEffect(() => {
setRevealedSecret(null);
void loadApplication(applicationId);
}, [applicationId]);
async function createCredential() {
try {
const created = await clientApi.createHttpApiCredential(applicationId, { name: `客户端凭据 ${credentials.length + 1}` });
setRevealedSecret({ title: '访问凭据仅展示一次,请立即保存', accessKey: created.accessKey, secret: created.secret ?? '' });
const created = await clientApi.createHttpApiCredential(applicationId, {
name: `客户端凭据 ${credentials.length + 1}`,
});
setRevealedSecret({
title: '访问凭据仅展示一次,请立即保存',
accessKey: created.accessKey,
secret: created.secret ?? '',
});
await loadApplication(applicationId);
} catch (failure) { setError(failure instanceof Error ? failure.message : '创建凭据失败'); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '创建凭据失败');
}
}
async function saveWebhook(eventType: 'receipt' | 'uplink', rotateSecret = false) {
const url = eventType === 'receipt' ? receiptUrl : uplinkUrl;
try {
const saved = await clientApi.saveHttpWebhook(applicationId, eventType, { url, rotateSecret });
if (saved.secret) setRevealedSecret({ title: `${eventType === 'receipt' ? '回执' : '上行'}回调签名密钥仅展示一次`, secret: saved.secret });
if (saved.secret)
setRevealedSecret({
title: `${eventType === 'receipt' ? '回执' : '上行'}回调签名密钥仅展示一次`,
secret: saved.secret,
});
await loadApplication(applicationId);
} catch (failure) { setError(failure instanceof Error ? failure.message : '保存Webhook失败'); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '保存Webhook失败');
}
}
const api = config?.config;
const publicApiOrigin = httpApiPublicOrigin(config, window.location.origin);
const overview = <div className="page-stack">
const overview = (
<div className="page-stack">
{!api?.enabled ? <p className="form-error"> HTTP </p> : null}
<div className="surface" style={{ padding: 18 }}><div className="section-heading"><div><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">{publicApiOrigin}/api/openapi/v1</p></div><Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">{paramsCopied ? '已复制' : '复制HTTP参数'}</Button></div><div className="table-actions">
<div className="surface" style={{ padding: 18 }}>
<div className="section-heading">
<div>
<h3>{config?.applicationName ?? '企业应用'}</h3>
<p className="muted">{publicApiOrigin}/api/openapi/v1</p>
</div>
<Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">
{paramsCopied ? '已复制' : '复制HTTP参数'}
</Button>
</div>
<div className="table-actions">
<Tag tone={api?.sendEnabled ? 'success' : 'info'}> {api?.sendEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.messageQueryEnabled ? 'success' : 'info'}> {api?.messageQueryEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.uplinkQueryEnabled ? 'success' : 'info'}> {api?.uplinkQueryEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.receiptWebhookEnabled ? 'success' : 'info'}> {api?.receiptWebhookEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.uplinkWebhookEnabled ? 'success' : 'info'}> {api?.uplinkWebhookEnabled ? '已开通' : '未开通'}</Tag>
</div></div>
<div className="surface" style={{ padding: 18 }}><h3></h3><p>QPS{api?.qpsLimit ?? '-'} · {api?.timestampToleranceSeconds ?? '-'} · {api?.maxQueryRangeDays ?? '-'} · {api?.maxPageSize ?? '-'}</p><p className="muted">HTTP IP {config?.ipAllowlist.join('、') || '未限制'}</p></div>
</div>;
<Tag tone={api?.messageQueryEnabled ? 'success' : 'info'}>
{api?.messageQueryEnabled ? '已开通' : '未开通'}
</Tag>
<Tag tone={api?.uplinkQueryEnabled ? 'success' : 'info'}>
{api?.uplinkQueryEnabled ? '已开通' : '未开通'}
</Tag>
<Tag tone={api?.receiptWebhookEnabled ? 'success' : 'info'}>
{api?.receiptWebhookEnabled ? '已开通' : '未开通'}
</Tag>
<Tag tone={api?.uplinkWebhookEnabled ? 'success' : 'info'}>
{api?.uplinkWebhookEnabled ? '已开通' : '未开通'}
</Tag>
</div>
</div>
<div className="surface" style={{ padding: 18 }}>
<h3></h3>
<p>
QPS{api?.qpsLimit ?? '-'} · {api?.timestampToleranceSeconds ?? '-'} ·
{api?.maxQueryRangeDays ?? '-'} · {api?.maxPageSize ?? '-'}
</p>
<p className="muted">HTTP IP {config?.ipAllowlist.join('、') || '未限制'}</p>
</div>
</div>
);
const credentialPanel = <div className="page-stack"><div className="section-heading"><div><h3><KeyRound size={17} />访</h3><p className="muted"></p></div><Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void createCredential()}></Button></div>
{credentials.map((item) => <div className="surface client-http-credential-row" key={item.id}><strong>{item.name}</strong><code>{item.accessKey}</code><span>****{item.secretLast4}</span><span className="muted">使{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}</span>{item.status === 'active' ? <Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void clientApi.revokeHttpApiCredential(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="danger"></Button> : <Tag tone="info"></Tag>}</div>)}
const credentialPanel = (
<div className="page-stack">
<div className="section-heading">
<div>
<h3>
<KeyRound size={17} />
访
</h3>
<p className="muted"></p>
</div>
<Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void createCredential()}>
</Button>
</div>
{credentials.map((item) => (
<div className="surface client-http-credential-row" key={item.id}>
<strong>{item.name}</strong>
<code>{item.accessKey}</code>
<span>****{item.secretLast4}</span>
<span className="muted">
使{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}
</span>
{item.status === 'active' ? (
<Button
disabled={!api?.credentialSelfServiceEnabled}
onClick={() =>
void clientApi
.revokeHttpApiCredential(applicationId, item.id)
.then(() => loadApplication(applicationId))
}
size="sm"
variant="danger"
>
</Button>
) : (
<Tag tone="info"></Tag>
)}
</div>
))}
{credentials.length === 0 ? <p className="muted">访</p> : null}
</div>;
</div>
);
const callbackPanel = <div className="page-stack"><div className="surface" style={{ padding: 18 }}><h3><Webhook size={17} /> </h3><Input label="回调 URL" onChange={(event) => setReceiptUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/receipt" value={receiptUrl} /><div className="table-actions" style={{ marginTop: 12 }}><Button onClick={() => void saveWebhook('receipt')}></Button>{webhooks.some((item) => item.eventType === 'receipt') ? <Button onClick={() => void saveWebhook('receipt', true)} variant="ghost"></Button> : null}</div></div>
<div className="surface" style={{ padding: 18 }}><h3><Webhook size={17} /> </h3><Input label="回调 URL" onChange={(event) => setUplinkUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/uplink" value={uplinkUrl} /><div className="table-actions" style={{ marginTop: 12 }}><Button onClick={() => void saveWebhook('uplink')}></Button>{webhooks.some((item) => item.eventType === 'uplink') ? <Button onClick={() => void saveWebhook('uplink', true)} variant="ghost"></Button> : null}</div></div></div>;
const callbackPanel = (
<div className="page-stack">
<div className="surface" style={{ padding: 18 }}>
<h3>
<Webhook size={17} />
</h3>
<Input
label="回调 URL"
onChange={(event) => setReceiptUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/receipt"
value={receiptUrl}
/>
<div className="table-actions" style={{ marginTop: 12 }}>
<Button onClick={() => void saveWebhook('receipt')}></Button>
{webhooks.some((item) => item.eventType === 'receipt') ? (
<Button onClick={() => void saveWebhook('receipt', true)} variant="ghost">
</Button>
) : null}
</div>
</div>
<div className="surface" style={{ padding: 18 }}>
<h3>
<Webhook size={17} />
</h3>
<Input
label="回调 URL"
onChange={(event) => setUplinkUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/uplink"
value={uplinkUrl}
/>
<div className="table-actions" style={{ marginTop: 12 }}>
<Button onClick={() => void saveWebhook('uplink')}></Button>
{webhooks.some((item) => item.eventType === 'uplink') ? (
<Button onClick={() => void saveWebhook('uplink', true)} variant="ghost">
</Button>
) : null}
</div>
</div>
</div>
);
const docsPanel = <div className="page-stack"><div className="surface" style={{ padding: 18 }}><h3><BookOpen size={17} /> </h3><p> <code>X-App-Key</code><code>X-Timestamp</code><code>X-Nonce</code><code>X-Signature</code></p><pre>{`METHOD
/api/openapi/v1/...
TIMESTAMP
NONCE
SHA256(rawBody)`}</pre><p>使访 HMAC-SHA256 <code>Idempotency-Key</code></p></div>
<div className="surface" style={{ padding: 18 }}><h3></h3><pre>{`POST /api/openapi/v1/sms/messages\nGET /api/openapi/v1/sms/messages/{messageId}\nGET /api/openapi/v1/sms/uplinks\nGET /api/openapi/v1/sms/uplinks/{uplinkId}`}</pre><p className="muted"> OpenAPI <a href={`${publicApiOrigin}/api/client-docs`} rel="noreferrer" target="_blank">{publicApiOrigin}/api/client-docs</a></p></div>
<div className="surface" style={{ padding: 18 }}><h3></h3><p> X-Event-IdX-Event-TypeX-TimestampX-Signature <code>TIMESTAMP + '\\n' + rawBody</code>使 HMAC-SHA256 X-Event-Id </p></div></div>;
const docsPanel = <HttpDeveloperDocs config={config} loading={loading} applicationId={applicationId} />;
const logsPanel = <div className="page-stack"><div className="surface" style={{ padding: 16 }}><h3></h3>{requests.map((item) => <p key={item.id}><code>{item.requestId}</code> · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}</p>)}{requests.length === 0 ? <p className="muted"></p> : null}</div>
<div className="surface" style={{ padding: 16 }}><h3></h3>{deliveries.map((item) => <div className="section-heading" key={item.id}><p><code>{item.event.eventId}</code> · {item.endpoint.eventType} · {item.status} · {item.attemptCount} {item.lastError ? ` · ${item.lastError}` : ''}</p>{item.status !== 'delivered' && api?.allowClientManualRetry ? <Button icon={<RefreshCw size={14} />} onClick={() => void clientApi.retryHttpWebhookDelivery(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="ghost"></Button> : null}</div>)}{deliveries.length === 0 ? <p className="muted"></p> : null}</div></div>;
const logsPanel = (
<div className="page-stack">
<div className="surface" style={{ padding: 16 }}>
<h3></h3>
{requests.map((item) => (
<p key={item.id}>
<code>{item.requestId}</code> · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} ·{' '}
{item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}
</p>
))}
{requests.length === 0 ? <p className="muted"></p> : null}
</div>
<div className="surface" style={{ padding: 16 }}>
<h3></h3>
{deliveries.map((item) => (
<div className="section-heading" key={item.id}>
<p>
<code>{item.event.eventId}</code> · {item.endpoint.eventType} · {item.status} · {item.attemptCount}{' '}
{item.lastError ? ` · ${item.lastError}` : ''}
</p>
{item.status !== 'delivered' && api?.allowClientManualRetry ? (
<Button
icon={<RefreshCw size={14} />}
onClick={() =>
void clientApi
.retryHttpWebhookDelivery(applicationId, item.id)
.then(() => loadApplication(applicationId))
}
size="sm"
variant="ghost"
>
</Button>
) : null}
</div>
))}
{deliveries.length === 0 ? <p className="muted"></p> : null}
</div>
</div>
);
const tabs = [
{ label: '接口概览', value: 'overview', content: overview }, { label: '访问凭据', value: 'credentials', content: credentialPanel },
{ label: '回调配置', value: 'callbacks', content: callbackPanel }, { label: '接口文档', value: 'docs', content: docsPanel },
{ label: '接口概览', value: 'overview', content: overview },
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
{ label: '接口文档', value: 'docs', content: docsPanel },
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
];
return <section className="page-stack"><div className="page-heading"><div><h1></h1><p> HTTP 访</p></div><Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={applications.map((item) => ({ label: item.name, value: item.id }))} value={applicationId} /></div>
{loading ? <p className="muted">...</p> : null}{error ? <p className="form-error">{error}</p> : null}
{revealedSecret ? <div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}><strong>{revealedSecret.title}</strong>{revealedSecret.accessKey ? <p>Access Key<code>{revealedSecret.accessKey}</code></p> : null}<p>Secret<code>{revealedSecret.secret}</code></p><Button icon={<Copy size={14} />} onClick={() => void copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch((failure: Error) => setError(failure.message))} size="sm"></Button></div> : null}
{!loading && applicationId ? <Tabs items={tabs} /> : null}
</section>;
return (
<section className="page-stack">
<div className="page-heading">
<div>
<h1></h1>
<p> HTTP 访</p>
</div>
<Select
label="企业应用"
onChange={(event) => setApplicationId(event.target.value)}
options={applications.map((item) => ({ label: item.name, value: item.id }))}
value={applicationId}
/>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
{revealedSecret ? (
<div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}>
<strong>{revealedSecret.title}</strong>
{revealedSecret.accessKey ? (
<p>
Access Key<code>{revealedSecret.accessKey}</code>
</p>
) : null}
<p>
Secret<code>{revealedSecret.secret}</code>
</p>
<Button
icon={<Copy size={14} />}
onClick={() =>
void copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch(
(failure: Error) => setError(failure.message),
)
}
size="sm"
>
</Button>
</div>
) : null}
{applicationId ? <Tabs items={tabs} /> : docsPanel}
</section>
);
}
@@ -0,0 +1,8 @@
.client-http-docs .client-http-docs-reader {
width: 100%;
min-height: 740px;
height: 80vh;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: #fff;
}
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { HttpDeveloperDocs } from './HttpDeveloperDocs';
describe('HttpDeveloperDocs', () => {
it('keeps the shared guide readable with no application or failed config', () => {
const { rerender } = render(<HttpDeveloperDocs applicationId="" config={null} loading={false} />);
expect(screen.getByRole('status')).toHaveTextContent('暂无可选应用');
expect(screen.getByTitle('HTTP接口接入文档')).toHaveAttribute('src', '/api/client-docs');
rerender(<HttpDeveloperDocs applicationId="app" config={null} loading={false} />);
expect(screen.getByRole('status')).toHaveTextContent('配置暂不可用');
expect(screen.getByRole('status')).not.toHaveTextContent('QPS');
});
it('never embeds private application parameters in the public document URL', () => {
render(
<HttpDeveloperDocs
applicationId="private-app"
config={
{
applicationId: 'private-app',
applicationName: '客户应用',
ipAllowlist: ['private-ip'],
config: {
enabled: true,
qpsLimit: 7,
timestampToleranceSeconds: 100,
maxQueryRangeDays: 5,
maxPageSize: 20,
},
} as never
}
loading={false}
/>,
);
expect(screen.getByRole('status')).toHaveTextContent('QPS 7');
expect(screen.getByTitle('HTTP接口接入文档')).toHaveAttribute('src', '/api/client-docs');
expect(document.querySelector('iframe')?.outerHTML).not.toContain('private');
});
});
@@ -0,0 +1,29 @@
import type { HttpApiConfigResponse } from '@/api/adminApi';
import './HttpDeveloperDocs.css';
export function HttpDeveloperDocs({
config,
loading,
applicationId,
}: {
config: HttpApiConfigResponse | null;
loading: boolean;
applicationId: string;
}) {
return (
<div className="client-http-docs">
<p role="status">
{loading
? '正在读取应用参数;可继续阅读通用文档。'
: !applicationId
? '暂无可选应用;以下是通用接入文档。'
: !config
? '应用配置暂不可用;以下仅为通用说明,请刷新重试。'
: !config.config
? '当前应用未开通 HTTP 接口;仍可阅读通用文档。'
: `${config.applicationName} · HTTP${config.config.enabled ? '已开通' : '未开通'} · QPS ${config.config.qpsLimit} · 时间容差 ${config.config.timestampToleranceSeconds} 秒 · 查询跨度 ${config.config.maxQueryRangeDays} 天 · 最大分页 ${config.config.maxPageSize}`}
</p>
<iframe className="client-http-docs-reader" title="HTTP接口接入文档" src="/api/client-docs" />
</div>
);
}
+6
View File
@@ -287,6 +287,12 @@
"owners": ["src/apps/admin/sending-monitor/MonitorRuleManager.tsx"],
"stylelintLegacy": false,
"roots": ["monitor-rules"]
},
{
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
"owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx"],
"stylelintLegacy": false,
"roots": ["client-http-docs"]
}
]
}