From 7f0af506514b831dfda228c26be1dca6cbb63a25 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 28 Aug 2026 22:24:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=89=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E8=8A=B1=E5=90=8D=E5=8F=91=E9=80=81=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- README.md | 2 + docs/接入指南.md | 21 ++++++-- docs/新项目接入清单.md | 5 +- docs/消息规范.md | 2 +- docs/统一通知中心接入交接说明.md | 81 +++++++++++++++++++++++------- docs/通知中心新项目开通操作手册.md | 27 ++++++---- docs/错误与状态规范.md | 3 ++ examples/node/README.md | 10 +++- scripts/deploy-material-host.sh | 3 ++ src/__tests__/deployment.test.ts | 2 + src/__tests__/server.test.ts | 41 ++++++++++++++- src/__tests__/signature.test.ts | 21 ++++++++ src/config.ts | 30 ++++++++++- src/server.ts | 15 +++++- 15 files changed, 228 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index 8a26b8a..913d6f4 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 292f547..ce15070 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ 接入的核心边界:业务项目只保存自己的项目代码和 HMAC 密钥;钉钉 `AppKey`、`AppSecret`、`RobotCode` 始终只由通知中心持有。 +收件人支持两种方式:业务项目可以直接提交钉钉 `userId`,也可以提交当前项目已由通知中心登记的准确花名。花名会在中心解析为 `userId`;未登记或冲突时拒绝发送,不做模糊猜测。 + ## 安全边界 - 每个项目使用独立 HMAC 密钥。 diff --git a/docs/接入指南.md b/docs/接入指南.md index 5e161f5..7bf7260 100644 --- a/docs/接入指南.md +++ b/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}:{消息版本} diff --git a/docs/新项目接入清单.md b/docs/新项目接入清单.md index 4b99d26..a56e397 100644 --- a/docs/新项目接入清单.md +++ b/docs/新项目接入清单.md @@ -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` 能进入明确失败状态。 +- [ ] 如使用花名,已登记花名能发送,未登记花名返回明确错误且不会发送。 - [ ] 重复发送同一请求不会产生第二条消息。 - [ ] 同一幂等键提交不同内容会被拒绝。 - [ ] 最终标题使用 `【项目名称】业务主题`,项目来源正确且醒目。 diff --git a/docs/消息规范.md b/docs/消息规范.md index 2519669..114e7ee 100644 --- a/docs/消息规范.md +++ b/docs/消息规范.md @@ -124,7 +124,7 @@ - 面向中国员工统一使用北京时间。 - 用户可见时间推荐 `2026年7月31日 18:00`,不要显示秒和时区偏移。 -- 人员名称使用公司花名或业务系统已确认的展示名,但投递对象必须使用钉钉 `userId`。 +- 人员名称使用公司花名或业务系统已确认的展示名;投递对象使用钉钉 `userId`,或当前项目已由通知中心登记的准确花名。 - 不在消息里用邮箱、手机号或内部 ID 代替人员名称。 ## 7. 示例 diff --git a/docs/统一通知中心接入交接说明.md b/docs/统一通知中心接入交接说明.md index b62ff27..a749192 100644 --- a/docs/统一通知中心接入交接说明.md +++ b/docs/统一通知中心接入交接说明.md @@ -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 签名 | diff --git a/docs/通知中心新项目开通操作手册.md b/docs/通知中心新项目开通操作手册.md index 1c34765..729d428 100644 --- a/docs/通知中心新项目开通操作手册.md +++ b/docs/通知中心新项目开通操作手册.md @@ -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`。 首次真实发送只做能证明链路的一次必要调用;未获得真实发送授权时使用客户端示例测试,不向员工发送消息。 diff --git a/docs/错误与状态规范.md b/docs/错误与状态规范.md index f58dc5f..b14b9fd 100644 --- a/docs/错误与状态规范.md +++ b/docs/错误与状态规范.md @@ -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` | 收件人、标题或内容缺失 | 补齐字段 | diff --git a/examples/node/README.md b/examples/node/README.md index a4e53e1..31f0562 100644 --- a/examples/node/README.md +++ b/examples/node/README.md @@ -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` 或已登记花名放入幂等键。 diff --git a/scripts/deploy-material-host.sh b/scripts/deploy-material-host.sh index a0e5761..50c58ea 100644 --- a/scripts/deploy-material-host.sh +++ b/scripts/deploy-material-host.sh @@ -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 diff --git a/src/__tests__/deployment.test.ts b/src/__tests__/deployment.test.ts index f35f948..2a957a5 100644 --- a/src/__tests__/deployment.test.ts +++ b/src/__tests__/deployment.test.ts @@ -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, /'家具业务系统'/) diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 421cd5b..c769e41 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -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) diff --git a/src/__tests__/signature.test.ts b/src/__tests__/signature.test.ts index cdb5994..07fcc15 100644 --- a/src/__tests__/signature.test.ts +++ b/src/__tests__/signature.test.ts @@ -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', diff --git a/src/config.ts b/src/config.ts index 72b639b..9616fef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ export type ProjectConfig = { name: string secret: string allowedHosts: string[] + recipientAliases: Map 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() + if (!Array.isArray(value)) throw new Error(`项目 ${projectCode} 的 recipients 必须是数组`) + const aliases = new Map() + for (const row of value) { + if (!row || typeof row !== 'object' || Array.isArray(row)) { + throw new Error(`项目 ${projectCode} 的 recipients 包含无效成员`) + } + const item = row as Record + 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 } diff --git a/src/server.ts b/src/server.ts index 774e74f..a04bc37 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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, 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 {