feat: simplify notification delivery contract
This commit is contained in:
parent
3fb60314a2
commit
1badf9ad68
@ -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"]}]
|
||||
|
||||
@ -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` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。
|
||||
|
||||
@ -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}
|
||||
|
||||
36
docs/接入指南.md
36
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`。
|
||||
|
||||
@ -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`,再依次阅读本清单、接入指南和消息规范;不要从其他业务项目复制钉钉应用凭证或机器人直连代码。
|
||||
以上通过即可完成接入,不增加其它前置表单或审批门槛。
|
||||
|
||||
175
docs/消息规范.md
175
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
|
||||
|
||||
**申请人:** 无邪
|
||||
|
||||
**报废原因:** 设备损坏,无法继续维修
|
||||
|
||||
> 请核对资产及配件明细后处理。
|
||||
|
||||
按钮:立即审批
|
||||
```
|
||||
|
||||
最终显示标题:`【固定资产】资产报废待审批`
|
||||
|
||||
最终卡片首行:`### 【固定资产】资产报废待审批`
|
||||
业务项目应自行保证内容清楚、按钮可理解、跳转地址适合自己的使用环境。通知中心只负责可靠接收、项目隔离、分发和错误返回。
|
||||
|
||||
@ -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)。
|
||||
|
||||
@ -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 提交:
|
||||
|
||||
@ -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` | 标题、正文或按钮超长 | 压缩内容,把详情放回业务页面 |
|
||||
|
||||
@ -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=(',', ':'))
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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<void>((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<void>((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<void>((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<Record<string, string>> = []
|
||||
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,
|
||||
|
||||
@ -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/)
|
||||
|
||||
@ -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<void>((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正文`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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、方法、路径和正文', () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<string, ProjectConfig>) => {
|
||||
if (projects.size === 0) throw new Error('未配置任何通知项目')
|
||||
const dingTalk = getDingTalkConfig()
|
||||
|
||||
94
src/directory.ts
Normal file
94
src/directory.ts
Normal file
@ -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)
|
||||
}
|
||||
119
src/server.ts
119
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<string>
|
||||
sendTestMessage?: typeof sendRobotMessage
|
||||
resolveRecipientAlias?: (recipientAlias: string) => Promise<string>
|
||||
}
|
||||
|
||||
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<string, unknown> | undefined
|
||||
requireIdempotency: boolean
|
||||
resolveRecipientAlias: (recipientAlias: string) => Promise<string>
|
||||
}) => {
|
||||
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<string, unknown> | undefined) => {
|
||||
const parsed = buildPublicNotificationInput({
|
||||
const buildAdminNotificationInput = async (
|
||||
project: ProjectConfig,
|
||||
body: Record<string, unknown> | undefined,
|
||||
resolveRecipientAlias: (recipientAlias: string) => Promise<string>,
|
||||
) => {
|
||||
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<string
|
||||
const parseAdminProjectCreateBody = (body: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined)
|
||||
const payload = await buildAdminNotificationInput(
|
||||
project,
|
||||
req.body as Record<string, unknown> | 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(),
|
||||
}),
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user