完善统一通知接入规范与示例

This commit is contained in:
WuYongJie 2026-07-16 14:48:35 +08:00
parent eda7717975
commit 5c9e53e159
9 changed files with 621 additions and 0 deletions

View File

@ -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 密钥。

158
docs/接入指南.md Normal file
View File

@ -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)。

View File

@ -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`,再依次阅读本清单、接入指南和消息规范;不要从其他业务项目复制钉钉应用凭证或机器人直连代码。

135
docs/消息规范.md Normal file
View File

@ -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
按钮:立即审批
```
最终显示标题:`【由壹点】固定资产·资产报废待审批`

View File

@ -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、免登码和一次性登录票据。

43
examples/node/README.md Normal file
View File

@ -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` 放入幂等键。

View File

@ -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)}`,
})
}
}

View File

@ -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)
})

View File

@ -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"
},