From 4665079ca3d2f985c85c5c1d08f8fe173047e9bd Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 14 Sep 2026 22:53:34 +0800 Subject: [PATCH] fix: polish client templates docs dashboard and receipt display --- api/src/open-api/docs/reader.css | 221 +++- api/src/open-api/docs/reader.js | 7 - api/src/open-api/docs/reader.ts | 11 +- api/src/open-api/open-api-remediation.spec.ts | 11 + api/src/operations/operations.service.spec.ts | 2 + .../operations/queries/messages.queries.ts | 2 + docs/client-http-api-guide.md | 1006 +++++++++-------- docs/client-ui-remediation-20260914.md | 12 + .../first-version-development-requirements.md | 5 + docs/system-functional-test-cases.md | 15 + docs/testing-progress.md | 12 + src/apps/client/ClientHome.tsx | 105 +- src/apps/client/ClientHttpApiPage.tsx | 12 +- src/apps/client/ClientQueryPages.test.tsx | 111 +- src/apps/client/ClientSendDetailPage.tsx | 203 ++-- src/apps/client/ClientTemplatesPage.css | 49 + src/apps/client/ClientTemplatesPage.test.tsx | 40 + src/apps/client/ClientTemplatesPage.tsx | 216 +++- .../client/http-docs/ClientHttpDocsPage.tsx | 16 + src/layouts/ClientLayout.tsx | 1 + src/routes/AppRoutes.tsx | 2 + tools/quality/css-ownership.json | 8 +- 22 files changed, 1305 insertions(+), 762 deletions(-) create mode 100644 docs/client-ui-remediation-20260914.md create mode 100644 src/apps/client/ClientTemplatesPage.css create mode 100644 src/apps/client/ClientTemplatesPage.test.tsx create mode 100644 src/apps/client/http-docs/ClientHttpDocsPage.tsx diff --git a/api/src/open-api/docs/reader.css b/api/src/open-api/docs/reader.css index 5480654..6c5a470 100644 --- a/api/src/open-api/docs/reader.css +++ b/api/src/open-api/docs/reader.css @@ -1,47 +1,182 @@ -.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; } +.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: block; + 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 .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: auto; + max-height: 560px; + 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 <= 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-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 { + 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 index deee12f..fc5a978 100644 --- a/api/src/open-api/docs/reader.js +++ b/api/src/open-api/docs/reader.js @@ -1,13 +1,6 @@ /* 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); diff --git a/api/src/open-api/docs/reader.ts b/api/src/open-api/docs/reader.ts index 9605857..d86768a 100644 --- a/api/src/open-api/docs/reader.ts +++ b/api/src/open-api/docs/reader.ts @@ -23,8 +23,8 @@ function inline(value: string): string { } 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[] }; + const sections: Array<{ id: string; title: string; body: string[] }> = []; + let current = { id: 'introduction', title: '接入指南', body: [] as string[] }; sections.push(current); let code: string[] | null = null; let language = ''; @@ -35,7 +35,7 @@ export function renderHttpGuide(markdown: string, origin: string) { for (const line of lines) { if (code) { if (/^```/.test(line)) { - current.samples.push(`
${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求
${escapeHtml(code.join('\n'))}
`); + current.body.push(`
${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求
${escapeHtml(code.join('\n'))}
`); code = null; } else code.push(line); continue; @@ -46,7 +46,7 @@ export function renderHttpGuide(markdown: string, origin: string) { closeTable(); sampleTitle = heading[2]; if (heading[1].length === 2) { - current = { id: 'section-' + sections.length, title: heading[2], body: [], samples: [] }; + current = { id: 'section-' + sections.length, title: heading[2], body: [] }; sections.push(current); } else if (heading[1].length > 2) current.body.push(`

${inline(heading[2])}

`); continue; @@ -58,8 +58,9 @@ export function renderHttpGuide(markdown: string, origin: string) { continue; } closeTable(); + if (line.trim().endsWith(':') && line.trim().length < 70) sampleTitle = line.trim().replace(/:$/, ''); 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('')}

`; + 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-remediation.spec.ts b/api/src/open-api/open-api-remediation.spec.ts index 23c8401..62477d5 100644 --- a/api/src/open-api/open-api-remediation.spec.ts +++ b/api/src/open-api/open-api-remediation.spec.ts @@ -167,6 +167,17 @@ describe('HTTP API remediation boundaries', () => { ).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) }); expect(send).not.toHaveBeenCalled(); }); + 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```', + '', + ); + expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明')); + expect(html).toContain('五行原文 · text'); + expect(html).not.toContain('data-show-sample'); + expect(html).not.toContain('示例 \d+ { const html = renderHttpGuide( '**接口版本:v1 · 2026-09-14**\n## 接入\n\n[bad](javascript:alert)\n```html\n\n```', diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 7dedb90..8613cc8 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -372,6 +372,8 @@ describe('OperationsService', () => { id: true, content: true, hasDrainageContent: true, + receiptStatus: true, + deliveredAt: true, tenant: { select: { id: true, name: true } }, }), }), diff --git a/api/src/operations/queries/messages.queries.ts b/api/src/operations/queries/messages.queries.ts index 840b6b9..89e0efb 100644 --- a/api/src/operations/queries/messages.queries.ts +++ b/api/src/operations/queries/messages.queries.ts @@ -90,6 +90,8 @@ export class OperationsMessageQueries { amountCents: true, status: true, submitStatus: true, + receiptStatus: true, + deliveredAt: true, queuedAt: true, tenant: { select: { id: true, name: true } }, application: { select: { id: true, name: true } }, diff --git a/docs/client-http-api-guide.md b/docs/client-http-api-guide.md index e11f298..7fa67f2 100644 --- a/docs/client-http-api-guide.md +++ b/docs/client-http-api-guide.md @@ -6,7 +6,7 @@ 本文说明 HTTP 接口的接入步骤、鉴权、请求与响应以及回调处理。所有号码、消息编号和响应示例均为说明用途,不是真实客户数据。GET 空请求体使用第 3 节规定的兼容摘要;每次请求都须重新生成请求唯一标识和签名。 -建议先完成上行查询与签名校对,再接入发送和回调。完整命令与报文示例见第 11 节;复制示例不会执行请求。 +建议先完成上行查询与签名校对,再接入发送和回调。每个接口的参数说明后均附有对应示例;复制示例不会执行请求。 ## 1. 开始接入前 @@ -94,21 +94,15 @@ Secret 只在创建或轮换时展示一次,后续末四位不能用于签名 | `Idempotency-Key`(业务幂等键) | 一次业务发送,防止重复创建短信 | 保持不变,并保持 body 原始字节相同 | | `clientMessageId`(客户短信编号) | 客户系统中的短信业务记录 | 保持不变 | -### 3.2 签名原文 +### 3.2 计算请求签名 -按下面的固定顺序拼接字符串;`BODY_HASH` 是第 3.3 节或 POST 原始字节得到的摘要: +签名分为三个步骤:计算请求体摘要、拼接五行原文、计算 HMAC。HTTP 请求头中的时间戳和 nonce 必须与签名时使用的值完全一致。 -```text -SIGNING_STRING = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH -SIGNATURE = HEX(HMAC_SHA256(UTF8(Secret), UTF8(SIGNING_STRING))) -``` +**第一步:计算请求体摘要。** POST 使用最终发送的 JSON 原始字节计算 SHA256;不要在签名后修改空格、字段顺序或中文转义。无请求体的 GET 使用第 3.3 节规定的兼容摘要。 -公式中的 `"\n"` 是程序语言表示法,指一个 LF 字节 `0x0A`,不是反斜杠和字母 n 两个字符,也不是 Windows 的 CRLF(`0x0D 0x0A`)。这是签名字符串内部的规则,与 HTTP 报文的行结束符不同。最后不加换行、不加空格、不加引号。 +**第二步:按以下顺序拼接五行。** 路径包含 /api,不含域名或查询参数;如路径参数经过 URL 编码,使用实际发送的编码后路径。 -`&` 仍用于实际 URL 的 query 参数连接,例如 `limit=25&cursor=...`,不用于替换本接口签名原文的分隔符。当前 query 不参与签名;保持此规则,不在文档修订中暗中改变协议。 - - -以下是同一个签名原文的分行展示,便于逐项核对: +五行签名原文: ```text 大写 HTTP 方法 @@ -118,16 +112,18 @@ X-Nonce 的原始字符串 请求体的 SHA256 十六进制摘要 ``` -然后用 **Secret 的 UTF-8 字节**作为 HMAC 密钥,对这五行的 UTF-8 字节计算 HMAC-SHA256,输出 64 位十六进制字符串。 +相邻两行用一个 LF 换行符(0x0A)连接,最后一行后不加换行。不要使用 Windows CRLF,也不要加入引号或多余空格。URL 查询参数使用的 & 不是签名分隔符,query 不参与本版本签名。 -具体约定: +**第三步:计算 HMAC-SHA256。** 使用 Secret 的 UTF-8 字节作为密钥,五行原文的 UTF-8 字节作为输入,输出 64 位十六进制字符串,放入 X-Signature。 -- 方法使用 `GET` 或 `POST`。 -- 路径包含 `/api`,不包含域名、`?` 和查询字符串。路径参数需要 URL 编码时,按实际发送的编码后路径签名。 -- POST 必须对**最终发送的原始 JSON 字节**取摘要。签名后不要再格式化、改变空格、调整字段顺序或改变中文转义方式。 -- Secret 按创建时取得的字符串直接使用,不先进行 Base64 解码。 -- 示例使用纯十六进制签名。当前服务端也接受 `sha256=` 前缀,但不要求添加。 -- Idempotency-Key 是单独的业务幂等头,不是 nonce,也不是上述五行之一。 +签名计算公式: + +```text +SIGNING_STRING = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH +SIGNATURE = HEX(HMAC_SHA256(UTF8(Secret), UTF8(SIGNING_STRING))) +``` + +公式中的 \n 表示一个 LF 字节,不是反斜杠和字母 n 两个字符。Secret 直接使用,不做 Base64 解码。示例输出纯十六进制签名;服务端也接受 sha256= 前缀,但无须添加。Idempotency-Key 是业务幂等头,不属于上述五行,也不能代替 nonce。 ### 3.3 当前 GET 空请求体的兼容规则 @@ -139,7 +135,7 @@ X-Nonce 的原始字符串 44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a ``` -这与通常“空请求体按空字节计算 SHA256”的规则不同,也是当前页面只写 `SHA256(rawBody)` 容易导致验签失败的原因。本节是现版本兼容说明,不是理想协议的新设计。后续修正必须由平台说明兼容策略,本稿示例按现行为编写。 +请勿使用空字节的 SHA256 代替这个摘要。本版本为兼容已有接入保留此规则;GET 请求仍不携带 body。 ### 3.4 Python 签名函数 @@ -196,6 +192,43 @@ 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,不发送短信。 @@ -250,7 +283,7 @@ except urllib.error.URLError: `POST /api/openapi/v1/sms/messages` -完整 cURL 请求和 HTTP 响应见第 11.2 节。 +本节后附完整 cURL 请求和 HTTP 响应示例。 除鉴权头外,需要 `Content-Type: application/json` 和 `Idempotency-Key`。 @@ -331,9 +364,156 @@ headers["Idempotency-Key"] = "sms-order-20260914-0001" 同一业务短信的幂等键要持久保存。JSON 字段顺序、空格变化也可能被视为不同 body。不要为了绕过 409 换键反复发送。修正确定失败的业务后是否发起新发送,由你的业务流程明确决定。 +### 5.4 单条发送 + +将第 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对应的请求内容不一致" +} +``` + +#### 5.4.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 语法执行。脚本用于演示,会打印鉴权头;正式接入时凭据从安全配置读取,不把完整鉴权信息写入共享日志。 + +#### 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 响应见第 11.3 节。 +完整 cURL 请求和 HTTP 响应见本节后附示例。 `GET /api/openapi/v1/sms/messages/{messageId}`,无请求体。 @@ -385,6 +565,55 @@ headers["Idempotency-Key"] = "sms-order-20260914-0001" 不存在或不属于本应用时返回 404 `MESSAGE_NOT_FOUND`。查询刚超时的发送得到 404 也不能单独证明业务永远不会落库。 +### 6.1 短信状态 + +```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": "短信记录不存在" +} +``` + + ## 7. 查询手机用户回复(上行) ### 7.0 先分清四种时间边界 @@ -402,7 +631,7 @@ headers["Idempotency-Key"] = "sms-order-20260914-0001" ### 7.1 列表与筛选 -第一页、空数据及下一页的完整报文见第 11.4~11.5 节。 +第一页、空数据及下一页的完整报文见第 7.1、7.2 节。 `GET /api/openapi/v1/sms/uplinks`,无请求体。 @@ -446,6 +675,69 @@ https://api.lisglo.com/api/openapi/v1/sms/uplinks?startTime=2026-09-13T16%3A00%3 `id` 是上行记录编号,用它查询详情;`messageId` 可能为空,不用它代替上行 id。`destId` 是接收用户回复的短信接入号。内部匹配状态与诊断不对外返回。只返回归属本应用的已匹配/已认领数据,不返回未匹配或存在归属歧义的数据。 +### 7.1.1 上行列表第一页 + +```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 +} +``` + + ### 7.2 翻页方法 1. 第一页固定 startTime、endTime 和筛选条件,不传 cursor。 @@ -454,9 +746,84 @@ https://api.lisglo.com/api/openapi/v1/sms/uplinks?startTime=2026-09-13T16%3A00%3 记录按接收时间、记录 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 + +{ + "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格式非法" +} +``` + + ### 7.3 上行详情 -完整 cURL 请求和 HTTP 响应见第 11.6 节。 +完整 cURL 请求和 HTTP 响应见第 7.3.1 节。 `GET /api/openapi/v1/sms/uplinks/{uplinkId}`,无请求体。 @@ -464,6 +831,51 @@ https://api.lisglo.com/api/openapi/v1/sms/uplinks?startTime=2026-09-13T16%3A00%3 详情额外返回当前客户自己的 `tenantId`、`applicationId`。不返回通道、供应商编号、内部事件/匹配诊断、数据库关联行号等字段。本次按数据边界收紧旧输出,旧客户需移除对这些内部字段的依赖。不存在或不属于当前企业应用时返回 404 `UPLINK_NOT_FOUND`。 +### 7.3.1 上行详情 + +```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": "上行记录不存在" +} +``` + + ## 8. 平台怎样主动通知你的系统 ### 8.1 配置与接收 @@ -479,7 +891,7 @@ https://api.lisglo.com/api/openapi/v1/sms/uplinks?startTime=2026-09-13T16%3A00%3 ### 8.2 请求头和事件格式 -两类通知的完整 HTTP 请求及接收端响应见第 11.7 节。 +两类通知的完整 HTTP 请求及接收端响应见第 8.5 节。 | 回调头 | 说明 | | --- | --- | @@ -586,481 +998,9 @@ def verify_callback(headers, raw_body, webhook_secret, now=None, 按设计:网络错误、408、429、5xx 可重试;其他 4xx 终结,重定向不跟随。默认总尝试上限 7 次(含首次),预期各次失败后的间隔为 1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时,并受应用策略限制。这些是相邻尝试的等待间隔,不是统一从首次起算。默认配置超时为 10 秒,不应依赖它作为严格端到端时限。 -整改后的新回调使用稳定任务编号和数据库耐久待办;Redis暂不可用时保留待办,由后续扫描恢复。每次投递按数据库租约认领,尝试结果与下一次等待时间同事务保存。网络中断可能造成重复接收,仍须按eventId幂等。历史pending/retrying不会在升级时自动补投,须另行核对处理;部署与验收完成情况见测试进度。 +平台会保存回调待办,并按重试规则处理暂时失败。网络中断可能造成同一事件被重复接收,请始终按 eventId 去重。对于长期未收到的历史事件,请先查询投递记录并联系平台核对,不要将其视为短信发送失败。 -## 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` | 检查时间格式、先后顺序和跨度 | -| 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` 等 | 保留请求标识和时间联系平台;发送场景先查询,避免重新创建业务 | - -表格覆盖明确的常见分支,不穷尽发送链下游的所有错误。当前 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.5 两类回调的完整交互 以下签名位置为说明占位符,必须用第 8.3 节规则计算,不是有效固定签名。平台投递 URL 是客户配置的地址。 @@ -1137,7 +1077,56 @@ temporarily unavailable 预期会进入可重试分支,但当前自动重试缺陷仍未修复,不能仅凭返回503就假设平台一定补投。验签失败可返回401/403,这类响应通常不自动重试;应排查密钥、时间戳和原始字节。 -### 11.8 公共鉴权失败 + +## 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` | 检查时间格式、先后顺序和跨度 | +| 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` 等 | 保留请求标识和时间联系平台;发送场景先查询,避免重新创建业务 | + +表格覆盖明确的常见分支,不穷尽发送链下游的所有错误。当前 5xx 文案/首次与重放错误码尚未完全统一,不依赖内部错误文本写业务逻辑。发生连接中断时可能没有任何 HTTP 状态或 JSON,按网络错误处理。 + +每次交互响应头 `X-Request-Id` 用于定位本次调用;发送受理正文中的 `requestId` 对应稳定业务幂等记录,两者含义不同。未知错误固定为 `INTERNAL_ERROR`,不返回依赖异常正文。 + +提交排障信息时提供:应用名称、请求时间与时区、接口路径、HTTP 状态、业务 code、requestId/messageId/clientMessageId 或 eventId。手机号和正文按需要脱敏,不提供 Secret 或完整鉴权头。 + +### 9.1 公共鉴权失败 四个接口都可能在进入业务处理前返回以下响应: @@ -1156,7 +1145,20 @@ Content-Type: application/problem+json; charset=utf-8 其他公共失败见第 9 节。返回体的业务码和说明用于展示当前常见分支;真实网关错误可能不是该格式。 -## 12. 版本与兼容说明 + +## 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,不再输出通道或内部匹配信息。 diff --git a/docs/client-ui-remediation-20260914.md b/docs/client-ui-remediation-20260914.md new file mode 100644 index 0000000..02ea003 --- /dev/null +++ b/docs/client-ui-remediation-20260914.md @@ -0,0 +1,12 @@ +# 客户端页面与接口文档整改 + + +## 2026-09-14 客户端页面与接口文档整理设计 + +本轮按用户要求提交、推送并部署测试环境;不操作预生产,不发送或重新入队短信。基于 main 7f9abe3 实施,发布包含上一轮引流运营商迁移,须独立复核兼容。 + +- 模板页添加按钮按内容宽度,工具栏使用输入+查询操作+新增三列,窄屏堆叠;正文固定 132px、14px 字体和纵向滚动,覆盖本需求对常规三行截断的例外。页脚使用公共 Button/DeleteRiskAction,移除本页对旧通用 footer 按钮样式的依赖,保留删除风险确认。编辑顺序为应用、名称、签名、内容;不再展示或发送 category,旧数据库分类保留,不清空历史数据。 +- 接口文档新增独立客户端路由 /client/http-docs 和导航,从接口配置页移除文档 Tab。共享服务端 /api/client-docs 仍为唯一内容源;示例紧随所属正文,使用说明名称,不按数字分组。第 11 节示例按鉴权、发送、查询、上行、回调、错误处理归并,不修改签名协议/GET 兼容摘要、认证或安全边界。 +- 工作台复用批次统一中文状态映射;最近批次列改为短信内容并读取 content,避免使用 category;保留详情入口及批次范围。 +- 回执 Bug:测试库已送达记录存在 receiptStatus/deliveredAt,而分页投影未查询两字段,客户白名单视图输出 null。分页补两个主记录标量,不全量加载回执关联、不改回执消费/计费/数据。展示以消息最终聚合状态为准,时间统一北京时间;真实未收到回执保留空时间并明确显示暂无回执。 +- 验收覆盖真实 API/PG 的有回执、无回执、失败回执与分页/租户边界;UI 三尺寸、首次进入/刷新/跨路由、长正文滚动、按钮 hover、编辑顺序、文档示例归属及检索/复制;完整测试、类型、构建、样式、安全/标准发布门禁。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 5676f8e..1133c7d 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2302,3 +2302,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后 - 引流报备按引流资料、通道、运营商独立配置,只有本运营商通过的通道可进入对应短信路由;显式未通过不能继承旧通道级通过状态。材料变化使旧审批失效。 - 发送详情保留敏感词命中及明确异常,不显示正常的零命中检查;审计数据保留。 - 兼容、迁移和验收以 [引流门禁方案第 11 节](drainage-send-gating-plan-20260910.md#11-2026-09-14-引流唯一性与通道运营商报备设计) 为准。 + + +## 2026-09-14 客户端页面与文档调整 + +模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 2a35cff..c59b18e 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5506,3 +5506,18 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转 | DRN-CARRIER-10 | 三尺寸首次打开、保存、刷新、切换详情;检查真实响应及数据库,不把隔离服务适配器视为完整认证/Gateway 验收。 | 执行结果与未执行边界见 testing-progress.md 本日记录。 + + +## 2026-09-14 客户端整改验收 + +| 编号 | 场景及预期 | +|---|---| +| CLIENT-0914-01 | 模板新增按钮宽度适配文字;长短正文均高 132px,长内容纵向滚动;删除 hover 沿用公共危险按钮,风险确认流程保留。 | +| CLIENT-0914-02 | 新增/编辑依次显示应用、名称、签名、内容;无分类输入,提交不覆写历史 category;签名切换与变量插入保留。 | +| CLIENT-0914-03 | /client/http-docs 独立导航、刷新可达;原接口对接页保留概览/凭据/回调/日志;无应用时仍能读文档。 | +| CLIENT-0914-04 | 3.2 三步签名说明与五行原文/公式相邻;各接口参数后有具名示例,无编号示例切换或独立汇总章节;检索与复制正常且不执行请求。 | +| CLIENT-0914-05 | 工作台列名短信内容,显示 content 而非 category;各批次状态使用共享中文映射。 | +| CLIENT-0914-06 | 分页 API 返回 PG 主记录 receiptStatus/deliveredAt,租户条件保持,不加载全量回执关系;成功、失败、未回执三种展示正确;UTC 07:34:41 显示北京时间 15:34:41。 | +| CLIENT-0914-07 | 最终聚合回执优先于旧尝试回执;无回执显示暂无回执且时间为空;三尺寸、刷新、路由切换和查询/分页语义回归。 | + +执行证据与在线验收边界见 testing-progress.md 本轮记录。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 03a18d0..c5cb4fa 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4997,3 +4997,15 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转 - 本轮仅本地修改、文档和本地提交;未推送、未部署测试、未部署预生产。迁移和代码未在两套线上环境生效。提交号见本条记录所在提交;最终汇报提供精确 SHA。 - 隔离 HTTP 适配器直接调用真实业务服务与 PG,不包含完整 Nest 全局认证、生产反向代理和 worker;不得将其当作在线全功能验收。测试环境/预生产完整登录页面、权限与租户隔离在线回归、MinIO 报备文件导出/导入实物、Redis/Gateway 实际短信发送与计费闭环本轮未执行。原链路单元回归通过不替代物理发送专项验收。 - 53 项已有保护文件在最终核对中保持摘要(仅本轮文档采用追加并精确暂存);其余脏文件和草稿不纳入提交。历史重复资料不自动清理,历史通道级审批不批量重写。上线须先按标准发布流程执行新索引迁移,回退不可直接删除三网任务或重建旧索引。 + + +## 2026-09-14 客户端模板、接口文档、工作台与回执整改(发布前) + +- 起点 main 7f9abe3,实际远端 ac64490,测试机 97d133442350b9725422ed4e55386f47e37004fd。用户明确要求修改、提交、推送、测试部署;预生产、短信发送/重投、客户配置变更不在范围。测试发布包含上一轮引流三网报备提交和 20260914093000_drainage_carrier_reports 兼容迁移。既有 metrics、tools/release 和文档草稿全部保护。 +- 只读根因:测试库 http-cmu0xgl6o000k5mle3hrw4avd 为 delivered,receiptStatus=delivered、deliveredAt=2026-09-14T07:34:41.935Z,回执表原码 DELIVRD;另外两条送达样本一致。旧 listClientMessagesPage 投影三条均 receiptStatus=null、deliveredAt=null、receiptRecords=[],因 listMessagesPage 未选择回执标量。仅补主记录两个标量,不加载全量尝试关系,不改消息状态/回执消费/账务。页面优先聚合最终回执并格式化北京时间。 +- 模板 toolbar 改为输入/查询操作/添加三列、窄屏堆叠;正文 132px 纵向滚动;页脚脱离 legacy button 规则,使用公共按钮和原风险确认流程;表单应用、名称、签名、内容,不提交 category,历史值保留。 +- /client/http-docs 独立路由与菜单;原接口配置页移除文档 Tab,保留其余功能。读者把示例移回所属正文,取消编号侧栏切换;第 11 节示例分配回鉴权与各接口章节,3.2 重写为三个步骤,37 段非 text 示例哈希逐一保持。新页面与 CSS 同目录登记所有权。 +- 工作台复用 batchTaskStatusMeta/normalizeBatchTaskStatus,短信内容列读取 content。设计见 client-ui-remediation-20260914.md,用例 CLIENT-0914-01 至 07。 +- 本地 API 74 suites / 812 tests 通过,API 构建通过;前端最终 31 files / 146 tests(maxWorkers=2)通过,类型/生产构建通过;代码 Lint 无错误(3 条旧依赖/any 提示),Stylelint、CSS治理15项、格式、包体及安全门禁通过。保留初始 worker 启动超时、回执标签断言、类型错误、CSS所有权/语法失败,后续修正和重测分开记录。精确发布候选仍需标准 validate。 +- 浏览器连接器仍 nodeRepl.fetch request failed;使用已安装 Playwright/Chrome。独立本地 PG 16414 + 真实服务 HTTP 适配器16419 + production preview16420,未启动 Gateway/发送Worker:模板长短正文高度均132px、按钮143px,三尺寸1600×1000/1366×768/390×844无横向溢出;编辑顺序、删除hover、真实回执成功/失败/空值及15:34:41时间、工作台正文/已完成、文档三步示例、检索与复制通过。复制未执行请求。此为真实服务组件验收,非完整在线登录/鉴权验收。 +- 本机证据 %TEMP%/cmpp-client-polish-20260914:api-full.log、frontend-final.log、css-final.log、style-final.log、format-final.log、browser-results.json 与 templates/receipts/home/docs 三尺寸截图。隔离库仅使用本轮专用样本;测试线上只读抽样。客户端登录账号尚待用户提供,不恢复旧账号、不造认证会话;在线页面与发布结果后续追加。 diff --git a/src/apps/client/ClientHome.tsx b/src/apps/client/ClientHome.tsx index 3a49b5c..f1d0b45 100644 --- a/src/apps/client/ClientHome.tsx +++ b/src/apps/client/ClientHome.tsx @@ -1,20 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; -import { - BadgeCheck, - ClipboardList, - FileText, - PenLine, - Plus, - ReceiptText, - Send, - WalletCards, -} from 'lucide-react'; +import { BadgeCheck, ClipboardList, FileText, PenLine, Plus, ReceiptText, Send, WalletCards } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Button, Table, Tag, type TableColumn } from '@/components/ui'; import { Chart } from '@/components/ui/Chart'; import { clientApi, type DashboardResponse } from '@/api/adminApi'; import { createLineOption, createPieOption } from '@/theme/chartOptions'; import { formatDateTime } from '@/utils/dateTime'; +import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus'; import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency'; type RecentTaskRow = { @@ -28,10 +20,23 @@ type RecentTaskRow = { const columns: Array> = [ { key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo }, - { key: 'scene', title: '发送场景', render: (record) => record.scene }, + { + key: 'scene', + title: '短信内容', + width: '300px', + render: (record) => {record.scene}, + }, { key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` }, { key: 'createdAt', title: '创建时间', render: (record) => record.createdAt }, - { key: 'status', title: '状态', render: (record) => {record.status} }, + { + key: 'status', + title: '状态', + render: (record) => ( + + {batchTaskStatusMeta[normalizeBatchTaskStatus(record.status)].label} + + ), + }, ]; export function ClientHome() { @@ -40,7 +45,8 @@ export function ClientHome() { const [error, setError] = useState(''); useEffect(() => { - clientApi.getDashboard() + clientApi + .getDashboard() .then(setDashboard) .catch((err) => { setError(err instanceof Error ? err.message : '客户端工作台加载失败'); @@ -51,33 +57,42 @@ export function ClientHome() { const account = dashboard?.accounts[0]; const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0)); const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0); - const recentMessages = useMemo(() => (dashboard?.recentTasks ?? []).map((task) => ({ - id: String(task.id ?? task.taskNo), - taskNo: String(task.taskNo ?? task.id), - scene: String(task.category ?? task.content ?? '短信发送'), - count: Number(task.phoneTotal ?? task.progressTotal ?? 0), - createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null), - status: String(task.status ?? 'unknown'), - })), [dashboard]); + const recentMessages = useMemo( + () => + (dashboard?.recentTasks ?? []).map((task) => ({ + id: String(task.id ?? task.taskNo), + taskNo: String(task.taskNo ?? task.id), + scene: String(task.content ?? '-'), + count: Number(task.phoneTotal ?? task.progressTotal ?? 0), + createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null), + status: String(task.status ?? 'unknown'), + })), + [dashboard], + ); const latestRecharge = dashboard?.recentRecharges[0]; const sendTrendOption = useMemo( - () => createLineOption({ - labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [], - series: [ - { name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] }, - { name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] }, - ], - }), + () => + createLineOption({ + labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [], + series: [ + { name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] }, + { name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] }, + ], + }), [dashboard], ); - const channelShareOption = useMemo(() => createPieOption({ - data: (dashboard?.gatewayConnections ?? []).map((item) => ({ - name: item.status, - value: item._sum.currentConnections ?? item._count._all, - })), - }), [dashboard]); + const channelShareOption = useMemo( + () => + createPieOption({ + data: (dashboard?.gatewayConnections ?? []).map((item) => ({ + name: item.status, + value: item._sum.currentConnections ?? item._count._all, + })), + }), + [dashboard], + ); return (
@@ -91,7 +106,9 @@ export function ClientHome() { - + @@ -179,7 +196,11 @@ export function ClientHome() {
- - - - + + )} onClose={onClose} @@ -144,22 +166,32 @@ function TemplateModal({ size="xl" title={item ? '编辑短信模板' : '添加短信模板'} > -
+
update('name', event.target.value)} + placeholder="请输入模板名称" + value={form.name} + /> update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} /> - update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />