feat: 支持按登记花名发送通知
This commit is contained in:
parent
e0b0457b8b
commit
7f0af50651
@ -16,7 +16,7 @@ DINGTALK_LOGIN_TICKET_TTL_SECONDS=60
|
||||
# 每个项目必须使用不同密钥;allowedHosts 用于限制卡片跳转域名。
|
||||
# login 省略或 enabled=false 时保持原直接跳转,不启用免登中转。
|
||||
# 生产环境请使用足够长的随机密钥,不要把真实值提交到 Git。
|
||||
YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","secret":"replace-with-random-secret","allowedHosts":["sc.example.com"]},{"code":"fixed_asset","name":"固定资产","secret":"replace-with-another-random-secret","allowedHosts":["asset.example.com"],"login":{"enabled":true,"callbackUrl":"https://asset.example.com/api/dingtalk/broker-login","allowedRedirectPathPrefixes":["/workflows/disposals","/dashboard"]}},{"code":"furniture_platform","name":"家具业务系统","secret":"replace-with-furniture-random-secret","allowedHosts":["cp.example.com"]}]
|
||||
YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","secret":"replace-with-random-secret","allowedHosts":["sc.example.com"],"recipients":[{"alias":"示例花名","userId":"replace-with-dingtalk-user-id"}]},{"code":"fixed_asset","name":"固定资产","secret":"replace-with-another-random-secret","allowedHosts":["asset.example.com"],"login":{"enabled":true,"callbackUrl":"https://asset.example.com/api/dingtalk/broker-login","allowedRedirectPathPrefixes":["/workflows/disposals","/dashboard"]}},{"code":"furniture_platform","name":"家具业务系统","secret":"replace-with-furniture-random-secret","allowedHosts":["cp.example.com"]}]
|
||||
|
||||
WORKER_ENABLED=true
|
||||
WORKER_INTERVAL_MS=1000
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
|
||||
接入的核心边界:业务项目只保存自己的项目代码和 HMAC 密钥;钉钉 `AppKey`、`AppSecret`、`RobotCode` 始终只由通知中心持有。
|
||||
|
||||
收件人支持两种方式:业务项目可以直接提交钉钉 `userId`,也可以提交当前项目已由通知中心登记的准确花名。花名会在中心解析为 `userId`;未登记或冲突时拒绝发送,不做模糊猜测。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 每个项目使用独立 HMAC 密钥。
|
||||
|
||||
21
docs/接入指南.md
21
docs/接入指南.md
@ -5,7 +5,7 @@
|
||||
## 1. 接入边界
|
||||
|
||||
- 通知中心负责:统一机器人身份、项目隔离、签名认证、幂等防重、异步投递、有限重试、已读状态查询和跳转域名校验。
|
||||
- 业务项目负责:确定通知触发时机、取得准确的钉钉 `userId`、生成稳定的幂等键、提交业务内容、保存通知中心返回的投递 ID。
|
||||
- 业务项目负责:确定通知触发时机、提供准确的钉钉 `userId` 或当前项目已登记花名、生成稳定的幂等键、提交业务内容、保存通知中心返回的投递 ID。
|
||||
- 钉钉 `AppKey`、`AppSecret`、`RobotCode` 只能保存在通知中心。
|
||||
- 业务项目只能保存自己独有的项目 HMAC 密钥;禁止把密钥提交到 Git、发送到前端或写入浏览器环境变量。
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
| `allowedHosts` | 卡片按钮允许跳转的 HTTPS 域名,不含协议、端口和路径 | `asset.jiansun.vip` |
|
||||
| 负责人 | 至少一名业务负责人和一名技术负责人 | 姓名及联系方式 |
|
||||
| 使用场景 | 说明触发条件、接收人和预计发送量 | `资产报废待审批` |
|
||||
| 收件人方式 | 能提供钉钉 `userId`,还是只能提供花名;花名方式需提交准确清单 | `花名:海棠、无邪` |
|
||||
| 是否需要免登 | 默认否;只有卡片进入项目后需要自动建立登录态时才开启 | `否` |
|
||||
|
||||
通知中心维护人员完成登记后,通过安全渠道提供:
|
||||
@ -53,12 +54,26 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥
|
||||
}
|
||||
```
|
||||
|
||||
只能提供花名时,将 `recipientUserId` 替换为当前项目已经登记的 `recipientAlias`:
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotencyKey": "disposal_applied:53:海棠:v1",
|
||||
"recipientAlias": "海棠",
|
||||
"title": "资产报废待审批",
|
||||
"content": "**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102",
|
||||
"actionTitle": "立即审批",
|
||||
"actionUrl": "https://asset.jiansun.vip/workflows/disposals/53"
|
||||
}
|
||||
```
|
||||
|
||||
字段约束:
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `idempotencyKey` | 必填,最长 160 字符;同一业务事件、同一收件人必须稳定不变 |
|
||||
| `recipientUserId` | 必填,只能使用企业通讯录中的钉钉 `userId` |
|
||||
| `recipientUserId` | 与 `recipientAlias` 二选一;企业通讯录中的钉钉 `userId` |
|
||||
| `recipientAlias` | 与 `recipientUserId` 二选一;当前项目由通知中心预先登记的准确花名,最长 50 字符 |
|
||||
| `title` | 必填,最长 100 字符;只写业务主题,不要添加 `【项目名】`,拼接项目名后的最终标题不能超过 120 字符 |
|
||||
| `content` | 必填,Markdown,业务正文最长 4800 字符;不要自行添加首行标题,各字段之间必须空一行,通知中心加上统一首行后的最终正文也不能超过 4800 字符 |
|
||||
| `actionTitle` | 可选,最长 20 字符,默认“查看详情” |
|
||||
@ -104,7 +119,7 @@ timestamp\nnonce\nHTTP_METHOD\nPATH\nSHA256(rawBody)
|
||||
|
||||
## 6. 幂等和多人通知
|
||||
|
||||
当前接口一次只接收一名收件人。向多人发送时,业务项目应逐人创建通知,每个人使用独立但稳定的幂等键:
|
||||
当前接口一次只接收一名收件人。向多人发送时,业务项目应逐人创建通知,每个人使用独立但稳定的幂等键。花名方式把下面的 `recipientUserId` 替换成已登记花名:
|
||||
|
||||
```text
|
||||
{事件类型}:{业务记录ID}:{recipientUserId}:{消息版本}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
- [ ] 项目中文名称简短,适合显示在钉钉标题中。
|
||||
- [ ] 已列出全部生产卡片跳转域名,且均为标准 HTTPS 域名。
|
||||
- [ ] 已说明通知场景、触发条件、收件人和预计发送量。
|
||||
- [ ] 已确认本地账户能够绑定权威钉钉 `userId`。
|
||||
- [ ] 已确认项目使用钉钉 `userId` 还是已登记花名;花名方式已提交准确清单。
|
||||
- [ ] 已确认是否需要钉钉 H5 免登;普通通知默认不启用。
|
||||
|
||||
## B. 通知中心配置
|
||||
@ -16,6 +16,7 @@
|
||||
- [ ] 为项目生成独立高强度随机 HMAC 密钥,不复用其他项目密钥。
|
||||
- [ ] 通过安全渠道向业务项目交付项目代码、密钥和通知中心地址。
|
||||
- [ ] 配置 `allowedHosts`,不使用通配符,不包含协议、端口或路径。
|
||||
- [ ] 如使用花名,`recipients` 中每个花名只绑定一个已确认的在职员工 `userId`,且归一化后无重复。
|
||||
- [ ] 如启用免登,配置精确 `callbackUrl` 和最小化的回跳路径前缀。
|
||||
- [ ] 备份生产配置后再发布,并验证 `/health`。
|
||||
|
||||
@ -27,6 +28,7 @@
|
||||
- [ ] 每次请求生成新 nonce,服务器时间已经同步。
|
||||
- [ ] 幂等键包含事件、业务记录、收件人和消息版本。
|
||||
- [ ] 多人通知逐人发送,并为每名收件人生成独立幂等键。
|
||||
- [ ] 每条通知只提交 `recipientUserId` 或 `recipientAlias` 其中一个。
|
||||
- [ ] 保存通知中心返回的投递 `id`。
|
||||
- [ ] `202` 不被当成“已送达”。
|
||||
- [ ] 标题、正文和按钮符合 [消息规范](./消息规范.md)。
|
||||
@ -37,6 +39,7 @@
|
||||
- [ ] 项目负责人收到真实单聊。
|
||||
- [ ] 至少另一名真实员工收到真实单聊。
|
||||
- [ ] 无效钉钉 `userId` 能进入明确失败状态。
|
||||
- [ ] 如使用花名,已登记花名能发送,未登记花名返回明确错误且不会发送。
|
||||
- [ ] 重复发送同一请求不会产生第二条消息。
|
||||
- [ ] 同一幂等键提交不同内容会被拒绝。
|
||||
- [ ] 最终标题使用 `【项目名称】业务主题`,项目来源正确且醒目。
|
||||
|
||||
@ -124,7 +124,7 @@
|
||||
|
||||
- 面向中国员工统一使用北京时间。
|
||||
- 用户可见时间推荐 `2026年7月31日 18:00`,不要显示秒和时区偏移。
|
||||
- 人员名称使用公司花名或业务系统已确认的展示名,但投递对象必须使用钉钉 `userId`。
|
||||
- 人员名称使用公司花名或业务系统已确认的展示名;投递对象使用钉钉 `userId`,或当前项目已由通知中心登记的准确花名。
|
||||
- 不在消息里用邮箱、手机号或内部 ID 代替人员名称。
|
||||
|
||||
## 7. 示例
|
||||
|
||||
@ -21,10 +21,11 @@ YYD_NOTIFICATION_PROJECT_SECRET=通知中心正式生成的项目独立密钥
|
||||
| 三项配置都没有 | 先填写本文第五节的接入申请,不要自己猜项目代码或生成密钥 |
|
||||
| 只有通知中心地址 | 仍未开通,等待通知中心维护人登记项目并发放代码和密钥 |
|
||||
| 已有地址、项目代码和项目密钥 | 从本文第七节开始配置业务后端 |
|
||||
| 还不知道通知发给谁 | 先按本文第六节确认钉钉 `userId` 的数据来源 |
|
||||
| 只想给一个人发送联调消息 | 准备该员工的真实钉钉 `userId`,它只用于本次测试,不是接入密钥 |
|
||||
| 业务系统只有员工花名 | 在接入申请中提交需使用的花名,由通知中心维护人登记花名映射 |
|
||||
| 还不知道通知发给谁 | 先按本文第六节选择 `userId` 或已登记花名的发送方式 |
|
||||
| 只想给一个人发送联调消息 | 准备该员工的真实钉钉 `userId`,或确认其花名已经登记;这不是接入密钥 |
|
||||
|
||||
请勿把“海棠的钉钉 `userId`”之类的收件人信息,与上述三项接入配置混为一谈。前三项用于证明“哪个项目在调用”;`recipientUserId` 用于说明“这一次通知发给谁”。
|
||||
请勿把“海棠”或“海棠的钉钉 `userId`”之类的收件人信息,与上述三项接入配置混为一谈。前三项用于证明“哪个项目在调用”;`recipientAlias` 或 `recipientUserId` 用于说明“这一次通知发给谁”。
|
||||
|
||||
## 一、通知中心是什么
|
||||
|
||||
@ -64,12 +65,12 @@ YYD_NOTIFICATION_PROJECT_SECRET=通知中心正式生成的项目独立密钥
|
||||
## 三、开始开发前先确认五件事
|
||||
|
||||
1. 哪个业务事件触发通知,例如“审批单创建”或“任务即将到期”。
|
||||
2. 通知发给谁,业务系统从哪里取得该员工的钉钉 `userId`。
|
||||
2. 通知发给谁,业务系统能够提供钉钉 `userId`,还是只能提供花名。
|
||||
3. 用户点击通知后进入哪个生产 HTTPS 域名。
|
||||
4. 同一事件是否可能重复执行,以及如何使用稳定的幂等键防止重复通知。
|
||||
5. 点击通知后是否必须自动建立业务登录态。普通通知默认不启用免登。
|
||||
|
||||
如果第 2 项没有可靠答案,请先解决员工账号与钉钉 `userId` 的对应关系。姓名、手机号、邮箱和业务系统内部 ID 都不能直接作为通知收件人。
|
||||
如果业务系统只能提供花名,必须在接入申请中列出花名,由通知中心维护人将其登记到当前项目。未登记的姓名、手机号、邮箱和业务系统内部 ID 都不能直接作为通知收件人。
|
||||
|
||||
## 四、正确的接入顺序
|
||||
|
||||
@ -108,7 +109,9 @@ YYD_NOTIFICATION_PROJECT_SECRET=通知中心正式生成的项目独立密钥
|
||||
通知场景:
|
||||
触发条件:
|
||||
收件人规则:
|
||||
钉钉 userId 的数据来源:
|
||||
收件人标识方式:钉钉 userId / 花名
|
||||
钉钉 userId 的数据来源(能够提供时填写):
|
||||
需要通知中心登记的花名清单(仅花名方式填写):
|
||||
预计每日通知量:
|
||||
预计峰值通知量:
|
||||
|
||||
@ -133,7 +136,9 @@ YYD_NOTIFICATION_PROJECT_SECRET=通知中心正式生成的项目独立密钥
|
||||
通知场景:产品任务即将到期
|
||||
触发条件:任务距截止时间不足 24 小时且仍未完成
|
||||
收件人规则:任务负责人
|
||||
钉钉 userId 的数据来源:员工账号绑定表 dingtalkUserId 字段
|
||||
收件人标识方式:花名
|
||||
钉钉 userId 的数据来源:无
|
||||
需要通知中心登记的花名清单:海棠、无邪
|
||||
预计每日通知量:30 条
|
||||
预计峰值通知量:每分钟 5 条
|
||||
|
||||
@ -153,6 +158,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=通知中心正式生成的项目独立密钥
|
||||
| 项目名称 | 用户能看懂的简短名称,会显示在通知标题中 | `固定资产` |
|
||||
| 跳转域名 | 标准 HTTPS 域名,不含协议、端口和路径;不使用通配符 | `asset.example.com` |
|
||||
| 通知场景 | 写清触发条件、接收人和需要采取的动作 | `资产报废申请创建后通知审批人` |
|
||||
| 收件人方式 | 优先使用钉钉 `userId`;只能提供花名时,提交需要登记的准确花名清单 | `花名:海棠、无邪` |
|
||||
| 预计发送量 | 至少说明日常量和峰值 | `日均 50 条,峰值每分钟 10 条` |
|
||||
|
||||
通知中心维护人完成登记后,应通过安全渠道分别交付:
|
||||
@ -192,25 +198,35 @@ YYD_NOTIFICATION_PROJECT_SECRET=项目独有密钥
|
||||
| 数据 | 从哪里取得 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| `recipientUserId` | 业务项目的员工账号绑定或权威通讯录映射 | 海棠对应的钉钉 `userId` |
|
||||
| `recipientAlias` | 通知中心已为当前项目登记的准确花名 | `海棠` |
|
||||
| `idempotencyKey` | 业务项目根据事件、记录、收件人和版本生成 | `task_due:53:user-id:v1` |
|
||||
| `title` | 当前业务事件 | `产品任务即将到期` |
|
||||
| `content` | 当前业务记录 | 截止时间、任务名称、负责人 |
|
||||
| `actionUrl` | 当前业务记录的 HTTPS 页面 | `https://product.example.com/tasks/53` |
|
||||
|
||||
### 6.3 钉钉 userId 到底是什么
|
||||
### 6.3 两种收件人方式怎么选择
|
||||
|
||||
钉钉 `userId` 是员工在当前企业通讯录中的稳定标识,不是姓名,也不是手机号、邮箱、工号或业务数据库主键。
|
||||
|
||||
业务项目按以下优先顺序取得:
|
||||
能够取得 `userId` 的项目,按以下优先顺序取得:
|
||||
|
||||
1. 已有员工账号绑定:读取当前收件人账号保存的 `dingtalkUserId` 或等价字段。
|
||||
2. 已有企业通讯录同步:从项目已经同步的钉钉员工记录中读取 `userId`。
|
||||
3. 仅做首次联调:由负责人确认一名真实员工,并通过公司现有的权威人员映射取得该员工 `userId`。
|
||||
4. 完全没有员工与钉钉身份对应关系:先补齐账号绑定或通讯录同步,不能通过姓名猜测。
|
||||
4. 完全没有员工与钉钉身份对应关系:改用已登记花名,或者先补齐账号绑定或通讯录同步。
|
||||
|
||||
如果正式业务永远只通知固定负责人,可以把该负责人的 `userId` 保存在业务项目的后端配置中;如果收件人随业务记录变化,就必须动态读取,不能把联调人员的 `userId` 写死在代码里。
|
||||
如果项目只能提供花名,使用 `recipientAlias`:
|
||||
|
||||
通知中心不会根据姓名自动猜收件人,也不会把某个测试人员设置成所有项目的默认收件人。
|
||||
- 花名必须由通知中心维护人预先登记,并绑定到经过确认的钉钉 `userId`;
|
||||
- 每个项目维护自己的可用花名,不会读取其他项目的映射;
|
||||
- 花名会进行空格、大小写和全半角归一化,但不会模糊猜测;
|
||||
- 未登记花名返回 `RECIPIENT_ALIAS_NOT_FOUND`,不会发送通知;
|
||||
- 同一项目不能登记两个归一化后相同的花名;
|
||||
- 同一请求只能传 `recipientUserId` 或 `recipientAlias` 其中一个。
|
||||
|
||||
如果正式业务永远只通知固定负责人,可以登记其花名或把 `userId` 保存在业务项目后端;如果收件人随业务记录变化,就从业务记录读取对应的已登记花名或 `userId`,不能把联调人员写死在代码里。
|
||||
|
||||
通知中心只解析当前项目已经登记的准确花名,不会根据任意姓名模糊猜人,也不会把某个测试人员设置成所有项目的默认收件人。
|
||||
|
||||
## 七、在业务项目中保存配置
|
||||
|
||||
@ -253,7 +269,7 @@ const notificationClient = new YouYiDianNotificationClient({
|
||||
})
|
||||
```
|
||||
|
||||
发送一条通知:
|
||||
使用钉钉 `userId` 发送一条通知:
|
||||
|
||||
```js
|
||||
const recipientUserId = employee.dingtalkUserId
|
||||
@ -280,6 +296,21 @@ const delivery = await notificationClient.send({
|
||||
await saveNotificationDeliveryId(disposal.id, recipientUserId, delivery.id)
|
||||
```
|
||||
|
||||
业务项目只能提供花名时,改传已经登记的 `recipientAlias`,不要同时传 `recipientUserId`:
|
||||
|
||||
```js
|
||||
const recipientAlias = task.ownerAlias
|
||||
|
||||
const delivery = await notificationClient.send({
|
||||
idempotencyKey: `task_due:${task.id}:${recipientAlias}:v1`,
|
||||
recipientAlias,
|
||||
title: '产品任务即将到期',
|
||||
content: `**截止时间:** ${deadline}\n\n**任务:** ${task.title}`,
|
||||
actionTitle: '查看任务',
|
||||
actionUrl: `https://product.example.com/tasks/${task.id}`,
|
||||
})
|
||||
```
|
||||
|
||||
查询投递状态:
|
||||
|
||||
```js
|
||||
@ -351,10 +382,24 @@ signature = HMAC_SHA256_HEX(projectSecret, canonical)
|
||||
}
|
||||
```
|
||||
|
||||
花名方式只替换收件人字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotencyKey": "asset_disposal:53:海棠:v1",
|
||||
"recipientAlias": "海棠",
|
||||
"title": "资产报废待审批",
|
||||
"content": "**申请时间:** 2026年8月5日 14:30\n\n**资产:** 摄影灯 A-102",
|
||||
"actionTitle": "立即审批",
|
||||
"actionUrl": "https://asset.example.com/workflows/disposals/53"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| `idempotencyKey` | 是 | 最长 160 字符;同一业务事件和同一收件人必须稳定不变 |
|
||||
| `recipientUserId` | 是 | 企业通讯录中的钉钉 `userId`,不是姓名、手机号或业务账号 ID |
|
||||
| `recipientUserId` | 二选一 | 企业通讯录中的钉钉 `userId`;与 `recipientAlias` 只能传一个 |
|
||||
| `recipientAlias` | 二选一 | 当前项目已登记的准确花名,最长 50 字符;与 `recipientUserId` 只能传一个 |
|
||||
| `title` | 是 | 只写业务主题,最长 100 字符;不要自己添加项目名或“由壹点”前缀 |
|
||||
| `content` | 是 | Markdown 业务正文;不要自己写首行标题;字段之间保留空行 |
|
||||
| `actionTitle` | 否 | 最长 20 字符,建议写明确动作,例如“立即审批” |
|
||||
@ -373,7 +418,7 @@ signature = HMAC_SHA256_HEX(projectSecret, canonical)
|
||||
推荐的幂等键格式:
|
||||
|
||||
```text
|
||||
{事件类型}:{业务记录ID}:{recipientUserId}:{消息版本}
|
||||
{事件类型}:{业务记录ID}:{recipientUserId或recipientAlias}:{消息版本}
|
||||
```
|
||||
|
||||
示例:
|
||||
@ -389,7 +434,7 @@ asset_disposal_created:53:16408293139985728:v1
|
||||
- 相同幂等键但内容不同会返回 `409 IDEMPOTENCY_CONFLICT`,必须修复业务逻辑,不能随机换键绕过。
|
||||
- 确实需要发送一条内容更新后的新通知时,经过业务确认后提升消息版本,例如从 `v1` 改为 `v2`。
|
||||
|
||||
当前接口一次发送给一名员工。向多人通知时逐人调用,并把每个人的钉钉 `userId` 放入幂等键:
|
||||
当前接口一次发送给一名员工。向多人通知时逐人调用,并把每个人的钉钉 `userId` 或已登记花名放入幂等键:
|
||||
|
||||
```js
|
||||
for (const recipient of recipients) {
|
||||
@ -485,6 +530,7 @@ GET /v1/notifications/{deliveryId}
|
||||
- [ ] 三项配置只存在于后端,仓库和前端产物中没有真实密钥。
|
||||
- [ ] 服务端时间已同步,请求签名路径不包含 `/notification-center` 反向代理前缀。
|
||||
- [ ] 使用本人真实钉钉 `userId` 能创建通知并取得投递 `id`。
|
||||
- [ ] 如使用花名,已登记花名能解析并发送,未登记花名会被明确拒绝。
|
||||
- [ ] 能使用投递 `id` 查询状态。
|
||||
- [ ] 通知标题自动包含正确项目名称,没有重复前缀或双标题。
|
||||
- [ ] 按钮进入已登记的 HTTPS 域名和正确业务记录。
|
||||
@ -528,7 +574,8 @@ projectCode:
|
||||
| --- | --- |
|
||||
| 前端直接调用通知中心 | 只允许业务后端调用 |
|
||||
| 从其他项目复制项目密钥 | 每个项目由通知中心单独生成密钥 |
|
||||
| 使用姓名、手机号或本地用户 ID 作为收件人 | 使用经过确认的钉钉 `userId` |
|
||||
| 把任意姓名直接当花名发送 | 只使用当前项目已由通知中心登记的准确花名 |
|
||||
| 同时发送 `recipientUserId` 和 `recipientAlias` | 两种收件人方式只能选择一种 |
|
||||
| 标题自己添加“【项目名】” | 只提交业务标题,由通知中心统一拼接 |
|
||||
| 签名路径包含公网 `/notification-center` 前缀 | 签名内部路径 `/v1/notifications` |
|
||||
| 先序列化签名,发送时再次序列化成不同正文 | 对实际发送的同一份 raw body 签名 |
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
- 业务负责人和技术负责人;
|
||||
- 通知场景和触发条件;
|
||||
- 收件人规则以及钉钉 `userId` 的数据来源;
|
||||
- 只能提供花名时,需要使用的准确花名清单;
|
||||
- 日常和峰值通知量;
|
||||
- 生产及测试跳转域名;
|
||||
- 是否需要钉钉 H5 免登;
|
||||
@ -35,7 +36,7 @@
|
||||
- `projectCode` 使用 2~40 位小写字母、数字、下划线或短横线,创建后不随中文名称改变。
|
||||
- 项目名称简短并适合出现在 `【项目名称】业务标题` 中。
|
||||
- 跳转域名为业务项目真实控制的 HTTPS 域名,不使用通配符,不包含协议、端口或路径。
|
||||
- 收件人规则明确,不能使用姓名猜测钉钉 `userId`。
|
||||
- 收件人规则明确;业务项目只能提供花名时,必须提交有限、准确的花名清单。
|
||||
- 预计发送量在当前串行投递能力可承受范围内;峰值明显较大时先做容量评估。
|
||||
- 普通通知默认不启用免登;只有点击后必须自动建立登录态时才审核免登配置。
|
||||
|
||||
@ -48,8 +49,9 @@
|
||||
1. 当前 `YYD_NOTIFICATION_PROJECTS_JSON` 能正常解析为数组。
|
||||
2. 数组中不存在相同 `code`。
|
||||
3. 申请的域名没有误写为协议、路径或非标准端口。
|
||||
4. 项目名称不会与已有项目造成用户无法分辨的通知来源。
|
||||
5. 如启用免登,回调地址属于该项目的 `allowedHosts`,回跳路径前缀采用最小范围。
|
||||
4. 同一项目的花名归一化后没有重复,每个花名只对应一个已确认的在职员工 `userId`。
|
||||
5. 项目名称不会与已有项目造成用户无法分辨的通知来源。
|
||||
6. 如启用免登,回调地址属于该项目的 `allowedHosts`,回跳路径前缀采用最小范围。
|
||||
|
||||
不要删除、重排或重新生成已有项目的密钥。新增项目时必须完整保留所有既有项目配置。
|
||||
|
||||
@ -60,10 +62,16 @@
|
||||
"code": "product_development",
|
||||
"name": "产品开发",
|
||||
"secret": "由通知中心维护人生成的独立随机密钥",
|
||||
"allowedHosts": ["product.example.com"]
|
||||
"allowedHosts": ["product.example.com"],
|
||||
"recipients": [
|
||||
{"alias": "海棠", "userId": "经过确认的钉钉userId"},
|
||||
{"alias": "无邪", "userId": "经过确认的钉钉userId"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`recipients` 是可选配置。业务项目能够直接提供 `recipientUserId` 时可以省略;只能提供花名时才登记。维护人必须从权威通讯录或已确认的账号绑定取得 `userId`,不能按姓名猜测。
|
||||
|
||||
启用免登时才增加:
|
||||
|
||||
```json
|
||||
@ -100,8 +108,8 @@
|
||||
1. 确认目标服务器和目录是通知中心,而不是其他业务项目。
|
||||
2. 备份通知中心 `.env`,备份文件包含密钥,权限必须限制为仅维护人可读。
|
||||
3. 备份通知中心 SQLite 数据目录,确保现有通知记录可恢复。
|
||||
4. 在 `YYD_NOTIFICATION_PROJECTS_JSON` 数组末尾新增项目,完整保留所有已有项目。
|
||||
5. 再次解析 JSON,确认项目代码唯一、密钥长度合格、域名格式正确。
|
||||
4. 在 `YYD_NOTIFICATION_PROJECTS_JSON` 数组末尾新增项目,完整保留所有已有项目和已有 `recipients`。
|
||||
5. 再次解析 JSON,确认项目代码唯一、密钥长度合格、域名格式正确、花名映射无重复。
|
||||
6. 使用当前正式发布方式重新加载通知中心配置。
|
||||
7. 验证本机 `/health` 与公网 `/notification-center/health`。
|
||||
8. 验证现有项目仍可鉴权,新项目能够通过签名请求进入通知队列。
|
||||
@ -142,8 +150,8 @@ YYD_NOTIFICATION_PROJECT_SECRET=实际项目独立密钥
|
||||
项目代码:<实际项目代码>
|
||||
项目密钥:将通过单独的安全渠道发送
|
||||
|
||||
请把三项配置只放在业务后端。通知收件人的钉钉 userId 不是接入密钥,
|
||||
应由你们的业务账号绑定或通讯录映射在每次发送时提供。
|
||||
请把三项配置只放在业务后端。收件人不是接入密钥:能够提供钉钉 userId 时传 recipientUserId;
|
||||
只能提供花名时传已登记的 recipientAlias,未登记花名不会发送。
|
||||
接入步骤请按《由壹点统一通知中心接入操作手册》执行。
|
||||
```
|
||||
|
||||
@ -151,7 +159,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=实际项目独立密钥
|
||||
|
||||
业务项目准备:
|
||||
|
||||
- 一名真实测试员工的钉钉 `userId`;
|
||||
- 一名真实测试员工的钉钉 `userId`,或已经登记的准确花名;
|
||||
- 一个只在该员工和业务负责人之间可见的低风险测试场景;
|
||||
- 一个属于项目白名单域名的 HTTPS 跳转地址;
|
||||
- 稳定且不会与生产业务事件冲突的测试幂等键。
|
||||
@ -166,6 +174,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=实际项目独立密钥
|
||||
6. 标题包含正确项目名称。
|
||||
7. 按钮只进入登记的项目域名和正确页面。
|
||||
8. 相同幂等键重复请求不产生第二条消息。
|
||||
9. 如使用花名,已登记花名能解析,未登记花名明确返回 `RECIPIENT_ALIAS_NOT_FOUND`。
|
||||
|
||||
首次真实发送只做能证明链路的一次必要调用;未获得真实发送授权时使用客户端示例测试,不向员工发送消息。
|
||||
|
||||
|
||||
@ -35,6 +35,9 @@
|
||||
| `NONCE_INVALID` | nonce 格式不符合要求 | 使用 16~100 位随机 URL 安全字符串 |
|
||||
| `SIGNATURE_INVALID` | 签名路径、正文或密钥不一致 | 使用实际 raw body 和内部 API path 重新计算 |
|
||||
| `NONCE_REPLAYED` | nonce 已经使用过 | 生成新 nonce,业务幂等键保持不变 |
|
||||
| `RECIPIENT_SELECTOR_CONFLICT` | 同时提交了 `recipientUserId` 和 `recipientAlias` | 两种收件人方式只保留一个 |
|
||||
| `RECIPIENT_ALIAS_INVALID` | 花名为空、过长或包含控制字符 | 提交不超过 50 字符的准确花名 |
|
||||
| `RECIPIENT_ALIAS_NOT_FOUND` | 当前项目没有登记该花名 | 申请登记花名或改用钉钉 `userId`,不得猜测重试 |
|
||||
| `ACTION_URL_INVALID` | 非 HTTPS、带非标准端口或域名不在白名单 | 修改链接或申请扩充域名白名单 |
|
||||
| `IDEMPOTENCY_KEY_INVALID` | 幂等键为空或过长 | 按统一格式生成稳定键 |
|
||||
| `MESSAGE_FIELDS_REQUIRED` | 收件人、标题或内容缺失 | 补齐字段 |
|
||||
|
||||
@ -35,9 +35,17 @@ const latest = await client.getDelivery(delivery.id)
|
||||
console.log('当前投递状态:', latest.status)
|
||||
```
|
||||
|
||||
如果业务项目只能提供花名,先由通知中心维护人登记花名映射,然后将发送参数中的 `recipientUserId` 替换为:
|
||||
|
||||
```js
|
||||
recipientAlias: '海棠'
|
||||
```
|
||||
|
||||
`recipientUserId` 与 `recipientAlias` 只能传一个;未登记花名会返回 `RECIPIENT_ALIAS_NOT_FOUND`,不会发送通知。
|
||||
|
||||
注意:
|
||||
|
||||
- 这段代码只能运行在后端。
|
||||
- 不要把三个环境变量使用 `NEXT_PUBLIC_`、`VITE_` 等前端公开前缀。
|
||||
- 业务调用失败后重试时复用原幂等键,但每次 HTTP 请求会自动生成新的 nonce。
|
||||
- 多人通知请逐人调用 `send`,并把收件人 `userId` 放入幂等键。
|
||||
- 多人通知请逐人调用 `send`,并把收件人 `userId` 或已登记花名放入幂等键。
|
||||
|
||||
@ -161,6 +161,9 @@ def project_with_preserved_login(code, name, allowed_hosts, required_redirect_pr
|
||||
prefixes.append(prefix)
|
||||
login['allowedRedirectPathPrefixes'] = prefixes
|
||||
project['login'] = login
|
||||
old_recipients = by_code.get(code, {}).get('recipients')
|
||||
if isinstance(old_recipients, list):
|
||||
project['recipients'] = list(old_recipients)
|
||||
return project
|
||||
|
||||
|
||||
|
||||
@ -21,6 +21,8 @@ test('生产发布面透传免登配置并保留已有项目 login', () => {
|
||||
assert.match(deploy, /old_login = by_code\.get\(code, \{\}\)\.get\('login'\)/)
|
||||
assert.match(deploy, /def project_with_preserved_login/)
|
||||
assert.match(deploy, /project\['login'\] = login/)
|
||||
assert.match(deploy, /old_recipients = by_code\.get\(code, \{\}\)\.get\('recipients'\)/)
|
||||
assert.match(deploy, /project\['recipients'\] = list\(old_recipients\)/)
|
||||
// 家具系统已接入生产;正式登记进初始化清单,防止重部署覆盖。
|
||||
assert.match(deploy, /'furniture_platform'/)
|
||||
assert.match(deploy, /'家具业务系统'/)
|
||||
|
||||
@ -9,7 +9,13 @@ import { createCanonicalSignature, sha256 } from '../signature'
|
||||
|
||||
const projectSecret = '123456789012345678901234'
|
||||
const projects = loadProjectConfigs(JSON.stringify([
|
||||
{ code: 'fixed_asset', name: '固定资产', secret: projectSecret, allowedHosts: ['asset.example.com'] },
|
||||
{
|
||||
code: 'fixed_asset',
|
||||
name: '固定资产',
|
||||
secret: projectSecret,
|
||||
allowedHosts: ['asset.example.com'],
|
||||
recipients: [{ alias: '海棠', userId: 'ding-user-haitang' }],
|
||||
},
|
||||
]))
|
||||
|
||||
const headers = (method: string, path: string, body: string, nonce = crypto.randomBytes(18).toString('base64url')) => {
|
||||
@ -57,6 +63,39 @@ test('通知中心验证签名、项目域名和幂等键', async () => {
|
||||
const secondPayload = await second.json() as any
|
||||
assert.equal(secondPayload.id, firstPayload.id)
|
||||
|
||||
const aliasBody = JSON.stringify({
|
||||
...JSON.parse(body),
|
||||
idempotencyKey: 'disposal_applied:53:haitang',
|
||||
recipientUserId: undefined,
|
||||
recipientAlias: '海棠',
|
||||
})
|
||||
const aliasResponse = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers: headers('POST', path, aliasBody), body: aliasBody,
|
||||
})
|
||||
assert.equal(aliasResponse.status, 202)
|
||||
const aliasPayload = await aliasResponse.json() as any
|
||||
assert.equal(aliasPayload.recipientUserId, 'ding-user-haitang')
|
||||
const aliasStored = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: aliasPayload.id } })
|
||||
assert.equal(aliasStored.recipientUserId, 'ding-user-haitang')
|
||||
|
||||
const unknownAliasBody = JSON.stringify({
|
||||
...JSON.parse(body), idempotencyKey: 'unknown-alias', recipientUserId: undefined, recipientAlias: '未登记花名',
|
||||
})
|
||||
const unknownAlias = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers: headers('POST', path, unknownAliasBody), body: unknownAliasBody,
|
||||
})
|
||||
assert.equal(unknownAlias.status, 422)
|
||||
assert.deepEqual(await unknownAlias.json(), { error: 'RECIPIENT_ALIAS_NOT_FOUND' })
|
||||
|
||||
const conflictingRecipientBody = JSON.stringify({
|
||||
...JSON.parse(body), idempotencyKey: 'conflicting-recipient', recipientAlias: '海棠',
|
||||
})
|
||||
const conflictingRecipient = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers: headers('POST', path, conflictingRecipientBody), body: conflictingRecipientBody,
|
||||
})
|
||||
assert.equal(conflictingRecipient.status, 422)
|
||||
assert.deepEqual(await conflictingRecipient.json(), { error: 'RECIPIENT_SELECTOR_CONFLICT' })
|
||||
|
||||
const badBody = JSON.stringify({ ...JSON.parse(body), actionUrl: 'https://evil.example.com/path' })
|
||||
const rejected = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, badBody), body: badBody })
|
||||
assert.equal(rejected.status, 422)
|
||||
|
||||
@ -16,6 +16,27 @@ test('每个项目必须使用独立且足够长的密钥', () => {
|
||||
])))
|
||||
})
|
||||
|
||||
test('项目可登记唯一花名并解析到钉钉 userId', () => {
|
||||
const project = loadProjectConfigs(JSON.stringify([{
|
||||
code: 'fixed_asset',
|
||||
name: '固定资产',
|
||||
secret: '123456789012345678901234',
|
||||
allowedHosts: ['asset.example.com'],
|
||||
recipients: [{ alias: '海棠', userId: 'ding-user-haitang' }],
|
||||
}])).get('fixed_asset')
|
||||
assert.equal(project?.recipientAliases.get('海棠'), 'ding-user-haitang')
|
||||
assert.throws(() => loadProjectConfigs(JSON.stringify([{
|
||||
code: 'fixed_asset',
|
||||
name: '固定资产',
|
||||
secret: '123456789012345678901234',
|
||||
allowedHosts: ['asset.example.com'],
|
||||
recipients: [
|
||||
{ alias: 'Alice', userId: 'ding-user-1' },
|
||||
{ alias: 'ALICE', userId: 'ding-user-2' },
|
||||
],
|
||||
}])), /收件人花名重复/)
|
||||
})
|
||||
|
||||
test('免登配置必须使用项目域名、标准 HTTPS 回调和内部路径白名单', () => {
|
||||
const project = loadProjectConfigs(JSON.stringify([{
|
||||
code: 'fixed_asset',
|
||||
|
||||
@ -3,6 +3,7 @@ export type ProjectConfig = {
|
||||
name: string
|
||||
secret: string
|
||||
allowedHosts: string[]
|
||||
recipientAliases: Map<string, string>
|
||||
login?: ProjectLoginConfig
|
||||
}
|
||||
|
||||
@ -14,6 +15,32 @@ export type ProjectLoginConfig = {
|
||||
|
||||
const clean = (value: unknown) => String(value || '').trim()
|
||||
|
||||
export const normalizeRecipientAlias = (value: unknown) => clean(value).normalize('NFKC').toLowerCase()
|
||||
|
||||
const parseRecipientAliases = (value: unknown, projectCode: string) => {
|
||||
if (value === undefined || value === null) return new Map<string, string>()
|
||||
if (!Array.isArray(value)) throw new Error(`项目 ${projectCode} 的 recipients 必须是数组`)
|
||||
const aliases = new Map<string, string>()
|
||||
for (const row of value) {
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||||
throw new Error(`项目 ${projectCode} 的 recipients 包含无效成员`)
|
||||
}
|
||||
const item = row as Record<string, unknown>
|
||||
const alias = clean(item.alias)
|
||||
const userId = clean(item.userId)
|
||||
if (!alias || alias.length > 50 || /[\u0000-\u001f\u007f]/.test(alias)) {
|
||||
throw new Error(`项目 ${projectCode} 包含无效收件人花名`)
|
||||
}
|
||||
if (!userId || userId.length > 128 || /[\u0000-\u001f\u007f]/.test(userId)) {
|
||||
throw new Error(`项目 ${projectCode} 的花名 ${alias} 缺少有效钉钉 userId`)
|
||||
}
|
||||
const key = normalizeRecipientAlias(alias)
|
||||
if (aliases.has(key)) throw new Error(`项目 ${projectCode} 的收件人花名重复: ${alias}`)
|
||||
aliases.set(key, userId)
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
const normalizeAllowedRedirectPrefix = (value: unknown, projectCode: string) => {
|
||||
const prefix = clean(value)
|
||||
if (!prefix.startsWith('/') || prefix.startsWith('//') || prefix.includes('\\') || /[\u0000-\u001f\u007f]/.test(prefix)) {
|
||||
@ -110,8 +137,9 @@ export const loadProjectConfigs = (raw = process.env.YYD_NOTIFICATION_PROJECTS_J
|
||||
}
|
||||
}
|
||||
if (projects.has(code)) throw new Error(`项目 code 重复: ${code}`)
|
||||
const recipientAliases = parseRecipientAliases(item.recipients, code)
|
||||
const login = parseProjectLogin(item.login, code, allowedHosts)
|
||||
projects.set(code, { code, name, secret, allowedHosts, login })
|
||||
projects.set(code, { code, name, secret, allowedHosts, recipientAliases, login })
|
||||
}
|
||||
return projects
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import express, { NextFunction, Request, Response } from 'express'
|
||||
import { buildH5EntryUrl, createH5EntryPage, getRedirectPathFromActionUrl, LoginInputError, validateRedirectPath } from './auth'
|
||||
import { getDingTalkConfig, getPublicBaseUrl, type ProjectConfig } from './config'
|
||||
import { getDingTalkConfig, getPublicBaseUrl, normalizeRecipientAlias, type ProjectConfig } from './config'
|
||||
import { DingTalkError, exchangeH5AuthCodeForUserId } from './dingtalk'
|
||||
import prisma from './prisma'
|
||||
import { createCanonicalSignature, safeEqual, sha256 } from './signature'
|
||||
@ -220,10 +220,21 @@ export const createServer = (projects: Map<string, ProjectConfig>, dependencies:
|
||||
try {
|
||||
const project = req.project!
|
||||
const idempotencyKey = clean(req.body?.idempotencyKey)
|
||||
const recipientUserId = clean(req.body?.recipientUserId)
|
||||
const requestedRecipientUserId = clean(req.body?.recipientUserId)
|
||||
const recipientAlias = clean(req.body?.recipientAlias)
|
||||
const title = clean(req.body?.title)
|
||||
const content = clean(req.body?.content)
|
||||
const actionTitle = clean(req.body?.actionTitle) || '查看详情'
|
||||
if (requestedRecipientUserId && recipientAlias) {
|
||||
return res.status(422).json({ error: 'RECIPIENT_SELECTOR_CONFLICT' })
|
||||
}
|
||||
if (recipientAlias.length > 50 || /[\u0000-\u001f\u007f]/.test(recipientAlias)) {
|
||||
return res.status(422).json({ error: 'RECIPIENT_ALIAS_INVALID' })
|
||||
}
|
||||
const recipientUserId = requestedRecipientUserId || project.recipientAliases.get(normalizeRecipientAlias(recipientAlias)) || ''
|
||||
if (recipientAlias && !recipientUserId) {
|
||||
return res.status(422).json({ error: 'RECIPIENT_ALIAS_NOT_FOUND' })
|
||||
}
|
||||
let destinationActionUrl: string
|
||||
let actionUrl: string
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user