feat: simplify notification delivery contract

This commit is contained in:
WuYongJie 2026-08-29 20:41:07 +08:00
parent 3fb60314a2
commit 1badf9ad68
20 changed files with 516 additions and 564 deletions

View File

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

View File

@ -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` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。

View File

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

View File

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

View File

@ -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`,再依次阅读本清单、接入指南和消息规范;不要从其他业务项目复制钉钉应用凭证或机器人直连代码。
以上通过即可完成接入,不增加其它前置表单或审批门槛。

View File

@ -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
**申请人:** 无邪
**报废原因:** 设备损坏,无法继续维修
> 请核对资产及配件明细后处理。
按钮:立即审批
```
最终显示标题:`【固定资产】资产报废待审批`
最终卡片首行:`### 【固定资产】资产报废待审批`
业务项目应自行保证内容清楚、按钮可理解、跳转地址适合自己的使用环境。通知中心只负责可靠接收、项目隔离、分发和错误返回。

View File

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

View File

@ -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 提交:

View File

@ -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` | 标题、正文或按钮超长 | 压缩内容,把详情放回业务页面 |

View File

@ -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=(',', ':'))

View File

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

View File

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

View File

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

View File

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

View File

@ -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、方法、路径和正文', () => {

View File

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

View File

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

View File

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

View File

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