175 lines
11 KiB
Markdown
Raw Permalink 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):项目登记、环境变量、签名、发送、多人通知和上线验证。
- [消息规范](docs/消息规范.md):固定项目抬头、ActionCard / Markdown 模板选择、正文排版、按钮跳转及完整请求示例。
- [错误与状态规范](docs/错误与状态规范.md):HTTP 返回、投递状态、错误码和重试边界。
- [新项目接入清单](docs/新项目接入清单.md):从申请到生产验证的逐项门禁。
- [Node.js 客户端示例](examples/node/README.md):可以直接复制到业务后端的无依赖实现。
接入的核心边界:业务项目只保存自己的项目代码和 HMAC 密钥;钉钉 `AppKey`、`AppSecret`、`RobotCode` 始终只由通知中心持有。
收件人支持两种方式:业务项目可以直接提交钉钉 `userId`,也可以提交准确花名 `recipientAlias`。花名会由通知中心调用素材平台内部目录接口精确解析为 `userId`;找不到时拒绝发送,不做模糊猜测,也不再要求项目级收件人预登记。
## 安全边界
- 每个项目使用独立 HMAC 密钥。
- 请求签名包含时间、nonce、HTTP 方法、路径和正文摘要。
- nonce 五分钟内只能使用一次。
- `actionUrl` 按业务项目提交值使用,不限制协议、公网/内网地址或项目域名白名单;只有启用免登且目标本身满足现有 HTTPS 登录流程时,才包装到统一 H5 入口。
- `projectCode + idempotencyKey` 唯一,防止业务重试造成重复通知。
- 项目无法读取其它项目的投递记录。
- 钉钉 `AppSecret` 只保存在通知中心;业务系统只用自己的项目 HMAC 密钥兑换登录票据。
- 登录票据随机生成、只存 SHA-256 摘要、最长 60 秒、绑定项目和回跳域名并且只能原子消费一次。
- 免登码和登录票据都只通过 POST 传递,不进入 URL;服务日志不记录二者和任何密钥。
## 本地启动
1. 复制 `.env.example` 为 `.env` 并填写真实配置;不要提交 `.env`。
Windows 下项目目录包含空格,`DATABASE_URL` 请保留为完整绝对路径。
`YYD_NOTIFICATION_ADMIN_API_SECRET`(兼容旧变量 `YYD_NOTIFICATION_ADMIN_TOKEN`)和 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY` 只保存在通知中心服务端。
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 文件。
## 项目配置来源与管理 API
当 `YYD_NOTIFICATION_ADMIN_API_SECRET`(或兼容旧变量 `YYD_NOTIFICATION_ADMIN_TOKEN`)与 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY` 都已配置时,通知项目配置以数据库为准;`YYD_NOTIFICATION_PROJECTS_JSON` 只承担“首次/补录导入缺失项目”的 bootstrap 角色:
- 服务启动时会尝试把 `YYD_NOTIFICATION_PROJECTS_JSON` 中尚未登记的项目导入数据库;
- 已存在项目不会被覆盖,已轮换密钥、已手工维护的 `recipients` 和登录配置保持不变;
- 运维可用 `POST /admin/runtime/import-env` 再次补录当前环境变量中的缺失项目;
- 运维可用 `POST /admin/runtime/refresh` 让运行时缓存重新读取数据库,不必重启进程。
- 如果管理员密钥或加密密钥缺失,服务会安全回退到 legacy 内存模式:继续直接使用 `YYD_NOTIFICATION_PROJECTS_JSON` 提供旧 `/v1/*` 能力,且不挂载 `/admin/*`。
管理 API 使用独立管理员 Bearer Secret,与业务项目 HMAC 密钥隔离;该 Secret 同时复用于内部花名目录调用:
- 主变量:`YYD_NOTIFICATION_ADMIN_API_SECRET`
- 兼容旧变量:`YYD_NOTIFICATION_ADMIN_TOKEN`
- 请求头:`Authorization: Bearer <YYD_NOTIFICATION_ADMIN_API_SECRET>`
- 可选 `x-yyd-admin-actor`:写入审计日志的操作人标识
当前管理接口:
- `GET /admin/projects` / `GET /admin/projects/:projectCode`
- `POST /admin/projects`
- `PATCH /admin/projects/:projectCode`
- `DELETE /admin/projects/:projectCode`
- `GET|PUT /admin/projects/:projectCode/recipients`
- `POST /admin/projects/:projectCode/rotate-secret`
- `POST /admin/projects/:projectCode/test`
- `GET /admin/deliveries`
- `GET /admin/audits`
- `POST /admin/runtime/import-env`
- `POST /admin/runtime/refresh`
稳定响应 contract:
- 项目列表:`{ projects: [...] }`,默认包含 `projectCode / name / recipientCount / recipients / deliverySummary`
- 创建/轮换密钥:`{ project, secret, env }`
- 投递列表:`{ deliveries, total }`
- 审计列表:`{ audits, total }`
- 管理错误:`{ error: { code, message, details? } }`
其中 `secret` 与 `env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET` 只会在 `POST /admin/projects` 和 `POST /admin/projects/:projectCode/rotate-secret` 当次响应里出现一次,后续列表、详情和审计都不会回显明文密钥。
请求体大小限制:
- 业务 `/v1/*`:64 KB
- 管理 `/admin/*`:256 KB
普通业务接入接口 `POST /v1/notifications`、`GET /v1/notifications/:id`、`GET /v1/notifications` 与原签名协议保持兼容。消息字段仍是 `title / content / actionTitle / actionUrl` 四件套:通知中心生成 `【项目名称】业务标题`,并将其作为 Markdown 标题固定显示在钉钉可见正文顶部;业务项目决定其后的正文排版、按钮文案和跳转目标。有 `actionUrl` 时使用原生 ActionCard,无地址时使用无按钮的普通 Markdown 消息。样式示例和总显示长度说明见[消息规范](docs/消息规范.md)。
生产环境建议只把容器端口绑定到 `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 依赖源可用;构建失败会在重建容器前终止,旧服务继续运行。
Git 发布入口会在生产机首次生成、后续稳定保留 `YYD_NOTIFICATION_ADMIN_API_SECRET` 与 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY`,并只把管理地址和管理员密钥同步到素材库后端 `.env`;脚本只打印配置完成状态,不输出密钥原文。
## 签名算法
请求头:
- `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` 地址。
- `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` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。