2026-07-17 17:39:13 +08:00

119 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 由壹点通知中心
由壹点内部项目统一钉钉机器人单聊出口。只有本服务保存钉钉应用密钥和 `RobotCode`;素材库、固定资产、产品开发平台等业务系统使用各自的项目密钥调用本服务。
## 业务项目接入
新项目不要从本 README 中零散复制接口代码,请按以下规范接入:
- [接入指南](docs/接入指南.md):项目登记、环境变量、签名、发送、多人通知和上线验证。
- [消息规范](docs/消息规范.md):标题、正文、状态和按钮的统一写法。
- [错误与状态规范](docs/错误与状态规范.md):HTTP 返回、投递状态、错误码和重试边界。
- [新项目接入清单](docs/新项目接入清单.md):从申请到生产验证的逐项门禁。
- [Node.js 客户端示例](examples/node/README.md):可以直接复制到业务后端的无依赖实现。
接入的核心边界:业务项目只保存自己的项目代码和 HMAC 密钥;钉钉 `AppKey`、`AppSecret`、`RobotCode` 始终只由通知中心持有。
## 安全边界
- 每个项目使用独立 HMAC 密钥。
- 请求签名包含时间、nonce、HTTP 方法、路径和正文摘要。
- nonce 五分钟内只能使用一次。
- 每个项目只能跳转到配置的 HTTPS 域名。
- `projectCode + idempotencyKey` 唯一,防止业务重试造成重复通知。
- 项目无法读取其它项目的投递记录。
- 钉钉 `AppSecret` 只保存在通知中心;业务系统只用自己的项目 HMAC 密钥兑换登录票据。
- 登录票据随机生成、只存 SHA-256 摘要、最长 60 秒、绑定项目和回跳域名并且只能原子消费一次。
- 免登码和登录票据都只通过 POST 传递,不进入 URL;服务日志不记录二者和任何密钥。
## 本地启动
1. 复制 `.env.example` 为 `.env` 并填写真实配置;不要提交 `.env`。
Windows 下项目目录包含空格,`DATABASE_URL` 请保留为完整绝对路径。
2. 执行 `npm install`。
3. 执行 `npm run prisma:generate`。
4. 执行 `npm run prisma:migrate`。
5. 执行 `npm run dev`。
健康检查:`GET /health`。
`npm test` 不读取本地 `.env`:测试入口会创建唯一临时 SQLite,先执行正式 Prisma 迁移,再串行运行测试,并在成功或失败后清理数据库、journal、WAL 和 SHM 文件。
生产环境建议只把容器端口绑定到 `127.0.0.1`,再由 Nginx 提供独立 HTTPS 域名。素材库和固定资产可能位于不同主机,因此不能把业务项目配置成通知中心的容器内网地址。
当前生产部署脚本位于:
- `scripts/deploy-material-host.sh`:只管理通知中心独立容器、配置和独立 FRP 服务;
- `scripts/deploy-public-nginx.sh`:备份并扩展素材库专用 FRP 端口白名单,然后为素材库公网域名增加 `/notification-center/` 反向代理;脚本会验证主站、S3 和通知中心,失败时回滚。
脚本不包含任何密钥。统一机器人凭证从素材库生产环境安全复制到通知中心独立 `.env`,项目 HMAC 密钥在服务器生成并以 `0600` 权限保存。
发布脚本会先一致性备份通知中心 SQLite、环境配置和源码,再从本次解压的源码强制构建镜像并 `force-recreate`,不会因为服务器上存在旧 `latest` 镜像而跳过新代码。素材库生产 NAS 无法稳定访问 Docker Hub,正式发布前应预载 `node:22-alpine` 基础镜像并确认 npm 依赖源可用;构建失败会在重建容器前终止,旧服务继续运行。
## 签名算法
请求头:
- `x-yyd-project-code`
- `x-yyd-timestamp`:Unix 秒
- `x-yyd-nonce`:每次请求生成新的随机字符串
- `x-yyd-signature`:HMAC-SHA256 十六进制字符串
待签名文本:
```text
timestamp\nnonce\nHTTP_METHOD\nPATH\nSHA256(rawBody)
```
发送接口:`POST /v1/notifications`。查询接口:`GET /v1/notifications/:id`。
通知中心只接受任务并异步发送,业务系统通过查询接口获取 `pending / sending / sent / unread / read / retrying / failed` 状态。
## 钉钉 H5 免登中转
项目的 `login` 配置是可选项;不配置时仍按原方式直接跳转,素材库等现有调用不受影响。启用后,通知中心会把业务传入的项目 HTTPS 地址转换为内部 `redirectPath`,并把卡片动作包装到统一入口:
```text
GET /v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fworkflows%2Fdisposals%2F53
```
入口页面向钉钉 JSAPI 同时传入公开的 `clientId`(即 AppKey)和 `corpId` 获取免登码,再以 JSON POST 调用:
```text
POST /v1/auth/h5-code
{"projectCode":"fixed_asset","redirectPath":"/workflows/disposals/53","code":"..."}
```
中心换取钉钉 `userId` 后返回 `{ticket, formPostUrl, expiresIn}`,页面立即通过隐藏表单把 `ticket` POST 到项目配置的精确 `callbackUrl`。业务后端收到票据后,使用与通知接口相同的 HMAC 头调用:
```text
POST /v1/auth/tickets/redeem
{"ticket":"..."}
```
成功响应只包含 `{dingtalkUserId, redirectPath}`。未知、过期、已消费或跨项目票据统一返回 `401 TICKET_INVALID_OR_EXPIRED`,不泄露票据是否存在。业务系统必须在创建 Session 前重新核验本地账号、员工绑定和在职状态。
项目配置示例:
```json
{
"code": "fixed_asset",
"name": "固定资产",
"secret": "独立随机项目密钥",
"allowedHosts": ["asset.jiansun.vip"],
"login": {
"enabled": true,
"callbackUrl": "https://asset.jiansun.vip/api/dingtalk/broker-login",
"allowedRedirectPathPrefixes": ["/workflows/disposals", "/dashboard", "/inventory"]
}
}
```
新增环境变量:
- `DINGTALK_CORP_ID`:统一钉钉应用所属企业 CorpId。
- `YYD_NOTIFICATION_PUBLIC_BASE_URL`:通知中心公网 HTTPS 基地址,生产为反向代理后的 `/notification-center` 地址。
- `DINGTALK_LOGIN_TICKET_TTL_SECONDS`:30~60 秒,默认 60 秒。
新建任务最多投递 5 次,明确的限流等未受理结果按 1、5、15、30 分钟退避。发送调用若在请求已发出后发生网络断开、超时、HTTP 408/5xx、钉钉成功响应缺少 `processQueryKey`,或进程在发送中途崩溃并遗留过期租约,会以 `DELIVERY_OUTCOME_UNKNOWN` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。