统一钉钉通知标题与正文排版

This commit is contained in:
WuYongJie 2026-07-16 21:00:33 +08:00
parent 5c9e53e159
commit 41b1c0c3dd
8 changed files with 278 additions and 38 deletions

View File

@ -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. 请求签名
每次请求必须携带:

View File

@ -39,8 +39,9 @@
- [ ] 无效钉钉 `userId` 能进入明确失败状态。
- [ ] 重复发送同一请求不会产生第二条消息。
- [ ] 同一幂等键提交不同内容会被拒绝。
- [ ] 最终标题中的“由壹点”和项目来源正确。
- [ ] 正文第一眼能看出当前状态和需要采取的动作。
- [ ] 最终标题使用 `【项目名称】业务主题`,项目来源正确且醒目。
- [ ] 正文首行与最终标题一致,时间紧随其后,各字段之间有空行。
- [ ] 正文第一眼能看出业务主题、发生时间、核心内容和需要采取的动作。
- [ ] 按钮名称明确,跳转到正确的白名单域名。
- [ ] 能查询到 `sent / unread / read / failed` 状态。
- [ ] 如启用免登,票据只能消费一次,且业务项目重新核验本地账号和权限。

View File

@ -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
**报废原因:** 设备损坏,无法继续维修
> 请核对资产及配件明细后处理。
按钮:立即审批
```
最终显示标题:`【由壹点】固定资产·资产报废待审批`
最终显示标题:`【固定资产】资产报废待审批`
最终卡片首行:`### 【固定资产】资产报废待审批`

View File

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

View File

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

View File

@ -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<void>((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<void>((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<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
await prisma.$disconnect()

View File

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

View File

@ -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<string, ProjectConfig>, 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<string, ProjectConfig>, 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<string, ProjectConfig>, dependencies:
recipientUserId,
businessTitle: title,
renderedTitle,
content,
content: renderedContent,
actionTitle,
actionUrl,
maxAttempts: 5,