From f0e843436c715010d3eaec72a5e0c81816a6e5bd Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 14 Sep 2026 12:48:10 +0800 Subject: [PATCH] feat: remediate HTTP API reliability and developer documentation --- .../migration.sql | 11 + api/prisma/schema.prisma | 15 + api/src/main.ts | 25 +- api/src/open-api/docs/reader.css | 47 + api/src/open-api/docs/reader.js | 38 + api/src/open-api/docs/reader.ts | 65 + api/src/open-api/open-api-auth.guard.ts | 116 +- api/src/open-api/open-api-contract.spec.ts | 66 + api/src/open-api/open-api-docs.controller.ts | 33 + api/src/open-api/open-api-exception.filter.ts | 55 +- api/src/open-api/open-api-remediation.spec.ts | 181 +++ .../open-api/open-api-trace.interceptor.ts | 28 + api/src/open-api/open-api.controller.ts | 82 +- api/src/open-api/open-api.dto.ts | 91 +- api/src/open-api/open-api.module.ts | 6 +- api/src/open-api/open-api.protocol.ts | 40 + api/src/open-api/open-api.recovery.spec.ts | 70 + api/src/open-api/open-api.recovery.ts | 113 ++ api/src/open-api/open-api.service.spec.ts | 109 +- api/src/open-api/open-api.service.ts | 349 ++--- api/src/open-api/open-api.types.ts | 1 + .../send-chain/send-batch-entry.service.ts | 344 +++-- api/src/send-chain/send-chain.contracts.ts | 3 + docs/client-http-api-guide.md | 1159 +++++++++++++++++ .../first-version-development-requirements.md | 6 + docs/http-api-assessment-20260910.md | 288 ++++ docs/system-functional-test-cases.md | 18 + docs/testing-progress.md | 13 + src/apps/client/ClientHttpApiPage.tsx | 330 ++++- .../client/http-docs/HttpDeveloperDocs.css | 8 + .../http-docs/HttpDeveloperDocs.test.tsx | 39 + .../client/http-docs/HttpDeveloperDocs.tsx | 29 + tools/quality/css-ownership.json | 6 + 33 files changed, 3332 insertions(+), 452 deletions(-) create mode 100644 api/prisma/migrations/20260914060000_http_api_durable_delivery/migration.sql create mode 100644 api/src/open-api/docs/reader.css create mode 100644 api/src/open-api/docs/reader.js create mode 100644 api/src/open-api/docs/reader.ts create mode 100644 api/src/open-api/open-api-contract.spec.ts create mode 100644 api/src/open-api/open-api-docs.controller.ts create mode 100644 api/src/open-api/open-api-remediation.spec.ts create mode 100644 api/src/open-api/open-api-trace.interceptor.ts create mode 100644 api/src/open-api/open-api.protocol.ts create mode 100644 api/src/open-api/open-api.recovery.spec.ts create mode 100644 api/src/open-api/open-api.recovery.ts create mode 100644 docs/client-http-api-guide.md create mode 100644 docs/http-api-assessment-20260910.md create mode 100644 src/apps/client/http-docs/HttpDeveloperDocs.css create mode 100644 src/apps/client/http-docs/HttpDeveloperDocs.test.tsx create mode 100644 src/apps/client/http-docs/HttpDeveloperDocs.tsx 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) => ``).join('') + ''); + continue; + } + closeTable(); + if (line.trim() && !/^---+$/.test(line)) current.body.push(`

${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}

`); + } + closeTable(); + return `聆界短信 · HTTP 接入文档
聆界短信 · 开发者文档

HTTP 接口接入文档

${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1

${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

- 单条发送 {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

+
+ +
+
+ 单条发送 {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 =

回执回调

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 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-KeyX-TimestampX-NonceX-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 ? : 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 ? ( + + ) : 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 访问凭据、回调地址、接口文档及真实投递记录。

setApplicationId(event.target.value)} + options={applications.map((item) => ({ label: item.name, value: item.id }))} + value={applicationId} + /> +
+ {loading ?

正在加载接口配置...

: null} + {error ?

{error}

: null} + {revealedSecret ? ( +
+ {revealedSecret.title} + {revealedSecret.accessKey ? ( +

+ Access Key:{revealedSecret.accessKey} +

+ ) : null} +

+ Secret:{revealedSecret.secret} +

+ +
+ ) : 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}`} +

+
${inline(cell.trim())}