diff --git a/api/prisma/migrations/20260914060000_http_api_durable_delivery/migration.sql b/api/prisma/migrations/20260914060000_http_api_durable_delivery/migration.sql
new file mode 100644
index 0000000..1e7f8df
--- /dev/null
+++ b/api/prisma/migrations/20260914060000_http_api_durable_delivery/migration.sql
@@ -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");
diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma
index ce4a305..e6489d3 100644
--- a/api/prisma/schema.prisma
+++ b/api/prisma/schema.prisma
@@ -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])
+}
diff --git a/api/src/main.ts b/api/src/main.ts
index a6a2db7..017ac93 100644
--- a/api/src/main.ts
+++ b/api/src/main.ts
@@ -33,13 +33,19 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
- const clientDocument = SwaggerModule.createDocument(app, new DocumentBuilder()
- .setTitle('CMPP短信平台 HTTP 客户接口')
- .setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
- .setVersion('1.0.0')
- .build(), { include: [OpenApiModule] });
- clientDocument.paths = Object.fromEntries(Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')));
- SwaggerModule.setup('api/client-docs', app, clientDocument);
+ const clientDocument = SwaggerModule.createDocument(
+ app,
+ new DocumentBuilder()
+ .setTitle('CMPP短信平台 HTTP 客户接口')
+ .setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
+ .setVersion('1.0.0')
+ .build(),
+ { include: [OpenApiModule] },
+ );
+ clientDocument.paths = Object.fromEntries(
+ Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')),
+ );
+ SwaggerModule.setup('api/client-docs', app, clientDocument, { ui: false });
const port = Number(process.env.API_PORT ?? 3000);
// 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。
@@ -55,7 +61,10 @@ async function bootstrap() {
response.writeHead(404).end();
return;
}
- response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
+ response.writeHead(200, {
+ 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ });
response.end(metrics.render());
});
// Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/.
diff --git a/api/src/open-api/docs/reader.css b/api/src/open-api/docs/reader.css
new file mode 100644
index 0000000..5480654
--- /dev/null
+++ b/api/src/open-api/docs/reader.css
@@ -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; }
diff --git a/api/src/open-api/docs/reader.js b/api/src/open-api/docs/reader.js
new file mode 100644
index 0000000..deee12f
--- /dev/null
+++ b/api/src/open-api/docs/reader.js
@@ -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;
diff --git a/api/src/open-api/docs/reader.ts b/api/src/open-api/docs/reader.ts
new file mode 100644
index 0000000..9605857
--- /dev/null
+++ b/api/src/open-api/docs/reader.ts
@@ -0,0 +1,65 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+export function httpDocVersion(markdown: string) {
+ const metadata = markdown.split(/\r?\n/).find((line) => line.startsWith('**接口版本:')) ?? '';
+ const version = /接口版本:([a-zA-Z0-9.-]+)/.exec(metadata)?.[1];
+ const revision = /\b\d{4}-\d{2}-\d{2}\b/.exec(metadata)?.[0];
+ if (!version || !revision) throw new Error('HTTP document metadata is missing');
+ return version + ' / ' + revision;
+}
+export function readHttpGuide() {
+ return readFileSync(resolve(__dirname, '../../../../docs/client-http-api-guide.md'), 'utf8');
+}
+function asset(name: string) {
+ return readFileSync(resolve(__dirname, '../../../src/open-api/docs', name), 'utf8');
+}
+export function escapeHtml(value: string) {
+ return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!);
+}
+function inline(value: string): string {
+ // Escape first; raw HTML can never execute. Only HTTP(S) and local anchors become links.
+ return escapeHtml(value).replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, url: string) => /^(https?:\/\/|#)/i.test(url) ? `${label}` : 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(''); table = false; } };
+ for (const line of lines) {
+ if (code) {
+ if (/^```/.test(line)) {
+ current.samples.push(`
${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求
${escapeHtml(code.join('\n'))}
`);
+ 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(`${inline(heading[2])}
`);
+ continue;
+ }
+ if (/^\s*\|/.test(line)) {
+ if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
+ if (!table) { current.body.push(''); table = true; }
+ current.body.push('' + line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => `| ${inline(cell.trim())} | `).join('') + '
');
+ continue;
+ }
+ closeTable();
+ if (line.trim() && !/^---+$/.test(line)) current.body.push(`${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}
`);
+ }
+ closeTable();
+ return `聆界短信 · HTTP 接入文档${sections.map((section) => `${inline(section.title)}
${section.body.join('')}`).join('')}没有匹配的文档内容,请更换关键词。
`;
+}
diff --git a/api/src/open-api/open-api-auth.guard.ts b/api/src/open-api/open-api-auth.guard.ts
index 2858321..049e0db 100644
--- a/api/src/open-api/open-api-auth.guard.ts
+++ b/api/src/open-api/open-api-auth.guard.ts
@@ -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();
+ 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();
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:/, '');
}
diff --git a/api/src/open-api/open-api-contract.spec.ts b/api/src/open-api/open-api-contract.spec.ts
new file mode 100644
index 0000000..da51e7b
--- /dev/null
+++ b/api/src/open-api/open-api-contract.spec.ts
@@ -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();
+ }
+ });
+});
diff --git a/api/src/open-api/open-api-docs.controller.ts b/api/src/open-api/open-api-docs.controller.ts
new file mode 100644
index 0000000..1fccb47
--- /dev/null
+++ b/api/src/open-api/open-api-docs.controller.ts
@@ -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(/\/+$/, '') ?? ''));
+ }
+}
diff --git a/api/src/open-api/open-api-exception.filter.ts b/api/src/open-api/open-api-exception.filter.ts
index 4e8471f..1c569a2 100644
--- a/api/src/open-api/open-api-exception.filter.ts
+++ b/api/src/open-api/open-api-exception.filter.ts
@@ -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 : {};
- 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();
+ 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,
+ });
}
}
diff --git a/api/src/open-api/open-api-remediation.spec.ts b/api/src/open-api/open-api-remediation.spec.ts
new file mode 100644
index 0000000..23c8401
--- /dev/null
+++ b/api/src/open-api/open-api-remediation.spec.ts
@@ -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\n[bad](javascript:alert)\n```html\n
\n```',
+ 'https://example.test',
+ );
+ expect(html).toContain('<script>');
+ expect(html).not.toContain('');
+ expect(html).not.toContain('href="javascript:');
+ expect(html).not.toContain('
{
+ const request = context.switchToHttp().getRequest();
+ 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) }));
+ }
+}
diff --git a/api/src/open-api/open-api.controller.ts b/api/src/open-api/open-api.controller.ts
index cd33483..1aa4aee 100644
--- a/api/src/open-api/open-api.controller.ts
+++ b/api/src/open-api/open-api.controller.ts
@@ -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) {
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;
+}
diff --git a/api/src/open-api/open-api.dto.ts b/api/src/open-api/open-api.dto.ts
index 8c14092..45dc902 100644
--- a/api/src/open-api/open-api.dto.ts
+++ b/api/src/open-api/open-api.dto.ts
@@ -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;
+}
diff --git a/api/src/open-api/open-api.module.ts b/api/src/open-api/open-api.module.ts
index 42c9865..043f6da 100644
--- a/api/src/open-api/open-api.module.ts
+++ b/api/src/open-api/open-api.module.ts
@@ -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 {}
diff --git a/api/src/open-api/open-api.protocol.ts b/api/src/open-api/open-api.protocol.ts
new file mode 100644
index 0000000..6b8bdc3
--- /dev/null
+++ b/api/src/open-api/open-api.protocol.ts
@@ -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) : {};
+ 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}`;
+}
diff --git a/api/src/open-api/open-api.recovery.spec.ts b/api/src/open-api/open-api.recovery.spec.ts
new file mode 100644
index 0000000..948d7c6
--- /dev/null
+++ b/api/src/open-api/open-api.recovery.spec.ts
@@ -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(':');
+ });
+});
diff --git a/api/src/open-api/open-api.recovery.ts b/api/src/open-api/open-api.recovery.ts
new file mode 100644
index 0000000..becab77
--- /dev/null
+++ b/api/src/open-api/open-api.recovery.ts
@@ -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;
+ private stopped = false;
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly sendChain: SendChainService,
+ private readonly queue: Queue<{ deliveryId: string }>,
+ ) {}
+
+ tick(): Promise {
+ 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 });
+ }
+ }
+}
diff --git a/api/src/open-api/open-api.service.spec.ts b/api/src/open-api/open-api.service.spec.ts
index 3c0a6c9..1dd12e2 100644
--- a/api/src/open-api/open-api.service.spec.ts
+++ b/api/src/open-api/open-api.service.spec.ts
@@ -60,13 +60,11 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => {
const prisma = {
openApiRequest: {
- findUnique: jest
- .fn()
- .mockResolvedValue({
- bodyHash: 'same',
- status: 'completed',
- responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
- }),
+ findUnique: jest.fn().mockResolvedValue({
+ bodyHash: 'same',
+ status: 'completed',
+ responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
+ }),
},
};
const sendChain = { createHttpBatchTask: jest.fn() };
@@ -97,14 +95,12 @@ describe('OpenApiService', () => {
it('replays the same persisted business rejection', async () => {
const prisma = {
openApiRequest: {
- findUnique: jest
- .fn()
- .mockResolvedValue({
- bodyHash: 'same',
- status: 'failed',
- httpStatus: 422,
- responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
- }),
+ findUnique: jest.fn().mockResolvedValue({
+ bodyHash: 'same',
+ status: 'failed',
+ httpStatus: 422,
+ responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
+ }),
},
};
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
@@ -117,37 +113,29 @@ describe('OpenApiService', () => {
).rejects.toMatchObject({ status: 422 });
});
- it('uses the real send chain and persists the accepted response', async () => {
+ it('returns only the response snapshot committed by the send chain', async () => {
+ const response = { code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' };
const prisma = {
openApiRequest: {
- findUnique: jest.fn().mockResolvedValue(null),
+ findUnique: jest
+ .fn()
+ .mockResolvedValueOnce(null)
+ .mockResolvedValue({ status: 'completed', responseBody: response }),
create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
- update: jest.fn().mockResolvedValue({}),
+ update: jest.fn(),
},
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
};
- const sendChain = {
- createHttpBatchTask: jest
- .fn()
- .mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
- };
+ const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({}) };
const service = new OpenApiService(prisma as never, sendChain as never);
- const result = await service.sendMessage(
- auth() as never,
- { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
- { idempotencyKey: 'idem-0001', bodyHash: 'hash' },
- );
- expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(
- expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }),
- );
- expect(result).toEqual(
- expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
- );
- expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
- expect.objectContaining({
- data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
- }),
- );
+ await expect(
+ service.sendMessage(
+ auth() as never,
+ { mobile: '18821203795', content: '示例', clientMessageId: 'client-1' },
+ { idempotencyKey: 'idem-0001', bodyHash: 'hash' },
+ ),
+ ).resolves.toEqual(response);
+ expect(prisma.openApiRequest.update).not.toHaveBeenCalled();
});
it('persists a 422 result when the real send chain rejects the business request', async () => {
@@ -180,13 +168,12 @@ describe('OpenApiService', () => {
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
const prisma = {
smsApplication: {
- findUnique: jest
- .fn()
- .mockResolvedValue({
- httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
- }),
+ findUnique: jest.fn().mockResolvedValue({
+ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
+ }),
},
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
+ $transaction: jest.fn().mockImplementation(async (callback) => callback(prisma)),
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
};
@@ -209,7 +196,7 @@ describe('OpenApiService', () => {
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
expect.objectContaining({
- create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
+ create: { eventId: 'event-row-1', endpointId: 'endpoint-1', recoveryVersion: 1 },
}),
);
});
@@ -217,15 +204,13 @@ describe('OpenApiService', () => {
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
const prisma = {
smsApplication: {
- findFirst: jest
- .fn()
- .mockResolvedValue({
- id: 'app-1',
- name: '应用A',
- interfaceEnabled: true,
- httpConfig: null,
- httpIpAllowlist: [],
- }),
+ findFirst: jest.fn().mockResolvedValue({
+ id: 'app-1',
+ name: '应用A',
+ interfaceEnabled: true,
+ httpConfig: null,
+ httpIpAllowlist: [],
+ }),
},
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
@@ -280,15 +265,13 @@ describe('OpenApiService', () => {
it('rejects an already expired credential before writing a secret', async () => {
const prisma = {
smsApplication: {
- findFirst: jest
- .fn()
- .mockResolvedValue({
- id: 'app-1',
- name: '应用A',
- interfaceEnabled: true,
- httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
- httpIpAllowlist: [],
- }),
+ findFirst: jest.fn().mockResolvedValue({
+ id: 'app-1',
+ name: '应用A',
+ interfaceEnabled: true,
+ httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
+ httpIpAllowlist: [],
+ }),
},
httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
};
diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts
index bcd2c52..57b7c78 100644
--- a/api/src/open-api/open-api.service.ts
+++ b/api/src/open-api/open-api.service.ts
@@ -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,
+ 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;
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, 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, 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, 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, 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, failure.httpStatus);
}
}
@@ -451,6 +445,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
async listUplinks(auth: OpenApiAuthContext, query: Record) {
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
+ for (const value of Object.values(query)) {
+ if (value !== undefined && typeof value !== 'string')
+ throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
+ }
const endTime = query.endTime ? new Date(query.endTime) : new Date();
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
@@ -460,7 +458,12 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
code: 'TIME_RANGE_TOO_LARGE',
message: `单次查询不能超过${auth.config.maxQueryRangeDays}天`,
});
- const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize);
+ if (
+ query.limit !== undefined &&
+ (!/^\d+$/.test(query.limit) || !Number.isSafeInteger(Number(query.limit)) || Number(query.limit) < 1)
+ )
+ throw new BadRequestException({ code: 'LIMIT_INVALID', message: 'limit必须为正整数' });
+ const limit = Math.min(Number(query.limit ?? 50), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({
where: {
@@ -482,8 +485,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: true,
destId: true,
content: true,
- matchStatus: true,
- matchReason: true,
receivedAt: true,
},
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
@@ -499,7 +500,22 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({
- where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' },
+ where: {
+ id: uplinkId,
+ applicationId: auth.application.id,
+ tenantId: auth.application.tenantId,
+ matchStatus: 'matched',
+ },
+ select: {
+ id: true,
+ messageId: true,
+ phoneNumber: true,
+ destId: true,
+ content: true,
+ receivedAt: true,
+ tenantId: true,
+ applicationId: true,
+ },
});
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row;
@@ -532,30 +548,32 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
: data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`;
- const event = await this.prisma.httpWebhookEvent.upsert({
- where: { eventId },
- update: {},
- create: {
- eventId,
- tenantId: data.tenantId,
- applicationId: data.applicationId,
- eventType: data.eventType,
- messageRecordId: data.messageRecordId,
- messageId: data.messageId,
- uplinkMessageId: data.uplinkMessageId,
- payload: data.payload as Prisma.InputJsonValue,
- },
+ const delivery = await this.prisma.$transaction(async (tx) => {
+ const event = await tx.httpWebhookEvent.upsert({
+ where: { eventId },
+ update: {},
+ create: {
+ eventId,
+ tenantId: data.tenantId,
+ applicationId: data.applicationId!,
+ eventType: data.eventType,
+ messageRecordId: data.messageRecordId,
+ messageId: data.messageId,
+ uplinkMessageId: data.uplinkMessageId,
+ payload: data.payload as Prisma.InputJsonValue,
+ },
+ });
+ return tx.httpWebhookDelivery.upsert({
+ where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
+ update: {},
+ create: { eventId: event.id, endpointId: endpoint.id, recoveryVersion: 1 },
+ });
});
- const delivery = await this.prisma.httpWebhookDelivery.upsert({
- where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
- update: {},
- create: { eventId: event.id, endpointId: endpoint.id },
- });
- if (delivery.status === 'delivered') return delivery;
+ if (delivery.status !== 'pending' || delivery.recoveryVersion !== 1) return delivery;
await this.queue?.add(
'deliver',
{ deliveryId: delivery.id },
- { jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 },
+ { jobId: webhookJobId(delivery.id, 1), removeOnComplete: 1000, removeOnFail: 1000 },
);
return delivery;
}
@@ -603,14 +621,27 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId, event: { applicationId } },
});
if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
- await this.prisma.httpWebhookDelivery.update({
- where: { id: delivery.id },
- data: { status: 'pending', nextRetryAt: null, lastError: null },
+ const reset = await this.prisma.httpWebhookDelivery.updateMany({
+ where: {
+ id: delivery.id,
+ status: { in: ['pending', 'retrying', 'failed'] },
+ attemptCount: delivery.attemptCount,
+ OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
+ },
+ data: {
+ status: 'pending',
+ nextRetryAt: null,
+ lastError: null,
+ recoveryVersion: 1,
+ leaseToken: null,
+ leaseUntil: null,
+ },
});
+ if (!reset.count) throw new ConflictException('回调正在投递或已成功,不能重投');
await this.queue?.add(
'deliver',
{ deliveryId },
- { jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 },
+ { jobId: webhookJobId(deliveryId, Date.now()), removeOnComplete: 1000, removeOnFail: 1000 },
);
return { id: deliveryId, status: 'pending' };
}
@@ -620,11 +651,32 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId },
include: { event: true, endpoint: true },
});
- if (!delivery || delivery.status === 'delivered') return;
+ if (!delivery || !['pending', 'retrying', 'delivering'].includes(delivery.status)) return;
+ if (delivery.nextRetryAt && delivery.nextRetryAt.getTime() > Date.now()) return;
const config = await this.prisma.smsApplicationHttpConfig.findUnique({
where: { applicationId: delivery.event.applicationId },
});
- if (!config) return;
+ if (
+ !config?.enabled ||
+ delivery.endpoint.status !== 'active' ||
+ !(delivery.event.eventType === 'receipt' ? config.receiptWebhookEnabled : config.uplinkWebhookEnabled)
+ )
+ return;
+ const leaseToken = randomUUID();
+ const claimed = await this.prisma.httpWebhookDelivery.updateMany({
+ where: {
+ id: deliveryId,
+ status: delivery.status,
+ attemptCount: delivery.attemptCount,
+ OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
+ },
+ data: {
+ status: 'delivering',
+ leaseToken,
+ leaseUntil: new Date(Date.now() + config.webhookTimeoutSeconds * 1000 + 60_000),
+ },
+ });
+ if (!claimed.count) return;
const attemptNo = delivery.attemptCount + 1;
const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({
@@ -641,7 +693,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
let responseSummary: string | undefined;
let errorMessage: string | undefined;
try {
- const response = await postWebhook(
+ const response = await (this.webhookTransport ?? postWebhook)(
delivery.endpoint.url,
body,
{
@@ -665,22 +717,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
responseStatus === 408 ||
responseStatus === 429 ||
(responseStatus !== undefined && responseStatus >= 500);
- await this.prisma.httpWebhookAttempt.create({
- data: {
- deliveryId,
- attemptNo,
- responseStatus,
- responseSummary,
- errorMessage,
- durationMs: Date.now() - startedAt,
- requestHeaders: {
- 'x-event-id': delivery.event.eventId,
- 'x-event-type': delivery.event.eventType,
- 'x-timestamp': timestamp,
- 'x-signature': 'sha256=***',
- },
- },
- });
this.protocolLogs?.record({
protocol: 'http',
direction: 'platform_to_client',
@@ -696,56 +732,53 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
retryCount: attemptNo - 1,
detail: { deliveryId, attemptNo, error: errorMessage },
});
- if (success) {
- await this.prisma.httpWebhookDelivery.update({
- where: { id: deliveryId },
- data: {
- status: 'delivered',
- attemptCount: attemptNo,
- lastHttpStatus: responseStatus,
- lastError: null,
- deliveredAt: new Date(),
- nextRetryAt: null,
- },
- });
- return;
- }
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
- if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) {
- const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
- const nextRetryAt = new Date(Date.now() + delaySeconds * 1000);
- await this.prisma.httpWebhookDelivery.update({
- where: { id: deliveryId },
+ const willRetry = !success && config.webhookRetryEnabled && retryable && attemptNo < maxAttempts;
+ const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
+ const nextRetryAt = willRetry ? new Date(Date.now() + delaySeconds * 1000) : null;
+ await this.prisma.$transaction(async (tx) => {
+ const updated = await tx.httpWebhookDelivery.updateMany({
+ where: { id: deliveryId, leaseToken },
data: {
- status: 'retrying',
+ status: success ? 'delivered' : willRetry ? 'retrying' : 'failed',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
- lastError: errorMessage ?? `HTTP ${responseStatus}`,
+ lastError: success ? null : (errorMessage ?? 'HTTP ' + responseStatus),
+ deliveredAt: success ? new Date() : null,
nextRetryAt,
+ leaseToken: null,
+ leaseUntil: null,
},
});
+ if (!updated.count) return;
+ await tx.httpWebhookAttempt.create({
+ data: {
+ deliveryId,
+ attemptNo,
+ responseStatus,
+ responseSummary,
+ errorMessage,
+ durationMs: Date.now() - startedAt,
+ requestHeaders: {
+ 'x-event-id': delivery.event.eventId,
+ 'x-event-type': delivery.event.eventType,
+ 'x-timestamp': timestamp,
+ 'x-signature': 'sha256=***',
+ },
+ },
+ });
+ });
+ if (willRetry)
await this.queue?.add(
'deliver',
{ deliveryId },
{
- jobId: `${deliveryId}:${attemptNo + 1}`,
+ jobId: webhookJobId(deliveryId, attemptNo + 1),
delay: delaySeconds * 1000,
removeOnComplete: 1000,
removeOnFail: 1000,
},
);
- return;
- }
- await this.prisma.httpWebhookDelivery.update({
- where: { id: deliveryId },
- data: {
- status: 'failed',
- attemptCount: attemptNo,
- lastHttpStatus: responseStatus,
- lastError: errorMessage ?? `HTTP ${responseStatus}`,
- nextRetryAt: null,
- },
- });
}
private async requireApplication(applicationId: string, tenantId?: string) {
@@ -783,23 +816,11 @@ function httpApiPublicOrigin() {
}
function normalizeOpenApiFailure(error: unknown) {
- if (error instanceof HttpException) {
- const value = error.getResponse();
- const object = typeof value === 'object' && value ? (value as Record) : {};
- const rawMessage = object.message ?? error.message;
- return {
- httpStatus: error.getStatus(),
- code: String(object.code ?? 'SEND_REJECTED'),
- responseBody: {
- code: String(object.code ?? 'SEND_REJECTED'),
- message: Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage),
- } as Prisma.InputJsonValue,
- };
- }
+ const failure = publicOpenApiFailure(error);
return {
- httpStatus: 500,
- code: 'INTERNAL_ERROR',
- responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
+ httpStatus: failure.status,
+ code: failure.code,
+ responseBody: { code: failure.code, message: failure.message } as Prisma.InputJsonValue,
};
}
@@ -968,7 +989,11 @@ function encodeCursor(receivedAt: Date, id: string) {
function decodeCursor(value?: string) {
if (!value) return null;
try {
- const [date, id] = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as [string, string];
+ if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error();
+ const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
+ if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string')
+ throw new Error();
+ const [date, id] = parsed;
const receivedAt = new Date(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id };
diff --git a/api/src/open-api/open-api.types.ts b/api/src/open-api/open-api.types.ts
index fd12777..c9bedaa 100644
--- a/api/src/open-api/open-api.types.ts
+++ b/api/src/open-api/open-api.types.ts
@@ -17,4 +17,5 @@ export type OpenApiRequestLike = {
headers: Record;
socket?: { remoteAddress?: string };
openApiAuth?: OpenApiAuthContext;
+ openApiRequestId?: string;
};
diff --git a/api/src/send-chain/send-batch-entry.service.ts b/api/src/send-chain/send-batch-entry.service.ts
index 7d90452..42b4b04 100644
--- a/api/src/send-chain/send-batch-entry.service.ts
+++ b/api/src/send-chain/send-batch-entry.service.ts
@@ -1,20 +1,41 @@
-import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
+import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
+import {
+ BadRequestException,
+ ConflictException,
+ HttpException,
+ HttpStatus,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common';
import { Prisma } from '@prisma/client';
-import { Queue, Worker } from 'bullmq';
-import IORedis from 'ioredis';
-import { createHash, randomUUID } from 'node:crypto';
-import { setTimeout as sleep } from 'node:timers/promises';
+import { randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
-import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
-import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
-import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
+import type {
+ CreateBatchTaskDto,
+ CreateHttpBatchTaskDto,
+ ImportPreviewDto,
+ ConfirmImportDto,
+ QueuePriority,
+} from './send-chain.contracts';
+import {
+ statusFromRisk,
+ parseSchedule,
+ parseImportRows,
+ normalizeQueuePriority,
+ matchTemplateContent,
+ shanghaiDateKey,
+} from './send-chain.helpers';
import { detectDrainageContent } from './drainage-content-detection';
-import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
+import type {
+ SendResourceValidationOptions,
+ SendSubmissionCallbacks,
+ SendSubmissionService,
+} from './send-submission.service';
/**
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
@@ -33,7 +54,13 @@ export class SendBatchEntryService {
) {}
private releaseMessageReservation(
- message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
+ message: {
+ tenantId: string;
+ batchTaskId: string;
+ messageId: string;
+ amountCents: number | bigint;
+ billingUnits: number;
+ },
remark: string,
) {
return this.callbacks.releaseMessageReservation(message, remark);
@@ -56,15 +83,20 @@ export class SendBatchEntryService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
-
-async createBatchTask(data: CreateBatchTaskDto) {
+ async createBatchTask(data: CreateBatchTaskDto) {
+ const httpRequest = data[HTTP_REQUEST_CONTEXT];
const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data);
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
- this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
+ this.facade.resolveTemplateMessageClassification(
+ data.tenantId,
+ data.applicationId,
+ data.templateId,
+ data.content,
+ ),
this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
@@ -73,16 +105,16 @@ async createBatchTask(data: CreateBatchTaskDto) {
const risk = messageClassification.rejectionReason
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
: await this.riskReview.evaluateTask({
- tenantId: data.tenantId,
- applicationId: data.applicationId,
- templateId: data.templateId,
- content: data.content,
- category: data.category,
- phones,
- variables: messageClassification.variables ?? data.variables,
- createdById: data.createdById,
- sourceType: data.sourceType ?? 'client',
- });
+ tenantId: data.tenantId,
+ applicationId: data.applicationId,
+ templateId: data.templateId,
+ content: data.content,
+ category: data.category,
+ phones,
+ variables: messageClassification.variables ?? data.variables,
+ createdById: data.createdById,
+ sourceType: data.sourceType ?? 'client',
+ });
let frequencyRejectedAll = false;
let frequencyBatchReason: string | undefined;
if (risk.status !== 'rejected' && sendablePhones.length > 0) {
@@ -97,9 +129,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
}
sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone));
frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0;
- frequencyBatchReason = frequencyRejectedAll
- ? [...frequencyRejections.values()][0]?.reason
- : undefined;
+ frequencyBatchReason = frequencyRejectedAll ? [...frequencyRejections.values()][0]?.reason : undefined;
}
if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) {
await this.prisma.smsSendTask.update({
@@ -124,7 +154,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
? 'rejected'
: risk.status === 'approved' && sendablePhones.length === 0
? 'failed'
- : statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
+ : statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
const shouldReserveBalance = batchStatus === 'ready';
if (risk.status === 'approved') {
const accountCheck = await this.billing.checkAccount({
@@ -140,6 +170,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
}
const task = await this.prisma.smsBatchTask.create({
data: {
+ ...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
@@ -150,7 +181,12 @@ async createBatchTask(data: CreateBatchTaskDto) {
phoneTotal: phones.length,
status: batchStatus,
riskTaskId: risk.task?.id,
- auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved',
+ auditStatus:
+ frequencyRejectedAll || risk.status === 'rejected'
+ ? 'rejected'
+ : risk.status === 'pending_review'
+ ? 'pending'
+ : 'approved',
reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null,
rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null,
progressTotal: phones.length,
@@ -185,44 +221,80 @@ async createBatchTask(data: CreateBatchTaskDto) {
},
});
if (phones.length > 0) {
- await this.prisma.smsMessageRecord.createMany({
- data: phones.map((phone) => {
- const rejection = phoneRejections.get(phone);
- const status = rejection
- ? 'submit_failed'
- : batchStatus === 'ready'
- ? 'queued'
- : batchStatus === 'scheduled'
- ? 'scheduled'
- : batchStatus;
- return {
- tenantId: data.tenantId,
- batchTaskId: task.id,
- applicationId: data.applicationId,
- templateId: data.templateId,
- signatureId: messageClassification.signatureId,
- drainageInfoId: messageClassification.drainageInfoId,
- reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
- messageId: `MSG-${randomUUID()}`,
- clientMessageId: data.clientMessageId,
- phoneNumber: phone,
- content: data.content,
- ...drainageDetection,
- billingUnits: billing.billingUnitsPerMessage,
- unitPrice: rejection ? 0 : billing.unitPrice,
- amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
- queuePriority,
- clientSrcId: accessNumber.clientSrcId,
- applicationExtension: accessNumber.applicationExtension,
- status,
- submitStatus: rejection ? 'rejected' : undefined,
- errorCode: rejection?.code,
- errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined),
- };
- }),
- });
+ const persistMessages = async (tx: Prisma.TransactionClient) => {
+ await tx.smsMessageRecord.createMany({
+ data: phones.map((phone) => {
+ const rejection = phoneRejections.get(phone);
+ const status = rejection
+ ? 'submit_failed'
+ : batchStatus === 'ready'
+ ? 'queued'
+ : batchStatus === 'scheduled'
+ ? 'scheduled'
+ : batchStatus;
+ return {
+ tenantId: data.tenantId,
+ batchTaskId: task.id,
+ applicationId: data.applicationId,
+ templateId: data.templateId,
+ signatureId: messageClassification.signatureId,
+ drainageInfoId: messageClassification.drainageInfoId,
+ reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
+ ...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
+ messageId: httpRequest ? `MSG-http-${httpRequest.id}` : `MSG-${randomUUID()}`,
+ clientMessageId: data.clientMessageId,
+ phoneNumber: phone,
+ content: data.content,
+ ...drainageDetection,
+ billingUnits: billing.billingUnitsPerMessage,
+ unitPrice: rejection ? 0 : billing.unitPrice,
+ amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
+ queuePriority,
+ clientSrcId: accessNumber.clientSrcId,
+ applicationExtension: accessNumber.applicationExtension,
+ status,
+ submitStatus: rejection ? 'rejected' : undefined,
+ errorCode: rejection?.code,
+ errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? (risk.reason ?? undefined) : undefined),
+ };
+ }),
+ });
+ if (httpRequest) {
+ const rejected = batchStatus === 'rejected';
+ const responseBody = rejected
+ ? { code: 'SEND_REJECTED', message: task.rejectReason ?? '短信未通过业务校验' }
+ : {
+ code: 'ACCEPTED',
+ requestId: httpRequest.requestId,
+ messageId: 'MSG-http-' + httpRequest.id,
+ clientMessageId: data.clientMessageId ?? null,
+ status: phoneRejections.has(phones[0])
+ ? 'submit_failed'
+ : batchStatus === 'ready'
+ ? 'queued'
+ : batchStatus,
+ acceptedAt: new Date().toISOString(),
+ };
+ const frozen = await tx.openApiRequest.updateMany({
+ where: { id: httpRequest.id, status: 'processing' },
+ data: {
+ status: rejected ? 'failed' : 'completed',
+ httpStatus: rejected ? 422 : 202,
+ businessCode: responseBody.code,
+ responseBody,
+ messageRecordId: 'http-' + httpRequest.id,
+ completedAt: new Date(),
+ },
+ });
+ if (frozen.count !== 1) throw new ConflictException('HTTP request is no longer processing');
+ if (batchStatus === 'ready' && sendablePhones.length > 0)
+ await tx.openApiDispatchOutbox.create({ data: { requestId: httpRequest.id, batchTaskId: task.id } });
+ }
+ };
+ if (httpRequest) await this.prisma.$transaction(persistMessages);
+ else await persistMessages(this.prisma);
}
- if (batchStatus === 'ready' && sendablePhones.length > 0) {
+ if (!httpRequest && batchStatus === 'ready' && sendablePhones.length > 0) {
await this.facade.enqueueBatchTask(task.id);
} else if (batchStatus === 'failed') {
await this.facade.refreshTaskProgress(task.id);
@@ -230,7 +302,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
}
-async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
+ async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
if (!data.applicationId) {
throw new BadRequestException('公开 HTTP 发送必须关联企业应用');
}
@@ -250,7 +322,7 @@ async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
});
}
-async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
+ async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
const task = await this.prisma.smsBatchTask.findFirst({
where: { id: taskId, tenantId, sourceType },
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
@@ -261,7 +333,7 @@ async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
return task;
}
-async previewImport(data: ImportPreviewDto) {
+ async previewImport(data: ImportPreviewDto) {
const sizeBytes = Buffer.byteLength(data.content, 'utf8');
if (sizeBytes > 20 * 1024 * 1024) {
throw new BadRequestException('导入文件不能超过 20MB');
@@ -270,10 +342,12 @@ async previewImport(data: ImportPreviewDto) {
const phones: string[] = [];
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
const requiredVariables = data.requiredVariables ?? [];
- const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({
- where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
- select: { phoneNumber: true },
- }) : [];
+ const enterpriseBlacklist = data.applicationId
+ ? await this.prisma.enterpriseBlacklist.findMany({
+ where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
+ select: { phoneNumber: true },
+ })
+ : [];
const globalBlacklist = await this.prisma.globalBlacklist.findMany({
where: { status: 'active' },
select: { phoneNumber: true },
@@ -299,7 +373,11 @@ async previewImport(data: ImportPreviewDto) {
}
const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
if (missingVariables.length > 0) {
- errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` });
+ errors.push({
+ rowNumber: row.rowNumber,
+ phoneNumber: row.phoneNumber,
+ reason: `变量列缺失:${missingVariables.join(',')}`,
+ });
continue;
}
seen.add(row.phoneNumber);
@@ -316,7 +394,7 @@ async previewImport(data: ImportPreviewDto) {
};
}
-async confirmImport(data: ConfirmImportDto) {
+ async confirmImport(data: ConfirmImportDto) {
const preview = await this.facade.previewImport({
tenantId: data.tenantId,
applicationId: data.applicationId,
@@ -329,7 +407,7 @@ async confirmImport(data: ConfirmImportDto) {
return this.facade.createBatchTask({ ...data, phones: preview.phones });
}
-async resolveUnitPrice(tenantId: string, applicationId?: string) {
+ async resolveUnitPrice(tenantId: string, applicationId?: string) {
if (!applicationId) {
return 0;
}
@@ -343,7 +421,7 @@ async resolveUnitPrice(tenantId: string, applicationId?: string) {
return moneyToNumber(application.customerUnitPrice);
}
-async resolveQueuePriority(tenantId: string, applicationId?: string): Promise {
+ async resolveQueuePriority(tenantId: string, applicationId?: string): Promise {
if (!applicationId) {
return 'normal';
}
@@ -357,7 +435,7 @@ async resolveQueuePriority(tenantId: string, applicationId?: string): Promise();
for (const phone of phones) {
if (!/^1\d{10}$/.test(phone)) {
@@ -469,7 +552,7 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
return rejected;
}
-async validateSendResources(
+ async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
@@ -499,12 +582,14 @@ async validateSendResources(
where: { id: templateId },
include: { signature: true },
});
- const templateBelongsToApplication = template
- && template.tenantId === tenantId
- && template.applicationId === applicationId;
+ const templateBelongsToApplication =
+ template && template.tenantId === tenantId && template.applicationId === applicationId;
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
- if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) {
+ if (
+ !templateBelongsToApplication ||
+ (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')
+ ) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
if (!template.signature || template.signature.auditStatus !== 'approved') {
@@ -512,20 +597,23 @@ async validateSendResources(
}
}
-async reserveDailySendQuota(applicationId: string, requestedCount: number) {
+ async reserveDailySendQuota(applicationId: string, requestedCount: number) {
const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount);
if (!result.reserved) {
- throw new HttpException({
- code: 'DAILY_SEND_LIMIT_EXCEEDED',
- message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
- dailyLimit: result.dailyLimit,
- requestedCount,
- }, HttpStatus.TOO_MANY_REQUESTS);
+ throw new HttpException(
+ {
+ code: 'DAILY_SEND_LIMIT_EXCEEDED',
+ message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
+ dailyLimit: result.dailyLimit,
+ requestedCount,
+ },
+ HttpStatus.TOO_MANY_REQUESTS,
+ );
}
return result;
}
-async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
+ async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
throw new BadRequestException('发送号码数量必须为正整数');
}
@@ -560,40 +648,42 @@ async tryReserveDailySendQuota(applicationId: string, requestedCount: number, re
const normalizedReservationKey = reservationKey?.trim();
const rows = normalizedReservationKey
? await this.prisma.$transaction(async (tx) => {
- // The quota increment and its idempotency record share one short transaction. A worker
- // crash can therefore neither lose a successful reservation nor increment it twice.
- await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'daily-quota:' + normalizedReservationKey}, 0))`;
- const existing = await tx.smsApplicationDailyReservation.findUnique({
- where: { reservationKey: normalizedReservationKey },
- });
- if (existing) {
- if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
- throw new ConflictException('日发送配额幂等键已用于另一笔预留');
- }
- return [{
- tenantId: existing.tenantId,
- dailyLimit: existing.dailyLimit,
- usedCount: existing.usedCount,
- }];
- }
- const reservedRows = await reserve(tx);
- if (reservedRows.length > 0) {
- const row = reservedRows[0];
- await tx.smsApplicationDailyReservation.create({
- data: {
- reservationKey: normalizedReservationKey,
- tenantId: row.tenantId,
- applicationId,
- usageDate: usageDateValue,
- requestedCount,
- dailyLimit: Number(row.dailyLimit),
- usedCount: row.usedCount == null ? null : Number(row.usedCount),
- reserved: row.usedCount != null,
- },
+ // The quota increment and its idempotency record share one short transaction. A worker
+ // crash can therefore neither lose a successful reservation nor increment it twice.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'daily-quota:' + normalizedReservationKey}, 0))`;
+ const existing = await tx.smsApplicationDailyReservation.findUnique({
+ where: { reservationKey: normalizedReservationKey },
});
- }
- return reservedRows;
- })
+ if (existing) {
+ if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
+ throw new ConflictException('日发送配额幂等键已用于另一笔预留');
+ }
+ return [
+ {
+ tenantId: existing.tenantId,
+ dailyLimit: existing.dailyLimit,
+ usedCount: existing.usedCount,
+ },
+ ];
+ }
+ const reservedRows = await reserve(tx);
+ if (reservedRows.length > 0) {
+ const row = reservedRows[0];
+ await tx.smsApplicationDailyReservation.create({
+ data: {
+ reservationKey: normalizedReservationKey,
+ tenantId: row.tenantId,
+ applicationId,
+ usageDate: usageDateValue,
+ requestedCount,
+ dailyLimit: Number(row.dailyLimit),
+ usedCount: row.usedCount == null ? null : Number(row.usedCount),
+ reserved: row.usedCount != null,
+ },
+ });
+ }
+ return reservedRows;
+ })
: await reserve(this.prisma);
if (rows.length === 0) {
throw new NotFoundException('短信应用不存在');
diff --git a/api/src/send-chain/send-chain.contracts.ts b/api/src/send-chain/send-chain.contracts.ts
index 155309d..ffc862c 100644
--- a/api/src/send-chain/send-chain.contracts.ts
+++ b/api/src/send-chain/send-chain.contracts.ts
@@ -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;
diff --git a/docs/client-http-api-guide.md b/docs/client-http-api-guide.md
new file mode 100644
index 0000000..fe758c2
--- /dev/null
+++ b/docs/client-http-api-guide.md
@@ -0,0 +1,1159 @@
+# 客户 HTTP 短信接口接入手册
+
+**接口版本:v1 · 手册修订:2026-09-14**
+
+适用对象:需要从自己的业务系统发送短信、查询短信结果或接收用户回复的开发人员。
+
+本文说明 HTTP 接口的接入步骤、鉴权、请求与响应以及回调处理。所有号码、消息编号和响应示例均为说明用途,不是真实客户数据。GET 空请求体使用第 3 节规定的兼容摘要;每次请求都须重新生成请求唯一标识和签名。
+
+建议先完成上行查询与签名校对,再接入发送和回调。完整命令与报文示例见第 11 节;复制示例不会执行请求。
+
+## 1. 开始接入前
+
+### 1.1 这套接口能做什么
+
+| 你的业务需求 | 使用的能力 |
+| --- | --- |
+| 向一个手机号发送一条短信 | 单条发送接口 |
+| 知道短信是否提交、送达或失败 | 短信状态查询接口 |
+| 短信有回执时,由平台主动通知你的系统 | 回执回调(Webhook) |
+| 获取手机用户回复的短信 | 上行列表、上行详情,或上行回调 |
+
+“上行短信”就是手机用户回复到短信接入号的内容。“回执”是短信提交后的送达或失败结果。两者不是同一种通知。
+
+本版只提供四个公开业务接口,不提供公开的批量发送、余额查询、签名管理或模板管理接口。签名、模板和应用配置通过平台页面办理。
+
+### 1.2 开户、开通和凭据
+
+依次完成以下准备:
+
+1. 由平台创建企业账号和企业应用,并开通该应用的 HTTP 接口及需要的发送、查询、回调能力。
+2. 登录客户端,进入 **接口对接**,选择正确的企业应用。在“接口概览”确认接口地址、能力、QPS、时间容差和 HTTP IP 白名单。
+3. 在“访问凭据”创建凭据,保存 **Access Key** 和 **Secret**。自助创建未开放时联系平台人员。
+4. 如果需要发送,先完成签名和模板审核、必要报备以及余额和可用路由准备。只开通 HTTP 不代表任意正文都可以发送。
+5. 如果需要回调,在“回调配置”分别保存回执、上行 HTTPS 地址,并保存各自的回调签名密钥。
+
+Secret 只在创建或轮换时展示一次,后续末四位不能用于签名。凭据属于特定企业应用,不要将应用 A 的凭据用于查询应用 B 的短信。
+
+| 名称 | 用途 | 是否放进请求头 |
+| --- | --- | --- |
+| Access Key | 标识调用哪个企业应用的接口 | 放入 `X-App-Key` |
+| Secret | 在你的服务端计算请求签名 | 不直接发送 |
+| 回调签名密钥 | 验证平台推送到你系统的通知 | 不直接发送;与请求 Secret 分开保存 |
+| 平台登录密码 | 登录客户端网页 | 不用于 HTTP 接口签名 |
+
+凭据应由你的后端服务保管,不放在网页前端代码、公开仓库或日志中。轮换请求凭据时,先建立新凭据、切换调用,再吊销旧凭据;可同时有效的数量以应用配置为准。回调密钥轮换需协调接收端,当前不要假设平台同时用新旧密钥签名。
+
+### 1.3 地址与文档入口
+
+| 用途 | 地址 |
+| --- | --- |
+| 客户接口基础地址 | `https://api.lisglo.com/api/openapi/v1` |
+| 在线 Swagger | https://api.lisglo.com/api/client-docs |
+| OpenAPI JSON | https://api.lisglo.com/api/client-docs-json |
+| 客户端文档入口 | 接口对接 → 选择企业应用 → 接口文档 |
+
+上述域名当前连接预生产真实业务系统。未开户也能查看文档,但业务调用需要有效凭据;Swagger 的调试按钮会发真实请求,不是匿名试用或模拟发送。测试环境使用平台另行提供的地址和凭据,不能混用。
+
+**建议第一次先查询上行列表。** 即使没有上行数据,也可以用空列表确认接入流程;不要用发送短信来测试网络是否通畅。
+
+## 2. 四个接口一览
+
+以下路径均在域名 `https://api.lisglo.com` 后拼接,已包含 `/api`,不要重复添加。
+
+| 方法 | 完整路径 | 成功状态 |
+| --- | --- | --- |
+| POST | `/api/openapi/v1/sms/messages` | 202:请求受理 |
+| GET | `/api/openapi/v1/sms/messages/{messageId}` | 200:返回短信状态 |
+| GET | `/api/openapi/v1/sms/uplinks` | 200:返回上行列表 |
+| GET | `/api/openapi/v1/sms/uplinks/{uplinkId}` | 200:返回上行详情 |
+
+所有接口都需要下面的鉴权头。请求和响应使用 UTF-8;POST 请求体使用 JSON。成功响应直接返回对象,没有额外的 `data` 包装层;回调通知则有自己的 `data` 字段。
+
+## 3. 每次请求怎样签名
+
+### 3.1 请求头
+
+| 请求头 | 必填 | 填写方式 |
+| --- | --- | --- |
+| `X-App-Key` | 是 | 该应用的 Access Key |
+| `X-Timestamp` | 是 | 当前 Unix 时间戳,单位为秒,例如 `1789344000`;不要传毫秒 |
+| `X-Nonce` | 是 | 本次 HTTP 请求的唯一标识,推荐 UUID v4,例如 `550e8400-e29b-41d4-a716-446655440000`;兼容原 8~128 字符规则 |
+| `X-Signature` | 是 | 按下文计算的 HMAC-SHA256,小写十六进制字符串 |
+| `Idempotency-Key` | 发送时必填 | 一次业务发送的稳定编号,8~128 字符,允许字母、数字、`.`、`_`、`:`、`-` |
+| `Content-Type` | POST 使用 | `application/json` |
+| `User-Agent` | 否 | 可填写客户端名称和版本,便于排错 |
+
+默认时间容差为正负 300 秒,具体以应用配置为准。服务器时钟应同步。每次请求包括网络重试都要生成新的 nonce;即使业务请求被限流,旧 nonce 也不能继续使用。
+
+推荐用标准库生成 UUID v4,例如 Python `str(uuid.uuid4())`。当前服务端仍兼容字母、数字、下划线和短横线组成的 8~128 字符,不要求旧客户全部改成 UUID。不要使用订单号或仅有时间戳的值代替每次请求的新标识。
+
+| 标识 | 代表的对象 | 同一次发送的网络重试 |
+| --- | --- | --- |
+| `X-Nonce`(请求唯一标识) | 一次 HTTP 调用,防止请求重放 | 重新生成 UUID v4 |
+| `Idempotency-Key`(业务幂等键) | 一次业务发送,防止重复创建短信 | 保持不变,并保持 body 原始字节相同 |
+| `clientMessageId`(客户短信编号) | 客户系统中的短信业务记录 | 保持不变 |
+
+### 3.2 签名原文
+
+按下面的固定顺序拼接字符串;`BODY_HASH` 是第 3.3 节或 POST 原始字节得到的摘要:
+
+```text
+SIGNING_STRING = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH
+SIGNATURE = HEX(HMAC_SHA256(UTF8(Secret), UTF8(SIGNING_STRING)))
+```
+
+公式中的 `"\n"` 是程序语言表示法,指一个 LF 字节 `0x0A`,不是反斜杠和字母 n 两个字符,也不是 Windows 的 CRLF(`0x0D 0x0A`)。这是签名字符串内部的规则,与 HTTP 报文的行结束符不同。最后不加换行、不加空格、不加引号。
+
+`&` 仍用于实际 URL 的 query 参数连接,例如 `limit=25&cursor=...`,不用于替换本接口签名原文的分隔符。当前 query 不参与签名;保持此规则,不在文档修订中暗中改变协议。
+
+
+以下是同一个签名原文的分行展示,便于逐项核对:
+
+```text
+大写 HTTP 方法
+请求路径
+X-Timestamp 的原始字符串
+X-Nonce 的原始字符串
+请求体的 SHA256 十六进制摘要
+```
+
+然后用 **Secret 的 UTF-8 字节**作为 HMAC 密钥,对这五行的 UTF-8 字节计算 HMAC-SHA256,输出 64 位十六进制字符串。
+
+具体约定:
+
+- 方法使用 `GET` 或 `POST`。
+- 路径包含 `/api`,不包含域名、`?` 和查询字符串。路径参数需要 URL 编码时,按实际发送的编码后路径签名。
+- POST 必须对**最终发送的原始 JSON 字节**取摘要。签名后不要再格式化、改变空格、调整字段顺序或改变中文转义方式。
+- Secret 按创建时取得的字符串直接使用,不先进行 Base64 解码。
+- 示例使用纯十六进制签名。当前服务端也接受 `sha256=` 前缀,但不要求添加。
+- Idempotency-Key 是单独的业务幂等头,不是 nonce,也不是上述五行之一。
+
+### 3.3 当前 GET 空请求体的兼容规则
+
+**当前版本的特殊行为:不带请求体的 GET,摘要需要按 UTF-8 字符串 `{}` 计算。GET 本身仍不发送请求体。**
+
+固定摘要为:
+
+```text
+44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
+```
+
+这与通常“空请求体按空字节计算 SHA256”的规则不同,也是当前页面只写 `SHA256(rawBody)` 容易导致验签失败的原因。本节是现版本兼容说明,不是理想协议的新设计。后续修正必须由平台说明兼容策略,本稿示例按现行为编写。
+
+### 3.4 Python 签名函数
+
+下面只计算请求头,不访问网络。使用 Python 标准库即可。
+
+```python
+import hashlib
+import hmac
+import uuid
+import time
+
+
+def make_headers(method, path, raw_body, access_key, secret,
+ timestamp=None, nonce=None):
+ # path 必须是实际发送的路径,不包含域名和 query。
+ if not path.startswith("/api/openapi/v1/") or "?" in path or "#" in path:
+ raise ValueError("请传入不含 query 的完整接口路径")
+ timestamp = str(int(time.time()) if timestamp is None else timestamp)
+ nonce = str(uuid.uuid4()) if nonce is None else nonce
+ # None 表示 GET 无请求体;兼容当前服务端的 {} 摘要规则。
+ bytes_to_hash = b"{}" if raw_body is None else raw_body
+ body_hash = hashlib.sha256(bytes_to_hash).hexdigest()
+ source = "\n".join([method.upper(), path, timestamp, nonce, body_hash])
+ signature = hmac.new(
+ secret.encode("utf-8"), source.encode("utf-8"), hashlib.sha256
+ ).hexdigest()
+ return {
+ "X-App-Key": access_key,
+ "X-Timestamp": timestamp,
+ "X-Nonce": nonce,
+ "X-Signature": signature,
+ }
+```
+
+不要把函数生成的请求头或真实 Secret 完整打印到日志。
+
+### 3.5 固定签名校对样例(不用于线上请求)
+
+使用虚构 Secret `doc-example-secret`、时间戳 `1789344000`、nonce `550e8400-e29b-41d4-a716-446655440000`,GET 路径 `/api/openapi/v1/sms/uplinks`,不发送 body。按当前兼容规则,实际签名字符串为:
+
+```text
+GET
+/api/openapi/v1/sms/uplinks
+1789344000
+550e8400-e29b-41d4-a716-446655440000
+44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
+```
+
+上面共有 4 个 LF 分隔字节,结尾没有 LF。期望签名为:
+
+```text
+f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65
+```
+
+使用第 3.4 节函数并显式传入上述 timestamp 和 nonce,应得到完全相同的 `X-Signature`。这些固定值只用于离线校对;真实请求使用当前时间、新 UUID 和自己的凭据。
+
+## 4. 第一个调用:查询最近的上行短信
+
+将第 3.4 节函数和下面代码放在同一个 Python 文件。通过本机安全配置提供 `CMPP_HTTP_ACCESS_KEY` 和 `CMPP_HTTP_SECRET` 环境变量,再运行脚本。此代码只执行 GET,不发送短信。
+
+```python
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+
+origin = "https://api.lisglo.com"
+path = "/api/openapi/v1/sms/uplinks"
+query = urllib.parse.urlencode({"limit": 10})
+headers = make_headers(
+ "GET", path, None,
+ os.environ["CMPP_HTTP_ACCESS_KEY"],
+ os.environ["CMPP_HTTP_SECRET"],
+)
+headers["Accept"] = "application/json"
+request = urllib.request.Request(
+ origin + path + "?" + query, headers=headers, method="GET"
+)
+try:
+ with urllib.request.urlopen(request, timeout=15) as response:
+ result = json.loads(response.read().decode("utf-8"))
+ # 只输出计数,不在示例中打印客户手机号和短信正文。
+ print("HTTP", response.status)
+ print("本页条数:", len(result["items"]))
+ print("是否有下一页:", result["nextCursor"] is not None)
+except urllib.error.HTTPError as error:
+ try:
+ problem = json.loads(error.read().decode("utf-8"))
+ except (ValueError, UnicodeError):
+ problem = {}
+ print("HTTP", error.code, "业务码:", problem.get("code", "非标准错误响应"))
+except urllib.error.URLError:
+ print("连接失败,请检查网络、域名及 HTTPS 连通性")
+```
+
+没有匹配到上行数据时,正常返回:
+
+```json
+{"items": [], "nextCursor": null}
+```
+
+空列表不是失败。401 优先检查签名,403 检查应用能力与白名单,429 降低请求频率。
+
+## 5. 发送单条短信
+
+### 5.1 请求
+
+`POST /api/openapi/v1/sms/messages`
+
+完整 cURL 请求和 HTTP 响应见第 11.2 节。
+
+除鉴权头外,需要 `Content-Type: application/json` 和 `Idempotency-Key`。
+
+| JSON 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `mobile` | string | 是 | 单个中国大陆手机号,例如 `13800138000`;不能传数组或逗号分隔号码 |
+| `content` | string | 是 | 完整短信正文,包含签名和已填入的变量值;不能为空 |
+| `clientMessageId` | string | 否,建议提供 | 你的业务系统消息编号,应用内唯一;按不超过 128 字符使用 |
+
+```json
+{
+ "mobile": "13800138000",
+ "content": "【示例签名】您的验证码是123456,5分钟内有效。",
+ "clientMessageId": "order-20260914-0001"
+}
+```
+
+正文由平台识别签名、模板和变量,不传 `tenantId`、`applicationId`、`signatureId`、`templateId` 或自选通道。上例签名和模板仅作说明,发送前必须使用你已审核且符合发送条件的内容。
+
+下面代码只准备请求,不执行发送;接在第 3.4 节函数之后使用:
+
+```python
+import json
+
+payload = {
+ "mobile": "13800138000",
+ "content": "【示例签名】您的验证码是123456,5分钟内有效。",
+ "clientMessageId": "order-20260914-0001",
+}
+raw_body = json.dumps(
+ payload, ensure_ascii=False, separators=(",", ":")
+).encode("utf-8")
+path = "/api/openapi/v1/sms/messages"
+# access_key、secret 从你的安全配置读取。
+headers = make_headers("POST", path, raw_body, access_key, secret)
+headers["Content-Type"] = "application/json"
+headers["Idempotency-Key"] = "sms-order-20260914-0001"
+# 由你的发送代码将同一个 raw_body 原样交给 HTTP 客户端。
+# 不要让 HTTP 客户端再把 payload 重新序列化成另一组字节。
+```
+
+### 5.2 受理响应
+
+成功受理返回 HTTP **202**,示例:
+
+```json
+{
+ "code": "ACCEPTED",
+ "requestId": "req_11111111-1111-4111-8111-111111111111",
+ "messageId": "MSG-22222222-2222-4222-8222-222222222222",
+ "clientMessageId": "order-20260914-0001",
+ "status": "queued",
+ "acceptedAt": "2026-09-14T02:00:00.000Z"
+}
+```
+
+| 字段 | 含义 |
+| --- | --- |
+| `code` | `ACCEPTED` 表示接口受理成功 |
+| `requestId` | 本次业务请求编号,排查问题时提供给平台 |
+| `messageId` | 平台短信编号,用于后续查询;请持久保存 |
+| `clientMessageId` | 你传入的编号,未提供时为 null |
+| `status` | 当前短信或任务状态,不保证总是 queued,也可能需要审核 |
+| `acceptedAt` | 受理响应时间,ISO 8601;示例中的 Z 表示 UTC |
+
+**202 ≠ 已提交运营商 ≠ 手机已收到。** 请用状态查询或回执判断结果;HTTP 200/202 也不能直接解释为计费成功或退款完成。若 202 缺少 messageId,保留 requestId 和 clientMessageId 联系平台核查,不因缺失就创建新发送。
+
+### 5.3 重复请求与超时处理
+
+| 情况 | 处理方式 |
+| --- | --- |
+| 网络超时,不确定平台是否受理 | 先按 clientMessageId 查询;必要时用原 Idempotency-Key 和完全相同的 body 重试请求,重新生成 timestamp/nonce/签名 |
+| 相同幂等键、相同 body,原请求已完成 | 返回原受理响应,原编号和 acceptedAt 保持不变 |
+| 相同幂等键、相同 body,原请求业务失败已保存 | 重放原失败,不会因稍后改好配置就自动重新发送 |
+| 相同幂等键、不同 body | 409 `IDEMPOTENCY_CONFLICT` |
+| 原请求仍处理中 | 409 `REQUEST_PROCESSING`;稍后查询,长期不结束联系平台 |
+| 换了幂等键,但 clientMessageId 已关联短信 | 409 `CLIENT_MESSAGE_ID_CONFLICT` |
+
+同一业务短信的幂等键要持久保存。JSON 字段顺序、空格变化也可能被视为不同 body。不要为了绕过 409 换键反复发送。修正确定失败的业务后是否发起新发送,由你的业务流程明确决定。
+
+## 6. 查询短信状态
+
+完整 cURL 请求和 HTTP 响应见第 11.3 节。
+
+`GET /api/openapi/v1/sms/messages/{messageId}`,无请求体。
+
+路径末尾可以填写平台返回的 messageId,也支持原来提交的 clientMessageId。请对路径参数做 URL 编码;只能查询当前凭据所属应用的数据。
+
+示例响应:
+
+```json
+{
+ "messageId": "MSG-22222222-2222-4222-8222-222222222222",
+ "clientMessageId": "order-20260914-0001",
+ "phoneNumber": "13800138000",
+ "status": "delivered",
+ "submitStatus": "accepted",
+ "receiptStatus": "delivered",
+ "errorCode": null,
+ "errorMessage": null,
+ "queuedAt": "2026-09-14T02:00:00.000Z",
+ "submittedAt": "2026-09-14T02:00:01.000Z",
+ "deliveredAt": "2026-09-14T02:00:03.000Z",
+ "updatedAt": "2026-09-14T02:00:03.100Z"
+}
+```
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `messageId` | string | 平台短信编号 |
+| `clientMessageId` | string/null | 客户消息编号 |
+| `phoneNumber` | string | 接收手机号;注意响应字段不是 mobile |
+| `status` | string | 平台业务状态 |
+| `submitStatus` | string/null | 提交状态,如 queued、accepted、rejected、timeout |
+| `receiptStatus` | string/null | 回执状态,如 delivered、undelivered、unknown;null 表示尚无结果 |
+| `errorCode` / `errorMessage` | string/null | 失败码与说明,未发生或暂无时可为空 |
+| `queuedAt` / `updatedAt` | string | 入队记录时间、最近更新时间 |
+| `submittedAt` / `deliveredAt` | string/null | 提交时间、结果时间,尚无数据时可为空 |
+
+常见 `status` 的业务含义:
+
+| 状态 | 你应如何理解 |
+| --- | --- |
+| `pending_review` | 等待审核,不代表已送出 |
+| `queued` / `submit_queued` | 等待平台处理或向通道提交 |
+| `submitted` | 已提交,等待明确回执 |
+| `delivered` | 已获得送达结果 |
+| `submit_failed` / `failed` / `rejected` | 提交失败、发送失败或业务拒绝,结合错误字段查看原因 |
+| `unknown` / `timeout` | 结果未知或等待超时,不能当成送达 |
+
+上述是常见值,不是服务端严格冻结的全部枚举。遇到其他值保留原值并展示“待确认”,不要默认成功。`deliveredAt` 在失败回执中也可能是结果发生时间,不能只看这个字段非空就认为送达。
+
+不存在或不属于本应用时返回 404 `MESSAGE_NOT_FOUND`。查询刚超时的发送得到 404 也不能单独证明业务永远不会落库。
+
+## 7. 查询手机用户回复(上行)
+
+### 7.0 先分清四种时间边界
+
+| 项目 | 当前规则 | 举例 |
+| --- | --- | --- |
+| 默认查询范围 | endTime 默认现在;startTime 默认 endTime 前 24 小时 | 什么时间都不传,就是最近 24 小时 |
+| 单次最大查询跨度 | 默认 31 天,以应用配置为准;限制起止时间之差 | 可查询三个月前某一天,并不是只能查询最近 31 天 |
+| 历史数据保留期 | 有默认 90 天的配置,但尚未确认存在按该配置执行的保留/清理闭环 | 不能承诺一定有 90 天数据,也不能断言更早数据必定被删除 |
+| 游标有效期 | 当前游标没有单独过期计时;不保证数据长期不变 | 不将旧 cursor 当作永久同步凭据,重新固定时间窗口并去重 |
+
+查询代码没有额外强制“只查最近 N 天”,但历史查询能否返回数据取决于实际留存、当前应用归属和筛选条件。startTime 必须不晚于 endTime,当前时间边界两端均包含。跨窗口同步可按上行 id 去重,避免公共边界重复计入。
+
+### 7.1 列表与筛选
+
+第一页、空数据及下一页的完整报文见第 11.4~11.5 节。
+
+`GET /api/openapi/v1/sms/uplinks`,无请求体。
+
+| Query 参数 | 类型 | 默认值/规则 |
+| --- | --- | --- |
+| `startTime` | ISO 8601 时间字符串 | 默认 endTime 向前 24 小时 |
+| `endTime` | ISO 8601 时间字符串 | 默认当前时间 |
+| `mobile` | string | 可选,精确匹配回复者手机号 |
+| `accessNumber` | string | 可选,精确匹配回复到的接入号,对应响应 destId |
+| `keyword` | string | 可选,按回复正文包含关键词筛选 |
+| `limit` | 正整数 | 默认 50;不超过应用最大分页配置,系统默认上限 100 |
+| `cursor` | string | 第一页不传,下一页使用上一响应 nextCursor |
+
+单次时间跨度默认上限 31 天,以应用配置为准。建议总是带时区,例如 `2026-09-14T00:00:00+08:00` 或 `2026-09-13T16:00:00Z`;URL 中的 `+` 应通过标准 query 编码转换为 `%2B`。空筛选项直接省略,不传空字符串。limit 按上述正整数约定使用,不依赖当前不完善的非法参数处理。
+
+示例请求 URL:
+
+```text
+https://api.lisglo.com/api/openapi/v1/sms/uplinks?startTime=2026-09-13T16%3A00%3A00Z&endTime=2026-09-14T16%3A00%3A00Z&limit=25
+```
+
+签名的路径仍为 `/api/openapi/v1/sms/uplinks`,不包含 `?` 后的内容。
+
+示例响应:
+
+```json
+{
+ "items": [
+ {
+ "id": "uplink-example-001",
+ "messageId": null,
+ "phoneNumber": "13800138000",
+ "destId": "106900000000",
+ "content": "收到,谢谢",
+ "receivedAt": "2026-09-14T02:05:00.000Z"
+ }
+ ],
+ "nextCursor": null
+}
+```
+
+`id` 是上行记录编号,用它查询详情;`messageId` 可能为空,不用它代替上行 id。`destId` 是接收用户回复的短信接入号。内部匹配状态与诊断不对外返回。只返回归属本应用的已匹配/已认领数据,不返回未匹配或存在归属歧义的数据。
+
+### 7.2 翻页方法
+
+1. 第一页固定 startTime、endTime 和筛选条件,不传 cursor。
+2. 若 nextCursor 非 null,下一次保持时间、筛选和 limit 不变,只加入 cursor;仍需新的 timestamp/nonce/签名。
+3. nextCursor 为 null 时结束。没有总条数、页码或 totalPages。
+
+记录按接收时间、记录 id 倒序返回。cursor 是不透明令牌,不自行解码、构造或当作长期同步水位。上行可能延迟到达或稍后才被认领;持续同步宜重叠查询时间窗口并按 id 去重,不把一次分页当作永不变化的快照。
+
+### 7.3 上行详情
+
+完整 cURL 请求和 HTTP 响应见第 11.6 节。
+
+`GET /api/openapi/v1/sms/uplinks/{uplinkId}`,无请求体。
+
+把列表的 `items[].id` 或回调的 `data.uplinkMessageId` 放入路径,按第 3 节签名。返回单个上行对象,核心字段与列表中的 item 相同,不再包裹 items。
+
+详情额外返回当前客户自己的 `tenantId`、`applicationId`。不返回通道、供应商编号、内部事件/匹配诊断、数据库关联行号等字段。本次按数据边界收紧旧输出,旧客户需移除对这些内部字段的依赖。不存在或不属于当前企业应用时返回 404 `UPLINK_NOT_FOUND`。
+
+## 8. 平台怎样主动通知你的系统
+
+### 8.1 配置与接收
+
+在客户端“回调配置”分别设置:
+
+- **回执回调 URL**:接收 `receipt`,用于获知短信结果。
+- **上行回调 URL**:接收 `uplink`,用于接收用户回复。
+
+平台向对应 URL 发起 HTTP POST。需开通该类回调能力、保存非空有效地址。使用平台可访问的公网 HTTPS 地址,不使用本机、内网或依赖重定向的地址。每个回调端点使用自己的签名密钥。
+
+不要假设每次发送请求都会有回调。同步参数错误、未受理的请求以及未生成该类事件的分支,应处理接口响应或查询结果。长短信的 HTTP 结果通知按业务消息处理,不按运营商内部计费分片逐片回调。
+
+### 8.2 请求头和事件格式
+
+两类通知的完整 HTTP 请求及接收端响应见第 11.7 节。
+
+| 回调头 | 说明 |
+| --- | --- |
+| `Content-Type` | `application/json` |
+| `X-Event-Id` | 稳定事件编号,用于去重 |
+| `X-Event-Type` | `receipt` 或 `uplink` |
+| `X-Timestamp` | 本次投递的 Unix 秒级时间戳 |
+| `X-Signature` | `sha256=` 加 HMAC-SHA256 十六进制值 |
+
+回执示例:
+
+```json
+{
+ "eventId": "evt_receipt_example001",
+ "eventType": "receipt",
+ "occurredAt": "2026-09-14T02:00:03.100Z",
+ "data": {
+ "messageId": "MSG-22222222-2222-4222-8222-222222222222",
+ "gatewayMessageId": "example-gateway-id",
+ "phoneNumber": "13800138000",
+ "receiptStatus": "delivered",
+ "rawStatus": "DELIVRD",
+ "errorCode": null,
+ "deliveredAt": "2026-09-14T02:00:03.000Z"
+ }
+}
+```
+
+`receiptStatus` 表示结果,`rawStatus` 是原始状态,`errorCode` 可空,部分失败事件还含 `errorMessage`。网关编号只作为辅助信息,使用 messageId 与自己的发送记录关联。当前回执不保证带 clientMessageId,请保存发送响应中的两种编号映射。未提供的可选字段可能缺省,也可能为 null。
+
+上行示例:
+
+```json
+{
+ "eventId": "evt_uplink_example001",
+ "eventType": "uplink",
+ "occurredAt": "2026-09-14T02:05:00.100Z",
+ "data": {
+ "applicationId": "application-example",
+ "phoneNumber": "13800138000",
+ "destId": "106900000000",
+ "content": "收到,谢谢",
+ "receivedAt": "2026-09-14T02:05:00.000Z",
+ "uplinkMessageId": "uplink-example-001"
+ }
+}
+```
+
+上行 data 还可能包含 messageId,人工认领的通知可含 `manualClaim: true`;没有 messageId 时不能强行关联一条发送记录。`occurredAt` 是平台创建事件的时间,与数据里的 deliveredAt/receivedAt 含义不同;重试时事件编号和事件体保持不变,投递时间戳与签名会重新生成。
+
+### 8.3 如何验签
+
+回调的签名原文是 **时间戳 + 一个 LF 换行 + 原始请求体字节**,使用该回调端点的签名密钥执行 HMAC-SHA256。它不是第 3 节的五行请求签名。
+
+先保留 HTTP 原始 body 再验签,不要解析 JSON 后重新序列化。下面是纯计算示例;header 名大小写不敏感:
+
+```python
+import hashlib
+import hmac
+import json
+import time
+
+
+def verify_callback(headers, raw_body, webhook_secret, now=None,
+ allowed_skew_seconds=300):
+ # 300 秒是本示例的接收端策略,可按双方约定调整。
+ hs = {str(k).lower(): str(v) for k, v in headers.items()}
+ timestamp = hs.get("x-timestamp", "")
+ if not timestamp.isdigit():
+ return False
+ now = time.time() if now is None else now
+ if abs(now - int(timestamp)) > allowed_skew_seconds:
+ return False
+ supplied = hs.get("x-signature", "")
+ if not supplied.startswith("sha256="):
+ return False
+ source = timestamp.encode("utf-8") + b"\n" + raw_body
+ expected = hmac.new(
+ webhook_secret.encode("utf-8"), source, hashlib.sha256
+ ).hexdigest()
+ if not hmac.compare_digest(expected, supplied[7:]):
+ return False
+ try:
+ event = json.loads(raw_body)
+ except (ValueError, UnicodeError):
+ return False
+ if not isinstance(event, dict):
+ return False
+ return (
+ isinstance(event.get("eventId"), str)
+ and bool(event["eventId"])
+ and event.get("eventId") == hs.get("x-event-id")
+ and event.get("eventType") == hs.get("x-event-type")
+ and event.get("eventType") in {"receipt", "uplink"}
+ and isinstance(event.get("data"), dict)
+ )
+```
+
+验签后,仍需在数据库以 eventId 做唯一约束或等效的原子去重,不能只用进程内集合。对重复且已成功保存的事件正常确认;不要因重复而再次更新余额、发券或触发另一条短信。不同事件的到达先后也不能代替业务状态判断。
+
+### 8.4 怎样确认接收、失败后会怎样
+
+平台把 **任意 HTTP 2xx** 视为接收成功,不要求固定响应 JSON;可以返回 `200 OK`。建议先验签并将事件可靠保存,再尽快返回 2xx,后续业务异步处理。返回了 2xx 即使正文写“失败”,平台也按成功处理。
+
+按设计:网络错误、408、429、5xx 可重试;其他 4xx 终结,重定向不跟随。默认总尝试上限 7 次(含首次),预期各次失败后的间隔为 1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时,并受应用策略限制。这些是相邻尝试的等待间隔,不是统一从首次起算。默认配置超时为 10 秒,不应依赖它作为严格端到端时限。
+
+整改后的新回调使用稳定任务编号和数据库耐久待办;Redis暂不可用时保留待办,由后续扫描恢复。每次投递按数据库租约认领,尝试结果与下一次等待时间同事务保存。网络中断可能造成重复接收,仍须按eventId幂等。历史pending/retrying不会在升级时自动补投,须另行核对处理;部署与验收完成情况见测试进度。
+
+## 9. 常见错误与处理
+
+由业务接口异常过滤器处理的错误,通常使用 `application/problem+json`:
+
+```json
+{
+ "type": "https://cmpp-platform.local/problems/signature_invalid",
+ "title": "UNAUTHORIZED",
+ "status": 401,
+ "code": "SIGNATURE_INVALID",
+ "detail": "请求签名校验失败"
+}
+```
+
+以 HTTP 状态和 code 做程序判断,detail 供人阅读;type 当前是标识符,不是可访问的帮助链接。代理、网络或 JSON 解析层也可能返回其他格式,应先检查 Content-Type,不要直接假设每次失败都能解析 JSON。
+
+| HTTP | code | 处理方法 |
+| --- | --- | --- |
+| 400 | `PARAMETER_INVALID` / `LIMIT_INVALID` | 检查字段类型、长度和正整数分页 |
+| 409 | `REQUEST_REQUIRES_REVIEW` | 提供requestId核对,不更换幂等键重发 |
+| 400 | `MOBILE_INVALID` / `CONTENT_REQUIRED` | 检查单个手机号和正文 |
+| 400 | `IDEMPOTENCY_KEY_INVALID` | 补齐有效幂等键,检查长度与字符 |
+| 400 | `TIME_RANGE_INVALID` / `TIME_RANGE_TOO_LARGE` | 检查时间格式、先后顺序和跨度 |
+| 400 | `CURSOR_INVALID` | 使用平台返回的 cursor,保持筛选条件一致 |
+| 401 | `AUTH_HEADERS_MISSING` | 补齐四个鉴权头 |
+| 401 | `CREDENTIAL_INVALID` | 核对应用、Access Key、有效期和吊销状态 |
+| 401 | `NONCE_INVALID` / `NONCE_REPLAYED` | 检查格式,每次调用生成新的 nonce |
+| 401 | `TIMESTAMP_EXPIRED` | 使用秒级时间戳并同步时钟 |
+| 401 | `SIGNATURE_INVALID` | 核对 Secret、五行原文、路径、原始 body 和 GET 兼容摘要 |
+| 403 | `HTTP_API_DISABLED` | 应用未启用或未开通 HTTP,联系平台 |
+| 403 | `IP_NOT_ALLOWED` | 核对调用机器公网出口 IP 和 HTTP 白名单 |
+| 403 | `SEND_NOT_ENABLED` / `MESSAGE_QUERY_NOT_ENABLED` / `UPLINK_QUERY_NOT_ENABLED` | 联系平台开通对应子能力 |
+| 404 | `MESSAGE_NOT_FOUND` / `UPLINK_NOT_FOUND` | 核对编号及凭据所属应用 |
+| 409 | `IDEMPOTENCY_CONFLICT` | 同键 body 不一致,停止盲重试并核对原请求 |
+| 409 | `REQUEST_PROCESSING` | 查询既有业务结果;长期停留联系平台 |
+| 409 | `CLIENT_MESSAGE_ID_CONFLICT` | 查询已有客户编号对应记录,避免重复业务发送 |
+| 422 | `SEND_REJECTED` | 正文、审核或其他业务条件不满足,按说明处理,不盲重试 |
+| 429 | `QPS_LIMIT_EXCEEDED` | 应用共享限流,降低并发、延后并加随机退避;新凭据不能扩大应用额度 |
+| 5xx | `REQUEST_FAILED` / `INTERNAL_ERROR` 等 | 保留请求标识和时间联系平台;发送场景先查询,避免重新创建业务 |
+
+表格覆盖明确的常见分支,不穷尽发送链下游的所有错误。当前 5xx 文案/首次与重放错误码尚未完全统一,不依赖内部错误文本写业务逻辑。发生连接中断时可能没有任何 HTTP 状态或 JSON,按网络错误处理。
+
+每次交互响应头 `X-Request-Id` 用于定位本次调用;发送受理正文中的 `requestId` 对应稳定业务幂等记录,两者含义不同。未知错误固定为 `INTERNAL_ERROR`,不返回依赖异常正文。
+
+提交排障信息时提供:应用名称、请求时间与时区、接口路径、HTTP 状态、业务 code、requestId/messageId/clientMessageId 或 eventId。手机号和正文按需要脱敏,不提供 Secret 或完整鉴权头。
+
+## 10. 当前版本的接入边界
+
+| 事项 | 当前说明 |
+| --- | --- |
+| Swagger 准确性 | 查询响应/上行参数未补全,Idempotency-Key 重复、User-Agent 必填及 clientMessageId 类型存在错误;以本文明确的现状说明对照实现 |
+| GET 空体 | 当前须使用 `{}` 摘要;后续变更需确认兼容策略 |
+| 回调重试 | 新事件耐久待办和安全任务编号;真实验收与部署状态单独记录,历史事件不自动重投 |
+| 超时与幂等恢复 | 受理快照与消息/待办同事务;结果无法确定时返回 REQUEST_REQUIRES_REVIEW,不能换幂等键绕过 |
+| 参数与响应模型 | clientMessageId为string/null且最长128;limit为正整数,按应用上限裁剪;上行不返回内部通道和匹配字段 |
+| 性能 | 以应用配置限制请求频率;QPS 配置不是对全链路送达能力的承诺 |
+| 文档验证 | 本稿完成源码对照、公开契约读取和离线示例校验;没有使用客户凭据发送或完成真实回调验收 |
+
+## 11. 逐接口 cURL 与 HTTP 报文示例
+
+### 11.1 公共准备与占位符
+
+本节每个接口均提供 cURL 请求、成功和常见失败 HTTP 响应;回调提供平台 POST 和客户 ACK。为便于阅读,HTTP 报文省略 Content-Length、Date、连接等由服务器或 HTTP 客户端生成的头;不要把签名原文的 LF 规则套到 HTTP 报文行结束符上。
+
+cURL 使用 **Bash** 语法(PowerShell 应使用 curl.exe 并调整续行/变量语法)。`$ACCESS_KEY`、`$TIMESTAMP`、`$NONCE`、`$SIGNATURE` 分别对应第 3.4 节函数返回的四个头;`$CURSOR` 必须取上一页真实 nextCursor。这些不是固定测试凭据。下面的可视 cURL 不能在未生成动态鉴权值时直接调用;固定签名只用于第 3.5 节离线校对。
+
+下面的公共辅助函数引用第 3.4 节 `make_headers`,直接生成带动态签名的 cURL 参数和 stdin 字节,免去手工设置上述变量。将它与签名函数放在同一 Python 文件,从安全环境变量读取凭据。**函数本身不访问网络;不要打印返回的完整鉴权参数。**
+
+```python
+import os
+from urllib.parse import urlencode
+
+
+def prepare_curl(method, path, raw_body=None, query=None, idempotency_key=None):
+ method = method.upper()
+ if method == "POST" and (raw_body is None or not idempotency_key):
+ raise ValueError("发送需提供最终原始字节和稳定幂等键")
+ headers = make_headers(method, path, raw_body,
+ os.environ["CMPP_HTTP_ACCESS_KEY"],
+ os.environ["CMPP_HTTP_SECRET"])
+ url = "https://api.lisglo.com" + path
+ if query:
+ url += "?" + urlencode(query)
+ args = ["curl", "--include", "--max-time", "15", "--request", method, url]
+ for key, value in headers.items():
+ args.extend(["--header", f"{key}: {value}"])
+ if raw_body is not None:
+ args.extend(["--header", "Content-Type: application/json",
+ "--data-binary", "@-"])
+ if idempotency_key:
+ args.extend(["--header", f"Idempotency-Key: {idempotency_key}"])
+ return args, raw_body
+```
+
+例如 `args, body = prepare_curl("GET", "/api/openapi/v1/sms/uplinks", query={"limit": 10})` 只准备查询。由客户在确认环境后使用 `subprocess.run(args, input=body, check=True)` 实际调用;HTTP 状态仍须单独判断,cURL 进程退出 0 不代表业务成功。POST 将完整 JSON 字节通过 stdin 传入,避免重新序列化;本稿没有自动执行发送的入口。
+
+### 11.2 单条发送
+
+将第 5.1 节 JSON 保存为 UTF-8 无 BOM 的 `sms.json`,签名时读取该文件的原始字节,随后原样发送。此命令会真实提交短信,示例不自动执行。
+
+```bash
+curl --include --max-time 15 --request POST \
+ "https://api.lisglo.com/api/openapi/v1/sms/messages" \
+ --header "X-App-Key: $ACCESS_KEY" \
+ --header "X-Timestamp: $TIMESTAMP" \
+ --header "X-Nonce: $NONCE" \
+ --header "X-Signature: $SIGNATURE" \
+ --header "Content-Type: application/json" \
+ --header "Idempotency-Key: sms-order-20260914-0001" \
+ --data-binary "@sms.json"
+```
+
+成功响应示例:
+
+```http
+HTTP/1.1 202 Accepted
+Content-Type: application/json; charset=utf-8
+
+{
+ "code": "ACCEPTED",
+ "requestId": "req_11111111-1111-4111-8111-111111111111",
+ "messageId": "MSG-22222222-2222-4222-8222-222222222222",
+ "clientMessageId": "order-20260914-0001",
+ "status": "queued",
+ "acceptedAt": "2026-09-14T02:00:00.000Z"
+}
+```
+
+失败响应示例:
+
+```http
+HTTP/1.1 409 Conflict
+Content-Type: application/problem+json; charset=utf-8
+
+{
+ "type": "https://cmpp-platform.local/problems/idempotency_conflict",
+ "title": "CONFLICT",
+ "status": 409,
+ "code": "IDEMPOTENCY_CONFLICT",
+ "detail": "同一Idempotency-Key对应的请求内容不一致"
+}
+```
+
+#### 11.2.1 完整生成脚本:不再手工填写请求头变量
+
+上面的 `$NONCE`、`$TIMESTAMP` 等是 Bash 变量引用,不是可以原样发送的参数值。未设置变量时,单独复制上面的结构示例不能正常鉴权。
+
+下面提供完整脚本:实际生成 UUID v4、当前时间戳、请求体摘要和 HMAC-SHA256 签名,再输出没有请求头变量占位符的 Bash cURL 命令。保存为 UTF-8 编码的 `generate_sms_curl.py`,运行 `python3 generate_sms_curl.py`。
+
+**脚本只生成并打印命令,不访问接口、不发送短信。UUID 和签名是真实生成、真实计算的;Access Key 和 Secret 明确为未开户的演示凭据,不能据此鉴权成功。**
+
+```python
+import hashlib
+import hmac
+import json
+import shlex
+import time
+import uuid
+
+# 演示凭据,不是真实客户账号。
+ACCESS_KEY = "DEMO_ACCESS_KEY_NOT_REGISTERED"
+SECRET = "DEMO_SECRET_NOT_A_REAL_CREDENTIAL"
+
+origin = "https://api.lisglo.com"
+path = "/api/openapi/v1/sms/messages"
+
+# 同一次业务发送重试时,两个业务编号和请求体保持不变。
+idempotency_key = "sms-doc-example-20260914-0001"
+payload = {
+ "mobile": "13800138000",
+ "content": "【示例签名】您的验证码是123456,5分钟内有效。",
+ "clientMessageId": "doc-example-20260914-0001",
+}
+
+# 只序列化一次:计算摘要与最终发送使用同一份内容。
+body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+raw_body = body.encode("utf-8")
+timestamp = str(int(time.time()))
+nonce = str(uuid.uuid4())
+body_hash = hashlib.sha256(raw_body).hexdigest()
+signing_string = "\n".join([
+ "POST", path, timestamp, nonce, body_hash,
+])
+signature = hmac.new(
+ SECRET.encode("utf-8"),
+ signing_string.encode("utf-8"),
+ hashlib.sha256,
+).hexdigest()
+
+headers = {
+ "Content-Type": "application/json",
+ "X-App-Key": ACCESS_KEY,
+ "X-Timestamp": timestamp,
+ "X-Nonce": nonce,
+ "X-Signature": signature,
+ "Idempotency-Key": idempotency_key,
+}
+lines = [
+ "curl --include --max-time 15 --request POST "
+ + shlex.quote(origin + path)
+]
+for name, value in headers.items():
+ lines.append("--header " + shlex.quote(f"{name}: {value}"))
+lines.append("--data-binary " + shlex.quote(body))
+
+print("本次实际生成的 UUID:", nonce)
+print("本次时间戳:", timestamp)
+print("请求体 SHA256:", body_hash)
+print("计算得到的签名:", signature)
+print("\n完整 cURL 命令(仅生成,未执行):\n")
+print(" \\\n ".join(lines))
+```
+
+该脚本每次运行都会产生新的 nonce 和时间戳,因而签名也会变化。输出命令按 Bash 和 UTF-8 编写,不应直接当作 PowerShell 语法执行。脚本用于演示,会打印鉴权头;正式接入时凭据从安全配置读取,不把完整鉴权信息写入共享日志。
+
+#### 11.2.2 实际计算的完整 cURL 示例
+
+以下为2026-09-14实际生成的一组结果,不是 `$NONCE` 或 `$SIGNATURE` 占位符,也不是成功发送记录:
+
+| 项目 | 本次实际使用或计算的值 |
+| --- | --- |
+| 演示 Access Key | `DEMO_ACCESS_KEY_NOT_REGISTERED`(未开户) |
+| 演示 Secret | `DEMO_SECRET_NOT_A_REAL_CREDENTIAL`(无真实账号权限) |
+| Unix 秒级时间戳 | `1789355443` |
+| 实际生成的 UUID v4 | `7921b5d1-3b99-48d4-a068-ea7cf0c998db` |
+| 原始请求体 SHA256 | `c6cd931342e983778342e2175c0c60eafc87f28c892378761d0e6f7065728a29` |
+| 实际计算的 HMAC-SHA256 | `90cd7c99308406868fcdad059a9e61aef0a5d1275756885c4e277aa20e787a4b` |
+
+```bash
+curl --include --max-time 15 --request POST \
+ 'https://api.lisglo.com/api/openapi/v1/sms/messages' \
+ --header 'Content-Type: application/json' \
+ --header 'X-App-Key: DEMO_ACCESS_KEY_NOT_REGISTERED' \
+ --header 'X-Timestamp: 1789355443' \
+ --header 'X-Nonce: 7921b5d1-3b99-48d4-a068-ea7cf0c998db' \
+ --header 'X-Signature: 90cd7c99308406868fcdad059a9e61aef0a5d1275756885c4e277aa20e787a4b' \
+ --header 'Idempotency-Key: sms-doc-example-20260914-0001' \
+ --data-binary '{"mobile":"13800138000","content":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"}'
+```
+
+**真实的是 UUID 生成过程、摘要和签名计算结果,不是客户凭据或业务调用成功。** 固定示例可离线复算,但演示凭据无效,时间戳也会过期。本次未执行这条命令。使用真实凭据时,必须重新生成时间戳、nonce 和签名,不能只替换 Access Key;执行有效的发送命令可能真正创建短信任务并产生费用。
+
+### 11.3 短信状态
+
+```bash
+curl --include --max-time 15 --request GET \
+ "https://api.lisglo.com/api/openapi/v1/sms/messages/MSG-22222222-2222-4222-8222-222222222222" \
+ --header "X-App-Key: $ACCESS_KEY" \
+ --header "X-Timestamp: $TIMESTAMP" \
+ --header "X-Nonce: $NONCE" \
+ --header "X-Signature: $SIGNATURE"
+```
+
+成功响应示例:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=utf-8
+
+{
+ "messageId": "MSG-22222222-2222-4222-8222-222222222222",
+ "clientMessageId": "order-20260914-0001",
+ "phoneNumber": "13800138000",
+ "status": "delivered",
+ "submitStatus": "accepted",
+ "receiptStatus": "delivered",
+ "errorCode": null,
+ "errorMessage": null,
+ "queuedAt": "2026-09-14T02:00:00.000Z",
+ "submittedAt": "2026-09-14T02:00:01.000Z",
+ "deliveredAt": "2026-09-14T02:00:03.000Z",
+ "updatedAt": "2026-09-14T02:00:03.100Z"
+}
+```
+
+失败响应示例:
+
+```http
+HTTP/1.1 404 Not Found
+Content-Type: application/problem+json; charset=utf-8
+
+{
+ "type": "https://cmpp-platform.local/problems/message_not_found",
+ "title": "NOT_FOUND",
+ "status": 404,
+ "code": "MESSAGE_NOT_FOUND",
+ "detail": "短信记录不存在"
+}
+```
+
+### 11.4 上行列表第一页
+
+```bash
+curl --include --max-time 15 --request GET \
+ "https://api.lisglo.com/api/openapi/v1/sms/uplinks" --get \
+ --data-urlencode "startTime=2026-09-13T16:00:00Z" \
+ --data-urlencode "endTime=2026-09-14T16:00:00Z" \
+ --data-urlencode "limit=25" \
+ --header "X-App-Key: $ACCESS_KEY" \
+ --header "X-Timestamp: $TIMESTAMP" \
+ --header "X-Nonce: $NONCE" \
+ --header "X-Signature: $SIGNATURE"
+```
+
+成功响应示例:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=utf-8
+
+{
+ "items": [
+ {
+ "id": "uplink-example-001",
+ "messageId": null,
+ "phoneNumber": "13800138000",
+ "destId": "106900000000",
+ "content": "收到,谢谢",
+ "receivedAt": "2026-09-14T02:05:00.000Z"
+ }
+ ],
+ "nextCursor": null
+}
+```
+
+失败响应示例:
+
+```http
+HTTP/1.1 400 Bad Request
+Content-Type: application/problem+json; charset=utf-8
+
+{
+ "type": "https://cmpp-platform.local/problems/time_range_too_large",
+ "title": "BAD_REQUEST",
+ "status": 400,
+ "code": "TIME_RANGE_TOO_LARGE",
+ "detail": "单次查询不能超过31天"
+}
+```
+
+空列表响应:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=utf-8
+
+{
+ "items": [],
+ "nextCursor": null
+}
+```
+
+### 11.5 上行下一页
+
+第 11.4 节只有一条记录的示例已结束,所以 nextCursor 为 null,不应继续请求。以下表示**另一个有两条记录的分页场景,第一页和下一页均使用 limit=1**。第一页省略 cursor,其他时间条件与第 11.4 节相同。将下面 nextCursor 完整保存到 `$CURSOR`;这是由虚构记录生成的格式示例,线上必须用真实上一响应的值。
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=utf-8
+
+{
+ "items": [
+ {
+ "id": "uplink-example-001",
+ "messageId": null,
+ "phoneNumber": "13800138000",
+ "destId": "106900000000",
+ "content": "收到,谢谢",
+ "receivedAt": "2026-09-14T02:05:00.000Z"
+ }
+ ],
+ "nextCursor": "WyIyMDI2LTA5LTE0VDAyOjA1OjAwLjAwMFoiLCJ1cGxpbmstZXhhbXBsZS0wMDEiXQ"
+}
+```
+
+保持第一页时间和筛选不变,生成新的请求唯一标识、时间戳和签名:
+
+```bash
+curl --include --max-time 15 --get \
+ "https://api.lisglo.com/api/openapi/v1/sms/uplinks" \
+ --data-urlencode "startTime=2026-09-13T16:00:00Z" \
+ --data-urlencode "endTime=2026-09-14T16:00:00Z" \
+ --data-urlencode "limit=1" \
+ --data-urlencode "cursor=$CURSOR" \
+ --header "X-App-Key: $ACCESS_KEY" \
+ --header "X-Timestamp: $TIMESTAMP" \
+ --header "X-Nonce: $NONCE" \
+ --header "X-Signature: $SIGNATURE"
+```
+
+下一页返回较早的第二条记录,nextCursor 为 null,表示没有更多页:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=utf-8
+
+{
+ "items": [
+ {
+ "id": "uplink-example-000",
+ "messageId": null,
+ "phoneNumber": "13800138000",
+ "destId": "106900000000",
+ "content": "好的",
+ "receivedAt": "2026-09-14T02:04:00.000Z"
+ }
+ ],
+ "nextCursor": null
+}
+```
+
+非法游标示例:
+
+```http
+HTTP/1.1 400 Bad Request
+Content-Type: application/problem+json; charset=utf-8
+
+{
+ "type": "https://cmpp-platform.local/problems/cursor_invalid",
+ "title": "BAD_REQUEST",
+ "status": 400,
+ "code": "CURSOR_INVALID",
+ "detail": "cursor格式非法"
+}
+```
+
+### 11.6 上行详情
+
+```bash
+curl --include --max-time 15 --request GET \
+ "https://api.lisglo.com/api/openapi/v1/sms/uplinks/uplink-example-001" \
+ --header "X-App-Key: $ACCESS_KEY" \
+ --header "X-Timestamp: $TIMESTAMP" \
+ --header "X-Nonce: $NONCE" \
+ --header "X-Signature: $SIGNATURE"
+```
+
+成功响应示例:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=utf-8
+
+{
+ "id": "uplink-example-001",
+ "messageId": null,
+ "phoneNumber": "13800138000",
+ "destId": "106900000000",
+ "content": "收到,谢谢",
+ "receivedAt": "2026-09-14T02:05:00.000Z",
+ "tenantId": "tenant-example",
+ "applicationId": "application-example"
+}
+```
+
+失败响应示例:
+
+```http
+HTTP/1.1 404 Not Found
+Content-Type: application/problem+json; charset=utf-8
+
+{
+ "type": "https://cmpp-platform.local/problems/uplink_not_found",
+ "title": "NOT_FOUND",
+ "status": 404,
+ "code": "UPLINK_NOT_FOUND",
+ "detail": "上行记录不存在"
+}
+```
+
+### 11.7 两类回调的完整交互
+
+以下签名位置为说明占位符,必须用第 8.3 节规则计算,不是有效固定签名。平台投递 URL 是客户配置的地址。
+
+**回执通知:**
+
+```http
+POST /webhooks/sms/receipt HTTP/1.1
+Host: customer.example.com
+Content-Type: application/json
+X-Event-Id: evt_receipt_example001
+X-Event-Type: receipt
+X-Timestamp: 1789344000
+X-Signature: sha256=<本次原始请求体的有效签名>
+
+{
+ "eventId": "evt_receipt_example001",
+ "eventType": "receipt",
+ "occurredAt": "2026-09-14T02:00:03.100Z",
+ "data": {
+ "messageId": "MSG-22222222-2222-4222-8222-222222222222",
+ "gatewayMessageId": "example-gateway-id",
+ "phoneNumber": "13800138000",
+ "receiptStatus": "delivered",
+ "rawStatus": "DELIVRD",
+ "errorCode": null,
+ "deliveredAt": "2026-09-14T02:00:03.000Z"
+ }
+}
+```
+
+**上行通知:**
+
+```http
+POST /webhooks/sms/uplink HTTP/1.1
+Host: customer.example.com
+Content-Type: application/json
+X-Event-Id: evt_uplink_example001
+X-Event-Type: uplink
+X-Timestamp: 1789344000
+X-Signature: sha256=<本次原始请求体的有效签名>
+
+{
+ "eventId": "evt_uplink_example001",
+ "eventType": "uplink",
+ "occurredAt": "2026-09-14T02:05:00.100Z",
+ "data": {
+ "applicationId": "application-example",
+ "phoneNumber": "13800138000",
+ "destId": "106900000000",
+ "content": "收到,谢谢",
+ "receivedAt": "2026-09-14T02:05:00.000Z",
+ "uplinkMessageId": "uplink-example-001"
+ }
+}
+```
+
+两类通知均在验签并可靠保存后确认:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: text/plain; charset=utf-8
+
+OK
+```
+
+若接收端暂时无法可靠保存,可返回如下失败(正文由客户自定义,不是平台统一错误格式):
+
+```http
+HTTP/1.1 503 Service Unavailable
+Content-Type: text/plain; charset=utf-8
+
+temporarily unavailable
+```
+
+预期会进入可重试分支,但当前自动重试缺陷仍未修复,不能仅凭返回503就假设平台一定补投。验签失败可返回401/403,这类响应通常不自动重试;应排查密钥、时间戳和原始字节。
+
+### 11.8 公共鉴权失败
+
+四个接口都可能在进入业务处理前返回以下响应:
+
+```http
+HTTP/1.1 401 Unauthorized
+Content-Type: application/problem+json; charset=utf-8
+
+{
+ "type": "https://cmpp-platform.local/problems/signature_invalid",
+ "title": "UNAUTHORIZED",
+ "status": 401,
+ "code": "SIGNATURE_INVALID",
+ "detail": "请求签名校验失败"
+}
+```
+
+其他公共失败见第 9 节。返回体的业务码和说明用于展示当前常见分支;真实网关错误可能不是该格式。
+
+## 12. 版本与兼容说明
+
+本次保持v1四个业务路径、五行LF签名、无体GET兼容摘要和请求唯一标识规则。clientMessageId必须为字符串或null、最长128字符;limit必须为正整数。上行列表与详情只输出公共业务字段,详情可含所属企业和应用ID,不再输出通道或内部匹配信息。
+
+回调是至少一次投递,请按eventId去重。查询和回调的字段、类型以当前环境提供的OpenAPI JSON为准;接入前完成签名校对,并保存requestId和messageId以便排障。
diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md
index 16dad61..fb2ecd9 100644
--- a/docs/first-version-development-requirements.md
+++ b/docs/first-version-development-requirements.md
@@ -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同源复用,未开通/无应用/配置失败可读通用文档,不能用默认值冒充真实应用配置。发送正向链路和历史回调处理须另有专项授权。本轮提交、推送、测试环境部署已授权;预生产未授权。
diff --git a/docs/http-api-assessment-20260910.md b/docs/http-api-assessment-20260910.md
new file mode 100644
index 0000000..d6d8b2d
--- /dev/null
+++ b/docs/http-api-assessment-20260910.md
@@ -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/query;clientMessageId只接受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不含query;GET空体按`{}`摘要是兼容现状,不是整改完成 | 不在本轮偷偷加入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-08~09;页面实现及扩展特殊字符验收仍待执行。
+
+## 六、分阶段交付与完成判定
+
+| 阶段 | 交付物 | 完成标准 |
+| --- | --- | --- |
+| D:文档讨论(当前) | 手册、整改方案、需求/用例/进度同步 | 四点建议及页面整改纳入,示例离线可核对;未确认的兼容/数据设计显式登记 |
+| I:设计细化 | R01兼容清单、R03数据/恢复设计、R04/R06兼容映射、页面实施设计 | 业务规则和重大范围变化由用户决定;不自动按草稿实施 |
+| C:代码实施 | 定向最小修复及页面/契约更新 | 后续明确授权后才执行;先复现后修改,不夹带旧工作区内容 |
+| V:真实验收 | API/PG/Redis/受控回调及浏览器证据 | 定向/回归/类型/构建/质量门禁按范围通过,未执行项明确;真实短信发送另授权 |
+| R:交付发布 | 精确提交与两环境独立验收 | 提交、推送、测试/预生产分别授权,使用标准发布工具;不因文档完成就发布 |
+
+本轮验收用例新增TC-HTTP-REMED-DOC-01~06,原HTTP-A01~A08、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 返回 200,HTML 3168 字节。
+- 机器可读契约:[OpenAPI JSON](https://api.lisglo.com/api/client-docs-json)。直接 HTTPS GET 返回 200,JSON 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=0;HttpWebhookDelivery 与 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)。后续实现发生变化时,签名函数、字段表、错误码、回调示例和在线契约须一起更新。
diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md
index 2e8294e..922f7f0 100644
--- a/docs/system-functional-test-cases.md
+++ b/docs/system-functional-test-cases.md
@@ -5427,3 +5427,21 @@ TC-DRAINAGE-GATE-01~16的实现范围以专项方案第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,不提交测试凭据、快照或客户数据。
diff --git a/docs/testing-progress.md b/docs/testing-progress.md
index 3c45ab4..57155a8 100644
--- a/docs/testing-progress.md
+++ b/docs/testing-progress.md
@@ -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秒后HTTP200,PG尝试[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需标准掩码入口,真实短信正向/计费与已登录客户端最终验收仍未执行。
diff --git a/src/apps/client/ClientHttpApiPage.tsx b/src/apps/client/ClientHttpApiPage.tsx
index 4c31d09..b989ef1 100644
--- a/src/apps/client/ClientHttpApiPage.tsx
+++ b/src/apps/client/ClientHttpApiPage.tsx
@@ -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([]);
const [applicationId, setApplicationId] = useState('');
const [config, setConfig] = useState(null);
@@ -15,7 +25,9 @@ export function ClientHttpApiPage() {
const [deliveries, setDeliveries] = useState([]);
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) => {
- 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));
+ 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));
}, []);
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 =
- {!api?.enabled ?
当前应用尚未由运营端开通 HTTP 接口。
: null}
-
{config?.applicationName ?? '企业应用'}
基础地址:{publicApiOrigin}/api/openapi/v1
} onClick={() => void copyHttpParams()} size="sm">{paramsCopied ? '已复制' : '复制HTTP参数'}
- 单条发送 {api?.sendEnabled ? '已开通' : '未开通'}
- 状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'}
- 上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'}
- 回执回调 {api?.receiptWebhookEnabled ? '已开通' : '未开通'}
- 上行回调 {api?.uplinkWebhookEnabled ? '已开通' : '未开通'}
-
-
调用限制
QPS:{api?.qpsLimit ?? '-'} · 签名时间容差:{api?.timestampToleranceSeconds ?? '-'} 秒 · 上行单次查询跨度:{api?.maxQueryRangeDays ?? '-'} 天 · 最大分页:{api?.maxPageSize ?? '-'}
HTTP IP 白名单:{config?.ipAllowlist.join('、') || '未限制'}
-
;
+ const overview = (
+
+ {!api?.enabled ?
当前应用尚未由运营端开通 HTTP 接口。
: null}
+
+
+
+
{config?.applicationName ?? '企业应用'}
+
基础地址:{publicApiOrigin}/api/openapi/v1
+
+
} onClick={() => void copyHttpParams()} size="sm">
+ {paramsCopied ? '已复制' : '复制HTTP参数'}
+
+
+
+ 单条发送 {api?.sendEnabled ? '已开通' : '未开通'}
+
+ 状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'}
+
+
+ 上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'}
+
+
+ 回执回调 {api?.receiptWebhookEnabled ? '已开通' : '未开通'}
+
+
+ 上行回调 {api?.uplinkWebhookEnabled ? '已开通' : '未开通'}
+
+
+
+
+
调用限制
+
+ QPS:{api?.qpsLimit ?? '-'} · 签名时间容差:{api?.timestampToleranceSeconds ?? '-'} 秒 · 上行单次查询跨度:
+ {api?.maxQueryRangeDays ?? '-'} 天 · 最大分页:{api?.maxPageSize ?? '-'}
+
+
HTTP IP 白名单:{config?.ipAllowlist.join('、') || '未限制'}
+
+
+ );
- const credentialPanel = 访问凭据
密钥只在创建时展示一次;建议轮换时先创建新凭据,完成切换后再吊销旧凭据。
- {credentials.map((item) =>
{item.name}{item.accessKey}****{item.secretLast4}最近使用:{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}{item.status === 'active' ? : 已吊销}
)}
- {credentials.length === 0 ?
暂无访问凭据。
: null}
-
;
+ const credentialPanel = (
+
+
+
+
+
+ 访问凭据
+
+
密钥只在创建时展示一次;建议轮换时先创建新凭据,完成切换后再吊销旧凭据。
+
+
+
+ {credentials.map((item) => (
+
+ {item.name}
+ {item.accessKey}
+ ****{item.secretLast4}
+
+ 最近使用:{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}
+
+ {item.status === 'active' ? (
+
+ ) : (
+ 已吊销
+ )}
+
+ ))}
+ {credentials.length === 0 ?
暂无访问凭据。
: null}
+
+ );
- const callbackPanel = ;
+ const callbackPanel = (
+
+
+
+ 回执回调
+
+
setReceiptUrl(event.target.value)}
+ placeholder="https://example.com/webhooks/sms/receipt"
+ value={receiptUrl}
+ />
+
+
+ {webhooks.some((item) => item.eventType === 'receipt') ? (
+
+ ) : null}
+
+
+
+
+ 上行回调
+
+
setUplinkUrl(event.target.value)}
+ placeholder="https://example.com/webhooks/sms/uplink"
+ value={uplinkUrl}
+ />
+
+
+ {webhooks.some((item) => item.eventType === 'uplink') ? (
+
+ ) : null}
+
+
+
+ );
- const docsPanel = 鉴权规则
每次请求携带 X-App-Key、X-Timestamp、X-Nonce、X-Signature。签名原文为:
{`METHOD
-/api/openapi/v1/...
-TIMESTAMP
-NONCE
-SHA256(rawBody)`}使用访问密钥执行 HMAC-SHA256,输出小写十六进制。单发还必须携带 Idempotency-Key。
-
接口清单
{`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}`}完整 OpenAPI 文档:{publicApiOrigin}/api/client-docs
-
回调验签
回调请求头包含 X-Event-Id、X-Event-Type、X-Timestamp、X-Signature。签名原文为 TIMESTAMP + '\\n' + rawBody,同样使用 HMAC-SHA256。客户系统必须按 X-Event-Id 幂等。
;
+ const docsPanel = ;
- const logsPanel = 最近调用
{requests.map((item) =>
{item.requestId} · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}
)}{requests.length === 0 ?
暂无调用记录。
: null}
-
最近回调投递
{deliveries.map((item) =>
{item.event.eventId} · {item.endpoint.eventType} · {item.status} · 尝试 {item.attemptCount} 次{item.lastError ? ` · ${item.lastError}` : ''}
{item.status !== 'delivered' && api?.allowClientManualRetry ?
} onClick={() => void clientApi.retryHttpWebhookDelivery(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="ghost">重投 : null}
)}{deliveries.length === 0 ?
暂无回调投递记录。
: null}
;
+ const logsPanel = (
+
+
+
最近调用
+ {requests.map((item) => (
+
+ {item.requestId} · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} ·{' '}
+ {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}
+
+ ))}
+ {requests.length === 0 ?
暂无调用记录。
: null}
+
+
+
最近回调投递
+ {deliveries.map((item) => (
+
+
+ {item.event.eventId} · {item.endpoint.eventType} · {item.status} · 尝试 {item.attemptCount}{' '}
+ 次{item.lastError ? ` · ${item.lastError}` : ''}
+
+ {item.status !== 'delivered' && api?.allowClientManualRetry ? (
+
}
+ onClick={() =>
+ void clientApi
+ .retryHttpWebhookDelivery(applicationId, item.id)
+ .then(() => loadApplication(applicationId))
+ }
+ size="sm"
+ variant="ghost"
+ >
+ 重投
+
+ ) : null}
+
+ ))}
+ {deliveries.length === 0 ?
暂无回调投递记录。
: null}
+
+
+ );
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 接口对接
管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。
- {loading ? 正在加载接口配置...
: null}{error ? {error}
: null}
- {revealedSecret ? {revealedSecret.title}{revealedSecret.accessKey ?
Access Key:{revealedSecret.accessKey}
: null}
Secret:{revealedSecret.secret}
} onClick={() => void copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch((failure: Error) => setError(failure.message))} size="sm">复制
: null}
- {!loading && applicationId ? : null}
- ;
+ return (
+
+
+
+
接口对接
+
管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。
+
+
+ {loading ? 正在加载接口配置...
: null}
+ {error ? {error}
: null}
+ {revealedSecret ? (
+
+
{revealedSecret.title}
+ {revealedSecret.accessKey ? (
+
+ Access Key:{revealedSecret.accessKey}
+
+ ) : null}
+
+ Secret:{revealedSecret.secret}
+
+
}
+ onClick={() =>
+ void copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch(
+ (failure: Error) => setError(failure.message),
+ )
+ }
+ size="sm"
+ >
+ 复制
+
+
+ ) : null}
+ {applicationId ? : docsPanel}
+
+ );
}
diff --git a/src/apps/client/http-docs/HttpDeveloperDocs.css b/src/apps/client/http-docs/HttpDeveloperDocs.css
new file mode 100644
index 0000000..6733c7c
--- /dev/null
+++ b/src/apps/client/http-docs/HttpDeveloperDocs.css
@@ -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;
+}
diff --git a/src/apps/client/http-docs/HttpDeveloperDocs.test.tsx b/src/apps/client/http-docs/HttpDeveloperDocs.test.tsx
new file mode 100644
index 0000000..a24ba04
--- /dev/null
+++ b/src/apps/client/http-docs/HttpDeveloperDocs.test.tsx
@@ -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();
+ expect(screen.getByRole('status')).toHaveTextContent('暂无可选应用');
+ expect(screen.getByTitle('HTTP接口接入文档')).toHaveAttribute('src', '/api/client-docs');
+ rerender();
+ expect(screen.getByRole('status')).toHaveTextContent('配置暂不可用');
+ expect(screen.getByRole('status')).not.toHaveTextContent('QPS');
+ });
+ it('never embeds private application parameters in the public document URL', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('status')).toHaveTextContent('QPS 7');
+ expect(screen.getByTitle('HTTP接口接入文档')).toHaveAttribute('src', '/api/client-docs');
+ expect(document.querySelector('iframe')?.outerHTML).not.toContain('private');
+ });
+});
diff --git a/src/apps/client/http-docs/HttpDeveloperDocs.tsx b/src/apps/client/http-docs/HttpDeveloperDocs.tsx
new file mode 100644
index 0000000..b21fb5e
--- /dev/null
+++ b/src/apps/client/http-docs/HttpDeveloperDocs.tsx
@@ -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 (
+
+
+ {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}`}
+
+
+
+ );
+}
diff --git a/tools/quality/css-ownership.json b/tools/quality/css-ownership.json
index 36bdb84..4b32de0 100644
--- a/tools/quality/css-ownership.json
+++ b/tools/quality/css-ownership.json
@@ -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"]
}
]
}