diff --git a/README.md b/README.md index 3233f3d..ba3efea 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ 由壹点内部项目统一钉钉机器人单聊出口。只有本服务保存钉钉应用密钥和 `RobotCode`;素材库、固定资产、产品开发平台等业务系统使用各自的项目密钥调用本服务。 +## 业务项目接入 + +新项目不要从本 README 中零散复制接口代码,请按以下规范接入: + +- [接入指南](docs/接入指南.md):项目登记、环境变量、签名、发送、多人通知和上线验证。 +- [消息规范](docs/消息规范.md):标题、正文、状态和按钮的统一写法。 +- [错误与状态规范](docs/错误与状态规范.md):HTTP 返回、投递状态、错误码和重试边界。 +- [新项目接入清单](docs/新项目接入清单.md):从申请到生产验证的逐项门禁。 +- [Node.js 客户端示例](examples/node/README.md):可以直接复制到业务后端的无依赖实现。 + +接入的核心边界:业务项目只保存自己的项目代码和 HMAC 密钥;钉钉 `AppKey`、`AppSecret`、`RobotCode` 始终只由通知中心持有。 + ## 安全边界 - 每个项目使用独立 HMAC 密钥。 diff --git a/docs/接入指南.md b/docs/接入指南.md new file mode 100644 index 0000000..3419f4e --- /dev/null +++ b/docs/接入指南.md @@ -0,0 +1,158 @@ +# 由壹点通知中心接入指南 + +本文面向需要接入“由壹点”统一钉钉单聊通知的业务项目。业务项目只调用通知中心,不直接持有钉钉应用密钥,也不直接调用钉钉机器人接口。 + +## 1. 接入边界 + +- 通知中心负责:统一机器人身份、项目隔离、签名认证、幂等防重、异步投递、有限重试、已读状态查询和跳转域名校验。 +- 业务项目负责:确定通知触发时机、取得准确的钉钉 `userId`、生成稳定的幂等键、提交业务内容、保存通知中心返回的投递 ID。 +- 钉钉 `AppKey`、`AppSecret`、`RobotCode` 只能保存在通知中心。 +- 业务项目只能保存自己独有的项目 HMAC 密钥;禁止把密钥提交到 Git、发送到前端或写入浏览器环境变量。 + +## 2. 接入前需要提供的信息 + +向通知中心维护人员提交以下信息: + +| 字段 | 要求 | 示例 | +| --- | --- | --- | +| `projectCode` | 2~40 位小写字母、数字、下划线或短横线;创建后不再修改 | `fixed_asset` | +| `projectName` | 用户能理解的简短产品名称 | `固定资产` | +| `allowedHosts` | 卡片按钮允许跳转的 HTTPS 域名,不含协议、端口和路径 | `asset.jiansun.vip` | +| 负责人 | 至少一名业务负责人和一名技术负责人 | 姓名及联系方式 | +| 使用场景 | 说明触发条件、接收人和预计发送量 | `资产报废待审批` | +| 是否需要免登 | 默认否;只有卡片进入项目后需要自动建立登录态时才开启 | `否` | + +通知中心维护人员完成登记后,通过安全渠道提供: + +- `YYD_NOTIFICATION_PROJECT_CODE` +- `YYD_NOTIFICATION_PROJECT_SECRET` +- `YYD_NOTIFICATION_CENTER_URL` + +## 3. 业务项目环境变量 + +```dotenv +YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center +YYD_NOTIFICATION_PROJECT_CODE=fixed_asset +YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥 +``` + +这些变量只允许出现在业务后端的生产环境中。前端代码不得直接调用通知中心。 + +## 4. 发送接口 + +接口:`POST /v1/notifications` + +```json +{ + "idempotencyKey": "disposal_applied:53:approver-user-id:v1", + "recipientUserId": "钉钉userId", + "title": "资产报废待审批", + "content": "### 待你审批\n\n**资产:** 摄影灯 A-102\n**申请人:** 张三", + "actionTitle": "立即审批", + "actionUrl": "https://asset.jiansun.vip/workflows/disposals/53" +} +``` + +字段约束: + +| 字段 | 规则 | +| --- | --- | +| `idempotencyKey` | 必填,最长 160 字符;同一业务事件、同一收件人必须稳定不变 | +| `recipientUserId` | 必填,只能使用企业通讯录中的钉钉 `userId` | +| `title` | 必填,最长 100 字符;不要添加 `【由壹点】` 或项目名称 | +| `content` | 必填,Markdown,最长 4800 字符 | +| `actionTitle` | 可选,最长 20 字符,默认“查看详情” | +| `actionUrl` | 必填,标准 443 端口的完整 HTTPS 地址,域名必须在项目白名单内 | + +通知中心会自动生成最终标题: + +```text +【由壹点】{项目名称}·{业务标题} +``` + +例如业务项目提交“资产报废待审批”,用户最终看到的是: + +```text +【由壹点】固定资产·资产报废待审批 +``` + +## 5. 请求签名 + +每次请求必须携带: + +- `x-yyd-project-code` +- `x-yyd-timestamp`:Unix 秒 +- `x-yyd-nonce`:16~100 位,每次请求重新生成 +- `x-yyd-signature`:HMAC-SHA256 十六进制字符串 + +签名原文: + +```text +timestamp\nnonce\nHTTP_METHOD\nPATH\nSHA256(rawBody) +``` + +注意: + +- `PATH` 是通知中心内部接口路径,如 `/v1/notifications`,不包含公网反向代理前缀 `/notification-center`,也不包含域名和查询参数。 +- 计算签名的 `rawBody` 必须与实际发送的请求正文逐字节一致。 +- 服务器时间与通知中心相差不能超过五分钟。 +- nonce 不能复用,即使上一次请求失败也要生成新的 nonce。 + +可直接复用的实现见 [`examples/node/notification-client.mjs`](../examples/node/notification-client.mjs)。 + +## 6. 幂等和多人通知 + +当前接口一次只接收一名收件人。向多人发送时,业务项目应逐人创建通知,每个人使用独立但稳定的幂等键: + +```text +{事件类型}:{业务记录ID}:{recipientUserId}:{消息版本} +``` + +例如: + +```text +survey_created:73a3f3e9:15891598262598724:v1 +survey_created:73a3f3e9:16408293139985728:v1 +``` + +相同 `projectCode + idempotencyKey`: + +- 请求内容相同:返回原投递记录,不重复发送。 +- 请求内容不同:返回 `409 IDEMPOTENCY_CONFLICT`,业务项目必须修正幂等键设计,不能通过随机换键绕过。 + +## 7. 接收返回值和查询状态 + +创建成功返回 HTTP `202`,表示通知中心已接受任务,不代表钉钉已经送达。业务项目应保存返回的 `id`,必要时调用: + +```text +GET /v1/notifications/{id} +``` + +查询请求也必须签名,GET 请求正文为空字符串。状态含义和处理方式见 [错误与状态规范](./错误与状态规范.md)。 + +## 8. 钉钉 H5 免登是可选能力 + +普通通知接入不需要实现免登。只有用户点击卡片后必须自动进入业务登录态时,才申请开启项目 `login` 配置。 + +开启后业务项目还必须: + +1. 提供精确的 HTTPS 回调地址和允许回跳的站内路径前缀。 +2. 后端接收一次性票据,并通过签名接口兑换钉钉 `userId`。 +3. 在创建本地 Session 前重新核验账号绑定、在职状态和业务权限。 + +通知中心票据只能证明钉钉身份,不能替代业务项目自身的授权判断。 + +## 9. 最小上线验证 + +接入完成后至少验证: + +1. 本人收到单聊通知。 +2. 另一名真实员工收到单聊通知。 +3. 最终标题包含正确的项目来源。 +4. 卡片按钮只能跳转到项目白名单域名。 +5. 使用相同幂等键重复请求不会重复发送。 +6. 无效 `userId` 能得到明确失败状态。 +7. 创建接口返回 `202` 后,最终状态能进入 `sent`、`unread` 或 `read`。 +8. 日志、错误页和前端响应中均没有项目密钥。 + +完整清单见 [新项目接入清单](./新项目接入清单.md)。 diff --git a/docs/新项目接入清单.md b/docs/新项目接入清单.md new file mode 100644 index 0000000..1bce05b --- /dev/null +++ b/docs/新项目接入清单.md @@ -0,0 +1,58 @@ +# 新项目接入清单 + +## A. 业务项目提交 + +- [ ] 项目有明确的业务负责人和技术负责人。 +- [ ] `projectCode` 使用稳定的英文标识,创建后不会随产品中文名变化。 +- [ ] 项目中文名称简短,适合显示在钉钉标题中。 +- [ ] 已列出全部生产卡片跳转域名,且均为标准 HTTPS 域名。 +- [ ] 已说明通知场景、触发条件、收件人和预计发送量。 +- [ ] 已确认本地账户能够绑定权威钉钉 `userId`。 +- [ ] 已确认是否需要钉钉 H5 免登;普通通知默认不启用。 + +## B. 通知中心配置 + +- [ ] 在 `YYD_NOTIFICATION_PROJECTS_JSON` 中新增独立项目配置。 +- [ ] 为项目生成独立高强度随机 HMAC 密钥,不复用其他项目密钥。 +- [ ] 通过安全渠道向业务项目交付项目代码、密钥和通知中心地址。 +- [ ] 配置 `allowedHosts`,不使用通配符,不包含协议、端口或路径。 +- [ ] 如启用免登,配置精确 `callbackUrl` 和最小化的回跳路径前缀。 +- [ ] 备份生产配置后再发布,并验证 `/health`。 + +## C. 业务项目实现 + +- [ ] 三项连接配置仅存在于服务端环境变量。 +- [ ] 签名使用实际发送的原始 JSON 正文。 +- [ ] 签名路径不包含域名和反向代理前缀。 +- [ ] 每次请求生成新 nonce,服务器时间已经同步。 +- [ ] 幂等键包含事件、业务记录、收件人和消息版本。 +- [ ] 多人通知逐人发送,并为每名收件人生成独立幂等键。 +- [ ] 保存通知中心返回的投递 `id`。 +- [ ] `202` 不被当成“已送达”。 +- [ ] 标题、正文和按钮符合 [消息规范](./消息规范.md)。 +- [ ] 前端、日志和错误响应均不会暴露项目密钥。 + +## D. 联调验证 + +- [ ] 项目负责人收到真实单聊。 +- [ ] 至少另一名真实员工收到真实单聊。 +- [ ] 无效钉钉 `userId` 能进入明确失败状态。 +- [ ] 重复发送同一请求不会产生第二条消息。 +- [ ] 同一幂等键提交不同内容会被拒绝。 +- [ ] 最终标题中的“由壹点”和项目来源正确。 +- [ ] 正文第一眼能看出当前状态和需要采取的动作。 +- [ ] 按钮名称明确,跳转到正确的白名单域名。 +- [ ] 能查询到 `sent / unread / read / failed` 状态。 +- [ ] 如启用免登,票据只能消费一次,且业务项目重新核验本地账号和权限。 + +## E. 上线与交接 + +- [ ] 已记录通知中心配置变更和业务项目接入版本。 +- [ ] 已明确发生失败时由谁处理。 +- [ ] 已配置必要的失败日志或监控,不记录敏感凭证。 +- [ ] 已完成生产环境双人真实通知验证。 +- [ ] 已把接入文档路径交给项目后续维护人员或开发 Agent。 + +只有 A~E 全部完成,才视为正式接入“由壹点”统一通知。 + +推荐交接入口:先阅读仓库根目录 `README.md`,再依次阅读本清单、接入指南和消息规范;不要从其他业务项目复制钉钉应用凭证或机器人直连代码。 diff --git a/docs/消息规范.md b/docs/消息规范.md new file mode 100644 index 0000000..26b0dc2 --- /dev/null +++ b/docs/消息规范.md @@ -0,0 +1,135 @@ +# 由壹点通知消息规范 + +统一机器人解决的是“谁发出的”,项目名称解决的是“消息来自哪里”,业务标题和首屏内容解决的是“用户现在要做什么”。所有接入项目必须遵守本规范。 + +## 1. 标题规则 + +最终标题由通知中心统一拼接: + +```text +【由壹点】项目名称·业务标题 +``` + +业务项目只提交业务标题,例如: + +- `新的考核调研待填写` +- `资产报废待审批` +- `产品任务即将到期` + +禁止: + +- 自己添加 `【由壹点】`、机器人名称或项目名称。 +- 使用“通知”“消息提醒”“统一消息测试”等无法表达任务的空泛标题。 +- 在标题中堆积人员、日期、编号等详细字段。 +- 使用多个 emoji 或连续感叹号制造紧迫感。 + +## 2. 内容结构 + +消息第一屏按以下顺序组织: + +1. 当前状态或用户动作。 +2. 最重要的业务对象。 +3. 截止时间或异常原因。 +4. 一句必要补充。 + +推荐模板: + +```markdown +### 待你填写 + +**调研:** 2026年7月 · 跨部门协同 + +**评价对象:** 无邪 + +**截止时间:** 7月31日 18:00 + +> 请在截止时间前完成评分。 +``` + +不要写成一整段: + +```text +你有一份新的考核调研需要填写。调研:2026年7月……对象……截止…… +``` + +## 3. 状态用语 + +优先使用用户可以立即理解的动作词: + +| 场景 | 推荐状态 | +| --- | --- | +| 需要填写 | `待你填写` | +| 需要审批 | `待你审批` | +| 任务完成 | `处理完成` | +| 操作失败 | `处理失败` | +| 即将到期 | `即将到期` | +| 仅告知 | `结果已更新` | + +不要使用仅开发人员能理解的内部状态码,例如 `pending`、`processed`、`job failed`。 + +## 4. 按钮规则 + +按钮必须描述点击后的动作: + +- `立即填写` +- `立即审批` +- `查看结果` +- `查看任务` +- `处理异常` + +避免使用没有方向的“点击这里”“打开系统”“查看详情”,除非确实无法给出更具体的动作。 + +每条通知只保留一个主要按钮。通知不是业务页面,不在消息中堆叠多个操作入口。 + +## 5. 信息取舍 + +消息中只放帮助用户判断和行动的信息: + +- 保留:业务对象、发起人、截止时间、核心结果、失败原因。 +- 省略:数据库 ID、内部状态码、部署版本、调试说明、完整操作日志。 +- 禁止:项目密钥、访问令牌、身份证号、完整手机号、敏感人事信息和无需展示的内部备注。 + +如果内容较长,消息只展示摘要,完整信息通过白名单内的业务页面查看。 + +## 6. 时间和名称 + +- 面向中国员工统一使用北京时间。 +- 用户可见时间推荐 `2026年7月31日 18:00`,不要显示秒和时区偏移。 +- 人员名称使用公司花名或业务系统已确认的展示名,但投递对象必须使用钉钉 `userId`。 +- 不在消息里用邮箱、手机号或内部 ID 代替人员名称。 + +## 7. 示例 + +### 考核调研 + +```text +标题:新的考核调研待填写 +内容: +### 待你填写 + +**调研:** 2026年7月 · 工作配合度 +**评价对象:** 无邪 +**截止时间:** 7月31日 18:00 + +> 请在截止时间前完成评分。 + +按钮:立即填写 +``` + +最终显示标题:`【由壹点】素材库·新的考核调研待填写` + +### 固定资产 + +```text +标题:资产报废待审批 +内容: +### 待你审批 + +**资产:** 摄影灯 A-102 +**申请人:** 无邪 +**申请时间:** 7月16日 14:30 + +按钮:立即审批 +``` + +最终显示标题:`【由壹点】固定资产·资产报废待审批` diff --git a/docs/错误与状态规范.md b/docs/错误与状态规范.md new file mode 100644 index 0000000..f58dc5f --- /dev/null +++ b/docs/错误与状态规范.md @@ -0,0 +1,68 @@ +# 错误与状态规范 + +## 1. HTTP 返回的含义 + +| HTTP 状态 | 含义 | 业务项目处理 | +| --- | --- | --- | +| `200` | 相同幂等请求已存在,返回原记录 | 按成功受理处理,不再次生成新幂等键 | +| `202` | 新通知已进入队列 | 保存 `id`,不要当作已经送达 | +| `401` | 项目、时间戳或签名认证失败 | 停止重试,检查配置、时钟和签名实现 | +| `404` | 查询的投递记录不存在或不属于当前项目 | 检查投递 ID,不能跨项目查询 | +| `409` | nonce 重放或幂等内容冲突 | nonce 重放可用新 nonce 重发;幂等冲突必须修正业务逻辑 | +| `422` | 消息字段、标题、长度或跳转地址不合法 | 修正请求内容后再提交 | +| `500` | 通知中心内部异常 | 保留原幂等键,短暂退避后有限重试并告警 | + +## 2. 投递状态 + +| 状态 | 含义 | 是否终态 | +| --- | --- | --- | +| `pending` | 已受理,等待发送 | 否 | +| `sending` | 工作进程正在投递 | 否 | +| `retrying` | 明确未受理,等待通知中心自动重试 | 否 | +| `sent` | 钉钉已接受发送请求,等待读取结果 | 否 | +| `unread` | 已投递但收件人尚未阅读 | 否 | +| `read` | 收件人已经阅读 | 是 | +| `failed` | 已确定失败或投递结果不确定且为避免重复而隔离 | 是 | + +业务项目一般不需要自己重发 `pending / sending / retrying / sent / unread` 状态。通知中心会处理明确可重试的失败并查询已读状态。 + +## 3. 常见错误码 + +| 错误码 | 原因 | 处理方式 | +| --- | --- | --- | +| `AUTH_REQUIRED` | 项目不存在或签名请求头缺失 | 检查项目配置和请求头 | +| `TIMESTAMP_EXPIRED` | 客户端与服务器时间相差超过五分钟 | 校准系统时间后使用新 nonce 重发 | +| `NONCE_INVALID` | nonce 格式不符合要求 | 使用 16~100 位随机 URL 安全字符串 | +| `SIGNATURE_INVALID` | 签名路径、正文或密钥不一致 | 使用实际 raw body 和内部 API path 重新计算 | +| `NONCE_REPLAYED` | nonce 已经使用过 | 生成新 nonce,业务幂等键保持不变 | +| `ACTION_URL_INVALID` | 非 HTTPS、带非标准端口或域名不在白名单 | 修改链接或申请扩充域名白名单 | +| `IDEMPOTENCY_KEY_INVALID` | 幂等键为空或过长 | 按统一格式生成稳定键 | +| `MESSAGE_FIELDS_REQUIRED` | 收件人、标题或内容缺失 | 补齐字段 | +| `MESSAGE_TOO_LONG` | 标题、正文或按钮超长 | 压缩内容,把详情放回业务页面 | +| `TITLE_BRAND_RESERVED` | 业务标题自行添加了“由壹点”品牌前缀 | 只提交业务标题 | +| `IDEMPOTENCY_CONFLICT` | 同一幂等键对应了不同内容 | 修复幂等键或业务数据,不得随机换键规避 | +| `NOT_FOUND` | 记录不存在或跨项目访问 | 检查投递 ID 和项目身份 | +| `INTERNAL_ERROR` | 通知中心内部异常 | 原幂等键退避重试并通知维护人员 | + +投递进入 `failed` 时,还应记录返回中的 `lastErrorCode` 和 `lastErrorMessage`,但不要直接把内部错误原文展示给最终用户。 + +## 4. 业务端重试原则 + +1. 连接失败或 HTTP `500` 时可以有限重试,必须复用原 `idempotencyKey`,每次生成新 nonce。 +2. HTTP `200 / 202` 后不要重新创建通知,改为查询返回的投递 ID。 +3. HTTP `401 / 409 / 422` 默认不自动重试;先修正配置或请求。 +4. 通知中心内部会处理明确未受理的钉钉限流等情况,业务项目不得再叠加无限重试。 +5. `DELIVERY_OUTCOME_UNKNOWN` 表示发送结果无法确定。为避免用户收到重复单聊,通知中心会停止自动重试;业务负责人确认后才能人工补发。 + +## 5. 建议监控 + +业务项目至少记录: + +- 业务事件 ID +- `idempotencyKey` +- 通知中心投递 `id` +- 收件人钉钉 `userId` +- 最近一次查询状态 +- `lastErrorCode` + +禁止记录项目密钥、签名原文中的密钥、钉钉 AppSecret、免登码和一次性登录票据。 diff --git a/examples/node/README.md b/examples/node/README.md new file mode 100644 index 0000000..7bad6d1 --- /dev/null +++ b/examples/node/README.md @@ -0,0 +1,43 @@ +# Node.js 接入示例 + +示例仅依赖 Node.js 18 及以上版本自带的 `fetch` 和 `crypto`,可以复制到业务项目后端使用。 + +在通知中心仓库内可执行 `npm run test:example` 验证客户端签名和请求格式。 + +```js +import { YouYiDianNotificationClient } from './notification-client.mjs' + +const client = new YouYiDianNotificationClient({ + baseUrl: process.env.YYD_NOTIFICATION_CENTER_URL, + projectCode: process.env.YYD_NOTIFICATION_PROJECT_CODE, + projectSecret: process.env.YYD_NOTIFICATION_PROJECT_SECRET, +}) + +const recipientUserId = '16408293139985728' +const delivery = await client.send({ + idempotencyKey: `asset_disposal:53:${recipientUserId}:v1`, + recipientUserId, + title: '资产报废待审批', + content: [ + '### 待你审批', + '', + '**资产:** 摄影灯 A-102 ', + '**申请人:** 无邪 ', + '**申请时间:** 2026年7月16日 14:30', + ].join('\n'), + actionTitle: '立即审批', + actionUrl: 'https://asset.jiansun.vip/workflows/disposals/53', +}) + +console.log('通知已受理:', delivery.id, delivery.status) + +const latest = await client.getDelivery(delivery.id) +console.log('当前投递状态:', latest.status) +``` + +注意: + +- 这段代码只能运行在后端。 +- 不要把三个环境变量使用 `NEXT_PUBLIC_`、`VITE_` 等前端公开前缀。 +- 业务调用失败后重试时复用原幂等键,但每次 HTTP 请求会自动生成新的 nonce。 +- 多人通知请逐人调用 `send`,并把收件人 `userId` 放入幂等键。 diff --git a/examples/node/notification-client.mjs b/examples/node/notification-client.mjs new file mode 100644 index 0000000..1bb2321 --- /dev/null +++ b/examples/node/notification-client.mjs @@ -0,0 +1,77 @@ +import crypto from 'node:crypto' + +const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex') + +const cleanBaseUrl = (value) => { + const url = new URL(String(value || '').trim()) + if (url.protocol !== 'https:' && url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') { + throw new Error('通知中心地址必须使用 HTTPS') + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('通知中心地址不能包含凭据、查询参数或片段') + } + return url.toString().replace(/\/$/, '') +} + +export class YouYiDianNotificationClient { + constructor({ baseUrl, projectCode, projectSecret, fetchImpl = fetch }) { + if (!/^[a-z0-9_-]{2,40}$/.test(String(projectCode || ''))) { + throw new Error('projectCode 格式不正确') + } + if (String(projectSecret || '').length < 24) { + throw new Error('projectSecret 未配置或长度不足') + } + this.baseUrl = cleanBaseUrl(baseUrl) + this.projectCode = projectCode + this.projectSecret = projectSecret + this.fetchImpl = fetchImpl + } + + createHeaders({ method, path, body = '' }) { + const timestamp = String(Math.floor(Date.now() / 1000)) + const nonce = crypto.randomBytes(18).toString('base64url') + const canonical = [timestamp, nonce, method.toUpperCase(), path, sha256(body)].join('\n') + const signature = crypto.createHmac('sha256', this.projectSecret).update(canonical).digest('hex') + return { + 'content-type': 'application/json', + 'x-yyd-project-code': this.projectCode, + 'x-yyd-timestamp': timestamp, + 'x-yyd-nonce': nonce, + 'x-yyd-signature': signature, + } + } + + async request({ method, path, body }) { + const rawBody = body === undefined ? '' : JSON.stringify(body) + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers: this.createHeaders({ method, path, body: rawBody }), + ...(rawBody ? { body: rawBody } : {}), + }) + const payload = await response.json().catch(() => ({ error: 'INVALID_JSON_RESPONSE' })) + if (!response.ok) { + const error = new Error(payload.message || payload.error || `通知中心返回 HTTP ${response.status}`) + error.status = response.status + error.code = payload.error + error.payload = payload + throw error + } + return payload + } + + send(input) { + return this.request({ + method: 'POST', + path: '/v1/notifications', + body: input, + }) + } + + getDelivery(id) { + if (!id) throw new Error('投递 id 不能为空') + return this.request({ + method: 'GET', + path: `/v1/notifications/${encodeURIComponent(id)}`, + }) + } +} diff --git a/examples/node/notification-client.test.mjs b/examples/node/notification-client.test.mjs new file mode 100644 index 0000000..a9ad884 --- /dev/null +++ b/examples/node/notification-client.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import test from 'node:test' +import { YouYiDianNotificationClient } from './notification-client.mjs' + +const secret = 'example-project-secret-at-least-24-chars' + +const verifySignature = (request) => { + const url = new URL(request.url) + const timestamp = request.init.headers['x-yyd-timestamp'] + const nonce = request.init.headers['x-yyd-nonce'] + const bodyHash = crypto.createHash('sha256').update(request.init.body || '').digest('hex') + const canonical = [timestamp, nonce, request.init.method, url.pathname.replace('/notification-center', ''), bodyHash].join('\n') + const expected = crypto.createHmac('sha256', secret).update(canonical).digest('hex') + assert.equal(request.init.headers['x-yyd-signature'], expected) +} + +test('发送通知使用业务正文原文签名并返回投递记录', async () => { + const requests = [] + const client = new YouYiDianNotificationClient({ + baseUrl: 'https://sc.example.com/notification-center', + projectCode: 'fixed_asset', + projectSecret: secret, + fetchImpl: async (url, init) => { + requests.push({ url, init }) + return new Response(JSON.stringify({ id: 'delivery-1', status: 'pending' }), { + status: 202, + headers: { 'content-type': 'application/json' }, + }) + }, + }) + + const payload = { + idempotencyKey: 'asset_disposal:53:user-1:v1', + recipientUserId: 'user-1', + title: '资产报废待审批', + content: '### 待你审批', + actionTitle: '立即审批', + actionUrl: 'https://asset.example.com/workflows/disposals/53', + } + const delivery = await client.send(payload) + + assert.deepEqual(delivery, { id: 'delivery-1', status: 'pending' }) + assert.equal(requests.length, 1) + assert.equal(requests[0].url, 'https://sc.example.com/notification-center/v1/notifications') + assert.equal(requests[0].init.body, JSON.stringify(payload)) + verifySignature(requests[0]) +}) + +test('查询通知使用空正文签名', async () => { + let request + const client = new YouYiDianNotificationClient({ + baseUrl: 'https://sc.example.com/notification-center', + projectCode: 'fixed_asset', + projectSecret: secret, + fetchImpl: async (url, init) => { + request = { url, init } + return new Response(JSON.stringify({ id: 'delivery-1', status: 'read' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + }) + + const delivery = await client.getDelivery('delivery-1') + assert.equal(delivery.status, 'read') + assert.equal(request.init.body, undefined) + verifySignature(request) +}) diff --git a/package.json b/package.json index 5ec6c6c..d75bccb 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "prisma generate && tsc", "start": "node dist/index.js", "test": "node scripts/run-tests.mjs", + "test:example": "node --test examples/node/notification-client.test.mjs", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate deploy" },