From 18ecf8045f488af349812938d513753aa25ec237 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Tue, 15 Sep 2026 14:58:31 +0800 Subject: [PATCH] feat: simplify HTTP request signing and publish revised client guide --- api/src/open-api/docs/reader.ts | 3 +- api/src/open-api/open-api-auth.guard.ts | 5 +- api/src/open-api/open-api-remediation.spec.ts | 30 +- api/src/open-api/open-api-signature.spec.ts | 79 + api/src/open-api/open-api.controller.ts | 7 +- api/src/open-api/open-api.protocol.ts | 20 +- docs/client-http-api-guide.md | 1424 +++++++---------- .../first-version-development-requirements.md | 5 + docs/http-api-assessment-20260910.md | 12 + docs/system-functional-test-cases.md | 15 + docs/testing-progress.md | 11 + tools/testing/verify-http-signature.mjs | 246 +++ 12 files changed, 1006 insertions(+), 851 deletions(-) create mode 100644 api/src/open-api/open-api-signature.spec.ts create mode 100644 tools/testing/verify-http-signature.mjs diff --git a/api/src/open-api/docs/reader.ts b/api/src/open-api/docs/reader.ts index d86768a..24d9309 100644 --- a/api/src/open-api/docs/reader.ts +++ b/api/src/open-api/docs/reader.ts @@ -58,7 +58,8 @@ export function renderHttpGuide(markdown: string, origin: string) { continue; } closeTable(); - if (line.trim().endsWith(':') && line.trim().length < 70) sampleTitle = line.trim().replace(/:$/, ''); + const caption = line.trim().replace(/^\*\*(.+)\*\*$/, '$1'); + if (caption.endsWith(':') && caption.length < 70) sampleTitle = caption.replace(/:$/, ''); if (line.trim() && !/^---+$/.test(line)) current.body.push(`

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

`); } closeTable(); diff --git a/api/src/open-api/open-api-auth.guard.ts b/api/src/open-api/open-api-auth.guard.ts index 049e0db..cc2c686 100644 --- a/api/src/open-api/open-api-auth.guard.ts +++ b/api/src/open-api/open-api-auth.guard.ts @@ -15,7 +15,7 @@ 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 { openApiSignature, publicOpenApiFailure } from './open-api.protocol'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; import { SecurityDetectionService } from '../security-detection/security-detection.service'; @@ -97,14 +97,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy { throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' }); } const path = (request.originalUrl ?? request.url ?? '').split('?')[0]; - const bodyHash = openApiBodyHash(request.rawBody, request.body); const expected = openApiSignature( decryptSecret(credential.secretEncrypted), request.method, path, timestampText, nonce, - bodyHash, + request.rawBody, ); const expectedBuffer = Buffer.from(expected, 'hex'); const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) diff --git a/api/src/open-api/open-api-remediation.spec.ts b/api/src/open-api/open-api-remediation.spec.ts index 62477d5..7944e41 100644 --- a/api/src/open-api/open-api-remediation.spec.ts +++ b/api/src/open-api/open-api-remediation.spec.ts @@ -20,37 +20,23 @@ describe('HTTP API remediation boundaries', () => { '/api/openapi/v1/sms/uplinks', '1789344000', '550e8400-e29b-41d4-a716-446655440000', - openApiBodyHash(undefined, undefined), + undefined, ), - ).toBe('f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65'); + ).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51'); }); - it('keeps GET absent-body compatibility and signs exact POST UTF8 bytes', () => { + it('preserves internal idempotency hashes but 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'); + const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw.toString('utf8')].join('\n'); expect( - openApiSignature( - 'offline-secret', - 'post', - '/api/openapi/v1/sms/messages?ignored=1', - '123', - 'nonce-0001', - openApiBodyHash(raw, {}), - ), + openApiSignature('offline-secret', 'post', '/api/openapi/v1/sms/messages?ignored=1', '123', 'nonce-0001', 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, {}), - ), + openApiSignature('offline-secret', 'POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw), ); }); @@ -169,11 +155,11 @@ describe('HTTP API remediation boundaries', () => { }); it('keeps named code examples beside their source paragraphs', () => { const html = renderHttpGuide( - '**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n五行原文:\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```', + '**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n**签名原文:**\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```', '', ); expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明')); - expect(html).toContain('五行原文 · text'); + expect(html).toContain('签名原文 · text'); expect(html).not.toContain('data-show-sample'); expect(html).not.toContain('示例 \d+ { + const path = '/api/openapi/v1/sms/messages'; + const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db'; + const body = Buffer.from( + '{"mobile":"13800138000","content":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"}', + ); + const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL'; + const sign = (raw: Buffer) => openApiSignature(secret, 'POST', path, '1789355443', nonce, raw); + + it('matches the independently computed published POST vector', () => { + expect(sign(body)).toBe('a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124'); + }); + it('does not accept legacy body digests or changed body bytes', () => { + const legacy = createHmac('sha256', secret) + .update(['POST', path, '1789355443', nonce, createHash('sha256').update(body).digest('hex')].join('\n')) + .digest('hex'); + expect(sign(body)).not.toBe(legacy); + for (const changed of [ + Buffer.concat([body, Buffer.from('\n')]), + Buffer.from(JSON.stringify(JSON.parse(body.toString()), null, 2)), + Buffer.from(body.toString().replace('123456', '654321')), + ]) { + expect(sign(changed)).not.toBe(sign(body)); + } + }); + it('rejects missing POST raw bytes instead of reconstructing JSON', () => { + expect(() => openApiSignature(secret, 'POST', path, '123', nonce)).toThrow('缺少原始请求体'); + }); + it('rejects nonempty GET bodies and distinguishes a trailing LF', () => { + const fields = ['GET', '/api/openapi/v1/sms/uplinks', '123', nonce]; + const actual = openApiSignature(secret, fields[0], fields[1], fields[2], fields[3]); + expect(actual).toBe(createHmac('sha256', secret).update(fields.join('\n')).digest('hex')); + expect(actual).not.toBe( + createHmac('sha256', secret) + .update(fields.join('\n') + '\n') + .digest('hex'), + ); + expect(() => openApiSignature(secret, 'GET', path, '123', nonce, Buffer.from('{}'))).toThrow('GET请求不得携带正文'); + }); + it('executes both handbook examples and verifies every complete request packet', () => { + const guide = readFileSync(resolve(__dirname, '../../../docs/client-http-api-guide.md'), 'utf8').replace( + /\r\n/g, + '\n', + ); + expect(guide).toContain('### 1.4 怎样使用后面的 cURL 示例'); + expect(guide).not.toContain('### 2.4'); + const scripts = [...guide.matchAll(/```javascript\n([\s\S]*?)\n```/g)]; + expect(scripts).toHaveLength(2); + for (const script of scripts) { + const outputs: string[] = []; + runInNewContext(script[1], { + Buffer, + require: () => ({ createHmac }), + console: { log: (value: string) => outputs.push(value) }, + }); + expect(outputs).toHaveLength(1); + expect(guide).toContain(outputs[0]); + } + const packets = [...guide.matchAll(/```http\n((?:GET|POST) \/api\/openapi\/[\s\S]*?)\n```/g)]; + expect(packets).toHaveLength(5); + for (const [, packet] of packets) { + const split = packet.indexOf('\n\n'); + const headers = packet.slice(0, split); + const [method, url] = headers.split('\n')[0].split(' '); + const header = (name: string) => headers.match(new RegExp('^' + name + ': (.+)$', 'm'))![1]; + const raw = method === 'POST' ? Buffer.from(packet.slice(split + 2)) : undefined; + if (raw) expect(raw.length).toBe(Number(header('Content-Length'))); + expect(openApiSignature(secret, method, url, header('X-Timestamp'), header('X-Nonce'), raw)).toBe( + header('X-Signature'), + ); + } + }); +}); diff --git a/api/src/open-api/open-api.controller.ts b/api/src/open-api/open-api.controller.ts index e5f4bf2..b2d11b7 100644 --- a/api/src/open-api/open-api.controller.ts +++ b/api/src/open-api/open-api.controller.ts @@ -37,7 +37,12 @@ import { @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 }) +@ApiHeader({ + name: 'X-Signature', + required: true, + description: + 'HMAC-SHA256小写十六进制。方法、路径(不含query)、时间戳、nonce以LF分隔;GET末尾无LF,POST追加LF及原始UTF-8正文,不计算正文摘要。', +}) @ApiResponse({ status: 400, type: OpenApiProblemDto }) @ApiResponse({ status: 401, type: OpenApiProblemDto }) @ApiResponse({ status: 403, type: OpenApiProblemDto }) diff --git a/api/src/open-api/open-api.protocol.ts b/api/src/open-api/open-api.protocol.ts index 4739e2e..2cfcc39 100644 --- a/api/src/open-api/open-api.protocol.ts +++ b/api/src/open-api/open-api.protocol.ts @@ -64,7 +64,7 @@ export function sendOpenApiProblem( }); } -/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */ +/** Internal idempotency fingerprint; this digest is not part of request authentication. */ export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) { return createHash('sha256') .update(rawBody ?? Buffer.from(JSON.stringify(body ?? {}))) @@ -77,11 +77,21 @@ export function openApiSignature( path: string, timestamp: string, nonce: string, - bodyHash: string, + rawBody?: Buffer, ) { - return createHmac('sha256', secret) - .update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n')) - .digest('hex'); + const verb = method.toUpperCase(); + if (verb === 'GET' && rawBody?.length) { + throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'GET请求不得携带正文' }); + } + if (verb !== 'GET' && !rawBody) { + throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '缺少原始请求体' }); + } + const signature = createHmac('sha256', secret).update( + [verb, path.split('?')[0], timestamp, nonce].join('\n'), + 'utf8', + ); + if (verb !== 'GET') signature.update('\n').update(rawBody!); + return signature.digest('hex'); } export function publicOpenApiFailure(error: unknown) { diff --git a/docs/client-http-api-guide.md b/docs/client-http-api-guide.md index 7fa67f2..458be1e 100644 --- a/docs/client-http-api-guide.md +++ b/docs/client-http-api-guide.md @@ -1,297 +1,261 @@ -# 客户 HTTP 短信接口接入手册 +# 聆界短信 HTTP API 参考手册 -**接口版本:v1 · 手册修订:2026-09-14** +**接口版本:v1 · 文档日期:2026-09-15** -适用对象:需要从自己的业务系统发送短信、查询短信结果或接收用户回复的开发人员。 +## 1. API 概览 -本文说明 HTTP 接口的接入步骤、鉴权、请求与响应以及回调处理。所有号码、消息编号和响应示例均为说明用途,不是真实客户数据。GET 空请求体使用第 3 节规定的兼容摘要;每次请求都须重新生成请求唯一标识和签名。 +聆界短信 HTTP API 提供单条短信发送、发送结果查询和用户回复查询。平台还可将短信回执和用户回复主动推送到客户系统。 -建议先完成上行查询与签名校对,再接入发送和回调。每个接口的参数说明后均附有对应示例;复制示例不会执行请求。 +### 1.1 服务地址与数据格式 -## 1. 开始接入前 +| 项目 | 约定 | +| ------------ | ------------------------------------------- | +| 接口基础地址 | https://api.lisglo.com/api/openapi/v1 | +| OpenAPI 定义 | https://api.lisglo.com/api/client-docs-json | +| 数据格式 | UTF-8 JSON | +| 发送方式 | POST | +| 查询方式 | GET,不携带请求体 | +| 授权方式 | Access Key + HMAC-SHA256 请求签名 | +| 发送受理 | HTTP 202 | +| 查询成功 | HTTP 200 | -### 1.1 这套接口能做什么 +业务接口的成功响应直接返回结果对象;Webhook 正文包含事件信息和 data 业务对象。 -| 你的业务需求 | 使用的能力 | +### 1.2 接口目录 + +| 接口名称 | 方法 | 接口路径 | 成功响应 | +| -------------------- | ---- | ---------------------------------------- | ----------------- | +| 单条短信发送接口 | 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:返回指定回复 | + +上表路径应拼接在 https://api.lisglo.com 后。收到 202 表示平台受理,不代表手机已经收到短信。最终结果通过回执查询或回执回调获取。 + +### 1.3 接入准备 + +1. 联系聆界平台客服创建企业账号和短信应用,开通 HTTP 及相应发送、查询、回调能力,配置调用服务器的 IP 白名单。 +2. 在客户端“接口对接”选择应用,确认接口地址、QPS 和白名单。 +3. 在“访问凭据”创建 Access Key 和 Secret。Secret 仅在创建或轮换时展示一次,请保存在后端安全配置中。 +4. 发送前完成短信签名和模板审核;提交的完整正文应符合本应用的审核与发送要求。 +5. 如需接收回调,在“回调配置”保存公网 HTTPS 地址和各自的回调签名密钥。 + +接口凭据只能用于所属应用,平台登录密码不能代替 Secret。回调验签密钥与接口 Secret 分开保存。轮换请求凭据时先建立新凭据并切换调用,再吊销旧凭据;回调密钥轮换需同步更新接收端。 + +建议先按第 2 节校对签名,再调用上行列表查询接口验证接入;没有回复时返回空列表。 + +### 1.4 怎样使用后面的 cURL 示例 + +HTTP 报文使用演示 Access Key、演示 Secret `DEMO_SECRET_NOT_A_REAL_CREDENTIAL` 和固定时间戳;签名已计算,POST 正文末尾不含换行,游标为格式示例。实际调用使用本应用凭据、当前时间、新 nonce 和上一页返回的游标。 + +示例中的变量按下表准备;它们表示你自己的调用参数,不是平台提供的固定凭据。 + +| 变量 | 准备方法 | +| ----------- | ---------------------------------------------------------- | +| $ACCESS_KEY | 填入本应用的 Access Key | +| $TIMESTAMP | 生成当前 Unix 秒级时间戳 | +| $NONCE | 为本次调用生成新的 UUID v4 | +| $SIGNATURE | 按第 2 节规则,使用本次方法、路径、时间戳、nonce 和请求体计算 | +| $CURSOR | 仅在翻页时使用,完整取自上一响应的 nextCursor | + +先在本机 Bash 会话中设置相应变量,再执行 cURL。每次调用重新计算,不可直接沿用另一接口或上一次调用的签名。示例使用 Bash 续行语法;PowerShell 请使用 curl.exe 并调整变量及续行写法。 + +POST 示例使用 --data-binary 原样发送文件;保存 sms.json 时采用 UTF-8 无 BOM,签名也读取该文件的同一份字节。cURL 进程成功退出后仍需检查 HTTP 状态和响应。 + +## 2. 授权与请求签名 + +### 2.1 填写请求头 + +| 请求头 | 必填 | 填写方式 | +| ----------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `X-App-Key` | 是 | 该应用的 Access Key | +| `X-Timestamp` | 是 | 当前 Unix 时间戳,单位为秒,例如 `1789344000`;不要传毫秒 | +| `X-Nonce` | 是 | 本次 HTTP 请求的唯一标识,推荐 UUID v4,例如 `550e8400-e29b-41d4-a716-446655440000`;同一次发送的网络重试时,需要重新生成 UUID v4 | +| `X-Signature` | 是 | 按下文计算的 HMAC-SHA256,小写十六进制字符串 | +| `Idempotency-Key` | 发送时必填 | 一次业务发送的稳定编号,8~128 字符,允许字母、数字、`.`、`_`、`:`、`-` | +| `Content-Type` | POST 使用 | `application/json` | +| `User-Agent` | 否 | 可填写客户端名称和版本,便于排错 | + +每次调用,包括网络重试,都使用当前秒级时间戳和新的请求唯一标识。默认允许服务器时间前后相差 300 秒,具体以应用配置为准。X-Nonce 推荐 UUID v4,也接受字母、数字、下划线、短横线组成的 8~128 字符。 + +### 2.2 POST 发送请求如何计算签名 + +将请求方法、路径、时间戳、nonce 和原始请求体按以下规则拼接,使用 Secret 计算一次 HMAC-SHA256: + +```text +签名原文 = 字节拼接(UTF8(请求方法 + LF + 请求路径 + LF + 时间戳 + LF + nonce + LF), 原始请求体字节) +X-Signature = HEX(HMAC_SHA256(UTF8(Secret), 签名原文)) +``` + +| 内容 | 规则 | | --- | --- | -| 向一个手机号发送一条短信 | 单条发送接口 | -| 知道短信是否提交、送达或失败 | 短信状态查询接口 | -| 短信有回执时,由平台主动通知你的系统 | 回执回调(Webhook) | -| 获取手机用户回复的短信 | 上行列表、上行详情,或上行回调 | +| 请求方法 | 大写,例如 POST | +| 请求路径 | 从 /api 开始,不含域名及问号后的查询参数;路径参数使用实际发送的 URL 编码形式 | +| 时间戳 | 与 X-Timestamp 完全一致的秒级时间戳 | +| nonce | 与 X-Nonce 完全一致 | +| LF | 一个换行字节(0x0A),不能替换成 CRLF | +| 原始请求体 | 实际发送的 UTF-8 JSON 字节,不预先计算摘要 | +| Secret | 直接使用 UTF-8 字节作为密钥,不做 Base64 解码 | +| HEX | 转为 64 位小写十六进制字符串 | -“上行短信”就是手机用户回复到短信接入号的内容。“回执”是短信提交后的送达或失败结果。两者不是同一种通知。 +前四项后各接一个 LF,再接完整请求体。请求体可以有多行,空格、换行和字段顺序必须与实际发送内容完全一致;不要在签名时额外增加换行。Idempotency-Key 和 URL 查询参数不参与签名。 -本版只提供四个公开业务接口,不提供公开的批量发送、余额查询、签名管理或模板管理接口。签名、模板和应用配置通过平台页面办理。 +**POST 签名校对示例:** -### 1.2 开户、开通和凭据 +演示 Secret:DEMO_SECRET_NOT_A_REAL_CREDENTIAL。请求体为下面这一整行,开头没有 BOM,末尾没有换行: -依次完成以下准备: +```text +{"mobile":"13800138000","content":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"} +``` -1. 由平台创建企业账号和企业应用,并开通该应用的 HTTP 接口及需要的发送、查询、回调能力。 -2. 登录客户端,进入 **接口对接**,选择正确的企业应用。在“接口概览”确认接口地址、能力、QPS、时间容差和 HTTP IP 白名单。 -3. 在“访问凭据”创建凭据,保存 **Access Key** 和 **Secret**。自助创建未开放时联系平台人员。 -4. 如果需要发送,先完成签名和模板审核、必要报备以及余额和可用路由准备。只开通 HTTP 不代表任意正文都可以发送。 -5. 如果需要回调,在“回调配置”分别保存回执、上行 HTTPS 地址,并保存各自的回调签名密钥。 +完整签名原文: -Secret 只在创建或轮换时展示一次,后续末四位不能用于签名。凭据属于特定企业应用,不要将应用 A 的凭据用于查询应用 B 的短信。 +```text +POST +/api/openapi/v1/sms/messages +1789355443 +7921b5d1-3b99-48d4-a068-ea7cf0c998db +{"mobile":"13800138000","content":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"} +``` -| 名称 | 用途 | 是否放进请求头 | -| --- | --- | --- | -| Access Key | 标识调用哪个企业应用的接口 | 放入 `X-App-Key` | -| Secret | 在你的服务端计算请求签名 | 不直接发送 | -| 回调签名密钥 | 验证平台推送到你系统的通知 | 不直接发送;与请求 Secret 分开保存 | -| 平台登录密码 | 登录客户端网页 | 不用于 HTTP 接口签名 | +**计算方法(Node.js):** -凭据应由你的后端服务保管,不放在网页前端代码、公开仓库或日志中。轮换请求凭据时,先建立新凭据、切换调用,再吊销旧凭据;可同时有效的数量以应用配置为准。回调密钥轮换需协调接收端,当前不要假设平台同时用新旧密钥签名。 +将以下代码保存为 post-signature.cjs,运行 `node post-signature.cjs`。代码使用 Node.js 自带的加密库,只进行本地计算。 -### 1.3 地址与文档入口 +```javascript +const { createHmac } = require('node:crypto'); -| 用途 | 地址 | +const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL'; +const method = 'POST'; +const path = '/api/openapi/v1/sms/messages'; +const timestamp = '1789355443'; +const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db'; +const body = Buffer.from( + '{"mobile":"13800138000","content":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"}', + 'utf8' +); + +// 前四项后各接一个 LF,再拼接实际发送的请求体字节。 +const prefix = method + '\n' + + path + '\n' + + timestamp + '\n' + + nonce + '\n'; +const signingBytes = Buffer.concat([ + Buffer.from(prefix, 'utf8'), + body, +]); + +const signature = createHmac('sha256', Buffer.from(secret, 'utf8')) + .update(signingBytes) + .digest('hex'); + +console.log(signature); +``` + +实际从文件发送时,将 body 的赋值改为 `const body = require('node:fs').readFileSync('sms.json');`,签名和发送使用同一份文件字节,不要解析后重新生成 JSON。 + +**输出结果,填入 X-Signature:** + +```text +a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124 +``` + +### 2.3 GET 查询请求如何计算签名 + +GET 请求不携带正文。签名只拼接请求方法、请求路径、时间戳和 nonce 四项,相邻项之间用一个 LF 换行符分隔,nonce 后不加换行。 + +**计算公式:** + +```text +签名原文 = 请求方法 + LF + 请求路径 + LF + 时间戳 + LF + nonce +X-Signature = HEX(HMAC_SHA256(UTF8(Secret), UTF8(签名原文))) +``` + +UTF8 表示将字符串转换为 UTF-8 字节;HMAC_SHA256 表示以 Secret 为密钥对签名原文进行运算;HEX 表示将运算结果转换为 64 位小写十六进制字符串。 + +**本例输入:** + +| 项目 | 值 | | --- | --- | -| 客户接口基础地址 | `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 | -| 客户端文档入口 | 接口对接 → 选择企业应用 → 接口文档 | +| Secret | doc-example-secret | +| 请求方法 | GET | +| 请求路径 | /api/openapi/v1/sms/uplinks | +| 时间戳 | 1789344000 | +| nonce | 550e8400-e29b-41d4-a716-446655440000 | -上述域名当前连接预生产真实业务系统。未开户也能查看文档,但业务调用需要有效凭据;Swagger 的调试按钮会发真实请求,不是匿名试用或模拟发送。测试环境使用平台另行提供的地址和凭据,不能混用。 +请求路径不含域名和问号后的查询参数;包含路径参数时,使用实际发送的 URL 编码形式。时间戳和 nonce 必须分别与 X-Timestamp、X-Nonce 一致。Secret 直接作为 UTF-8 密钥使用,不做 Base64 解码;Access Key 和 Idempotency-Key 不参与本公式。 -**建议第一次先查询上行列表。** 即使没有上行数据,也可以用空列表确认接入流程;不要用发送短信来测试网络是否通畅。 - -## 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 计算请求签名 - -签名分为三个步骤:计算请求体摘要、拼接五行原文、计算 HMAC。HTTP 请求头中的时间戳和 nonce 必须与签名时使用的值完全一致。 - -**第一步:计算请求体摘要。** POST 使用最终发送的 JSON 原始字节计算 SHA256;不要在签名后修改空格、字段顺序或中文转义。无请求体的 GET 使用第 3.3 节规定的兼容摘要。 - -**第二步:按以下顺序拼接五行。** 路径包含 /api,不含域名或查询参数;如路径参数经过 URL 编码,使用实际发送的编码后路径。 - -五行签名原文: +**拼接签名原文:** ```text -大写 HTTP 方法 -请求路径 -X-Timestamp 的原始字符串 -X-Nonce 的原始字符串 -请求体的 SHA256 十六进制摘要 +GET\n/api/openapi/v1/sms/uplinks\n1789344000\n550e8400-e29b-41d4-a716-446655440000 ``` -相邻两行用一个 LF 换行符(0x0A)连接,最后一行后不加换行。不要使用 Windows CRLF,也不要加入引号或多余空格。URL 查询参数使用的 & 不是签名分隔符,query 不参与本版本签名。 +上面每个 \n 表示一个 LF 换行字节(0x0A),不是反斜杠和字母 n,也不能替换为 CRLF。签名原文在 nonce 的最后一个字符处结束,不追加 LF、{} 或其他正文。 -**第三步:计算 HMAC-SHA256。** 使用 Secret 的 UTF-8 字节作为密钥,五行原文的 UTF-8 字节作为输入,输出 64 位十六进制字符串,放入 X-Signature。 +**计算方法(Node.js):** -签名计算公式: +将以下代码保存为 get-signature.cjs,运行 `node get-signature.cjs`。代码使用 Node.js 自带的加密库,只进行本地计算。 + +```javascript +const { createHmac } = require('node:crypto'); + +const secret = 'doc-example-secret'; +const method = 'GET'; +const path = '/api/openapi/v1/sms/uplinks'; +const timestamp = '1789344000'; +const nonce = '550e8400-e29b-41d4-a716-446655440000'; + +// 四项之间用 LF 分隔,nonce 后不加 LF。 +const signingText = method + '\n' + + path + '\n' + + timestamp + '\n' + + nonce; + +const signature = createHmac('sha256', Buffer.from(secret, 'utf8')) + .update(Buffer.from(signingText, 'utf8')) + .digest('hex'); + +console.log(signature); +``` + +**输出结果:** ```text -SIGNING_STRING = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH -SIGNATURE = HEX(HMAC_SHA256(UTF8(Secret), UTF8(SIGNING_STRING))) +3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51 ``` -公式中的 \n 表示一个 LF 字节,不是反斜杠和字母 n 两个字符。Secret 直接使用,不做 Base64 解码。示例输出纯十六进制签名;服务端也接受 sha256= 前缀,但无须添加。Idempotency-Key 是业务幂等头,不属于上述五行,也不能代替 nonce。 +将该结果填入 X-Signature 请求头。其他语言按相同输入字节调用标准库的 HMAC-SHA256,并输出小写十六进制,结果应一致。 -### 3.3 当前 GET 空请求体的兼容规则 +本例的固定时间戳和演示密钥仅用于离线校对。实际调用使用本应用 Secret、当前秒级时间戳和新的 nonce,并重新计算签名。 -**当前版本的特殊行为:不带请求体的 GET,摘要需要按 UTF-8 字符串 `{}` 计算。GET 本身仍不发送请求体。** +## 3. API:单条短信发送 -固定摘要为: +### 3.1 接口说明 -```text -44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a -``` +向一个中国大陆手机号发送一条短信。正文填写完整短信签名及已替换变量的内容,平台据此匹配本应用的签名与模板。 -请勿使用空字节的 SHA256 代替这个摘要。本版本为兼容已有接入保留此规则;GET 请求仍不携带 body。 +| 项目 | 内容 | +| ------------ | -------------------------------------------------- | +| URL | https://api.lisglo.com/api/openapi/v1/sms/messages | +| HTTP 方法 | POST | +| Content-Type | application/json | +| 是否鉴权 | 是,四个鉴权头见第 2 节 | +| 业务幂等头 | Idempotency-Key,必填 | +| 所需能力 | 短信发送 | +| 成功状态 | 202 Accepted | -### 3.4 Python 签名函数 +### 3.2 请求参数 -下面只计算请求头,不访问网络。使用 Python 标准库即可。 +| JSON 字段 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ------------ | ---------------------------------------------------------------- | +| `mobile` | string | 是 | 单个中国大陆手机号,例如 `13800138000`;不能传数组或逗号分隔号码 | +| `content` | string | 是 | 完整短信正文,包含签名和已填入的变量值;不能为空 | +| `clientMessageId` | string | 否,建议提供 | 你的业务系统消息编号,应用内唯一;按不超过 128 字符使用 | -```python -import hashlib -import hmac -import uuid -import time +只传上述业务字段,不传企业、应用、签名、模板或通道选择字段。Idempotency-Key 为稳定的业务发送编号,8~128 字符,可使用字母、数字、点、下划线、冒号和短横线。 - -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 和自己的凭据。 - -### 3.6 公共准备与占位符 - -本节每个接口均提供 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 传入,避免重新序列化;本稿没有自动执行发送的入口。 - - -## 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 响应示例。 - -除鉴权头外,需要 `Content-Type: application/json` 和 `Idempotency-Key`。 - -| JSON 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `mobile` | string | 是 | 单个中国大陆手机号,例如 `13800138000`;不能传数组或逗号分隔号码 | -| `content` | string | 是 | 完整短信正文,包含签名和已填入的变量值;不能为空 | -| `clientMessageId` | string | 否,建议提供 | 你的业务系统消息编号,应用内唯一;按不超过 128 字符使用 | +**请求体示例:** ```json { @@ -301,72 +265,9 @@ except urllib.error.URLError: } ``` -正文由平台识别签名、模板和变量,不传 `tenantId`、`applicationId`、`signatureId`、`templateId` 或自选通道。上例签名和模板仅作说明,发送前必须使用你已审核且符合发送条件的内容。 +### 3.3 请求示例 -下面代码只准备请求,不执行发送;接在第 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 换键反复发送。修正确定失败的业务后是否发起新发送,由你的业务流程明确决定。 - -### 5.4 单条发送 - -将第 5.1 节 JSON 保存为 UTF-8 无 BOM 的 `sms.json`,签名时读取该文件的原始字节,随后原样发送。此命令会真实提交短信,示例不自动执行。 +将上面的 JSON 保存为 UTF-8 无 BOM 的 sms.json。按第 2 节对文件原始字节计算签名,并准备相应变量。发送命令: ```bash curl --include --max-time 15 --request POST \ @@ -380,7 +281,32 @@ curl --include --max-time 15 --request POST \ --data-binary "@sms.json" ``` -成功响应示例: +**HTTP 请求报文:** + +```http +POST /api/openapi/v1/sms/messages HTTP/1.1 +Host: api.lisglo.com +Accept: */* +X-App-Key: DEMO_ACCESS_KEY_NOT_REGISTERED +X-Timestamp: 1789355443 +X-Nonce: 4755e099-ba9a-4e6f-affa-3630261e7feb +X-Signature: e2a70fdd364cc865e251d475cec722bb949500d255649d215b0039de89f68125 +Content-Type: application/json +Idempotency-Key: sms-order-20260914-0001 +Content-Length: 154 + +{ + "mobile": "13800138000", + "content": "【示例签名】您的验证码是123456,5分钟内有效。", + "clientMessageId": "order-20260914-0001" +} +``` + +执行使用有效凭据的发送命令会提交短信请求;文档样例不自动执行。 + +### 3.4 返回结果 + +**受理成功:** ```http HTTP/1.1 202 Accepted @@ -396,7 +322,18 @@ Content-Type: application/json; charset=utf-8 } ``` -失败响应示例: +| 字段 | 类型 | 说明 | +| --------------- | ----------- | ------------------------------------------- | +| code | string | ACCEPTED 表示受理成功 | +| requestId | string | 业务请求编号,用于排障 | +| messageId | string | 平台短信编号,用于查询 | +| clientMessageId | string/null | 调用方传入的业务短信编号 | +| status | string | 当前业务状态,例如 queued 或 pending_review | +| acceptedAt | string | 受理时间,ISO 8601 | + +保存 messageId 与客户业务编号的对应关系。202 不等于短信送达;若未取得明确结果,先查询已有记录。 + +**幂等键与正文不一致:** ```http HTTP/1.1 409 Conflict @@ -411,161 +348,43 @@ Content-Type: application/problem+json; charset=utf-8 } ``` -#### 5.4.1 完整生成脚本:不再手工填写请求头变量 +### 3.5 超时、重试与重复请求 -上面的 `$NONCE`、`$TIMESTAMP` 等是 Bash 变量引用,不是可以原样发送的参数值。未设置变量时,单独复制上面的结构示例不能正常鉴权。 +| 情况 | 建议处理 | +| ---------------------------- | ----------------------------------------------------------------- | +| 网络超时,无法确认受理结果 | 先按 clientMessageId 查询;必要时使用相同幂等键和相同原始正文重试 | +| 重试一次 HTTP 调用 | 重新生成时间戳、nonce 和签名;业务幂等键及正文保持不变 | +| 相同幂等键且正文一致 | 已完成时重放原受理响应或已保存的业务失败结果 | +| 相同幂等键但正文不同 | 返回 IDEMPOTENCY_CONFLICT | +| 原请求仍处理中 | 返回 REQUEST_PROCESSING;稍后查询,长期不结束联系平台 | +| 换幂等键但客户短信编号已使用 | 返回 CLIENT_MESSAGE_ID_CONFLICT,核对已有业务 | -下面提供完整脚本:实际生成 UUID v4、当前时间戳、请求体摘要和 HMAC-SHA256 签名,再输出没有请求头变量占位符的 Bash cURL 命令。保存为 UTF-8 编码的 `generate_sms_curl.py`,运行 `python3 generate_sms_curl.py`。 +保存业务幂等键,不通过换键绕过冲突。JSON 空格或字段顺序变化也会被判定为不同正文。 -**脚本只生成并打印命令,不访问接口、不发送短信。UUID 和签名是真实生成、真实计算的;Access Key 和 Secret 明确为未开户的演示凭据,不能据此鉴权成功。** +## 4. API:单条短信回执查询 -```python -import hashlib -import hmac -import json -import shlex -import time -import uuid +### 4.1 接口说明 -# 演示凭据,不是真实客户账号。 -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" +| 项目 | 内容 | +| --------- | -------------------------------------------------------------- | +| URL | https://api.lisglo.com/api/openapi/v1/sms/messages/{messageId} | +| HTTP 方法 | GET | +| 请求体 | 无 | +| 是否鉴权 | 是,按第 2 节 GET 规则签名 | +| 所需能力 | 短信状态查询 | +| 成功状态 | 200 OK | -# 同一次业务发送重试时,两个业务编号和请求体保持不变。 -idempotency_key = "sms-doc-example-20260914-0001" -payload = { - "mobile": "13800138000", - "content": "【示例签名】您的验证码是123456,5分钟内有效。", - "clientMessageId": "doc-example-20260914-0001", -} +### 4.2 路径参数 -# 只序列化一次:计算摘要与最终发送使用同一份内容。 -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() +| 参数 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | -------------------------------------------------------------- | +| messageId | string | 是 | 平台短信编号,也支持发送时提交的 clientMessageId;仅限当前应用 | -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)) +路径参数需 URL 编码,签名使用编码后的实际路径。 -print("本次实际生成的 UUID:", nonce) -print("本次时间戳:", timestamp) -print("请求体 SHA256:", body_hash) -print("计算得到的签名:", signature) -print("\n完整 cURL 命令(仅生成,未执行):\n") -print(" \\\n ".join(lines)) -``` - -该脚本每次运行都会产生新的 nonce 和时间戳,因而签名也会变化。输出命令按 Bash 和 UTF-8 编写,不应直接当作 PowerShell 语法执行。脚本用于演示,会打印鉴权头;正式接入时凭据从安全配置读取,不把完整鉴权信息写入共享日志。 - -#### 5.4.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;执行有效的发送命令可能真正创建短信任务并产生费用。 - - -## 6. 查询短信状态 - -完整 cURL 请求和 HTTP 响应见本节后附示例。 - -`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 也不能单独证明业务永远不会落库。 - -### 6.1 短信状态 +### 4.3 请求示例 ```bash curl --include --max-time 15 --request GET \ @@ -576,7 +395,23 @@ curl --include --max-time 15 --request GET \ --header "X-Signature: $SIGNATURE" ``` -成功响应示例: +**HTTP 请求报文:** + +```http +GET /api/openapi/v1/sms/messages/MSG-22222222-2222-4222-8222-222222222222 HTTP/1.1 +Host: api.lisglo.com +Accept: */* +X-App-Key: DEMO_ACCESS_KEY_NOT_REGISTERED +X-Timestamp: 1789355443 +X-Nonce: 3efb63ee-b99d-47c2-aa87-cbfeb7730a30 +X-Signature: 8843aa140566a16a9fddd81339235a94abc218af125963ca2df365e5454f8212 + + +``` + +### 4.4 返回结果 + +**查询成功:** ```http HTTP/1.1 200 OK @@ -598,7 +433,34 @@ Content-Type: application/json; charset=utf-8 } ``` -失败响应示例: +| 字段 | 类型 | 说明 | +| ----------------------------- | ----------- | --------------------------------------------------------------- | +| `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 也不能单独证明业务永远不会落库。 + +**记录不存在或不属于当前应用:** ```http HTTP/1.1 404 Not Found @@ -613,75 +475,39 @@ Content-Type: application/problem+json; charset=utf-8 } ``` +## 5. API:上行短信列表查询 -## 7. 查询手机用户回复(上行) +### 5.1 接口说明 -### 7.0 先分清四种时间边界 +查询手机用户回复的短信。无需填写时间参数,默认读取最近 24 小时内已归属本应用的回复。 -| 项目 | 当前规则 | 举例 | -| --- | --- | --- | -| 默认查询范围 | endTime 默认现在;startTime 默认 endTime 前 24 小时 | 什么时间都不传,就是最近 24 小时 | -| 单次最大查询跨度 | 默认 31 天,以应用配置为准;限制起止时间之差 | 可查询三个月前某一天,并不是只能查询最近 31 天 | -| 历史数据保留期 | 有默认 90 天的配置,但尚未确认存在按该配置执行的保留/清理闭环 | 不能承诺一定有 90 天数据,也不能断言更早数据必定被删除 | -| 游标有效期 | 当前游标没有单独过期计时;不保证数据长期不变 | 不将旧 cursor 当作永久同步凭据,重新固定时间窗口并去重 | +| 项目 | 内容 | +| --------- | ------------------------------------------------- | +| URL | https://api.lisglo.com/api/openapi/v1/sms/uplinks | +| HTTP 方法 | GET | +| 请求体 | 无 | +| 是否鉴权 | 是,按第 2 节 GET 规则签名 | +| 所需能力 | 上行查询 | +| 成功状态 | 200 OK | +| 排序 | 接收时间、记录编号倒序 | -查询代码没有额外强制“只查最近 N 天”,但历史查询能否返回数据取决于实际留存、当前应用归属和筛选条件。startTime 必须不晚于 endTime,当前时间边界两端均包含。跨窗口同步可按上行 id 去重,避免公共边界重复计入。 +### 5.2 查询参数 -时间接受 `YYYY-MM-DD`(按UTC零点)或带 `Z` / `±HH:mm` 时区的ISO 8601时间;秒可省略,小数秒按毫秒精度解析,更高精度输入保留兼容。日期必须在日历上真实存在,不接受2月30日、24:00、无时区日期时间或本地化日期格式;空字符串也不是省略参数。游标中的时间执行同样校验。 +| 参数 | 类型 | 必填 | 默认值 / 说明 | +| ------------ | ------ | ---- | ------------------------------------------------- | +| mobile | string | 否 | 精确匹配回复者手机号 | +| accessNumber | string | 否 | 精确匹配短信接入号,对应返回字段 destId | +| keyword | string | 否 | 筛选正文包含指定关键词的回复 | +| limit | 正整数 | 否 | 默认 50;按应用最大分页配置裁剪,系统默认上限 100 | +| cursor | string | 否 | 首次不传;下一页使用上一响应的 nextCursor | -### 7.1 列表与筛选 +不使用的筛选项省略,所有 query 参数都不放入签名原文。 -第一页、空数据及下一页的完整报文见第 7.1、7.2 节。 - -`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.1.1 上行列表第一页 +### 5.3 请求示例 ```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" \ +curl --include --max-time 15 --get \ + "https://api.lisglo.com/api/openapi/v1/sms/uplinks" \ --data-urlencode "limit=25" \ --header "X-App-Key: $ACCESS_KEY" \ --header "X-Timestamp: $TIMESTAMP" \ @@ -689,7 +515,23 @@ curl --include --max-time 15 --request GET \ --header "X-Signature: $SIGNATURE" ``` -成功响应示例: +**HTTP 请求报文:** + +```http +GET /api/openapi/v1/sms/uplinks?limit=25 HTTP/1.1 +Host: api.lisglo.com +Accept: */* +X-App-Key: DEMO_ACCESS_KEY_NOT_REGISTERED +X-Timestamp: 1789355443 +X-Nonce: 567a9bdd-7d62-4097-9fa8-50586fa14c32 +X-Signature: 7ca4cb9fe5fb455d9314797d00eeab797257e2671d0a547414122e971f534214 + + +``` + +### 5.4 返回结果 + +**查询成功:** ```http HTTP/1.1 200 OK @@ -710,73 +552,56 @@ Content-Type: application/json; charset=utf-8 } ``` -失败响应示例: +| 字段 | 类型 | 说明 | +| ---------- | ----------- | --------------------------------- | +| items | array | 本页上行记录,没有记录时为空数组 | +| nextCursor | string/null | 下一页凭据;null 表示本次查询结束 | -```http -HTTP/1.1 400 Bad Request -Content-Type: application/problem+json; charset=utf-8 +items 中每条记录的字段: -{ - "type": "https://cmpp-platform.local/problems/time_range_too_large", - "title": "BAD_REQUEST", - "status": 400, - "code": "TIME_RANGE_TOO_LARGE", - "detail": "单次查询不能超过31天" -} -``` +| 字段 | 类型 | 说明 | +| ----------- | ----------- | ----------------------------------- | +| id | string | 上行记录编号,用于查询详情 | +| messageId | string/null | 关联的平台短信编号,未关联时为 null | +| phoneNumber | string | 回复短信的手机号 | +| destId | string | 接收回复的短信接入号 | +| content | string | 用户回复的正文 | +| receivedAt | string | 接收时间,ISO 8601;Z 表示 UTC | -空列表响应: - -```http -HTTP/1.1 200 OK -Content-Type: application/json; charset=utf-8 +**没有回复:** +```json { "items": [], "nextCursor": null } ``` +HTTP 状态为 200。无总条数或页码字段。 -### 7.2 翻页方法 - -1. 第一页固定 startTime、endTime 和筛选条件,不传 cursor。 -2. 若 nextCursor 非 null,下一次保持时间、筛选和 limit 不变,只加入 cursor;仍需新的 timestamp/nonce/签名。 -3. nextCursor 为 null 时结束。没有总条数、页码或 totalPages。 - -记录按接收时间、记录 id 倒序返回。cursor 是不透明令牌,不自行解码、构造或当作长期同步水位。上行可能延迟到达或稍后才被认领;持续同步宜重叠查询时间窗口并按 id 去重,不把一次分页当作永不变化的快照。 - -### 7.2.1 上行下一页 - -第 7.1.1 节只有一条记录的示例已结束,所以 nextCursor 为 null,不应继续请求。以下表示**另一个有两条记录的分页场景,第一页和下一页均使用 limit=1**。第一页省略 cursor,其他时间条件与第 7.1.1 节相同。将下面 nextCursor 完整保存到 `$CURSOR`;这是由虚构记录生成的格式示例,线上必须用真实上一响应的值。 +**分页参数错误:** ```http -HTTP/1.1 200 OK -Content-Type: application/json; charset=utf-8 +HTTP/1.1 400 Bad Request +Content-Type: application/problem+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" + "type": "https://cmpp-platform.local/problems/limit_invalid", + "title": "BAD_REQUEST", + "status": 400, + "code": "LIMIT_INVALID", + "detail": "limit必须为正整数" } ``` -保持第一页时间和筛选不变,生成新的请求唯一标识、时间戳和签名: +### 5.5 下一页查询 + +nextCursor 非 null 时,将完整值赋给 $CURSOR,保持首次使用的 limit 和其他筛选不变,重新生成鉴权头。假设第一页使用 limit=25,下一页示例: ```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 "limit=25" \ --data-urlencode "cursor=$CURSOR" \ --header "X-App-Key: $ACCESS_KEY" \ --header "X-Timestamp: $TIMESTAMP" \ @@ -784,57 +609,47 @@ curl --include --max-time 15 --get \ --header "X-Signature: $SIGNATURE" ``` -下一页返回较早的第二条记录,nextCursor 为 null,表示没有更多页: +**HTTP 请求报文:** ```http -HTTP/1.1 200 OK -Content-Type: application/json; charset=utf-8 +GET /api/openapi/v1/sms/uplinks?limit=25&cursor=WyIyMDI2LTA5LTE0VDAyOjA1OjAwLjAwMFoiLCJ1cGxpbmstZXhhbXBsZS0wMDEiXQ HTTP/1.1 +Host: api.lisglo.com +Accept: */* +X-App-Key: DEMO_ACCESS_KEY_NOT_REGISTERED +X-Timestamp: 1789355443 +X-Nonce: 2232e6cf-144d-41c6-ac86-df7fb6184d78 +X-Signature: 50869db4ff5a3b2d3ba5e6c1184020fc0ed50fbc594a56ccc6e2a16f7b617657 + -{ - "items": [ - { - "id": "uplink-example-000", - "messageId": null, - "phoneNumber": "13800138000", - "destId": "106900000000", - "content": "好的", - "receivedAt": "2026-09-14T02:04:00.000Z" - } - ], - "nextCursor": null -} ``` -非法游标示例: +直到 nextCursor 为 null 后停止。游标由平台返回,不自行生成或解码;格式错误返回 400 CURSOR_INVALID。每次默认查询范围随当前时间滚动,同一轮翻页应尽快完成,定期采集按 id 去重。历史补查或完整历史同步请联系平台。 -```http -HTTP/1.1 400 Bad Request -Content-Type: application/problem+json; charset=utf-8 +## 6. API:上行短信详情查询 -{ - "type": "https://cmpp-platform.local/problems/cursor_invalid", - "title": "BAD_REQUEST", - "status": 400, - "code": "CURSOR_INVALID", - "detail": "cursor格式非法" -} -``` +### 6.1 接口说明 +查看一条用户回复的完整业务信息。 -### 7.3 上行详情 +| 项目 | 内容 | +| --------- | ------------------------------------------------------------ | +| URL | https://api.lisglo.com/api/openapi/v1/sms/uplinks/{uplinkId} | +| HTTP 方法 | GET | +| 请求体 | 无 | +| 是否鉴权 | 是,按第 2 节 GET 规则签名 | +| 所需能力 | 上行查询 | +| 成功状态 | 200 OK | -完整 cURL 请求和 HTTP 响应见第 7.3.1 节。 +### 6.2 路径参数 -`GET /api/openapi/v1/sms/uplinks/{uplinkId}`,无请求体。 +| 参数 | 类型 | 必填 | 说明 | +| -------- | ------ | ---- | ------------------------------------------------------- | +| uplinkId | string | 是 | 列表中的 id 或回调中的 uplinkMessageId,按 URL 路径编码 | -把列表的 `items[].id` 或回调的 `data.uplinkMessageId` 放入路径,按第 3 节签名。返回单个上行对象,核心字段与列表中的 item 相同,不再包裹 items。 - -详情额外返回当前客户自己的 `tenantId`、`applicationId`。不返回通道、供应商编号、内部事件/匹配诊断、数据库关联行号等字段。本次按数据边界收紧旧输出,旧客户需移除对这些内部字段的依赖。不存在或不属于当前企业应用时返回 404 `UPLINK_NOT_FOUND`。 - -### 7.3.1 上行详情 +### 6.3 请求示例 ```bash -curl --include --max-time 15 --request GET \ +curl --include --max-time 15 --get \ "https://api.lisglo.com/api/openapi/v1/sms/uplinks/uplink-example-001" \ --header "X-App-Key: $ACCESS_KEY" \ --header "X-Timestamp: $TIMESTAMP" \ @@ -842,7 +657,23 @@ curl --include --max-time 15 --request GET \ --header "X-Signature: $SIGNATURE" ``` -成功响应示例: +**HTTP 请求报文:** + +```http +GET /api/openapi/v1/sms/uplinks/uplink-example-001 HTTP/1.1 +Host: api.lisglo.com +Accept: */* +X-App-Key: DEMO_ACCESS_KEY_NOT_REGISTERED +X-Timestamp: 1789355443 +X-Nonce: 7229fc46-7526-4726-a42b-a8edfe68fbdf +X-Signature: 869e57abbd91ceaadb73dbca1a091d0deba083e93acccc58cc4a941d9c18a1c8 + + +``` + +### 6.4 返回结果 + +**查询成功:** ```http HTTP/1.1 200 OK @@ -860,7 +691,9 @@ Content-Type: application/json; charset=utf-8 } ``` -失败响应示例: +字段与第 5 节单条记录相同,增加 tenantId(本企业 ID)、applicationId(本应用 ID);只返回客户自己的业务信息,不提供通道字段。 + +**查询失败:** ```http HTTP/1.1 404 Not Found @@ -875,136 +708,56 @@ Content-Type: application/problem+json; charset=utf-8 } ``` +## 7. Webhook:短信回执通知 -## 8. 平台怎样主动通知你的系统 +### 7.1 通知说明 -### 8.1 配置与接收 +短信产生回执结果时,平台向客户配置的回执地址发送 receipt 事件。 -在客户端“回调配置”分别设置: +| 项目 | 内容 | +| ------------ | ------------------------------------- | +| URL | 客户在回调配置中保存的公网 HTTPS 地址 | +| HTTP 方法 | POST | +| Content-Type | application/json | +| 事件类型 | receipt | +| 签名密钥 | 回执回调地址对应的密钥 | +| 成功确认 | 客户返回任意 HTTP 2xx | -- **回执回调 URL**:接收 `receipt`,用于获知短信结果。 -- **上行回调 URL**:接收 `uplink`,用于接收用户回复。 +参数错误或未受理的同步请求不保证生成回调;长短信按业务消息通知。保存发送响应中的编号映射,按 data.messageId 关联业务,回调不保证携带 clientMessageId。 -平台向对应 URL 发起 HTTP POST。需开通该类回调能力、保存非空有效地址。使用平台可访问的公网 HTTPS 地址,不使用本机、内网或依赖重定向的地址。每个回调端点使用自己的签名密钥。 +### 7.2 请求头 -不要假设每次发送请求都会有回调。同步参数错误、未受理的请求以及未生成该类事件的分支,应处理接口响应或查询结果。长短信的 HTTP 结果通知按业务消息处理,不按运营商内部计费分片逐片回调。 +| 请求头 | 说明 | +| ------------ | --------------------------------------- | +| Content-Type | application/json | +| X-Event-Id | 事件编号,用于去重 | +| X-Event-Type | receipt(回执)或 uplink(用户回复) | +| X-Timestamp | 本次投递的 Unix 秒级时间戳 | +| X-Signature | sha256= 前缀加 HMAC-SHA256 十六进制签名 | -### 8.2 请求头和事件格式 +### 7.3 请求字段 -两类通知的完整 HTTP 请求及接收端响应见第 8.5 节。 +| 字段 | 类型 | 说明 | +| ---------- | ------ | ---------------------------------- | +| eventId | string | 同一事件的唯一编号,重复投递时不变 | +| eventType | string | 事件类型 | +| occurredAt | string | 事件生成时间,ISO 8601 | +| data | object | 对应事件的业务数据 | -| 回调头 | 说明 | -| --- | --- | -| `Content-Type` | `application/json` | -| `X-Event-Id` | 稳定事件编号,用于去重 | -| `X-Event-Type` | `receipt` 或 `uplink` | -| `X-Timestamp` | 本次投递的 Unix 秒级时间戳 | -| `X-Signature` | `sha256=` 加 HMAC-SHA256 十六进制值 | +receipt 事件的 data 字段: -回执示例: +| data 字段 | 类型 | 说明 | +| ---------------- | ----------- | -------------------------------------------- | +| messageId | string | 平台短信编号,与发送响应关联 | +| gatewayMessageId | string/null | 短信传输编号,可缺省;关联业务使用 messageId | +| phoneNumber | string | 接收短信的手机号 | +| receiptStatus | string | 回执结果,delivered 表示送达 | +| rawStatus | string/null | 原始回执状态,如 DELIVRD;可缺省 | +| errorCode | string/null | 错误码,可缺省 | +| errorMessage | string/null | 错误说明,部分失败事件提供 | +| deliveredAt | string/null | 结果时间,可缺省;时间存在不等于发送成功 | -```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 秒,不应依赖它作为严格端到端时限。 - -平台会保存回调待办,并按重试规则处理暂时失败。网络中断可能造成同一事件被重复接收,请始终按 eventId 去重。对于长期未收到的历史事件,请先查询投递记录并联系平台核对,不要将其视为短信发送失败。 - -### 8.5 两类回调的完整交互 - -以下签名位置为说明占位符,必须用第 8.3 节规则计算,不是有效固定签名。平台投递 URL 是客户配置的地址。 - -**回执通知:** +### 7.4 通知示例 ```http POST /webhooks/sms/receipt HTTP/1.1 @@ -1013,7 +766,7 @@ Content-Type: application/json X-Event-Id: evt_receipt_example001 X-Event-Type: receipt X-Timestamp: 1789344000 -X-Signature: sha256=<本次原始请求体的有效签名> +X-Signature: sha256=<本次投递的签名> { "eventId": "evt_receipt_example001", @@ -1031,7 +784,91 @@ X-Signature: sha256=<本次原始请求体的有效签名> } ``` -**上行通知:** +地址和签名为说明用途,实际平台使用客户配置的地址并生成本次签名。 + +### 7.5 验证通知签名 + +回调验签与接口请求签名不同。回调只对“时间戳、一个 LF 换行、原始请求体”签名,使用该回调地址对应的签名密钥。 + +```text +签名原文 = UTF8(X-Timestamp) + LF + 原始请求体字节 +期望签名 = "sha256=" + HEX(HMAC_SHA256(回调签名密钥, 签名原文)) +``` + +接收端按以下顺序处理: + +1. 保留原始请求体字节,不要解析后重新生成 JSON 再验签。 +2. 检查时间戳为秒级时间戳并校验时间偏差;接收端可采用前后 300 秒的策略。 +3. 计算期望签名,使用常量时间比较方法与 X-Signature 比较。 +4. 验签通过后解析 JSON,核对请求头与正文中的事件编号、事件类型一致。 +5. 以 eventId 做持久化唯一约束或原子去重,再保存事件。 + +同一事件重试时,eventId 和事件正文不变,投递时间戳和签名重新生成。已成功保存的重复事件正常确认,不重复执行后续业务。occurredAt 是事件生成时间,短信实际结果或回复时间分别取 data.deliveredAt、data.receivedAt。 + +### 7.6 接收确认与失败重试 + +平台将任意 HTTP 2xx 视为接收成功,不要求固定响应正文。建议验签并可靠保存后及时确认,后续业务异步处理。 + +**接收成功响应示例:** + +```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 +``` + +| 接收端结果 | 平台处理 | +| ----------------------- | ---------------------------------------------------- | +| 任意 2xx | 视为成功,不再自动重试;即使正文写“失败”也按成功处理 | +| 网络错误、408、429、5xx | 在应用开启重试且未达到次数上限时安排重试 | +| 其他 4xx | 视为失败,通常不自动重试 | +| 3xx | 不跟随重定向 | + +默认最多尝试 7 次,包含第一次投递。第一次失败后依次等待 1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时;这些是相邻尝试之间的等待时间,具体受应用重试配置限制。默认回调超时配置为 10 秒,请尽快完成可靠保存和确认。 + +回调未收到不等于短信发送失败,可使用短信回执查询接口核对结果。回调可能重复或延迟到达,业务系统应按事件编号去重。 + +## 8. Webhook:上行短信通知 + +### 8.1 通知说明 + +收到并确认归属本应用的用户回复后,平台向上行回调地址发送 uplink 事件。 + +| 项目 | 内容 | +| ------------ | ----------------------------------------- | +| URL | 客户在回调配置中保存的上行公网 HTTPS 地址 | +| HTTP 方法 | POST | +| Content-Type | application/json | +| 事件类型 | uplink | +| 签名密钥 | 上行回调地址对应的密钥 | +| 成功确认 | 客户返回任意 HTTP 2xx | + +### 8.2 请求字段 + +公共请求头和事件外层字段与第 7 节相同,事件类型为 uplink。data 包含: + +| data 字段 | 类型 | 说明 | +| --------------- | ----------- | ---------------------------------------- | +| applicationId | string | 本应用 ID | +| phoneNumber | string | 回复者手机号 | +| destId | string | 接收回复的短信接入号 | +| content | string | 回复正文 | +| receivedAt | string | 接收时间,ISO 8601 | +| uplinkMessageId | string | 上行记录编号,可用于详情查询 | +| messageId | string/null | 可能提供的关联短信编号;缺省时不强行关联 | +| manualClaim | boolean | 可选;true 表示该回复经人工确认归属 | + +### 8.3 通知示例 ```http POST /webhooks/sms/uplink HTTP/1.1 @@ -1040,7 +877,7 @@ Content-Type: application/json X-Event-Id: evt_uplink_example001 X-Event-Type: uplink X-Timestamp: 1789344000 -X-Signature: sha256=<本次原始请求体的有效签名> +X-Signature: sha256=<本次投递的签名> { "eventId": "evt_uplink_example001", @@ -1057,7 +894,11 @@ X-Signature: sha256=<本次原始请求体的有效签名> } ``` -两类通知均在验签并可靠保存后确认: +没有 messageId 时,按 uplinkMessageId 保存用户回复,不强行关联发送记录。 + +### 8.4 验签与确认 + +按第 7.5 节规则验签,但使用上行回调密钥。以 eventId 原子去重并可靠保存后,按第 7.6 节返回 2xx;失败重试规则相同。 ```http HTTP/1.1 200 OK @@ -1066,21 +907,9 @@ Content-Type: text/plain; charset=utf-8 OK ``` -若接收端暂时无法可靠保存,可返回如下失败(正文由客户自定义,不是平台统一错误格式): +## 9. HTTP 状态与错误码 -```http -HTTP/1.1 503 Service Unavailable -Content-Type: text/plain; charset=utf-8 - -temporarily unavailable -``` - -预期会进入可重试分支,但当前自动重试缺陷仍未修复,不能仅凭返回503就假设平台一定补投。验签失败可返回401/403,这类响应通常不自动重试;应排查密钥、时间戳和原始字节。 - - -## 9. 常见错误与处理 - -由业务接口异常过滤器处理的错误,通常使用 `application/problem+json`: +错误响应示例: ```json { @@ -1092,74 +921,31 @@ temporarily unavailable } ``` -以 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` | 补齐有效幂等键,检查长度与字符 | +| 413 | `PAYLOAD_TOO_LARGE` | JSON 请求体超过 2 MiB 限制,减少内容后再提交 | +| 415 | `UNSUPPORTED_MEDIA_TYPE` | 检查请求体编码及字符集,使用 UTF-8 JSON | +| 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、签名原文、路径、原始请求体及 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` 等 | 保留请求标识和时间联系平台;发送场景先查询,避免重新创建业务 | -| 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` | 检查时间格式、先后顺序和跨度 | -| 413 | `PAYLOAD_TOO_LARGE` | JSON请求体超过2 MiB限制,减少内容后再提交 | -| 415 | `UNSUPPORTED_MEDIA_TYPE` | 检查请求体编码及字符集,使用UTF-8 JSON | -| 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` 等 | 保留请求标识和时间联系平台;发送场景先查询,避免重新创建业务 | +遇到未知状态或错误码时,保留原始响应并联系平台;不要默认成功。网络中断也可能没有 HTTP 响应。发送结果不确定时先核对已有记录,避免创建重复业务。 -表格覆盖明确的常见分支,不穷尽发送链下游的所有错误。当前 5xx 文案/首次与重放错误码尚未完全统一,不依赖内部错误文本写业务逻辑。发生连接中断时可能没有任何 HTTP 状态或 JSON,按网络错误处理。 - -每次交互响应头 `X-Request-Id` 用于定位本次调用;发送受理正文中的 `requestId` 对应稳定业务幂等记录,两者含义不同。未知错误固定为 `INTERNAL_ERROR`,不返回依赖异常正文。 - -提交排障信息时提供:应用名称、请求时间与时区、接口路径、HTTP 状态、业务 code、requestId/messageId/clientMessageId 或 eventId。手机号和正文按需要脱敏,不提供 Secret 或完整鉴权头。 - -### 9.1 公共鉴权失败 - -四个接口都可能在进入业务处理前返回以下响应: - -```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 节。返回体的业务码和说明用于展示当前常见分支;真实网关错误可能不是该格式。 - - -## 10. 当前版本的接入边界 - -| 事项 | 当前说明 | -| --- | --- | -| 接口定义 | 当前环境的 OpenAPI JSON 提供请求参数与响应结构,可用于生成客户端;本文补充接入步骤和业务含义 | -| GET 空体 | 当前须使用 `{}` 摘要;后续变更需确认兼容策略 | -| 回调重试 | 暂时失败按第 8 节规则重试,接收端按 eventId 去重 | -| 超时与幂等恢复 | 受理快照与消息/待办同事务;结果无法确定时返回 REQUEST_REQUIRES_REVIEW,不能换幂等键绕过 | -| 参数与响应模型 | clientMessageId为string/null且最长128;limit为正整数,按应用上限裁剪;上行不返回内部通道和匹配字段 | -| 性能 | 以应用配置限制请求频率;QPS 配置不是对全链路送达能力的承诺 | -| 示例用途 | 示例使用演示数据;实际接入需使用本应用凭据并重新生成时间戳、nonce 和签名 | - -## 11. 版本与兼容说明 - -本次保持v1四个业务路径、五行LF签名、无体GET兼容摘要和请求唯一标识规则。clientMessageId必须为字符串或null、最长128字符;limit必须为正整数。上行列表与详情只输出公共业务字段,详情可含所属企业和应用ID,不再输出通道或内部匹配信息。 - -回调是至少一次投递,请按eventId去重。查询和回调的字段、类型以当前环境提供的OpenAPI JSON为准;接入前完成签名校对,并保存requestId和messageId以便排障。 +联系平台排查时,请提供应用名称、请求时间及时区、接口路径、HTTP 状态、业务 code,以及相关 requestId、messageId、clientMessageId 或 eventId。响应头 X-Request-Id 用于定位本次 HTTP 调用;发送正文 requestId 用于定位该次业务受理。不要提供 Secret 或完整鉴权信息。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 1133c7d..b6bf4a4 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2307,3 +2307,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后 ## 2026-09-14 客户端页面与文档调整 模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。 + + +## 2026-09-15 HTTP 客户手册与签名简化 + +按用户确认的 B6 手册实施,原2.4移为1.4。POST直接签原始UTF-8正文,GET四项LF分隔且末尾无LF;不兼容尝试旧摘要签名。保持凭据、权限、时间窗、nonce去重、内部发送幂等及Webhook规则。公开页、客户端独立文档页与MD下载共用权威手册,Node示例和完整HTTP报文按新规则验证。新规则替代此前三步正文摘要签名需求,适用本次代码版本;仅授权测试部署,预生产不变。设计及兼容影响见[HTTP整改方案](http-api-assessment-20260910.md)的2026-09-15章节。 diff --git a/docs/http-api-assessment-20260910.md b/docs/http-api-assessment-20260910.md index f34ddcf..91dcffa 100644 --- a/docs/http-api-assessment-20260910.md +++ b/docs/http-api-assessment-20260910.md @@ -305,3 +305,15 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转 截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。 完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。 + + +## 2026-09-15 已确认的新请求签名规则(实施中) + +本节按用户最终 B6 手册及本轮实施授权,替代前述 R01/3.2 的摘要签名保留要求;历史日期的实现证据仍保留。本轮公开手册采用 B6 内容,将原 2.4 cURL 使用说明移到 1.4;公开阅读器、客户端 iframe 和 MD 下载继续共用 docs/client-http-api-guide.md。 + +- POST:UTF-8 编码的方法、路径、timestamp、nonce 以 LF 分隔,nonce 后一个 LF,再原样追加 rawBody 字节,直接 HMAC-SHA256。禁止对解析后 JSON 重建正文;POST 缺失 rawBody 时拒绝,避免签署与收到的字节不一致。 +- GET:仅四项以 LF 分隔,末尾无 LF,无正文;携带非空正文拒绝。query 仍不参与签名,路径仍不含域名及 query,不新增编码/排序协议。 +- 使用原四个鉴权头、路径和凭据;不自动尝试旧摘要算法,不引入隐式双算法。旧签名返回 SIGNATURE_INVALID,既有接入须同步升级。测试环境本轮发布,预生产不发布;回退应用同时恢复原手册,客户须恢复匹配算法。 +- 时间戳窗口、nonce Redis 原子去重、租户/应用隔离、白名单、QPS 和日志脱敏保持。发送幂等使用的内部 bodyHash 保持,已有请求快照/计费/消息/队列均不迁移、不补投。Webhook 验签不变。 +- 数据模型、基础设施配置和 Gateway 无改动。历史查询 startTime/endTime 能力保留;手册简化介绍不代表删除后台兼容参数。 +- 验收:固定 POST/GET 向量、旧签名拒绝、末尾 LF/CRLF/字面反斜杠/正文空格与字段顺序篡改、缺失 rawBody、非空 GET、重放、权限与隔离;真实 HTTP/PG/Redis 验证以隔离数据和无发送请求完成,线上不创建短信或更改客户配置。两段 Node 示例从权威文档提取运行并与全部 HTTP 报文复算;公开页面三尺寸、目录/检索/下载及客户端共享入口验收。按精确提交标准 validate/preflight/prepare/deploy/verify。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 4285562..89b4e00 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5526,3 +5526,18 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转 ### CLIENT-0914 系列执行结果(2026-09-14 23:25 CST) CLIENT-0914-01~07 的模板样式/顺序、文档归属与检索、中文状态/短信正文、回执聚合字段与时区已在独立真实 PostgreSQL/服务组件环境验证;测试部署应用为 4665079。在线文档三尺寸、接口内容和真实数据库查询回执已复核通过;HTTP 环境剪贴板自动复制受限时选中示例并提示手动复制。线上登录后模板/工作台/短信详情全流程因未取得客户端账号仍未执行,不以本地隔离组件或公开文档页面替代。详细日志、首轮 worker 超时与完整复测结果见 [整改记录](client-ui-remediation-20260914.md)。 + + +## 2026-09-15 HTTP 签名简化验收 + +本组替代现行版本的旧GET{}摘要/三步签名断言,历史执行记录不改写。 + +| 用例 | 验收内容 | +| --- | --- | +| HTTP-SIGN-0915-01 | 手册2.2 POST原始字节HMAC与固定向量一致;2.3 GET四项以LF分隔、末尾无LF;两段Node示例实际运行,五个完整HTTP报文签名复算一致。 | +| HTTP-SIGN-0915-02 | 真实HTTP鉴权拒绝旧摘要签名、GET末尾LF、CRLF和字面反斜杠n;POST正文空格/末尾换行/内容篡改拒绝。 | +| HTTP-SIGN-0915-03 | POST无rawBody、GET非空正文返回400;不从解析后JSON重建签名字节。 | +| HTTP-SIGN-0915-04 | 真实PG查询仅当前应用资料,其他应用详情404;Redis nonce重放401、过期timestamp及无效凭据401。 | +| HTTP-SIGN-0915-05 | 同幂等键/同正文仍读取既有requires_review;改正文409冲突;无消息、批次或队列新增,不恢复未知发送。 | +| HTTP-SIGN-0915-06 | 原2.4移到1.4并修正引用;MD下载与源一致,三尺寸目录/刷新/检索/空结果/复制正常,加粗标题成为具名示例;不执行示例请求。 | +| HTTP-SIGN-0915-07 | 精确提交测试发布后核对真实文档及新签名查询;旧算法拒绝。账号/凭据缺失时不恢复或新建线上配置,明确未执行范围。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index d9fd2bf..31c1c67 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -5018,3 +5018,14 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转 三条真实测试消息的数据库回执保持不变,已部署客户端分页查询服务已由 null 恢复返回 delivered 和真实时间。线上文档三尺寸/43 内嵌示例/检索通过;HTTP 剪贴板受限时实际提供选中后手动复制。当前版本、服务、迁移、队列和日志检查通过无警告。完整登录后客户端页面仍待可用账号;连接器失败不等于用户未登录,本地真实 PG/服务组件验收不替代线上登录验收。 计划 20260914T151450-4665079ca3d2-874a39f8,备份 attempt-1789399125391792291。远端准备 61.7 秒、备份 34.5 秒、停止开始至启动完成约 13.4 秒。系统盘净增约 1.46 GiB、可用约 17.21 GiB,历史资产未清理。设计、详细证据及未验证项见 [客户端页面与接口文档整改](client-ui-remediation-20260914.md)。预生产未部署;未发送短信或修改业务配置。 + + +## 2026-09-15 HTTP 签名简化实施与测试发布(进行中) + +起点main/实际远端bcb278be29857b73ec11e04650923f473875ca9b,暂存空,53个已有文件保护摘要在.local-data/http-signature-20260915/protected-hashes.json。测试机SSH初期超时、Tailscale relay可达,后续恢复并回读线上4665079ca3d2f985c85c5c1d08f8fe173047e9bd;sudo仍需掩码输入。 + +按B6实施POST原始正文HMAC、GET末尾无LF,保留内部幂等摘要和Webhook规则;旧签名不回退尝试。原2.4移1.4、公开/客户端共用MD,补OpenAPI签名描述。页面只读复现加粗示例标题未识别,reader支持加粗标题。相关设计先更新于http-api-assessment-20260910.md。 + +定向31项、初轮API全量75套817项及构建通过。独立本地PG16424/cmpp_qa_http_signature(104迁移)、Redis16425、真实Nest16426验收16组通过,包含查询、隔离、篡改、旧算法、重放、幂等及MD下载,短信/批次数均0。仅安全辅助日志隔离为空实现,不将日志持久化标为验收。未启动发送或回调Worker;Redis5.0.14提示建议6.2+,实际nonce命令通过。样本初轮漏carriers、参数错误码断言与真实DTO不一致、幂等样本漏credentialId,修正并保留失败日志;不视为业务缺陷。另一本地PG16安装缺dict_snowball,使用既有完整pgsql安装启动本轮隔离实例。 + +CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页面横向溢出;1.4/2.2/2.3顺序、36个具名示例、目录跳转、复制、刷新、错误码检索和空结果通过,控制台无warn/error。当前仅本地页面,线上验收另记。证据目录.local-data/http-signature-20260915;最终精确候选门禁/提交/推送/发布后补。无预生产或业务配置修改、无短信发送。 diff --git a/tools/testing/verify-http-signature.mjs b/tools/testing/verify-http-signature.mjs new file mode 100644 index 0000000..ce752fa --- /dev/null +++ b/tools/testing/verify-http-signature.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createHmac, createHash, randomUUID } from 'node:crypto'; +import { request } from 'node:http'; +import fs from 'node:fs'; +const require = createRequire(new URL('../../api/package.json', import.meta.url)); +const dbUrl = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || ''); +const redisUrl = new URL(process.env.SIGNATURE_TEST_REDIS_URL || ''); +assert(['127.0.0.1', 'localhost'].includes(dbUrl.hostname) && dbUrl.pathname.startsWith('/cmpp_qa_')); +assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname) && Number(redisUrl.port) > 10000); +process.env.DATABASE_URL = dbUrl.toString(); +process.env.REDIS_URL = redisUrl.toString(); +process.env.NODE_ENV = 'test'; +process.env.HTTP_API_MASTER_KEY = randomUUID(); +require('reflect-metadata'); +const { Module } = require('@nestjs/common'); +const { NestFactory } = require('@nestjs/core'); +const { PrismaService } = require('./dist/prisma/prisma.service'); +const { OpenApiService } = require('./dist/open-api/open-api.service'); +const { OpenApiAuthGuard } = require('./dist/open-api/open-api-auth.guard'); +const { OpenApiController } = require('./dist/open-api/open-api.controller'); +const { OpenApiDocsController } = require('./dist/open-api/open-api-docs.controller'); +const { OpenApiTraceInterceptor } = require('./dist/open-api/open-api-trace.interceptor'); +const { SecurityDetectionService } = require('./dist/security-detection/security-detection.service'); +const { encryptSecret } = require('./dist/open-api/open-api.crypto'); +const { configureHttpBodyParsers } = require('./dist/http-body-limits'); +const db = new PrismaService(); +const service = new OpenApiService(db, undefined); +const checks = []; +let app; +const ok = (name) => { + checks.push(name); + console.log('PASS', name); +}; +try { + assert.equal(await db.smsMessageRecord.count(), 0, 'Dedicated empty QA database required'); + const tenant = await db.tenant.create({ data: { name: '签名隔离验收', code: randomUUID() } }); + const createApp = () => + db.smsApplication.create({ + data: { + tenantId: tenant.id, + name: '签名隔离应用', + cmppAccount: randomUUID(), + cmppEnterpriseCode: '000001', + secretHash: 'not-login', + interfaceEnabled: false, + httpConfig: { create: { enabled: true, sendEnabled: true, qpsLimit: 100 } }, + }, + }); + const own = await createApp(); + const other = await createApp(); + const secret = randomUUID(); + const accessKey = randomUUID(); + const credential = await db.httpApiCredential.create({ + data: { + applicationId: own.id, + name: '签名测试', + accessKey, + secretEncrypted: encryptSecret(secret), + secretLast4: secret.slice(-4), + }, + }); + const channel = await db.smsChannel.create({ + data: { + name: '隔离占位', + code: randomUUID(), + gatewayHost: '127.0.0.1', + gatewayPort: 1, + account: 'none', + passwordCipher: 'none', + srcId: 'none', + status: 'disabled', + carriers: ['mobile'], + }, + }); + const uplink = await db.smsUplinkMessage.create({ + data: { + tenantId: tenant.id, + applicationId: own.id, + channelId: channel.id, + phoneNumber: '13800138000', + destId: '10690000', + content: '签名验收', + receivedAt: new Date(), + matchStatus: 'matched', + }, + }); + const foreign = await db.smsUplinkMessage.create({ + data: { + tenantId: tenant.id, + applicationId: other.id, + channelId: channel.id, + phoneNumber: '13800138000', + destId: '10690000', + content: '其他应用', + receivedAt: new Date(), + matchStatus: 'matched', + }, + }); + class Harness {} + Module({ + controllers: [OpenApiController, OpenApiDocsController], + providers: [ + { provide: PrismaService, useValue: db }, + { provide: OpenApiService, useValue: service }, + { provide: SecurityDetectionService, useValue: { recordEvent: async () => {} } }, + OpenApiAuthGuard, + OpenApiTraceInterceptor, + ], + })(Harness); + app = await NestFactory.create(Harness, { logger: false, rawBody: true, bodyParser: false }); + app.setGlobalPrefix('api'); + configureHttpBodyParsers(app); + await app.listen(16426, '127.0.0.1'); + const invoke = async (path, options = {}) => { + const method = options.method || 'GET'; + const timestamp = options.timestamp || String(Math.floor(Date.now() / 1000)); + const nonce = options.nonce || randomUUID(); + const body = options.body; + const fields = [method, path.split('?')[0], timestamp, nonce]; + let bytes = Buffer.from(fields.join(options.separator || '\n')); + if (options.legacy) + bytes = Buffer.from( + [ + ...fields, + createHash('sha256') + .update(body || '{}') + .digest('hex'), + ].join('\n'), + ); + else if (method === 'POST') + bytes = Buffer.concat([bytes, Buffer.from('\n'), Buffer.from(options.signedBody ?? body ?? '')]); + else if (options.trailing) bytes = Buffer.concat([bytes, Buffer.from('\n')]); + const signature = createHmac('sha256', secret).update(bytes).digest('hex'); + const headers = { + 'X-App-Key': options.key || accessKey, + 'X-Timestamp': timestamp, + 'X-Nonce': nonce, + 'X-Signature': signature, + ...options.headers, + }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + headers['Content-Length'] = String(Buffer.byteLength(body)); + } + return new Promise((resolve, reject) => { + const req = request('http://127.0.0.1:16426' + path, { method, headers, timeout: 10000 }, (res) => { + let data = ''; + res.on('data', (x) => (data += x)); + res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data) })); + }); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('timeout'))); + req.end(body); + }); + }; + const list = '/api/openapi/v1/sms/uplinks'; + let r = await invoke(list); + assert.equal(r.status, 200); + assert.deepEqual( + r.body.items.map((x) => x.id), + [uplink.id], + ); + ok('new GET authenticates and queries actual PostgreSQL with app isolation'); + r = await invoke(list + '/' + uplink.id); + assert.equal(r.body.content, uplink.content); + assert(!('channelId' in r.body)); + ok('detail matches database and hides channel fields'); + assert.equal((await invoke(list + '/' + foreign.id)).status, 404); + ok('foreign application detail excluded'); + for (const options of [{ legacy: true }, { trailing: true }, { separator: '\r\n' }, { separator: '\\n' }]) { + r = await invoke(list, options); + assert.equal(r.status, 401); + assert.equal(r.body.code, 'SIGNATURE_INVALID'); + } + ok('legacy GET, trailing LF, CRLF and literal escape rejected'); + const nonce = randomUUID(); + assert.equal((await invoke(list, { nonce })).status, 200); + assert.equal((await invoke(list, { nonce })).body.code, 'NONCE_REPLAYED'); + ok('real Redis atomic nonce replay rejection'); + assert.equal((await invoke(list, { timestamp: '100' })).body.code, 'TIMESTAMP_EXPIRED'); + ok('expired timestamp rejected'); + assert.equal((await invoke(list, { key: 'unknown' })).body.code, 'CREDENTIAL_INVALID'); + ok('unknown credential rejected'); + assert.equal((await invoke(list + '?limit=1.5')).body.code, 'LIMIT_INVALID'); + ok('signed invalid query reaches parameter validation'); + assert.equal((await invoke(list, { body: '{}' })).status, 400); + ok('nonempty GET body rejected'); + const post = '/api/openapi/v1/sms/messages'; + const raw = '{ "mobile": "invalid", "content": "中文测试" }'; + r = await invoke(post, { method: 'POST', body: raw }); + assert.equal(r.body.code, 'PARAMETER_INVALID'); + ok('new POST original UTF8 body authenticates before safe validation rejection'); + for (const options of [ + { legacy: true }, + { signedBody: raw.trim() + '\n' }, + { signedBody: JSON.stringify(JSON.parse(raw)) }, + ]) { + r = await invoke(post, { method: 'POST', body: raw, ...options }); + assert.equal(r.body.code, 'SIGNATURE_INVALID'); + } + ok('legacy POST, changed whitespace and trailing newline rejected'); + r = await invoke(post, { method: 'POST' }); + assert.equal(r.status, 400); + ok('missing raw POST body rejected'); + // Stored uncertain request proves internal idempotency remains; never create an SMS. + const valid = '{"mobile":"13800138000","content":"仅幂等核验"}', + idem = 'qa-' + randomUUID(); + await db.openApiRequest.create({ + data: { + applicationId: own.id, + tenantId: tenant.id, + requestId: randomUUID(), + idempotencyKey: idem, + credentialId: credential.id, + bodyHash: createHash('sha256').update(valid).digest('hex'), + status: 'requires_review', + }, + }); + r = await invoke(post, { method: 'POST', body: valid, headers: { 'Idempotency-Key': idem } }); + assert.equal(r.body.code, 'REQUEST_REQUIRES_REVIEW'); + r = await invoke(post, { method: 'POST', body: valid + ' ', headers: { 'Idempotency-Key': idem } }); + assert.equal(r.body.code, 'IDEMPOTENCY_CONFLICT'); + ok('existing idempotency fingerprint preserved without requeue'); + const last = await db.httpApiCredential.findUnique({ where: { id: credential.id } }); + assert(last.lastUsedAt); + ok('actual credential usage persisted'); + assert.equal(await db.smsMessageRecord.count(), 0); + assert.equal(await db.smsBatchTask.count(), 0); + ok('zero SMS records or batches created'); + const md = await (await fetch('http://127.0.0.1:16426/api/client-docs?format=md')).text(); + assert.equal(md, fs.readFileSync(new URL('../../docs/client-http-api-guide.md', import.meta.url), 'utf8')); + ok('actual docs download equals authoritative guide'); + if (process.env.SIGNATURE_TEST_KEEP_OPEN === '1') { + console.log('READY_BROWSER'); + await new Promise((resolve) => { + process.on('SIGINT', resolve); + process.on('SIGTERM', resolve); + }); + } +} finally { + if (process.env.SIGNATURE_TEST_REPORT) + fs.writeFileSync(process.env.SIGNATURE_TEST_REPORT, JSON.stringify({ checks }, null, 2)); + await app?.close(); + await db.$disconnect(); +}