From 41b1c0c3dd3b63605bf2c824596e2f60a5c45563 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 16 Jul 2026 21:00:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=92=89=E9=92=89=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=A0=87=E9=A2=98=E4=B8=8E=E6=AD=A3=E6=96=87=E6=8E=92?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/接入指南.md | 12 +- docs/新项目接入清单.md | 5 +- docs/消息规范.md | 77 +++++++++--- examples/node/README.md | 8 +- examples/node/notification-client.test.mjs | 2 +- src/__tests__/server.test.ts | 140 ++++++++++++++++++++- src/__tests__/worker.test.ts | 47 +++++++ src/server.ts | 25 +++- 8 files changed, 278 insertions(+), 38 deletions(-) diff --git a/docs/接入指南.md b/docs/接入指南.md index 3419f4e..5e161f5 100644 --- a/docs/接入指南.md +++ b/docs/接入指南.md @@ -47,7 +47,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥 "idempotencyKey": "disposal_applied:53:approver-user-id:v1", "recipientUserId": "钉钉userId", "title": "资产报废待审批", - "content": "### 待你审批\n\n**资产:** 摄影灯 A-102\n**申请人:** 张三", + "content": "**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102\n\n**申请人:** 张三", "actionTitle": "立即审批", "actionUrl": "https://asset.jiansun.vip/workflows/disposals/53" } @@ -59,23 +59,25 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥 | --- | --- | | `idempotencyKey` | 必填,最长 160 字符;同一业务事件、同一收件人必须稳定不变 | | `recipientUserId` | 必填,只能使用企业通讯录中的钉钉 `userId` | -| `title` | 必填,最长 100 字符;不要添加 `【由壹点】` 或项目名称 | -| `content` | 必填,Markdown,最长 4800 字符 | +| `title` | 必填,最长 100 字符;只写业务主题,不要添加 `【项目名】`,拼接项目名后的最终标题不能超过 120 字符 | +| `content` | 必填,Markdown,业务正文最长 4800 字符;不要自行添加首行标题,各字段之间必须空一行,通知中心加上统一首行后的最终正文也不能超过 4800 字符 | | `actionTitle` | 可选,最长 20 字符,默认“查看详情” | | `actionUrl` | 必填,标准 443 端口的完整 HTTPS 地址,域名必须在项目白名单内 | 通知中心会自动生成最终标题: ```text -【由壹点】{项目名称}·{业务标题} +【{项目名称}】{业务标题} ``` 例如业务项目提交“资产报废待审批”,用户最终看到的是: ```text -【由壹点】固定资产·资产报废待审批 +【固定资产】资产报废待审批 ``` +卡片正文首行也由通知中心统一生成为 `### 【项目名称】业务标题`。为兼容已接入项目,业务正文如果仍以三级 Markdown 标题(`###`)开头,通知中心会先移除该旧标题,再生成统一首行,避免出现双标题;其它级别标题按正文原样保留。 + ## 5. 请求签名 每次请求必须携带: diff --git a/docs/新项目接入清单.md b/docs/新项目接入清单.md index 1bce05b..4b99d26 100644 --- a/docs/新项目接入清单.md +++ b/docs/新项目接入清单.md @@ -39,8 +39,9 @@ - [ ] 无效钉钉 `userId` 能进入明确失败状态。 - [ ] 重复发送同一请求不会产生第二条消息。 - [ ] 同一幂等键提交不同内容会被拒绝。 -- [ ] 最终标题中的“由壹点”和项目来源正确。 -- [ ] 正文第一眼能看出当前状态和需要采取的动作。 +- [ ] 最终标题使用 `【项目名称】业务主题`,项目来源正确且醒目。 +- [ ] 正文首行与最终标题一致,时间紧随其后,各字段之间有空行。 +- [ ] 正文第一眼能看出业务主题、发生时间、核心内容和需要采取的动作。 - [ ] 按钮名称明确,跳转到正确的白名单域名。 - [ ] 能查询到 `sent / unread / read / failed` 状态。 - [ ] 如启用免登,票据只能消费一次,且业务项目重新核验本地账号和权限。 diff --git a/docs/消息规范.md b/docs/消息规范.md index 26b0dc2..2519669 100644 --- a/docs/消息规范.md +++ b/docs/消息规范.md @@ -1,51 +1,80 @@ # 由壹点通知消息规范 -统一机器人解决的是“谁发出的”,项目名称解决的是“消息来自哪里”,业务标题和首屏内容解决的是“用户现在要做什么”。所有接入项目必须遵守本规范。 +统一机器人已经说明“由壹点发出的”,每条消息不再重复品牌名。项目名称解决“消息来自哪里”,业务标题、时间和首屏内容解决“发生了什么、用户现在要做什么”。所有接入项目必须遵守本规范。 ## 1. 标题规则 最终标题由通知中心统一拼接: ```text -【由壹点】项目名称·业务标题 +【项目名称】业务标题 ``` +例如: + +- `【固定资产】资产报废待审批` +- `【素材库】新的考核调研待填写` +- `【产品开发】任务即将到期` + 业务项目只提交业务标题,例如: - `新的考核调研待填写` - `资产报废待审批` - `产品任务即将到期` -禁止: +通知中心根据 `projectCode` 生成 `【项目名称】`,业务项目不能自行拼接。禁止: -- 自己添加 `【由壹点】`、机器人名称或项目名称。 +- 自己添加 `【项目名称】`、机器人名称或其它来源前缀。 - 使用“通知”“消息提醒”“统一消息测试”等无法表达任务的空泛标题。 - 在标题中堆积人员、日期、编号等详细字段。 - 使用多个 emoji 或连续感叹号制造紧迫感。 ## 2. 内容结构 -消息第一屏按以下顺序组织: - -1. 当前状态或用户动作。 -2. 最重要的业务对象。 -3. 截止时间或异常原因。 -4. 一句必要补充。 - -推荐模板: +通知中心会把卡片正文首行统一生成为: ```markdown -### 待你填写 +### 【项目名称】业务标题 +``` + +业务项目不要再提交自己的 Markdown 首行标题。为兼容旧项目,通知中心只会移除正文开头已有的三级 Markdown 标题(`###`),再生成统一首行;其它级别标题按正文原样保留。 + +消息第一屏按以下顺序组织: + +1. 项目名称和业务主题,由通知中心统一生成。 +2. 申请时间、处理时间或截止时间。 +3. 最重要的业务对象和相关人员。 +4. 原因、结果或异常说明。 +5. 一句必要的行动提示。 + +业务项目推荐提交: + +```markdown +**截止时间:** 2026年7月31日 18:00 **调研:** 2026年7月 · 跨部门协同 **评价对象:** 无邪 -**截止时间:** 7月31日 18:00 +> 请在截止时间前完成评分。 +``` + +通知中心最终渲染为: + +```markdown +### 【素材库】新的考核调研待填写 + +**截止时间:** 2026年7月31日 18:00 + +**调研:** 2026年7月 · 跨部门协同 + +**评价对象:** 无邪 > 请在截止时间前完成评分。 ``` +每个字段之间必须保留一个空行。不要只使用单换行;钉钉会把连续字段合并成一段,明显降低阅读性。 + 不要写成一整段: ```text @@ -105,31 +134,39 @@ ```text 标题:新的考核调研待填写 内容: -### 待你填写 +**截止时间:** 2026年7月31日 18:00 **调研:** 2026年7月 · 工作配合度 + **评价对象:** 无邪 -**截止时间:** 7月31日 18:00 > 请在截止时间前完成评分。 按钮:立即填写 ``` -最终显示标题:`【由壹点】素材库·新的考核调研待填写` +最终显示标题:`【素材库】新的考核调研待填写` + +最终卡片首行:`### 【素材库】新的考核调研待填写` ### 固定资产 ```text 标题:资产报废待审批 内容: -### 待你审批 +**申请时间:** 2026年7月16日 14:30 **资产:** 摄影灯 A-102 + **申请人:** 无邪 -**申请时间:** 7月16日 14:30 + +**报废原因:** 设备损坏,无法继续维修 + +> 请核对资产及配件明细后处理。 按钮:立即审批 ``` -最终显示标题:`【由壹点】固定资产·资产报废待审批` +最终显示标题:`【固定资产】资产报废待审批` + +最终卡片首行:`### 【固定资产】资产报废待审批` diff --git a/examples/node/README.md b/examples/node/README.md index 7bad6d1..a4e53e1 100644 --- a/examples/node/README.md +++ b/examples/node/README.md @@ -19,11 +19,11 @@ const delivery = await client.send({ recipientUserId, title: '资产报废待审批', content: [ - '### 待你审批', - '', - '**资产:** 摄影灯 A-102 ', - '**申请人:** 无邪 ', '**申请时间:** 2026年7月16日 14:30', + '', + '**资产:** 摄影灯 A-102', + '', + '**申请人:** 无邪', ].join('\n'), actionTitle: '立即审批', actionUrl: 'https://asset.jiansun.vip/workflows/disposals/53', diff --git a/examples/node/notification-client.test.mjs b/examples/node/notification-client.test.mjs index a9ad884..d74cceb 100644 --- a/examples/node/notification-client.test.mjs +++ b/examples/node/notification-client.test.mjs @@ -34,7 +34,7 @@ test('发送通知使用业务正文原文签名并返回投递记录', async () idempotencyKey: 'asset_disposal:53:user-1:v1', recipientUserId: 'user-1', title: '资产报废待审批', - content: '### 待你审批', + content: '**申请时间:** 2026年7月16日 14:30', actionTitle: '立即审批', actionUrl: 'https://asset.example.com/workflows/disposals/53', } diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 63ee2a2..9760035 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -4,8 +4,8 @@ import test from 'node:test' import { AddressInfo } from 'node:net' import { loadProjectConfigs } from '../config' import prisma from '../prisma' -import { createServer } from '../server' -import { createCanonicalSignature } from '../signature' +import { createServer, renderNotificationContent, renderNotificationTitle } from '../server' +import { createCanonicalSignature, sha256 } from '../signature' const projectSecret = '123456789012345678901234' const projects = loadProjectConfigs(JSON.stringify([ @@ -34,7 +34,7 @@ test('通知中心验证签名、项目域名和幂等键', async () => { idempotencyKey: 'disposal_applied:53:1', recipientUserId: 'ding-user-1', title: '待审批报废', - content: '有一项报废申请等待审批', + content: '### 待你审批\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102', actionTitle: '查看详情', actionUrl: 'https://asset.example.com/workflows/disposals/53', }) @@ -43,9 +43,14 @@ test('通知中心验证签名、项目域名和幂等键', async () => { const first = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body }) assert.equal(first.status, 202) const firstPayload = await first.json() as any - assert.equal(firstPayload.title, '【由壹点】固定资产·待审批报废') + assert.equal(firstPayload.title, '【固定资产】待审批报废') const stored = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: firstPayload.id } }) assert.equal(stored.maxAttempts, 5) + assert.equal( + stored.content, + '### 【固定资产】待审批报废\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102', + ) + assert.doesNotMatch(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) @@ -55,6 +60,133 @@ test('通知中心验证签名、项目域名和幂等键', async () => { 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 reservedTitleBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'reserved-title', title: '【固定资产】待审批报废' }) + const reservedTitle = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', headers: headers('POST', path, reservedTitleBody), body: reservedTitleBody, + }) + assert.equal(reservedTitle.status, 422) + assert.deepEqual(await reservedTitle.json(), { error: 'TITLE_BRAND_RESERVED' }) + + const urgentTitleBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'urgent-title', title: '【紧急】资产报废待审批' }) + const urgentTitle = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', headers: headers('POST', path, urgentTitleBody), body: urgentTitleBody, + }) + assert.equal(urgentTitle.status, 202) + assert.equal((await urgentTitle.json() as any).title, '【固定资产】【紧急】资产报废待审批') + + const tooLongBody = JSON.stringify({ + ...JSON.parse(body), + idempotencyKey: 'rendered-content-too-long', + content: '内'.repeat(4800), + }) + const tooLong = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', headers: headers('POST', path, tooLongBody), body: tooLongBody, + }) + assert.equal(tooLong.status, 422) + assert.deepEqual(await tooLong.json(), { error: 'MESSAGE_TOO_LONG' }) + + const legacyRequest = { + idempotencyKey: 'legacy-existing', + recipientUserId: 'ding-user-1', + title: '【固定资产】旧版消息', + content: '旧'.repeat(4800), + actionTitle: '查看详情', + actionUrl: 'https://asset.example.com/workflows/disposals/53', + } + const legacyRequestHash = sha256(JSON.stringify({ + recipientUserId: legacyRequest.recipientUserId, + title: legacyRequest.title, + content: legacyRequest.content, + actionTitle: legacyRequest.actionTitle, + actionUrl: legacyRequest.actionUrl, + })) + await prisma.notificationDelivery.create({ + data: { + id: 'legacy-existing-delivery', + projectCode: 'fixed_asset', + idempotencyKey: legacyRequest.idempotencyKey, + requestHash: legacyRequestHash, + recipientUserId: legacyRequest.recipientUserId, + businessTitle: legacyRequest.title, + renderedTitle: `【由壹点】固定资产·${legacyRequest.title}`, + content: legacyRequest.content, + actionTitle: legacyRequest.actionTitle, + actionUrl: legacyRequest.actionUrl, + }, + }) + const legacyBody = JSON.stringify(legacyRequest) + const legacyResponse = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', headers: headers('POST', path, legacyBody), body: legacyBody, + }) + assert.equal(legacyResponse.status, 200) + const legacyPayload = await legacyResponse.json() as any + assert.equal(legacyPayload.id, 'legacy-existing-delivery') + assert.equal(legacyPayload.title, `【由壹点】固定资产·${legacyRequest.title}`) + const unchangedLegacy = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: 'legacy-existing-delivery' } }) + assert.equal(unchangedLegacy.content, legacyRequest.content) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + await prisma.$disconnect() + } +}) + +test('通知标题突出项目,正文首行由通知中心统一生成', () => { + assert.equal(renderNotificationTitle('固定资产', '资产报废待审批'), '【固定资产】资产报废待审批') + assert.equal( + renderNotificationContent('固定资产', '资产报废待审批', '**申请时间:** 2026年7月16日 14:30'), + '### 【固定资产】资产报废待审批\n\n**申请时间:** 2026年7月16日 14:30', + ) + assert.equal( + renderNotificationContent('固定资产', '资产报废待审批', '### 待你审批\n\n正文'), + '### 【固定资产】资产报废待审批\n\n正文', + ) + assert.equal( + renderNotificationContent('固定资产', '资产报废待审批', '### 【固定资产】资产报废待审批\n\n正文'), + '### 【固定资产】资产报废待审批\n\n正文', + ) + for (const heading of ['# 一级标题', '## 二级标题', '#### 四级标题']) { + assert.equal( + renderNotificationContent('固定资产', '资产报废待审批', `${heading}\n\n正文`), + `### 【固定资产】资产报废待审批\n\n${heading}\n\n正文`, + ) + } +}) + +test('渲染后标题长度边界由通知入口校验', async () => { + const longName = '项'.repeat(20) + const longSecret = 'abcdefghijklmnopqrstuvwxyz123456' + const longProjects = loadProjectConfigs(JSON.stringify([ + { code: 'long_project', name: longName, secret: longSecret, allowedHosts: ['long.example.com'] }, + ])) + const server = createServer(longProjects).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + const path = '/v1/notifications' + const body = JSON.stringify({ + idempotencyKey: 'rendered-title-too-long', + recipientUserId: 'ding-user-1', + title: '题'.repeat(100), + content: '正文', + actionTitle: '查看详情', + actionUrl: 'https://long.example.com/detail', + }) + const timestamp = String(Math.floor(Date.now() / 1000)) + const nonce = crypto.randomBytes(18).toString('base64url') + try { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-yyd-project-code': 'long_project', + 'x-yyd-timestamp': timestamp, + 'x-yyd-nonce': nonce, + 'x-yyd-signature': createCanonicalSignature({ secret: longSecret, timestamp, nonce, method: 'POST', path, body }), + }, + body, + }) + assert.equal(response.status, 422) + assert.deepEqual(await response.json(), { error: 'MESSAGE_TOO_LONG' }) } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) await prisma.$disconnect() diff --git a/src/__tests__/worker.test.ts b/src/__tests__/worker.test.ts index ff04c5f..b65119d 100644 --- a/src/__tests__/worker.test.ts +++ b/src/__tests__/worker.test.ts @@ -41,6 +41,53 @@ test('发送请求超时属于结果不确定并停止自动重试', async () => } }) +test('机器人 ActionCard 原样发送统一标题、分段正文和按钮跳转', async () => { + const originalFetch = global.fetch + const oldAppKey = process.env.DINGTALK_APP_KEY + const oldAppSecret = process.env.DINGTALK_APP_SECRET + const oldRobotCode = process.env.DINGTALK_ROBOT_CODE + process.env.DINGTALK_APP_KEY = 'test-app-key' + process.env.DINGTALK_APP_SECRET = 'test-app-secret' + process.env.DINGTALK_ROBOT_CODE = 'test-robot-code' + let batchRequest: Record | undefined + global.fetch = (async (input, init) => { + const url = String(input) + if (url.includes('/gettoken?')) { + return new Response(JSON.stringify({ errcode: 0, access_token: 'test-token', expires_in: 7200 }), { status: 200 }) + } + assert.equal(url, 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend') + batchRequest = JSON.parse(String(init?.body || '{}')) + return new Response(JSON.stringify({ processQueryKey: 'process-query-key-1' }), { status: 200 }) + }) as typeof fetch + + const input = { + recipientUserId: 'ding-user-1', + title: '【固定资产】资产报废待审批', + content: '### 【固定资产】资产报废待审批\n\n**申请时间:** 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') + assert.ok(batchRequest) + assert.equal(batchRequest.robotCode, 'test-robot-code') + assert.deepEqual(batchRequest.userIds, ['ding-user-1']) + assert.equal(batchRequest.msgKey, 'sampleActionCard') + assert.deepEqual(JSON.parse(batchRequest.msgParam), { + title: input.title, + text: input.content, + singleTitle: input.actionTitle, + singleURL: input.actionUrl, + }) + } finally { + global.fetch = originalFetch + process.env.DINGTALK_APP_KEY = oldAppKey + process.env.DINGTALK_APP_SECRET = oldAppSecret + process.env.DINGTALK_ROBOT_CODE = oldRobotCode + } +}) + test('明确限流可安全重试,确定性错误和耗尽次数则停止', () => { assert.equal(classifyDeliveryFailure({ error: new DingTalkError('限流', 'FLOW_CONTROLLED'), attemptCount: 1, maxAttempts: 4, diff --git a/src/server.ts b/src/server.ts index b05a4f8..cb8ef2f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,21 @@ import { createCanonicalSignature, safeEqual, sha256 } from './signature' type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig } const clean = (value: unknown) => String(value || '').trim() + +export const renderNotificationTitle = (projectName: string, businessTitle: string) => + `【${clean(projectName)}】${clean(businessTitle)}` + +export const renderNotificationContent = ( + 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 +} const ticketTtlSeconds = () => { const configured = Number(process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS || 60) return Number.isInteger(configured) && configured >= 30 && configured <= 60 ? configured : 60 @@ -216,7 +231,6 @@ export const createServer = (projects: Map, dependencies: if (title.length > 100 || content.length > 4800 || actionTitle.length > 20) return res.status(422).json({ error: 'MESSAGE_TOO_LONG' }) if (title.startsWith('【由壹点')) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' }) - const renderedTitle = `【由壹点】${project.name}·${title}` const normalized = { recipientUserId, title, content, actionTitle, actionUrl: destinationActionUrl } const requestHash = sha256(JSON.stringify(normalized)) const existing = await prisma.notificationDelivery.findUnique({ @@ -227,6 +241,13 @@ export const createServer = (projects: Map, dependencies: return res.status(200).json(deliveryView(existing)) } + if (title.startsWith(`【${project.name}】`)) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' }) + const renderedTitle = renderNotificationTitle(project.name, title) + const renderedContent = renderNotificationContent(project.name, title, content) + if (renderedTitle.length > 120 || renderedContent.length > 4800) { + return res.status(422).json({ error: 'MESSAGE_TOO_LONG' }) + } + const created = await prisma.notificationDelivery.create({ data: { id: crypto.randomUUID(), @@ -236,7 +257,7 @@ export const createServer = (projects: Map, dependencies: recipientUserId, businessTitle: title, renderedTitle, - content, + content: renderedContent, actionTitle, actionUrl, maxAttempts: 5,