From 1badf9ad68818c3b0d505ce4df9f273098fed572 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 29 Aug 2026 20:41:07 +0800 Subject: [PATCH] feat: simplify notification delivery contract --- .env.example | 5 +- README.md | 9 +- docker-compose.yml | 1 + docs/接入指南.md | 36 +++--- docs/新项目接入清单.md | 79 +++++-------- docs/消息规范.md | 175 ++++------------------------- docs/统一通知中心接入交接说明.md | 148 +++++++----------------- docs/通知中心新项目开通操作手册.md | 89 +++++---------- docs/错误与状态规范.md | 7 +- scripts/deploy-material-host.sh | 5 + scripts/git-build-deploy.ps1 | 6 + src/__tests__/admin.test.ts | 61 ++++++---- src/__tests__/deployment.test.ts | 4 + src/__tests__/server.test.ts | 44 +++++--- src/__tests__/signature.test.ts | 21 +++- src/__tests__/worker.test.ts | 32 ++++-- src/admin-project-registry.ts | 131 +++++++++++---------- src/config.ts | 14 ++- src/directory.ts | 94 ++++++++++++++++ src/server.ts | 119 +++++++++++++------- 20 files changed, 516 insertions(+), 564 deletions(-) create mode 100644 src/directory.ts diff --git a/.env.example b/.env.example index 69b71f9..d5ab602 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,14 @@ DINGTALK_CORP_ID= # 通知中心经公网访问的 HTTPS 基地址,可包含反向代理路径前缀。 YYD_NOTIFICATION_PUBLIC_BASE_URL=https://sc.example.com/notification-center +# 内部花名目录解析地址;默认指向素材平台的内部精确花名解析接口。 +YYD_NOTIFICATION_DIRECTORY_URL=https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve # 登录票据只能设置为 30~60 秒;无效值会收紧为 60 秒。 DINGTALK_LOGIN_TICKET_TTL_SECONDS=60 -# 每个项目必须使用不同密钥;allowedHosts 用于限制卡片跳转域名。 +# 每个项目必须使用不同密钥;allowedHosts 仅在需要免登包装时用于识别项目 HTTPS 站点。 # login 省略或 enabled=false 时保持原直接跳转,不启用免登中转。 +# recipients 仅保留给旧管理接口兼容;新发送链路中的 recipientAlias 会走素材平台内部目录解析,不依赖项目级预登记。 # 该环境变量只用于首次或补录导入:启动期和 /admin/runtime/import-env 都只导入缺失项目,不覆盖数据库里已存在项目、已轮换密钥或手工维护的 recipients。 # 生产环境请使用足够长的随机密钥,不要把真实值提交到 Git。 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"]},{"code":"script_monitor","name":"脚本监控","secret":"replace-with-script-monitor-random-secret","allowedHosts":["cp.example.com"]}] diff --git a/README.md b/README.md index c8a7eff..7f7c5e3 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ 接入的核心边界:业务项目只保存自己的项目代码和 HMAC 密钥;钉钉 `AppKey`、`AppSecret`、`RobotCode` 始终只由通知中心持有。 -收件人支持两种方式:业务项目可以直接提交钉钉 `userId`,也可以提交当前项目已由通知中心登记的准确花名。花名会在中心解析为 `userId`;未登记或冲突时拒绝发送,不做模糊猜测。 +收件人支持两种方式:业务项目可以直接提交钉钉 `userId`,也可以提交准确花名 `recipientAlias`。花名会由通知中心调用素材平台内部目录接口精确解析为 `userId`;找不到时拒绝发送,不做模糊猜测,也不再要求项目级收件人预登记。 ## 安全边界 - 每个项目使用独立 HMAC 密钥。 - 请求签名包含时间、nonce、HTTP 方法、路径和正文摘要。 - nonce 五分钟内只能使用一次。 -- 每个项目只能跳转到配置的 HTTPS 域名。 +- `actionUrl` 按业务项目提交值使用,不限制协议、公网/内网地址或项目域名白名单;只有启用免登且目标本身满足现有 HTTPS 登录流程时,才包装到统一 H5 入口。 - `projectCode + idempotencyKey` 唯一,防止业务重试造成重复通知。 - 项目无法读取其它项目的投递记录。 - 钉钉 `AppSecret` 只保存在通知中心;业务系统只用自己的项目 HMAC 密钥兑换登录票据。 @@ -54,7 +54,7 @@ - 运维可用 `POST /admin/runtime/refresh` 让运行时缓存重新读取数据库,不必重启进程。 - 如果管理员密钥或加密密钥缺失,服务会安全回退到 legacy 内存模式:继续直接使用 `YYD_NOTIFICATION_PROJECTS_JSON` 提供旧 `/v1/*` 能力,且不挂载 `/admin/*`。 -管理 API 使用独立管理员 Bearer Secret,与业务项目 HMAC 密钥隔离: +管理 API 使用独立管理员 Bearer Secret,与业务项目 HMAC 密钥隔离;该 Secret 同时复用于内部花名目录调用: - 主变量:`YYD_NOTIFICATION_ADMIN_API_SECRET` - 兼容旧变量:`YYD_NOTIFICATION_ADMIN_TOKEN` @@ -90,7 +90,7 @@ - 业务 `/v1/*`:64 KB - 管理 `/admin/*`:256 KB -普通业务接入接口 `POST /v1/notifications`、`GET /v1/notifications/:id`、`GET /v1/notifications` 与原签名协议保持兼容。 +普通业务接入接口 `POST /v1/notifications`、`GET /v1/notifications/:id`、`GET /v1/notifications` 与原签名协议保持兼容。消息字段仍是 `title / content / actionTitle / actionUrl` 四件套:通知中心只统一给标题加上 `【项目名称】` 来源前缀,正文、按钮文案和跳转地址都保持业务项目提交值。 生产环境建议只把容器端口绑定到 `127.0.0.1`,再由 Nginx 提供独立 HTTPS 域名。素材库和固定资产可能位于不同主机,因此不能把业务项目配置成通知中心的容器内网地址。 @@ -168,6 +168,7 @@ POST /v1/auth/tickets/redeem - `DINGTALK_CORP_ID`:统一钉钉应用所属企业 CorpId。 - `YYD_NOTIFICATION_PUBLIC_BASE_URL`:通知中心公网 HTTPS 基地址,生产为反向代理后的 `/notification-center` 地址。 +- `YYD_NOTIFICATION_DIRECTORY_URL`:素材平台内部花名目录解析接口地址;默认可设为 `https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve`。 - `DINGTALK_LOGIN_TICKET_TTL_SECONDS`:30~60 秒,默认 60 秒。 新建任务最多投递 5 次,钉钉 HTTP 429、`QpsLimit` 和其他明确限流统一归一为 `FLOW_CONTROLLED`,按 1、5、15、30 分钟退避;接入方只观察统一状态和错误码,不解析钉钉厂商错误码。中心默认将发送请求启动间隔限制为至少 250ms(`WORKER_SEND_MIN_INTERVAL_MS` 可在 100~5000ms 内调整),避免整点任务集中冲击下游。发送调用若在请求已发出后发生网络断开、超时、非限流 HTTP 408/5xx、钉钉成功响应缺少 `processQueryKey`,或进程在发送中途崩溃并遗留过期租约,会以 `DELIVERY_OUTCOME_UNKNOWN` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。 diff --git a/docker-compose.yml b/docker-compose.yml index bbdf657..d8cfc07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: DINGTALK_ROBOT_CODE: ${DINGTALK_ROBOT_CODE} DINGTALK_CORP_ID: ${DINGTALK_CORP_ID:-} YYD_NOTIFICATION_PUBLIC_BASE_URL: ${YYD_NOTIFICATION_PUBLIC_BASE_URL:-} + YYD_NOTIFICATION_DIRECTORY_URL: ${YYD_NOTIFICATION_DIRECTORY_URL:-https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve} DINGTALK_LOGIN_TICKET_TTL_SECONDS: ${DINGTALK_LOGIN_TICKET_TTL_SECONDS:-60} YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON} YYD_NOTIFICATION_ADMIN_API_SECRET: ${YYD_NOTIFICATION_ADMIN_API_SECRET} diff --git a/docs/接入指南.md b/docs/接入指南.md index 7bf7260..5437cb3 100644 --- a/docs/接入指南.md +++ b/docs/接入指南.md @@ -4,26 +4,16 @@ ## 1. 接入边界 -- 通知中心负责:统一机器人身份、项目隔离、签名认证、幂等防重、异步投递、有限重试、已读状态查询和跳转域名校验。 -- 业务项目负责:确定通知触发时机、提供准确的钉钉 `userId` 或当前项目已登记花名、生成稳定的幂等键、提交业务内容、保存通知中心返回的投递 ID。 +- 通知中心负责:统一机器人身份、项目隔离、签名认证、幂等防重、异步投递、有限重试、已读状态查询,以及在启用免登时按既有 HTTPS 规则包装可用的跳转地址。 +- 业务项目负责:确定通知触发时机、提供准确的钉钉 `userId` 或准确花名、生成稳定的幂等键、提交业务内容、保存通知中心返回的投递 ID。 - 钉钉 `AppKey`、`AppSecret`、`RobotCode` 只能保存在通知中心。 - 业务项目只能保存自己独有的项目 HMAC 密钥;禁止把密钥提交到 Git、发送到前端或写入浏览器环境变量。 -## 2. 接入前需要提供的信息 +## 2. 接入前只需提供项目名称 -向通知中心维护人员提交以下信息: +业务项目只需告诉通知中心一个用户能看懂的项目名称,例如“固定资产”。项目代码和项目密钥由通知中心自动生成;不需要提前提交负责人、使用场景、通知量、跳转域名、收件人方式或花名清单。 -| 字段 | 要求 | 示例 | -| --- | --- | --- | -| `projectCode` | 2~40 位小写字母、数字、下划线或短横线;创建后不再修改 | `fixed_asset` | -| `projectName` | 用户能理解的简短产品名称 | `固定资产` | -| `allowedHosts` | 卡片按钮允许跳转的 HTTPS 域名,不含协议、端口和路径 | `asset.jiansun.vip` | -| 负责人 | 至少一名业务负责人和一名技术负责人 | 姓名及联系方式 | -| 使用场景 | 说明触发条件、接收人和预计发送量 | `资产报废待审批` | -| 收件人方式 | 能提供钉钉 `userId`,还是只能提供花名;花名方式需提交准确清单 | `花名:海棠、无邪` | -| 是否需要免登 | 默认否;只有卡片进入项目后需要自动建立登录态时才开启 | `否` | - -通知中心维护人员完成登记后,通过安全渠道提供: +通知中心完成创建后,通过安全渠道提供: - `YYD_NOTIFICATION_PROJECT_CODE` - `YYD_NOTIFICATION_PROJECT_SECRET` @@ -54,7 +44,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥 } ``` -只能提供花名时,将 `recipientUserId` 替换为当前项目已经登记的 `recipientAlias`: +只能提供花名时,将 `recipientUserId` 替换为准确 `recipientAlias`: ```json { @@ -73,11 +63,11 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥 | --- | --- | | `idempotencyKey` | 必填,最长 160 字符;同一业务事件、同一收件人必须稳定不变 | | `recipientUserId` | 与 `recipientAlias` 二选一;企业通讯录中的钉钉 `userId` | -| `recipientAlias` | 与 `recipientUserId` 二选一;当前项目由通知中心预先登记的准确花名,最长 50 字符 | +| `recipientAlias` | 与 `recipientUserId` 二选一;通知中心会调用素材平台内部目录精确解析,最长 50 字符 | | `title` | 必填,最长 100 字符;只写业务主题,不要添加 `【项目名】`,拼接项目名后的最终标题不能超过 120 字符 | -| `content` | 必填,Markdown,业务正文最长 4800 字符;不要自行添加首行标题,各字段之间必须空一行,通知中心加上统一首行后的最终正文也不能超过 4800 字符 | -| `actionTitle` | 可选,最长 20 字符,默认“查看详情” | -| `actionUrl` | 必填,标准 443 端口的完整 HTTPS 地址,域名必须在项目白名单内 | +| `content` | 必填,Markdown 或纯文本都可以;正文最长 4800 字符,通知中心不会再自动插入标题或固定说明 | +| `actionTitle` | 可选,最长 20 字符;有 `actionUrl` 但未传时默认“查看详情” | +| `actionUrl` | 可选;非空时按业务项目提交值使用,通知中心不限制协议、公网/内网地址或项目域名白名单 | 通知中心会自动生成最终标题: @@ -91,7 +81,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥 【固定资产】资产报废待审批 ``` -卡片正文首行也由通知中心统一生成为 `### 【项目名称】业务标题`。为兼容已接入项目,业务正文如果仍以三级 Markdown 标题(`###`)开头,通知中心会先移除该旧标题,再生成统一首行,避免出现双标题;其它级别标题按正文原样保留。 +通知中心不会再改写正文首行。业务项目提交什么正文,钉钉卡片就发送什么正文。 ## 5. 请求签名 @@ -119,7 +109,7 @@ timestamp\nnonce\nHTTP_METHOD\nPATH\nSHA256(rawBody) ## 6. 幂等和多人通知 -当前接口一次只接收一名收件人。向多人发送时,业务项目应逐人创建通知,每个人使用独立但稳定的幂等键。花名方式把下面的 `recipientUserId` 替换成已登记花名: +当前接口一次只接收一名收件人。向多人发送时,业务项目应逐人创建通知,每个人使用独立但稳定的幂等键。花名方式把下面的 `recipientUserId` 替换成准确花名: ```text {事件类型}:{业务记录ID}:{recipientUserId}:{消息版本} @@ -166,7 +156,7 @@ GET /v1/notifications/{id} 1. 本人收到单聊通知。 2. 另一名真实员工收到单聊通知。 3. 最终标题包含正确的项目来源。 -4. 卡片按钮只能跳转到项目白名单域名。 +4. 标题包含正确的项目来源,正文保持业务项目提交内容。 5. 使用相同幂等键重复请求不会重复发送。 6. 无效 `userId` 能得到明确失败状态。 7. 创建接口返回 `202` 后,最终状态能进入 `sent`、`unread` 或 `read`。 diff --git a/docs/新项目接入清单.md b/docs/新项目接入清单.md index a56e397..4fdb770 100644 --- a/docs/新项目接入清单.md +++ b/docs/新项目接入清单.md @@ -1,62 +1,41 @@ # 新项目接入清单 -## A. 业务项目提交 +## 1. 开通项目 -- [ ] 项目有明确的业务负责人和技术负责人。 -- [ ] `projectCode` 使用稳定的英文标识,创建后不会随产品中文名变化。 -- [ ] 项目中文名称简短,适合显示在钉钉标题中。 -- [ ] 已列出全部生产卡片跳转域名,且均为标准 HTTPS 域名。 -- [ ] 已说明通知场景、触发条件、收件人和预计发送量。 -- [ ] 已确认项目使用钉钉 `userId` 还是已登记花名;花名方式已提交准确清单。 -- [ ] 已确认是否需要钉钉 H5 免登;普通通知默认不启用。 +- [ ] 业务项目只提供一个可识别的项目名称。 +- [ ] 通知中心自动生成项目代码和项目密钥。 +- [ ] 将通知中心地址、项目代码、项目密钥通过安全渠道交给业务项目后端。 -## B. 通知中心配置 +不需要提前填写负责人、通知场景、预计发送量、跳转域名、收件人方式或花名清单。 -- [ ] 在 `YYD_NOTIFICATION_PROJECTS_JSON` 中新增独立项目配置。 -- [ ] 为项目生成独立高强度随机 HMAC 密钥,不复用其他项目密钥。 -- [ ] 通过安全渠道向业务项目交付项目代码、密钥和通知中心地址。 -- [ ] 配置 `allowedHosts`,不使用通配符,不包含协议、端口或路径。 -- [ ] 如使用花名,`recipients` 中每个花名只绑定一个已确认的在职员工 `userId`,且归一化后无重复。 -- [ ] 如启用免登,配置精确 `callbackUrl` 和最小化的回跳路径前缀。 -- [ ] 备份生产配置后再发布,并验证 `/health`。 +## 2. 业务项目配置 -## C. 业务项目实现 +```dotenv +YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center +YYD_NOTIFICATION_PROJECT_CODE=通知中心生成的项目代码 +YYD_NOTIFICATION_PROJECT_SECRET=通知中心生成的项目密钥 +``` -- [ ] 三项连接配置仅存在于服务端环境变量。 -- [ ] 签名使用实际发送的原始 JSON 正文。 -- [ ] 签名路径不包含域名和反向代理前缀。 -- [ ] 每次请求生成新 nonce,服务器时间已经同步。 -- [ ] 幂等键包含事件、业务记录、收件人和消息版本。 -- [ ] 多人通知逐人发送,并为每名收件人生成独立幂等键。 -- [ ] 每条通知只提交 `recipientUserId` 或 `recipientAlias` 其中一个。 -- [ ] 保存通知中心返回的投递 `id`。 -- [ ] `202` 不被当成“已送达”。 -- [ ] 标题、正文和按钮符合 [消息规范](./消息规范.md)。 -- [ ] 前端、日志和错误响应均不会暴露项目密钥。 +- [ ] 三项配置只保存在业务后端,不进入前端或 Git。 +- [ ] 请求使用项目密钥生成 HMAC 签名。 +- [ ] 每次请求生成新 nonce,并使用稳定的 `idempotencyKey` 防止重复通知。 -## D. 联调验证 +## 3. 每次发送 -- [ ] 项目负责人收到真实单聊。 -- [ ] 至少另一名真实员工收到真实单聊。 -- [ ] 无效钉钉 `userId` 能进入明确失败状态。 -- [ ] 如使用花名,已登记花名能发送,未登记花名返回明确错误且不会发送。 -- [ ] 重复发送同一请求不会产生第二条消息。 -- [ ] 同一幂等键提交不同内容会被拒绝。 -- [ ] 最终标题使用 `【项目名称】业务主题`,项目来源正确且醒目。 -- [ ] 正文首行与最终标题一致,时间紧随其后,各字段之间有空行。 -- [ ] 正文第一眼能看出业务主题、发生时间、核心内容和需要采取的动作。 -- [ ] 按钮名称明确,跳转到正确的白名单域名。 -- [ ] 能查询到 `sent / unread / read / failed` 状态。 -- [ ] 如启用免登,票据只能消费一次,且业务项目重新核验本地账号和权限。 +- [ ] `recipientUserId` 与 `recipientAlias` 二选一。 +- [ ] 花名由通知中心从 `gongxiangp2` 花名册解析;找不到对应钉钉 `userId` 时不会推送。 +- [ ] 业务项目提交标题和正文;通知中心只给标题增加 `【项目名称】` 前缀。 +- [ ] 按钮文字和跳转地址均为可选,由业务项目决定。 +- [ ] 跳转地址按业务项目提交值使用,不限制 HTTPS、公网域名或项目域名白名单。 +- [ ] 保存通知中心返回的投递 ID;`202` 只表示已接收,不表示已经送达。 -## E. 上线与交接 +## 4. 上线验证 -- [ ] 已记录通知中心配置变更和业务项目接入版本。 -- [ ] 已明确发生失败时由谁处理。 -- [ ] 已配置必要的失败日志或监控,不记录敏感凭证。 -- [ ] 已完成生产环境双人真实通知验证。 -- [ ] 已把接入文档路径交给项目后续维护人员或开发 Agent。 +- [ ] 正常收件人能够收到通知,标题中的项目名称正确。 +- [ ] 花名不存在时返回明确错误,且没有产生投递记录。 +- [ ] 相同项目和幂等键不会产生重复通知。 +- [ ] 一个项目不能查询另一个项目的投递记录。 +- [ ] HTTP、HTTPS 或内网跳转地址按业务项目提交内容进入通知。 +- [ ] 发送失败时能查询到明确状态和错误原因。 -只有 A~E 全部完成,才视为正式接入“由壹点”统一通知。 - -推荐交接入口:先阅读仓库根目录 `README.md`,再依次阅读本清单、接入指南和消息规范;不要从其他业务项目复制钉钉应用凭证或机器人直连代码。 +以上通过即可完成接入,不增加其它前置表单或审批门槛。 diff --git a/docs/消息规范.md b/docs/消息规范.md index 114e7ee..ff36e59 100644 --- a/docs/消息规范.md +++ b/docs/消息规范.md @@ -1,172 +1,37 @@ -# 由壹点通知消息规范 +# 消息规范 -统一机器人已经说明“由壹点发出的”,每条消息不再重复品牌名。项目名称解决“消息来自哪里”,业务标题、时间和首屏内容解决“发生了什么、用户现在要做什么”。所有接入项目必须遵守本规范。 - -## 1. 标题规则 - -最终标题由通知中心统一拼接: +通知中心只统一一件可见样式:在业务标题前增加项目名称,让收件人知道通知来源。 ```text 【项目名称】业务标题 ``` -例如: +例如外部项目提交: -- `【固定资产】资产报废待审批` -- `【素材库】新的考核调研待填写` -- `【产品开发】任务即将到期` - -业务项目只提交业务标题,例如: - -- `新的考核调研待填写` -- `资产报废待审批` -- `产品任务即将到期` - -通知中心根据 `projectCode` 生成 `【项目名称】`,业务项目不能自行拼接。禁止: - -- 自己添加 `【项目名称】`、机器人名称或其它来源前缀。 -- 使用“通知”“消息提醒”“统一消息测试”等无法表达任务的空泛标题。 -- 在标题中堆积人员、日期、编号等详细字段。 -- 使用多个 emoji 或连续感叹号制造紧迫感。 - -## 2. 内容结构 - -通知中心会把卡片正文首行统一生成为: - -```markdown -### 【项目名称】业务标题 +```json +{ + "title": "脚本执行失败", + "content": "数据同步任务执行失败,请查看日志。", + "actionTitle": "查看详情", + "actionUrl": "http://192.168.58.20:8080/jobs/123" +} ``` -业务项目不要再提交自己的 Markdown 首行标题。为兼容旧项目,通知中心只会移除正文开头已有的三级 Markdown 标题(`###`),再生成统一首行;其它级别标题按正文原样保留。 - -消息第一屏按以下顺序组织: - -1. 项目名称和业务主题,由通知中心统一生成。 -2. 申请时间、处理时间或截止时间。 -3. 最重要的业务对象和相关人员。 -4. 原因、结果或异常说明。 -5. 一句必要的行动提示。 - -业务项目推荐提交: - -```markdown -**截止时间:** 2026年7月31日 18:00 - -**调研:** 2026年7月 · 跨部门协同 - -**评价对象:** 无邪 - -> 请在截止时间前完成评分。 -``` - -通知中心最终渲染为: - -```markdown -### 【素材库】新的考核调研待填写 - -**截止时间:** 2026年7月31日 18:00 - -**调研:** 2026年7月 · 跨部门协同 - -**评价对象:** 无邪 - -> 请在截止时间前完成评分。 -``` - -每个字段之间必须保留一个空行。不要只使用单换行;钉钉会把连续字段合并成一段,明显降低阅读性。 - -不要写成一整段: +最终标题为: ```text -你有一份新的考核调研需要填写。调研:2026年7月……对象……截止…… +【脚本监控】脚本执行失败 ``` -## 3. 状态用语 +正文、按钮文字和跳转地址均由业务项目决定,通知中心不再插入固定标题、时间、字段顺序或排版模板。 -优先使用用户可以立即理解的动作词: +字段规则: -| 场景 | 推荐状态 | +| 字段 | 规则 | | --- | --- | -| 需要填写 | `待你填写` | -| 需要审批 | `待你审批` | -| 任务完成 | `处理完成` | -| 操作失败 | `处理失败` | -| 即将到期 | `即将到期` | -| 仅告知 | `结果已更新` | +| `title` | 必填,业务项目只写业务标题;通知中心自动增加项目名称前缀 | +| `content` | 必填,纯文本或 Markdown 均可;按业务项目内容发送 | +| `actionTitle` | 可选;有跳转地址但未填写时默认“查看详情” | +| `actionUrl` | 可选;按业务项目提交值使用,不限制协议、公网/内网地址或项目域名白名单 | -不要使用仅开发人员能理解的内部状态码,例如 `pending`、`processed`、`job failed`。 - -## 4. 按钮规则 - -按钮必须描述点击后的动作: - -- `立即填写` -- `立即审批` -- `查看结果` -- `查看任务` -- `处理异常` - -避免使用没有方向的“点击这里”“打开系统”“查看详情”,除非确实无法给出更具体的动作。 - -每条通知只保留一个主要按钮。通知不是业务页面,不在消息中堆叠多个操作入口。 - -## 5. 信息取舍 - -消息中只放帮助用户判断和行动的信息: - -- 保留:业务对象、发起人、截止时间、核心结果、失败原因。 -- 省略:数据库 ID、内部状态码、部署版本、调试说明、完整操作日志。 -- 禁止:项目密钥、访问令牌、身份证号、完整手机号、敏感人事信息和无需展示的内部备注。 - -如果内容较长,消息只展示摘要,完整信息通过白名单内的业务页面查看。 - -## 6. 时间和名称 - -- 面向中国员工统一使用北京时间。 -- 用户可见时间推荐 `2026年7月31日 18:00`,不要显示秒和时区偏移。 -- 人员名称使用公司花名或业务系统已确认的展示名;投递对象使用钉钉 `userId`,或当前项目已由通知中心登记的准确花名。 -- 不在消息里用邮箱、手机号或内部 ID 代替人员名称。 - -## 7. 示例 - -### 考核调研 - -```text -标题:新的考核调研待填写 -内容: -**截止时间:** 2026年7月31日 18:00 - -**调研:** 2026年7月 · 工作配合度 - -**评价对象:** 无邪 - -> 请在截止时间前完成评分。 - -按钮:立即填写 -``` - -最终显示标题:`【素材库】新的考核调研待填写` - -最终卡片首行:`### 【素材库】新的考核调研待填写` - -### 固定资产 - -```text -标题:资产报废待审批 -内容: -**申请时间:** 2026年7月16日 14:30 - -**资产:** 摄影灯 A-102 - -**申请人:** 无邪 - -**报废原因:** 设备损坏,无法继续维修 - -> 请核对资产及配件明细后处理。 - -按钮:立即审批 -``` - -最终显示标题:`【固定资产】资产报废待审批` - -最终卡片首行:`### 【固定资产】资产报废待审批` +业务项目应自行保证内容清楚、按钮可理解、跳转地址适合自己的使用环境。通知中心只负责可靠接收、项目隔离、分发和错误返回。 diff --git a/docs/统一通知中心接入交接说明.md b/docs/统一通知中心接入交接说明.md index a749192..8110dfa 100644 --- a/docs/统一通知中心接入交接说明.md +++ b/docs/统一通知中心接入交接说明.md @@ -18,12 +18,12 @@ YYD_NOTIFICATION_PROJECT_SECRET=通知中心正式生成的项目独立密钥 | 你现在的情况 | 下一步 | | --- | --- | -| 三项配置都没有 | 先填写本文第五节的接入申请,不要自己猜项目代码或生成密钥 | +| 三项配置都没有 | 只把项目名称告诉通知中心维护人,系统会自动生成项目代码和密钥 | | 只有通知中心地址 | 仍未开通,等待通知中心维护人登记项目并发放代码和密钥 | | 已有地址、项目代码和项目密钥 | 从本文第七节开始配置业务后端 | -| 业务系统只有员工花名 | 在接入申请中提交需使用的花名,由通知中心维护人登记花名映射 | -| 还不知道通知发给谁 | 先按本文第六节选择 `userId` 或已登记花名的发送方式 | -| 只想给一个人发送联调消息 | 准备该员工的真实钉钉 `userId`,或确认其花名已经登记;这不是接入密钥 | +| 业务系统只有员工花名 | 发送时直接提交准确花名,通知中心会查询 `gongxiangp2` 花名册 | +| 还不知道通知发给谁 | 发送前准备准确花名或钉钉 `userId` | +| 只想给一个人发送联调消息 | 发送时填写该员工的准确花名或钉钉 `userId`;这不是接入配置 | 请勿把“海棠”或“海棠的钉钉 `userId`”之类的收件人信息,与上述三项接入配置混为一谈。前三项用于证明“哪个项目在调用”;`recipientAlias` 或 `recipientUserId` 用于说明“这一次通知发给谁”。 @@ -57,109 +57,48 @@ YYD_NOTIFICATION_PROJECT_SECRET=通知中心正式生成的项目独立密钥 | 角色 | 必须完成的工作 | | --- | --- | -| 业务项目负责人 | 说明通知场景、收件人规则、跳转域名、预计发送量,指定开发和验收人员 | -| 业务项目开发 | 在业务后端保存配置,接入客户端,提供正确的收件人 `userId`,实现发送和必要的状态查询 | -| 通知中心维护人 | 审核申请、登记项目、生成独立密钥、配置跳转域名白名单、安全交付三项配置 | +| 业务项目负责人 | 提供项目名称 | +| 业务项目开发 | 在业务后端保存三项配置,发送收件人、标题、正文和可选跳转信息 | +| 通知中心维护人 | 创建项目,安全交付通知中心地址、项目代码和项目密钥 | | 测试或验收人员 | 使用真实员工验证发送、防重、状态和按钮跳转,不接触项目密钥 | -## 三、开始开发前先确认五件事 +## 三、开始开发前只需确认发送内容 1. 哪个业务事件触发通知,例如“审批单创建”或“任务即将到期”。 -2. 通知发给谁,业务系统能够提供钉钉 `userId`,还是只能提供花名。 -3. 用户点击通知后进入哪个生产 HTTPS 域名。 -4. 同一事件是否可能重复执行,以及如何使用稳定的幂等键防止重复通知。 -5. 点击通知后是否必须自动建立业务登录态。普通通知默认不启用免登。 +2. 通知发给谁:准确花名或钉钉 `userId` 二选一。 +3. 标题和正文写什么。 +4. 是否需要按钮;需要时提交按钮文字和跳转地址。 +5. 为同一业务事件生成稳定的幂等键,避免重复通知。 -如果业务系统只能提供花名,必须在接入申请中列出花名,由通知中心维护人将其登记到当前项目。未登记的姓名、手机号、邮箱和业务系统内部 ID 都不能直接作为通知收件人。 +花名无需提前登记到项目。通知中心会在发送时查询 `gongxiangp2` 已有花名册;找不到对应钉钉 `userId` 时不会推送,并返回明确错误。 ## 四、正确的接入顺序 必须按顺序执行,不要跳过项目开通直接编写生产调用: ```text -第 1 步:业务项目填写接入申请 +第 1 步:业务项目提供项目名称 ↓ -第 2 步:通知中心维护人审核名称、代码和跳转域名 +第 2 步:通知中心自动生成项目代码和项目密钥 ↓ -第 3 步:通知中心维护人登记项目并生成独立密钥 +第 3 步:维护人安全交付 URL、PROJECT_CODE、PROJECT_SECRET ↓ -第 4 步:维护人安全交付 URL、PROJECT_CODE、PROJECT_SECRET +第 4 步:业务后端配置三项环境变量并接入客户端 ↓ -第 5 步:业务后端配置三项环境变量并接入客户端 - ↓ -第 6 步:使用一名真实员工完成首次发送和状态查询 - ↓ -第 7 步:生产环境完成两名真实员工验收 +第 5 步:使用一名真实员工完成首次发送和状态查询 ``` 业务项目不能自行宣布开通完成。没有收到正式项目代码和项目密钥时,说明通知中心还没有为该项目建立调用身份。 -## 五、填写并提交接入申请 +## 五、开通项目 -复制下面的模板,填写后交给通知中心维护人。业务项目不需要也不能自己提供项目密钥。 +业务项目只需发送一行信息: ```text -【统一通知中心接入申请】 - -项目中文名称: -建议 projectCode: -业务负责人及联系方式: -技术负责人及联系方式: - -通知场景: -触发条件: -收件人规则: -收件人标识方式:钉钉 userId / 花名 -钉钉 userId 的数据来源(能够提供时填写): -需要通知中心登记的花名清单(仅花名方式填写): -预计每日通知量: -预计峰值通知量: - -生产跳转域名(只写域名,不写协议和路径): -测试跳转域名(如有): -是否需要点击后免登:否 / 是 - -计划联调日期: -计划上线日期: +项目名称:产品开发平台 ``` -不会填写时可参考以下完整示例: - -```text -【统一通知中心接入申请】 - -项目中文名称:产品开发平台 -建议 projectCode:product_development -业务负责人及联系方式:张三 / 钉钉 -技术负责人及联系方式:李四 / 钉钉 - -通知场景:产品任务即将到期 -触发条件:任务距截止时间不足 24 小时且仍未完成 -收件人规则:任务负责人 -收件人标识方式:花名 -钉钉 userId 的数据来源:无 -需要通知中心登记的花名清单:海棠、无邪 -预计每日通知量:30 条 -预计峰值通知量:每分钟 5 条 - -生产跳转域名:product.example.com -测试跳转域名:product-test.example.com -是否需要点击后免登:否 - -计划联调日期:2026年9月1日 -计划上线日期:2026年9月5日 -``` - -登记字段要求: - -| 字段 | 要求 | 示例 | -| --- | --- | --- | -| `projectCode` | 2~40 位小写字母、数字、下划线或短横线;创建后不再修改 | `fixed_asset` | -| 项目名称 | 用户能看懂的简短名称,会显示在通知标题中 | `固定资产` | -| 跳转域名 | 标准 HTTPS 域名,不含协议、端口和路径;不使用通配符 | `asset.example.com` | -| 通知场景 | 写清触发条件、接收人和需要采取的动作 | `资产报废申请创建后通知审批人` | -| 收件人方式 | 优先使用钉钉 `userId`;只能提供花名时,提交需要登记的准确花名清单 | `花名:海棠、无邪` | -| 预计发送量 | 至少说明日常量和峰值 | `日均 50 条,峰值每分钟 10 条` | +通知中心管理面板会自动生成项目代码和项目密钥。项目代码如有特殊需要,可以在高级设置中自定义;普通接入无需填写。 通知中心维护人完成登记后,应通过安全渠道分别交付: @@ -198,11 +137,11 @@ YYD_NOTIFICATION_PROJECT_SECRET=项目独有密钥 | 数据 | 从哪里取得 | 示例 | | --- | --- | --- | | `recipientUserId` | 业务项目的员工账号绑定或权威通讯录映射 | 海棠对应的钉钉 `userId` | -| `recipientAlias` | 通知中心已为当前项目登记的准确花名 | `海棠` | +| `recipientAlias` | 通知中心会通过素材平台内部目录解析的准确花名 | `海棠` | | `idempotencyKey` | 业务项目根据事件、记录、收件人和版本生成 | `task_due:53:user-id:v1` | | `title` | 当前业务事件 | `产品任务即将到期` | | `content` | 当前业务记录 | 截止时间、任务名称、负责人 | -| `actionUrl` | 当前业务记录的 HTTPS 页面 | `https://product.example.com/tasks/53` | +| `actionUrl` | 当前业务记录页面;可为空,非空时原样透传 | `http://10.0.0.8:8080/tasks/53` | ### 6.3 两种收件人方式怎么选择 @@ -213,20 +152,19 @@ YYD_NOTIFICATION_PROJECT_SECRET=项目独有密钥 1. 已有员工账号绑定:读取当前收件人账号保存的 `dingtalkUserId` 或等价字段。 2. 已有企业通讯录同步:从项目已经同步的钉钉员工记录中读取 `userId`。 3. 仅做首次联调:由负责人确认一名真实员工,并通过公司现有的权威人员映射取得该员工 `userId`。 -4. 完全没有员工与钉钉身份对应关系:改用已登记花名,或者先补齐账号绑定或通讯录同步。 +4. 完全没有员工与钉钉身份对应关系:改用准确花名,或者先补齐账号绑定或通讯录同步。 如果项目只能提供花名,使用 `recipientAlias`: -- 花名必须由通知中心维护人预先登记,并绑定到经过确认的钉钉 `userId`; -- 每个项目维护自己的可用花名,不会读取其他项目的映射; -- 花名会进行空格、大小写和全半角归一化,但不会模糊猜测; -- 未登记花名返回 `RECIPIENT_ALIAS_NOT_FOUND`,不会发送通知; -- 同一项目不能登记两个归一化后相同的花名; +- 花名由通知中心调用素材平台内部目录精确解析到钉钉 `userId`; +- 不做模糊猜测,不按姓名、手机号、邮箱或业务 ID 猜人; +- 找不到对应 `userId` 时返回 `RECIPIENT_ALIAS_NOT_FOUND`,不会发送通知; +- 目录暂时不可用时返回 `503 RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE`,同样不会入队; - 同一请求只能传 `recipientUserId` 或 `recipientAlias` 其中一个。 -如果正式业务永远只通知固定负责人,可以登记其花名或把 `userId` 保存在业务项目后端;如果收件人随业务记录变化,就从业务记录读取对应的已登记花名或 `userId`,不能把联调人员写死在代码里。 +如果正式业务永远只通知固定负责人,可以把其花名或 `userId` 保存在业务项目后端;如果收件人随业务记录变化,就从业务记录读取对应花名或 `userId`,不能把联调人员写死在代码里。 -通知中心只解析当前项目已经登记的准确花名,不会根据任意姓名模糊猜人,也不会把某个测试人员设置成所有项目的默认收件人。 +通知中心只做精确花名解析,不会根据任意姓名模糊猜人,也不会把某个测试人员设置成所有项目的默认收件人。 ## 七、在业务项目中保存配置 @@ -296,7 +234,7 @@ const delivery = await notificationClient.send({ await saveNotificationDeliveryId(disposal.id, recipientUserId, delivery.id) ``` -业务项目只能提供花名时,改传已经登记的 `recipientAlias`,不要同时传 `recipientUserId`: +业务项目只能提供花名时,改传准确 `recipientAlias`,不要同时传 `recipientUserId`: ```js const recipientAlias = task.ownerAlias @@ -399,11 +337,11 @@ signature = HMAC_SHA256_HEX(projectSecret, canonical) | --- | --- | --- | | `idempotencyKey` | 是 | 最长 160 字符;同一业务事件和同一收件人必须稳定不变 | | `recipientUserId` | 二选一 | 企业通讯录中的钉钉 `userId`;与 `recipientAlias` 只能传一个 | -| `recipientAlias` | 二选一 | 当前项目已登记的准确花名,最长 50 字符;与 `recipientUserId` 只能传一个 | +| `recipientAlias` | 二选一 | 通知中心会通过素材平台内部目录精确解析的准确花名,最长 50 字符;与 `recipientUserId` 只能传一个 | | `title` | 是 | 只写业务主题,最长 100 字符;不要自己添加项目名或“由壹点”前缀 | -| `content` | 是 | Markdown 业务正文;不要自己写首行标题;字段之间保留空行 | +| `content` | 是 | Markdown 或纯文本业务正文;通知中心不会再自动插入标题或固定说明 | | `actionTitle` | 否 | 最长 20 字符,建议写明确动作,例如“立即审批” | -| `actionUrl` | 是 | 标准 443 端口的完整 HTTPS 地址,域名必须已登记在项目白名单中 | +| `actionUrl` | 否 | 可为空;非空时按原样透传,支持 HTTP、HTTPS、内网 IP/域名和端口 | 通知中心会根据项目配置统一生成: @@ -434,7 +372,7 @@ asset_disposal_created:53:16408293139985728:v1 - 相同幂等键但内容不同会返回 `409 IDEMPOTENCY_CONFLICT`,必须修复业务逻辑,不能随机换键绕过。 - 确实需要发送一条内容更新后的新通知时,经过业务确认后提升消息版本,例如从 `v1` 改为 `v2`。 -当前接口一次发送给一名员工。向多人通知时逐人调用,并把每个人的钉钉 `userId` 或已登记花名放入幂等键: +当前接口一次发送给一名员工。向多人通知时逐人调用,并把每个人的钉钉 `userId` 或准确花名放入幂等键: ```js for (const recipient of recipients) { @@ -530,10 +468,10 @@ GET /v1/notifications/{deliveryId} - [ ] 三项配置只存在于后端,仓库和前端产物中没有真实密钥。 - [ ] 服务端时间已同步,请求签名路径不包含 `/notification-center` 反向代理前缀。 - [ ] 使用本人真实钉钉 `userId` 能创建通知并取得投递 `id`。 -- [ ] 如使用花名,已登记花名能解析并发送,未登记花名会被明确拒绝。 +- [ ] 如使用花名,准确花名能解析并发送,找不到对应 `userId` 时会被明确拒绝。 - [ ] 能使用投递 `id` 查询状态。 - [ ] 通知标题自动包含正确项目名称,没有重复前缀或双标题。 -- [ ] 按钮进入已登记的 HTTPS 域名和正确业务记录。 +- [ ] 按钮进入正确业务记录;如启用免登且目标满足现有 HTTPS 登录流程,会自动包装到统一入口。 - [ ] 重复执行同一业务事件不会产生第二条通知。 - [ ] 无效钉钉 `userId` 最终进入明确失败状态。 @@ -562,8 +500,6 @@ projectCode: 验证通知的投递 ID: 发送结果: 点击跳转结果: -业务负责人: -技术负责人: 通知中心维护人: 遗留问题: ``` @@ -574,7 +510,7 @@ projectCode: | --- | --- | | 前端直接调用通知中心 | 只允许业务后端调用 | | 从其他项目复制项目密钥 | 每个项目由通知中心单独生成密钥 | -| 把任意姓名直接当花名发送 | 只使用当前项目已由通知中心登记的准确花名 | +| 把任意姓名直接当花名发送 | 只使用素材平台内部目录能够精确解析的准确花名 | | 同时发送 `recipientUserId` 和 `recipientAlias` | 两种收件人方式只能选择一种 | | 标题自己添加“【项目名】” | 只提交业务标题,由通知中心统一拼接 | | 签名路径包含公网 `/notification-center` 前缀 | 签名内部路径 `/v1/notifications` | @@ -589,11 +525,11 @@ projectCode: 只有同时满足以下条件,才算正式接入完成: -1. 项目已经在通知中心登记,并使用独立项目密钥和域名白名单。 +1. 项目已经在通知中心创建,并使用自己的项目代码和项目密钥。 2. 通知由业务后端发出,前端和 Git 仓库中没有任何密钥。 3. 业务项目保存投递 ID,正确处理幂等、状态查询和有限重试。 4. 通知内容符合[消息规范](./消息规范.md)。 -5. 已完成两名真实员工的生产通知、跳转和防重验收。 -6. 已记录双方负责人、上线版本、验证证据和故障处理方式。 +5. 已完成一次真实生产通知、跳转和防重验收。 +6. 已记录上线版本、验证证据和故障处理方式。 更细的逐项门禁可继续使用[新项目接入清单](./新项目接入清单.md)。 diff --git a/docs/通知中心新项目开通操作手册.md b/docs/通知中心新项目开通操作手册.md index a73f8b8..1c4a214 100644 --- a/docs/通知中心新项目开通操作手册.md +++ b/docs/通知中心新项目开通操作手册.md @@ -8,7 +8,7 @@ 新项目开通完成后,应同时满足: -1. 通知中心生产配置中存在唯一项目代码、项目名称、独立密钥和跳转域名白名单。 +1. 通知中心中存在唯一项目代码、项目名称和独立密钥。 2. 通知中心服务已重新加载配置并通过健康检查。 3. 业务项目已通过安全渠道取得 URL、项目代码和项目密钥。 4. 业务项目使用一名真实员工完成首次通知和状态查询。 @@ -16,61 +16,25 @@ 仅把接入文档发给对方,不代表项目已经开通。 -## 二、收到申请后先审核 +## 二、收到项目名称后直接创建 -业务项目应提交: +业务项目只需要提供项目名称,例如“产品开发平台”。在 `gongxiangp2` 超级管理员面板中点击“新增接入项目”,填写项目名称后创建: -- 项目中文名称; -- 建议 `projectCode`; -- 业务负责人和技术负责人; -- 通知场景和触发条件; -- 收件人规则以及钉钉 `userId` 的数据来源; -- 只能提供花名时,需要使用的准确花名清单; -- 日常和峰值通知量; -- 生产及测试跳转域名; -- 是否需要钉钉 H5 免登; -- 计划联调和上线日期。 +- 项目代码由通知中心自动生成;确有需要时才在高级设置中自定义。 +- 项目密钥自动生成并在创建结果中展示。 +- 不要求填写负责人、通知场景、通知量、跳转域名、收件人方式或花名清单。 +- 花名在实际发送时由通知中心查询 `gongxiangp2` 花名册。 -审核门槛: +## 三、确认创建结果 -- `projectCode` 使用 2~40 位小写字母、数字、下划线或短横线,创建后不随中文名称改变。 -- 项目名称简短并适合出现在 `【项目名称】业务标题` 中。 -- 跳转域名为业务项目真实控制的 HTTPS 域名,不使用通配符,不包含协议、端口或路径。 -- 收件人规则明确;业务项目只能提供花名时,必须提交有限、准确的花名清单。 -- 预计发送量在当前串行投递能力可承受范围内;峰值明显较大时先做容量评估。 -- 普通通知默认不启用免登;只有点击后必须自动建立登录态时才审核免登配置。 +创建完成后只需确认: -信息不完整时退回补充,不先生成密钥。 +1. 项目名称正确,最终通知标题会显示 `【项目名称】业务标题`。 +2. 项目代码没有与现有项目冲突。 +3. 项目密钥只展示给需要接入的业务后端。 +4. 现有项目的代码和密钥没有改变。 -## 三、确认项目身份没有冲突 - -修改生产配置前只读核对: - -1. 当前 `YYD_NOTIFICATION_PROJECTS_JSON` 能正常解析为数组。 -2. 数组中不存在相同 `code`。 -3. 申请的域名没有误写为协议、路径或非标准端口。 -4. 同一项目的花名归一化后没有重复,每个花名只对应一个已确认的在职员工 `userId`。 -5. 项目名称不会与已有项目造成用户无法分辨的通知来源。 -6. 如启用免登,回调地址属于该项目的 `allowedHosts`,回跳路径前缀采用最小范围。 - -不要删除、重排或重新生成已有项目的密钥。新增项目时必须完整保留所有既有项目配置。 - -生产项目配置结构: - -```json -{ - "code": "product_development", - "name": "产品开发", - "secret": "由通知中心维护人生成的独立随机密钥", - "allowedHosts": ["product.example.com"], - "recipients": [ - {"alias": "海棠", "userId": "经过确认的钉钉userId"}, - {"alias": "无邪", "userId": "经过确认的钉钉userId"} - ] -} -``` - -`recipients` 是可选配置。业务项目能够直接提供 `recipientUserId` 时可以省略;只能提供花名时才登记。维护人必须从权威通讯录或已确认的账号绑定取得 `userId`,不能按姓名猜测。 +普通通知不需要 `allowedHosts` 或 `recipients`。只有项目明确启用钉钉 H5 免登时,才额外维护登录回调与回跳路径。 启用免登时才增加: @@ -111,7 +75,7 @@ 2. 备份通知中心 `.env`,备份文件包含密钥,权限必须限制为仅维护人可读。 3. 备份通知中心 SQLite 数据目录,确保现有通知记录可恢复。 4. 如需用环境变量补录,在 `YYD_NOTIFICATION_PROJECTS_JSON` 数组末尾新增项目;该数组中的既有项目只作为参考,不会覆盖数据库里已存在项目。 -5. 再次解析 JSON,确认项目代码唯一、密钥长度合格、域名格式正确、花名映射无重复。 +5. 再次解析 JSON,确认项目代码唯一、密钥长度合格。 6. 使用当前正式发布方式重新加载通知中心配置,或在管理员 API 调用 `POST /admin/runtime/import-env` 导入缺失项目后执行 `POST /admin/runtime/refresh`。 7. 验证本机 `/health` 与公网 `/notification-center/health`。 8. 验证现有项目仍可鉴权,新项目能够通过签名请求进入通知队列。 @@ -139,8 +103,8 @@ 常用接口: - `POST /admin/projects`:创建项目,并**只在本次响应**返回新密钥和交付业务方的 `env` -- `PATCH /admin/projects/:projectCode`:修改名称、域名白名单、启停状态、免登配置 -- `PUT /admin/projects/:projectCode/recipients`:替换当前项目花名清单 +- `PATCH /admin/projects/:projectCode`:修改名称、启停状态;免登项目可另行维护登录配置 +- `PUT /admin/projects/:projectCode/recipients`:旧版项目级花名映射兼容接口,新接入不使用 - `POST /admin/projects/:projectCode/rotate-secret`:轮换项目密钥,并**只在本次响应**返回新密钥和 `env` - `POST /admin/projects/:projectCode/test`:发一条测试通知 - `GET /admin/deliveries` / `GET /admin/audits`:查看投递与审计 @@ -154,7 +118,7 @@ ## 六、安全交付三项配置 -向业务项目技术负责人交付: +向业务项目后端维护人交付: ```dotenv YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center @@ -180,7 +144,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=实际项目独立密钥 项目密钥:将通过单独的安全渠道发送 请把三项配置只放在业务后端。收件人不是接入密钥:能够提供钉钉 userId 时传 recipientUserId; -只能提供花名时传已登记的 recipientAlias,未登记花名不会发送。 +只能提供花名时直接传准确的 recipientAlias;通知中心会查询 `gongxiangp2` 花名册,找不到对应钉钉 userId 时不会发送。 接入步骤请按《由壹点统一通知中心接入操作手册》执行。 ``` @@ -188,9 +152,9 @@ YYD_NOTIFICATION_PROJECT_SECRET=实际项目独立密钥 业务项目准备: -- 一名真实测试员工的钉钉 `userId`,或已经登记的准确花名; -- 一个只在该员工和业务负责人之间可见的低风险测试场景; -- 一个属于项目白名单域名的 HTTPS 跳转地址; +- 一名真实测试员工的钉钉 `userId`,或准确花名; +- 一个不会影响正常业务的低风险测试场景; +- 一个真实跳转地址;如启用免登,则该地址必须满足现有 HTTPS 登录流程; - 稳定且不会与生产业务事件冲突的测试幂等键。 维护人与业务项目共同确认: @@ -201,22 +165,19 @@ YYD_NOTIFICATION_PROJECT_SECRET=实际项目独立密钥 4. 最终状态进入 `sent`、`unread`、`read` 或给出明确 `failed` 原因。 5. 测试员工实际收到“由壹点”机器人单聊。 6. 标题包含正确项目名称。 -7. 按钮只进入登记的项目域名和正确页面。 +7. 按钮进入业务项目提交的正确页面。 8. 相同幂等键重复请求不产生第二条消息。 -9. 如使用花名,已登记花名能解析,未登记花名明确返回 `RECIPIENT_ALIAS_NOT_FOUND`。 +9. 如使用花名,花名册中存在对应 userId 时能发送,找不到时明确返回 `RECIPIENT_ALIAS_NOT_FOUND`。 首次真实发送只做能证明链路的一次必要调用;未获得真实发送授权时使用客户端示例测试,不向员工发送消息。 ## 八、生产验收和交接记录 -正式上线至少由两名真实员工完成通知与跳转验证。记录中不保存密钥,只保存: +正式上线使用一名真实员工完成一次必要的通知与跳转验证。记录中不保存密钥,只保存: ```text 项目名称: projectCode: -allowedHosts: -业务负责人: -技术负责人: 通知中心维护人: 业务项目版本或 Git 提交: 通知中心版本或 Git 提交: diff --git a/docs/错误与状态规范.md b/docs/错误与状态规范.md index f6f16ce..7d8546b 100644 --- a/docs/错误与状态规范.md +++ b/docs/错误与状态规范.md @@ -28,7 +28,7 @@ ## 3. 标准边界 -- 业务项目必须按统一请求契约提交 `recipientUserId` 或已登记的 `recipientAlias`、标题、正文、跳转地址和幂等键;通知中心不识别采购流程、供应商账号、邮箱等项目私有字段。 +- 业务项目必须按统一请求契约提交 `recipientUserId` 或 `recipientAlias`、标题、正文和幂等键;`actionUrl` 可空且非空时原样透传。通知中心不识别采购流程、供应商账号、邮箱等项目私有字段。 - 收件人解析和业务人员配置由接入方负责。姓名、邮箱、外部联系人 ID 和自行拼接的标识不能代替钉钉内部员工 `userId`。 - 接入方以 HTTP 状态和投递 `status` 作为自动控制流程依据;`lastErrorCode` 用于诊断和人工处置,不得根据未知错误码自行重发。 - 本规范公布的统一 `lastErrorCode` 语义稳定;尚未归一化的钉钉厂商错误码或附在 `lastErrorMessage` 中的厂商信息只供通知中心维护人排查,不构成对接契约。 @@ -44,8 +44,9 @@ | `NONCE_REPLAYED` | nonce 已经使用过 | 生成新 nonce,业务幂等键保持不变 | | `RECIPIENT_SELECTOR_CONFLICT` | 同时提交了 `recipientUserId` 和 `recipientAlias` | 两种收件人方式只保留一个 | | `RECIPIENT_ALIAS_INVALID` | 花名为空、过长或包含控制字符 | 提交不超过 50 字符的准确花名 | -| `RECIPIENT_ALIAS_NOT_FOUND` | 当前项目没有登记该花名 | 申请登记花名或改用钉钉 `userId`,不得猜测重试 | -| `ACTION_URL_INVALID` | 非 HTTPS、带非标准端口或域名不在白名单 | 修改链接或申请扩充域名白名单 | +| `RECIPIENT_ALIAS_NOT_FOUND` | 素材平台内部目录没有找到该花名对应的钉钉 `userId` | 核对准确花名或改用钉钉 `userId`,不得猜测重试 | +| `RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE` | 素材平台内部目录暂时不可用 | 不入队,稍后重试或联系维护人 | +| `ACTION_URL_INVALID` | `actionUrl` 过长或包含非法控制字符 | 修正链接后重试 | | `IDEMPOTENCY_KEY_INVALID` | 幂等键为空或过长 | 按统一格式生成稳定键 | | `MESSAGE_FIELDS_REQUIRED` | 收件人、标题或内容缺失 | 补齐字段 | | `MESSAGE_TOO_LONG` | 标题、正文或按钮超长 | 压缩内容,把详情放回业务页面 | diff --git a/scripts/deploy-material-host.sh b/scripts/deploy-material-host.sh index 3b86cd8..b0d4c20 100644 --- a/scripts/deploy-material-host.sh +++ b/scripts/deploy-material-host.sh @@ -229,6 +229,10 @@ admin_api_secret = ( or existing.get('YYD_NOTIFICATION_ADMIN_TOKEN') or secrets.token_hex(32) ) +directory_url = ( + existing.get('YYD_NOTIFICATION_DIRECTORY_URL') + or 'https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve' +) config_encryption_key = ( existing.get('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY') or secrets.token_hex(32) @@ -244,6 +248,7 @@ center_lines = [ existing.get('YYD_NOTIFICATION_PUBLIC_BASE_URL') or 'https://sc.jiansun.vip/notification-center' ), + 'YYD_NOTIFICATION_DIRECTORY_URL=' + quoted(directory_url), 'DINGTALK_LOGIN_TICKET_TTL_SECONDS=' + str(ticket_ttl), 'YYD_NOTIFICATION_PROJECTS_JSON=' + quoted( json.dumps(projects, ensure_ascii=False, separators=(',', ':')) diff --git a/scripts/git-build-deploy.ps1 b/scripts/git-build-deploy.ps1 index 070c3bb..e35155c 100644 --- a/scripts/git-build-deploy.ps1 +++ b/scripts/git-build-deploy.ps1 @@ -134,6 +134,7 @@ compose_path = Path('docker-compose.yml') compose_text = compose_path.read_text(encoding='utf-8') compose_marker = ' YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON}' compose_required = [ + ' YYD_NOTIFICATION_DIRECTORY_URL: ${YYD_NOTIFICATION_DIRECTORY_URL:-https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve}', ' YYD_NOTIFICATION_ADMIN_API_SECRET: ${YYD_NOTIFICATION_ADMIN_API_SECRET}', ' YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY: ${YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY}', ] @@ -186,6 +187,10 @@ admin_api_secret = ( or existing_env.get('YYD_NOTIFICATION_ADMIN_TOKEN') or secrets.token_hex(32) ) +directory_url = ( + existing_env.get('YYD_NOTIFICATION_DIRECTORY_URL') + or 'https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve' +) config_encryption_key = ( existing_env.get('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY') or secrets.token_hex(32) @@ -230,6 +235,7 @@ if isinstance(login, dict) and login.get('enabled') is True: env_path.chmod(0o600) lines = upsert_env(lines, { + 'YYD_NOTIFICATION_DIRECTORY_URL': directory_url, 'YYD_NOTIFICATION_ADMIN_API_SECRET': admin_api_secret, 'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY': config_encryption_key, }) diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 44446c1..584d955 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -3,6 +3,7 @@ import crypto from 'node:crypto' import test from 'node:test' import { AddressInfo } from 'node:net' import { AdminProjectRegistry, maskUserId } from '../admin-project-registry' +import { NotificationDirectoryError } from '../directory' import { initializeRuntime } from '../index' import prisma from '../prisma' import { createServer } from '../server' @@ -13,6 +14,21 @@ const encryptionKey = '0123456789abcdef0123456789abcdef0123456789abcdef012345678 const clean = (value: unknown) => String(value || '').trim() +const resolveRecipientAlias = async (recipientAlias: string) => { + const directory = new Map([ + ['环境海棠', 'ding-env-user'], + ['素材同学', 'ding-material-user'], + ['海棠原样', 'ding-db-user'], + ['旧版海棠', 'ding-legacy-user'], + ['海棠', 'ding-script-monitor-user'], + ['海棠 Ops', 'ding-ops-user-1'], + ['海棠 新', 'ding-ops-user-2'], + ]) + const recipientUserId = directory.get(recipientAlias) + if (recipientUserId) return recipientUserId + throw new NotificationDirectoryError('RECIPIENT_ALIAS_NOT_FOUND', '花名未找到对应钉钉 userId', 422) +} + const assertEncryptedRecipientRow = (row: { aliasDisplay: string userIdCiphertext: string @@ -132,7 +148,7 @@ test('环境变量项目只导入一次且不覆盖数据库配置,refresh 后 assert.equal(fixedAsset.project.recipients?.[0].alias, '海棠原样') assert.equal(fixedAsset.project.recipients?.[0].userId, 'ding-db-user') - const server = createServer(registry).listen(0) + const server = createServer(registry, { resolveRecipientAlias }).listen(0) await new Promise((resolve) => server.once('listening', resolve)) const port = (server.address() as AddressInfo).port try { @@ -245,7 +261,7 @@ test('缺少 admin 配置时启动安全回退到 legacy env source,旧 /v1 assert.equal(runtime.activeProjectCount, 1) assert.ok(runtime.projectSource instanceof Map) - const server = createServer(runtime.projectSource).listen(0) + const server = createServer(runtime.projectSource, { resolveRecipientAlias }).listen(0) await new Promise((resolve) => server.once('listening', resolve)) const port = (server.address() as AddressInfo).port try { @@ -304,7 +320,7 @@ test('admin reveal-secret 可重复查看当前密钥,不轮换且审计不泄 await resetDatabase() const registry = new AdminProjectRegistry() await registry.refresh() - const server = createServer(registry).listen(0) + const server = createServer(registry, { resolveRecipientAlias }).listen(0) await new Promise((resolve) => server.once('listening', resolve)) const port = (server.address() as AddressInfo).port @@ -507,6 +523,7 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 await registry.refresh() const sentMessages: Array> = [] const server = createServer(registry, { + resolveRecipientAlias, sendTestMessage: async (input) => { sentMessages.push({ recipientUserId: input.recipientUserId, @@ -526,27 +543,25 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 method: 'POST', headers: withAdminAuth(), body: JSON.stringify({ - projectCode: 'ops_center', name: '运营中心', - allowedHosts: ['ops.example.com'], - recipients: [{ alias: '海棠 Ops', userId: 'ding-ops-user-1' }], }), }) assert.equal(createResponse.status, 201) const created = await createResponse.json() as any - assert.equal(created.project.projectCode, 'ops_center') + const generatedProjectCode = created.project.projectCode + assert.match(generatedProjectCode, /^project_[0-9a-f]{12}$/) assert.equal(created.project.name, '运营中心') assert.equal(created.project.isActive, true) - assert.deepEqual(created.project.allowedHosts, ['ops.example.com']) + assert.deepEqual(created.project.allowedHosts, []) assert.equal(created.project.recipientMode, 'userId_or_alias') - assert.equal(created.project.recipientCount, 1) - assert.equal(created.project.recipients[0].alias, '海棠 Ops') + assert.equal(created.project.recipientCount, 0) + assert.deepEqual(created.project.recipients, []) assert.equal(created.project.deliverySummary.lastStatus, null) assert.equal(typeof created.secret, 'string') assert.ok(created.secret.length >= 24) assert.deepEqual(created.env, { YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center', - YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'ops_center', + YYD_NOTIFICATION_CENTER_PROJECT_CODE: generatedProjectCode, YYD_NOTIFICATION_CENTER_PROJECT_SECRET: created.secret, }) @@ -568,7 +583,7 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 'updatedAt', ].sort()) - const updateResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center`, { + const updateResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/${generatedProjectCode}`, { method: 'PATCH', headers: withAdminAuth(), body: JSON.stringify({ @@ -586,7 +601,7 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 assert.equal(updated.project.login.enabled, true) assert.deepEqual(updated.project.login.allowedRedirectPathPrefixes, ['/tasks', '/dashboard']) - const replaceRecipients = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center/recipients`, { + const replaceRecipients = await fetch(`http://127.0.0.1:${port}/admin/projects/${generatedProjectCode}/recipients`, { method: 'PUT', headers: withAdminAuth(), body: JSON.stringify({ @@ -599,7 +614,7 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 assert.equal(replaced.project.recipients[0].alias, '海棠 新') assert.equal(replaced.project.recipients[0].userId, 'ding-ops-user-2') - const testResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center/test`, { + const testResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/${generatedProjectCode}/test`, { method: 'POST', headers: withAdminAuth(), body: JSON.stringify({ @@ -620,23 +635,23 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 assert.equal('actionUrl' in testPayload.delivery, false) assert.equal(sentMessages.length, 1) assert.equal(sentMessages[0].title, '【运营中心升级版】管理台联调') - assert.match(sentMessages[0].content, /### 【运营中心升级版】管理台联调/) + assert.equal(sentMessages[0].content, '这是测试正文') assert.match(sentMessages[0].actionUrl, /\/notification-center\/v1\/auth\/h5-entry/) - const deliveriesResponse = await fetch(`http://127.0.0.1:${port}/admin/deliveries?projectCode=ops_center&isTest=true`, { + const deliveriesResponse = await fetch(`http://127.0.0.1:${port}/admin/deliveries?projectCode=${generatedProjectCode}&isTest=true`, { headers: withAdminAuth({ Accept: 'application/json' }), }) assert.equal(deliveriesResponse.status, 200) const deliveriesPayload = await deliveriesResponse.json() as any assert.equal(deliveriesPayload.total, 1) - assert.equal(deliveriesPayload.deliveries[0].projectCode, 'ops_center') + assert.equal(deliveriesPayload.deliveries[0].projectCode, generatedProjectCode) assert.equal(deliveriesPayload.deliveries[0].isTest, true) assert.equal(deliveriesPayload.deliveries[0].recipientAlias, null) assert.equal(deliveriesPayload.deliveries[0].recipientUserIdMasked, maskUserId('ding-ops-user-2')) assert.equal('recipientUserId' in deliveriesPayload.deliveries[0], false) assert.equal('actionUrl' in deliveriesPayload.deliveries[0], false) - const auditsResponse = await fetch(`http://127.0.0.1:${port}/admin/audits?projectCode=ops_center`, { + const auditsResponse = await fetch(`http://127.0.0.1:${port}/admin/audits?projectCode=${generatedProjectCode}`, { headers: withAdminAuth({ Accept: 'application/json' }), }) assert.equal(auditsResponse.status, 200) @@ -654,17 +669,17 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 assert.doesNotMatch(testJson, /https:\/\/ops\.example\.com\/tasks\/1/) assert.match(auditsJson, /\[redacted\]|\*\*\*/) - const rotateResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center/rotate-secret`, { + const rotateResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/${generatedProjectCode}/rotate-secret`, { method: 'POST', headers: withAdminAuth(), }) assert.equal(rotateResponse.status, 200) const rotated = await rotateResponse.json() as any - assert.equal(rotated.project.projectCode, 'ops_center') + assert.equal(rotated.project.projectCode, generatedProjectCode) assert.notEqual(rotated.secret, created.secret) assert.deepEqual(rotated.env, { YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center', - YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'ops_center', + YYD_NOTIFICATION_CENTER_PROJECT_CODE: generatedProjectCode, YYD_NOTIFICATION_CENTER_PROJECT_SECRET: rotated.secret, }) @@ -680,7 +695,7 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 const oldSecretRejected = await fetch(`http://127.0.0.1:${port}${notificationPath}`, { method: 'POST', headers: signedHeaders({ - projectCode: 'ops_center', + projectCode: generatedProjectCode, secret: created.secret, method: 'POST', path: notificationPath, @@ -702,7 +717,7 @@ test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可 const newSecretAccepted = await fetch(`http://127.0.0.1:${port}${notificationPath}`, { method: 'POST', headers: signedHeaders({ - projectCode: 'ops_center', + projectCode: generatedProjectCode, secret: rotated.secret, method: 'POST', path: notificationPath, diff --git a/src/__tests__/deployment.test.ts b/src/__tests__/deployment.test.ts index 4b1a806..f52c0e3 100644 --- a/src/__tests__/deployment.test.ts +++ b/src/__tests__/deployment.test.ts @@ -19,6 +19,10 @@ test('生产发布面透传免登配置并保留已有项目 login', () => { assert.match(example, new RegExp(key)) assert.match(deploy, new RegExp(key)) } + assert.match(compose, /YYD_NOTIFICATION_DIRECTORY_URL/) + assert.match(example, /YYD_NOTIFICATION_DIRECTORY_URL/) + assert.match(deploy, /YYD_NOTIFICATION_DIRECTORY_URL/) + assert.match(gitDeploy, /YYD_NOTIFICATION_DIRECTORY_URL/) 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/) diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index c769e41..22811fd 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -3,6 +3,7 @@ import crypto from 'node:crypto' import test from 'node:test' import { AddressInfo } from 'node:net' import { loadProjectConfigs } from '../config' +import { NotificationDirectoryError } from '../directory' import prisma from '../prisma' import { createServer, renderNotificationContent, renderNotificationTitle } from '../server' import { createCanonicalSignature, sha256 } from '../signature' @@ -29,10 +30,15 @@ const headers = (method: string, path: string, body: string, nonce = crypto.rand } } -test('通知中心验证签名、项目域名和幂等键', async () => { +const resolveRecipientAlias = async (recipientAlias: string) => { + if (recipientAlias === '海棠') return 'ding-user-haitang' + throw new NotificationDirectoryError('RECIPIENT_ALIAS_NOT_FOUND', '花名未找到对应钉钉 userId', 422) +} + +test('通知中心验证签名、花名目录和幂等键', async () => { await prisma.requestNonce.deleteMany() await prisma.notificationDelivery.deleteMany() - const server = createServer(projects).listen(0) + const server = createServer(projects, { resolveRecipientAlias }).listen(0) await new Promise((resolve) => server.once('listening', resolve)) const port = (server.address() as AddressInfo).port const path = '/v1/notifications' @@ -54,9 +60,9 @@ test('通知中心验证签名、项目域名和幂等键', async () => { assert.equal(stored.maxAttempts, 5) assert.equal( stored.content, - '### 【固定资产】待审批报废\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102', + '### 待你审批\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102', ) - assert.doesNotMatch(stored.content, /待你审批/) + assert.match(stored.content, /待你审批/) const second = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body }) assert.equal(second.status, 200) @@ -85,7 +91,10 @@ test('通知中心验证签名、项目域名和幂等键', async () => { method: 'POST', headers: headers('POST', path, unknownAliasBody), body: unknownAliasBody, }) assert.equal(unknownAlias.status, 422) - assert.deepEqual(await unknownAlias.json(), { error: 'RECIPIENT_ALIAS_NOT_FOUND' }) + assert.deepEqual(await unknownAlias.json(), { + error: 'RECIPIENT_ALIAS_NOT_FOUND', + message: '花名未找到对应钉钉 userId', + }) const conflictingRecipientBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'conflicting-recipient', recipientAlias: '海棠', @@ -96,9 +105,14 @@ test('通知中心验证签名、项目域名和幂等键', async () => { 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) + const intranetBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'intranet-link', actionUrl: 'http://10.0.0.8:8080/path' }) + const intranetAccepted = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', headers: headers('POST', path, intranetBody), body: intranetBody, + }) + assert.equal(intranetAccepted.status, 202) + const intranetPayload = await intranetAccepted.json() as any + const intranetStored = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: intranetPayload.id } }) + assert.equal(intranetStored.actionUrl, 'http://10.0.0.8:8080/path') // 按状态列出本项目投递记录,接入方对账失败用(签名按纯路径,与现有协议一致) await prisma.notificationDelivery.update({ @@ -137,7 +151,7 @@ test('通知中心验证签名、项目域名和幂等键', async () => { const tooLongBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'rendered-content-too-long', - content: '内'.repeat(4800), + content: '内'.repeat(4801), }) const tooLong = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, tooLongBody), body: tooLongBody, @@ -190,24 +204,20 @@ test('通知中心验证签名、项目域名和幂等键', async () => { } }) -test('通知标题突出项目,正文首行由通知中心统一生成', () => { +test('通知标题突出项目,正文保持外部项目提交内容', () => { assert.equal(renderNotificationTitle('固定资产', '资产报废待审批'), '【固定资产】资产报废待审批') assert.equal( renderNotificationContent('固定资产', '资产报废待审批', '**申请时间:** 2026年7月16日 14:30'), - '### 【固定资产】资产报废待审批\n\n**申请时间:** 2026年7月16日 14:30', + '**申请时间:** 2026年7月16日 14:30', ) assert.equal( renderNotificationContent('固定资产', '资产报废待审批', '### 待你审批\n\n正文'), - '### 【固定资产】资产报废待审批\n\n正文', - ) - assert.equal( - renderNotificationContent('固定资产', '资产报废待审批', '### 【固定资产】资产报废待审批\n\n正文'), - '### 【固定资产】资产报废待审批\n\n正文', + '### 待你审批\n\n正文', ) for (const heading of ['# 一级标题', '## 二级标题', '#### 四级标题']) { assert.equal( renderNotificationContent('固定资产', '资产报废待审批', `${heading}\n\n正文`), - `### 【固定资产】资产报废待审批\n\n${heading}\n\n正文`, + `${heading}\n\n正文`, ) } }) diff --git a/src/__tests__/signature.test.ts b/src/__tests__/signature.test.ts index 07fcc15..abdf63a 100644 --- a/src/__tests__/signature.test.ts +++ b/src/__tests__/signature.test.ts @@ -3,13 +3,13 @@ import test from 'node:test' import { loadProjectConfigs } from '../config' import { createCanonicalSignature, safeEqual } from '../signature' -test('每个项目必须使用独立且足够长的密钥', () => { +test('每个项目必须使用独立且足够长的密钥,allowedHosts 可选', () => { const projects = loadProjectConfigs(JSON.stringify([ - { code: 'fixed_asset', name: '固定资产', secret: '123456789012345678901234', allowedHosts: ['asset.example.com'] }, + { code: 'fixed_asset', name: '固定资产', secret: '123456789012345678901234' }, ])) assert.equal(projects.get('fixed_asset')?.name, '固定资产') assert.throws(() => loadProjectConfigs(JSON.stringify([ - { code: 'bad', name: '错误项目', secret: 'short', allowedHosts: ['example.com'] }, + { code: 'bad', name: '错误项目', secret: 'short' }, ]))) assert.throws(() => loadProjectConfigs(JSON.stringify([ { code: 'bad', name: '错误项目', secret: '123456789012345678901234', allowedHosts: ['example.com:444'] }, @@ -37,7 +37,7 @@ test('项目可登记唯一花名并解析到钉钉 userId', () => { }])), /收件人花名重复/) }) -test('免登配置必须使用项目域名、标准 HTTPS 回调和内部路径白名单', () => { +test('免登配置必须使用标准 HTTPS 回调和内部路径白名单', () => { const project = loadProjectConfigs(JSON.stringify([{ code: 'fixed_asset', name: '固定资产', @@ -53,7 +53,6 @@ test('免登配置必须使用项目域名、标准 HTTPS 回调和内部路径 for (const callbackUrl of [ 'http://asset.example.com/api/dingtalk/broker-login', - 'https://evil.example.com/api/dingtalk/broker-login', 'https://asset.example.com:444/api/dingtalk/broker-login', 'https://user:pass@asset.example.com/api/dingtalk/broker-login', ]) { @@ -62,6 +61,18 @@ test('免登配置必须使用项目域名、标准 HTTPS 回调和内部路径 login: { enabled: true, callbackUrl, allowedRedirectPathPrefixes: ['/workflows/disposals'] }, }]))) } + + const crossHostLogin = loadProjectConfigs(JSON.stringify([{ + code: 'fixed_asset', + name: '固定资产', + secret: '123456789012345678901234', + login: { + enabled: true, + callbackUrl: 'https://login.example.com/api/dingtalk/broker-login', + allowedRedirectPathPrefixes: ['/workflows/disposals'], + }, + }])).get('fixed_asset') + assert.equal(crossHostLogin?.login?.callbackUrl, 'https://login.example.com/api/dingtalk/broker-login') }) test('签名包含项目密钥、时间、nonce、方法、路径和正文', () => { diff --git a/src/__tests__/worker.test.ts b/src/__tests__/worker.test.ts index c61633b..2b86577 100644 --- a/src/__tests__/worker.test.ts +++ b/src/__tests__/worker.test.ts @@ -9,6 +9,21 @@ import { quarantineExpiredSendingLeases, } from '../worker' +const actionCardInput = (overrides: Partial<{ + recipientUserId: string + title: string + content: string + actionTitle: string + actionUrl: string +}> = {}) => ({ + recipientUserId: 'ding-user-1', + title: '测试', + content: '测试', + actionTitle: '查看', + actionUrl: 'https://example.com', + ...overrides, +}) + test('token 失效先刷新重试一次,避免缓存过期导致批量误判终态', async () => { const originalFetch = global.fetch const oldAppKey = process.env.DINGTALK_APP_KEY @@ -33,7 +48,7 @@ test('token 失效先刷新重试一次,避免缓存过期导致批量误判 }) as typeof fetch try { - const key = await sendRobotMessage({ recipientUserId: 'ding-user-1', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' }) + const key = await sendRobotMessage(actionCardInput()) assert.equal(key, 'pqk-refreshed') assert.deepEqual(batchSendTokens, ['stale-token', 'fresh-token']) } finally { @@ -67,7 +82,7 @@ test('token 刷新后仍失效则维持确定性失败语义', async () => { try { let caught: unknown try { - await sendRobotMessage({ recipientUserId: 'ding-user-1', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' }) + await sendRobotMessage(actionCardInput()) } catch (error) { caught = error } @@ -131,7 +146,7 @@ test('发送请求超时属于结果不确定并停止自动重试', async () => try { let caught: unknown try { - await sendRobotMessage({ recipientUserId: 'ding-user-1', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' }) + await sendRobotMessage(actionCardInput()) } catch (error) { caught = error } @@ -169,13 +184,12 @@ test('机器人 ActionCard 原样发送统一标题、分段正文和按钮跳 return new Response(JSON.stringify({ processQueryKey: 'process-query-key-1' }), { status: 200 }) }) as typeof fetch - const input = { - recipientUserId: 'ding-user-1', + const input = actionCardInput({ title: '【固定资产】资产报废待审批', - content: '### 【固定资产】资产报废待审批\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 主机(JS-PC-0010)', + content: '**申请时间:** 2026年7月16日 14:30\n\n**资产:** 主机(JS-PC-0010)', actionTitle: '立即审批', actionUrl: 'https://asset.example.com/workflows/disposals/53', - } + }) try { assert.equal(await sendRobotMessage(input), 'process-query-key-1') @@ -218,7 +232,7 @@ test('钉钉 HTTP 403 QPS 限流归一为中心标准错误并进入自动重试 try { let caught: unknown try { - await sendRobotMessage({ recipientUserId: 'ding-user-1', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' }) + await sendRobotMessage(actionCardInput()) } catch (error) { caught = error } @@ -261,7 +275,7 @@ test('钉钉 staff 不存在归一为收件人无效并保持终态失败', asyn try { let caught: unknown try { - await sendRobotMessage({ recipientUserId: 'ding-user-invalid', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' }) + await sendRobotMessage(actionCardInput({ recipientUserId: 'ding-user-invalid' })) } catch (error) { caught = error } diff --git a/src/admin-project-registry.ts b/src/admin-project-registry.ts index c66db74..a8aef1e 100644 --- a/src/admin-project-registry.ts +++ b/src/admin-project-registry.ts @@ -30,9 +30,9 @@ export type ProjectRecipientInput = { } export type ProjectUpsertInput = { - projectCode: string + projectCode?: string name: string - allowedHosts: string[] + allowedHosts?: string[] isActive?: boolean recipients?: ProjectRecipientInput[] login?: ProjectLoginConfig | { enabled: false } | null @@ -55,7 +55,7 @@ export type ProjectListItem = { projectCode: string isActive: boolean allowedHosts: string[] - recipientMode: 'userId_only' | 'userId_or_alias' + recipientMode: 'userId_or_alias' recipientCount: number recipients?: ProjectRecipientView[] updatedAt: Date @@ -119,7 +119,7 @@ const normalizeProjectInput = (candidate: { code: string name: string secret: string - allowedHosts: string[] + allowedHosts?: string[] recipients?: ProjectRecipientInput[] login?: ProjectLoginConfig | { enabled: false } | null }) => { @@ -127,7 +127,7 @@ const normalizeProjectInput = (candidate: { code: candidate.code, name: candidate.name, secret: candidate.secret, - allowedHosts: candidate.allowedHosts, + allowedHosts: candidate.allowedHosts || [], recipients: candidate.recipients?.map((item) => ({ alias: item.alias, userId: item.userId, @@ -203,7 +203,7 @@ const buildProjectView = (input: { projectCode: input.code, isActive: input.isActive, allowedHosts: input.allowedHosts, - recipientMode: input.recipients.length > 0 ? 'userId_or_alias' : 'userId_only', + recipientMode: 'userId_or_alias', recipientCount: input.recipients.length, ...(input.includeRecipients ? { recipients: input.recipients } : {}), updatedAt: input.updatedAt, @@ -213,6 +213,8 @@ const buildProjectView = (input: { export const createProjectSecret = () => crypto.randomBytes(32).toString('base64url') +const createProjectCode = () => `project_${crypto.randomBytes(6).toString('hex')}` + export const getAdminToken = () => clean(process.env.YYD_NOTIFICATION_ADMIN_API_SECRET || process.env.YYD_NOTIFICATION_ADMIN_TOKEN) @@ -559,69 +561,82 @@ export class AdminProjectRegistry { } async createProject(input: ProjectUpsertInput, actor: AdminAuditActor) { + if (!clean(input.name)) throw new AdminApiError(422, 'PROJECT_INPUT_INVALID', 'name 不能为空') const secret = createProjectSecret() - const normalized = normalizeProjectInput({ - code: input.projectCode, + const requestedCode = clean(input.projectCode) + let normalized = normalizeProjectInput({ + code: requestedCode || createProjectCode(), name: input.name, secret, - allowedHosts: input.allowedHosts, + allowedHosts: input.allowedHosts || [], recipients: input.recipients || [], login: input.login, }) const encrypted = this.encryptSecret(secret) - try { - await prisma.$transaction(async (tx) => { - const projectId = crypto.randomUUID() - await tx.notificationProject.create({ - data: { - id: projectId, - code: normalized.code, - name: normalized.name, - ...encrypted, - allowedHostsJson: JSON.stringify(normalized.allowedHosts), - loginEnabled: Boolean(normalized.login), - loginCallbackUrl: normalized.login?.callbackUrl || null, - loginAllowedRedirectPrefixesJson: normalized.login - ? JSON.stringify(normalized.login.allowedRedirectPathPrefixes) - : null, - isActive: input.isActive !== false, - lastSecretRotatedAt: new Date(), - recipients: normalized.recipientAliases.size > 0 ? { - create: this.buildRecipientCreateRows(normalized.recipientAliases, input.recipients || []), - } : undefined, - }, - }) - await tx.notificationAudit.create({ - data: { - id: crypto.randomUUID(), - actorType: actor.actorType, - actorId: actor.actorId, - action: 'project.create', - targetProjectCode: normalized.code, - requestId: actor.requestId || null, - ipAddress: actor.ipAddress || null, - summary: `创建项目 ${normalized.code}`, - detailsJson: createAuditDetailsJson({ - allowedHosts: normalized.allowedHosts, - login: projectConfigToLoginPayload(normalized.login), - recipientCount: normalized.recipientAliases.size, + for (let attempt = 0; attempt < 5; attempt++) { + try { + await prisma.$transaction(async (tx) => { + const projectId = crypto.randomUUID() + await tx.notificationProject.create({ + data: { + id: projectId, + code: normalized.code, + name: normalized.name, + ...encrypted, + allowedHostsJson: JSON.stringify(normalized.allowedHosts), + loginEnabled: Boolean(normalized.login), + loginCallbackUrl: normalized.login?.callbackUrl || null, + loginAllowedRedirectPrefixesJson: normalized.login + ? JSON.stringify(normalized.login.allowedRedirectPathPrefixes) + : null, isActive: input.isActive !== false, - }), - }, + lastSecretRotatedAt: new Date(), + recipients: normalized.recipientAliases.size > 0 ? { + create: this.buildRecipientCreateRows(normalized.recipientAliases, input.recipients || []), + } : undefined, + }, + }) + await tx.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: actor.actorType, + actorId: actor.actorId, + action: 'project.create', + targetProjectCode: normalized.code, + requestId: actor.requestId || null, + ipAddress: actor.ipAddress || null, + summary: `创建项目 ${normalized.code}`, + detailsJson: createAuditDetailsJson({ + allowedHosts: normalized.allowedHosts, + login: projectConfigToLoginPayload(normalized.login), + recipientCount: normalized.recipientAliases.size, + isActive: input.isActive !== false, + }), + }, + }) + }) + await this.refresh() + return { + project: (await this.getProjectView(normalized.code, true)).project, + secret, + env: buildProjectEnv(normalized.code, secret), + } + } catch (error: any) { + if (clean(error?.code) !== 'P2002') throw error + if (requestedCode) { + throw new AdminApiError(409, 'PROJECT_CODE_CONFLICT', `项目 ${normalized.code} 已存在`) + } + normalized = normalizeProjectInput({ + code: createProjectCode(), + name: input.name, + secret, + allowedHosts: input.allowedHosts || [], + recipients: input.recipients || [], + login: input.login, }) - }) - } catch (error: any) { - if (clean(error?.code) === 'P2002') { - throw new AdminApiError(409, 'PROJECT_CODE_CONFLICT', `项目 ${normalized.code} 已存在`) } - throw error - } - await this.refresh() - return { - project: (await this.getProjectView(normalized.code, true)).project, - secret, - env: buildProjectEnv(normalized.code, secret), } + throw new AdminApiError(503, 'PROJECT_CODE_GENERATION_FAILED', '自动生成项目代码失败,请稍后重试') } async updateProject(projectCode: string, input: ProjectUpdateInput, actor: AdminAuditActor) { diff --git a/src/config.ts b/src/config.ts index 9616fef..7405c75 100644 --- a/src/config.ts +++ b/src/config.ts @@ -61,7 +61,6 @@ const normalizeAllowedRedirectPrefix = (value: unknown, projectCode: string) => const parseProjectLogin = ( value: unknown, projectCode: string, - allowedHosts: string[], ): ProjectLoginConfig | undefined => { if (value === undefined || value === null) return undefined if (typeof value !== 'object' || Array.isArray(value)) { @@ -84,10 +83,9 @@ const parseProjectLogin = ( callback.username || callback.password || callback.search || - callback.hash || - !allowedHosts.includes(callback.hostname.toLowerCase()) + callback.hash ) { - throw new Error(`项目 ${projectCode} 的 login.callbackUrl 必须是 allowedHosts 内使用标准 443 端口的完整 HTTPS 地址且不能包含凭据、查询或片段`) + throw new Error(`项目 ${projectCode} 的 login.callbackUrl 必须是标准 443 端口的完整 HTTPS 地址且不能包含凭据、查询或片段`) } const prefixes = Array.isArray(row.allowedRedirectPathPrefixes) @@ -124,7 +122,6 @@ export const loadProjectConfigs = (raw = process.env.YYD_NOTIFICATION_PROJECTS_J if (!/^[a-z0-9_-]{2,40}$/.test(code)) throw new Error(`项目 code 不合法: ${code || '(空)'}`) if (!name) throw new Error(`项目 ${code} 缺少 name`) if (secret.length < 24) throw new Error(`项目 ${code} 的 secret 至少需要 24 个字符`) - if (allowedHosts.length === 0) throw new Error(`项目 ${code} 至少需要一个 allowedHosts`) for (const host of allowedHosts) { let parsedHost: URL try { @@ -138,7 +135,7 @@ 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) + const login = parseProjectLogin(item.login, code) projects.set(code, { code, name, secret, allowedHosts, recipientAliases, login }) } return projects @@ -166,6 +163,11 @@ export const getPublicBaseUrl = () => { return url.toString().replace(/\/$/, '') } +export const getNotificationDirectoryConfig = () => ({ + url: clean(process.env.YYD_NOTIFICATION_DIRECTORY_URL), + bearerToken: clean(process.env.YYD_NOTIFICATION_ADMIN_API_SECRET || process.env.YYD_NOTIFICATION_ADMIN_TOKEN), +}) + export const assertRuntimeConfig = (projects: Map) => { if (projects.size === 0) throw new Error('未配置任何通知项目') const dingTalk = getDingTalkConfig() diff --git a/src/directory.ts b/src/directory.ts new file mode 100644 index 0000000..113649d --- /dev/null +++ b/src/directory.ts @@ -0,0 +1,94 @@ +import { getNotificationDirectoryConfig } from './config' + +const clean = (value: unknown) => String(value || '').trim() + +export class NotificationDirectoryError extends Error { + constructor( + public readonly code: 'RECIPIENT_ALIAS_NOT_FOUND' | 'RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE', + message: string, + public readonly status: 422 | 503, + ) { + super(message) + this.name = 'NotificationDirectoryError' + } +} + +const DIRECTORY_TIMEOUT_MS = 8_000 + +const requestDirectory = async (url: string, recipientAlias: string, bearerToken: string) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DIRECTORY_TIMEOUT_MS) + try { + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + Authorization: `Bearer ${bearerToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ recipientAlias }), + }) + const payload = await response.json().catch(() => ({})) as { + recipientUserId?: unknown + error?: { code?: unknown; message?: unknown } | unknown + } + if (response.status === 404) { + const message = clean((payload.error as { message?: unknown } | undefined)?.message) || '花名未找到对应钉钉 userId' + throw new NotificationDirectoryError('RECIPIENT_ALIAS_NOT_FOUND', message, 422) + } + if (!response.ok) { + throw new NotificationDirectoryError( + 'RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE', + '花名目录暂时不可用,请稍后重试', + 503, + ) + } + const recipientUserId = clean(payload.recipientUserId) + if (!recipientUserId) { + throw new NotificationDirectoryError( + 'RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE', + '花名目录返回结果无效,请稍后重试', + 503, + ) + } + return recipientUserId + } catch (error: any) { + if (error instanceof NotificationDirectoryError) throw error + if (error?.name === 'AbortError') { + throw new NotificationDirectoryError( + 'RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE', + '花名目录请求超时,请稍后重试', + 503, + ) + } + throw new NotificationDirectoryError( + 'RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE', + '花名目录暂时不可用,请稍后重试', + 503, + ) + } finally { + clearTimeout(timeout) + } +} + +export const resolveRecipientAlias = async (recipientAlias: string) => { + const config = getNotificationDirectoryConfig() + if (!config.url || !config.bearerToken) { + throw new NotificationDirectoryError( + 'RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE', + '花名目录未配置,请联系通知中心维护人', + 503, + ) + } + let endpoint: URL + try { + endpoint = new URL(config.url) + } catch { + throw new NotificationDirectoryError( + 'RECIPIENT_ALIAS_DIRECTORY_UNAVAILABLE', + '花名目录地址配置无效,请联系通知中心维护人', + 503, + ) + } + return requestDirectory(endpoint.toString(), recipientAlias, config.bearerToken) +} diff --git a/src/server.ts b/src/server.ts index 36a61c3..30bfa16 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,8 @@ import express, { NextFunction, Request, Response } from 'express' import { buildH5EntryUrl, createH5EntryPage, getRedirectPathFromActionUrl, LoginInputError, validateRedirectPath } from './auth' import { authenticateAdmin, type AdminRequest } from './admin-auth' import { AdminApiError, AdminProjectRegistry, assertAdminRuntimeConfig, maskUserId, type ProjectRecipientInput, type ProjectUpdateInput, type ProjectUpsertInput } from './admin-project-registry' -import { assertRuntimeConfig, getDingTalkConfig, getPublicBaseUrl, loadProjectConfigs, normalizeRecipientAlias, type ProjectConfig, type ProjectLoginConfig } from './config' +import { assertRuntimeConfig, getDingTalkConfig, getPublicBaseUrl, loadProjectConfigs, type ProjectConfig, type ProjectLoginConfig } from './config' +import { NotificationDirectoryError, resolveRecipientAlias as resolveRecipientAliasFromDirectory } from './directory' import { DingTalkError, exchangeH5AuthCodeForUserId, sendRobotMessage } from './dingtalk' import prisma from './prisma' import { createCanonicalSignature, safeEqual, sha256 } from './signature' @@ -41,16 +42,10 @@ export const renderNotificationTitle = (projectName: string, businessTitle: stri `【${clean(projectName)}】${clean(businessTitle)}` export const renderNotificationContent = ( - projectName: string, - businessTitle: string, + _projectName: string, + _businessTitle: string, businessContent: string, -) => { - const contentWithoutLegacyHeading = clean(businessContent) - .replace(/^###(?!#)(?:[ \t]+[^\r\n]*)?(?:\r?\n|$)/, '') - .replace(/^(?:\r?\n)+/, '') - const heading = `### ${renderNotificationTitle(projectName, businessTitle)}` - return contentWithoutLegacyHeading ? `${heading}\n\n${contentWithoutLegacyHeading}` : heading -} +) => clean(businessContent) const ticketTtlSeconds = () => { const configured = Number(process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS || 60) @@ -140,25 +135,50 @@ const adminDeliveryView = (row: { updatedAt: row.updatedAt, }) -const validateActionUrl = (value: string, project: ProjectConfig) => { - let url: URL +const hasControlCharacters = (value: string) => /[\u0000-\u001f\u007f]/.test(value) + +const normalizeActionUrl = (value: string) => { + if (!value) return '' + if (value.length > 2048 || hasControlCharacters(value)) { + throw new Error('actionUrl 过长或包含非法字符') + } + return value +} + +const canWrapActionUrlWithLogin = (value: string, project: ProjectConfig) => { + if (!value || !project.login) return false try { - url = new URL(value) + const url = new URL(value) + const callbackHost = new URL(project.login.callbackUrl).hostname.toLowerCase() + const allowedHosts = new Set(project.allowedHosts.map((host) => host.toLowerCase())) + allowedHosts.add(callbackHost) + return url.protocol === 'https:' && + !url.username && + !url.password && + !url.port && + allowedHosts.has(url.hostname.toLowerCase()) } catch { - throw new Error('actionUrl 必须是完整 HTTPS 地址') + return false } - if (url.protocol !== 'https:' || url.username || url.password || url.port) { - throw new Error('actionUrl 只允许不含凭据且使用标准 443 端口的 HTTPS 地址') +} + +const renderActionUrl = (value: string, project: ProjectConfig) => { + if (!canWrapActionUrlWithLogin(value, project)) return value + try { + return buildH5EntryUrl({ + publicBaseUrl: getPublicBaseUrl(), + project, + redirectPath: getRedirectPathFromActionUrl(value, project), + }) + } catch { + return value } - if (!project.allowedHosts.includes(url.hostname.toLowerCase())) { - throw new Error(`actionUrl 域名不在项目白名单内: ${url.hostname}`) - } - return url.toString() } type CreateServerDependencies = { exchangeH5Code?: (code: string) => Promise sendTestMessage?: typeof sendRobotMessage + resolveRecipientAlias?: (recipientAlias: string) => Promise } type NormalizedNotificationInput = { @@ -194,6 +214,7 @@ const parseRecipientsInput = (value: unknown): ProjectRecipientInput[] => { } const parseAllowedHosts = (value: unknown) => { + if (value === undefined) return [] if (!Array.isArray(value)) throw new AdminApiError(422, 'ALLOWED_HOSTS_INVALID', 'allowedHosts 必须是数组') return value.map((item) => clean(item)).filter(Boolean) } @@ -223,10 +244,11 @@ const parseLoginPayload = (value: unknown): ProjectLoginConfig | { enabled: fals } } -const buildPublicNotificationInput = (input: { +const buildPublicNotificationInput = async (input: { project: ProjectConfig body: Record | undefined requireIdempotency: boolean + resolveRecipientAlias: (recipientAlias: string) => Promise }) => { const body = input.body || {} const idempotencyKey = clean(body.idempotencyKey) @@ -242,25 +264,25 @@ const buildPublicNotificationInput = (input: { if (recipientAlias.length > 50 || /[\u0000-\u001f\u007f]/.test(recipientAlias)) { throw new PublicApiError(422, 'RECIPIENT_ALIAS_INVALID') } - const recipientUserId = requestedRecipientUserId || input.project.recipientAliases.get(normalizeRecipientAlias(recipientAlias)) || '' - if (recipientAlias && !recipientUserId) { - throw new PublicApiError(422, 'RECIPIENT_ALIAS_NOT_FOUND') + let recipientUserId = requestedRecipientUserId + if (!recipientUserId && recipientAlias) { + try { + recipientUserId = clean(await input.resolveRecipientAlias(recipientAlias)) + } catch (error) { + if (error instanceof NotificationDirectoryError) { + throw new PublicApiError(error.status, error.code, error.message) + } + throw error + } } - let destinationActionUrl: string - let renderedActionUrl: string + let destinationActionUrl = '' try { - destinationActionUrl = validateActionUrl(clean(body.actionUrl), input.project) - renderedActionUrl = input.project.login - ? buildH5EntryUrl({ - publicBaseUrl: getPublicBaseUrl(), - project: input.project, - redirectPath: getRedirectPathFromActionUrl(destinationActionUrl, input.project), - }) - : destinationActionUrl + destinationActionUrl = normalizeActionUrl(clean(body.actionUrl)) } catch (error: any) { throw new PublicApiError(422, 'ACTION_URL_INVALID', clean(error?.message)) } + const renderedActionUrl = renderActionUrl(destinationActionUrl, input.project) if (input.requireIdempotency && (!idempotencyKey || idempotencyKey.length > 160)) { throw new PublicApiError(422, 'IDEMPOTENCY_KEY_INVALID') @@ -340,14 +362,19 @@ const createOrReuseDelivery = async (input: { return { row: created, reused: false } } -const buildAdminNotificationInput = (project: ProjectConfig, body: Record | undefined) => { - const parsed = buildPublicNotificationInput({ +const buildAdminNotificationInput = async ( + project: ProjectConfig, + body: Record | undefined, + resolveRecipientAlias: (recipientAlias: string) => Promise, +) => { + const parsed = await buildPublicNotificationInput({ project, body: { ...body, idempotencyKey: body?.idempotencyKey || `admin-test:${project.code}:${Date.now()}:${crypto.randomUUID()}`, }, requireIdempotency: true, + resolveRecipientAlias, }) return { ...parsed, @@ -358,7 +385,7 @@ const buildAdminNotificationInput = (project: ProjectConfig, body: Record | undefined): ProjectUpsertInput => { const input = body || {} return { - projectCode: clean(input.projectCode), + projectCode: clean(input.projectCode || input.code), name: clean(input.name), allowedHosts: parseAllowedHosts(input.allowedHosts), isActive: parseOptionalBoolean(input.isActive, 'isActive'), @@ -386,6 +413,7 @@ const writeAdminError = (res: Response, error: AdminApiError) => export const createServer = (projectSource: ProjectSource, dependencies: CreateServerDependencies = {}) => { const exchangeH5Code = dependencies.exchangeH5Code || exchangeH5AuthCodeForUserId const sendTestMessage = dependencies.sendTestMessage || sendRobotMessage + const resolveRecipientAlias = dependencies.resolveRecipientAlias || resolveRecipientAliasFromDirectory const app = express() app.disable('x-powered-by') @@ -544,10 +572,11 @@ export const createServer = (projectSource: ProjectSource, dependencies: CreateS app.post('/v1/notifications', authenticate, async (req: SignedRequest, res, next) => { try { - const payload = buildPublicNotificationInput({ + const payload = await buildPublicNotificationInput({ project: req.project!, body: req.body as Record | undefined, requireIdempotency: true, + resolveRecipientAlias, }) const created = await createOrReuseDelivery({ payload }) res.status(created.reused ? 200 : 202).json(deliveryView(created.row)) @@ -748,7 +777,11 @@ export const createServer = (projectSource: ProjectSource, dependencies: CreateS app.post('/admin/projects/:projectCode/test', authenticateAdmin, async (req: AdminRequest, res, next) => { try { const project = await projectSource.resolveProjectConfigForCreate(clean(req.params.projectCode)) - const payload = buildAdminNotificationInput(project, req.body as Record | undefined) + const payload = await buildAdminNotificationInput( + project, + req.body as Record | undefined, + resolveRecipientAlias, + ) const created = await createOrReuseDelivery({ payload, isTest: true, @@ -788,7 +821,13 @@ export const createServer = (projectSource: ProjectSource, dependencies: CreateS detailsJson: JSON.stringify({ recipientUserIdMasked: maskUserId(created.row.recipientUserId), title: created.row.businessTitle, - actionUrlHost: new URL(payload.destinationActionUrl).host, + actionUrlHost: (() => { + try { + return payload.destinationActionUrl ? new URL(payload.destinationActionUrl).host : null + } catch { + return null + } + })(), sentDate: getTodayLabel(), }), },