接入由壹点统一通知中心
This commit is contained in:
parent
a6463fc30a
commit
8e295e48cd
@ -23,6 +23,12 @@ HR_SYNC_EXCLUDED_NAME_KEYWORDS=
|
||||
DINGTALK_APP_KEY=
|
||||
DINGTALK_APP_SECRET=
|
||||
DINGTALK_AGENT_ID=
|
||||
DINGTALK_ROBOT_CODE=
|
||||
DINGTALK_CORP_ID=
|
||||
DINGTALK_PUBLIC_BASE_URL=https://asset.example.com
|
||||
DINGTALK_SEND_ENABLED=false
|
||||
DINGTALK_NOTIFICATION_CHANNEL=work_notice
|
||||
YYD_NOTIFICATION_CENTER_URL=
|
||||
YYD_NOTIFICATION_CENTER_PROJECT_CODE=fixed_asset
|
||||
YYD_NOTIFICATION_CENTER_PROJECT_SECRET=
|
||||
YYD_NOTIFICATION_CENTER_TEST_USER_IDS=
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE `dingtalk_delivery`
|
||||
ADD COLUMN `provider_type` VARCHAR(20) NOT NULL DEFAULT 'work_notice' AFTER `lease_expires_at`;
|
||||
@ -739,6 +739,7 @@ model DingTalkDelivery {
|
||||
next_attempt_at DateTime @default(now())
|
||||
lease_owner String? @db.VarChar(100)
|
||||
lease_expires_at DateTime?
|
||||
provider_type String @default("work_notice") @db.VarChar(20)
|
||||
dingtalk_task_id String? @db.VarChar(100)
|
||||
payload_json String @db.Text
|
||||
last_error_code String? @db.VarChar(100)
|
||||
|
||||
@ -78,6 +78,7 @@ export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) =>
|
||||
const config = getDingTalkConfigStatus()
|
||||
successResponse(res, {
|
||||
configured: config.configured,
|
||||
channel: config.channel,
|
||||
send_enabled: config.send_enabled,
|
||||
enabled: config.send_enabled,
|
||||
missing: config.missing,
|
||||
@ -130,6 +131,7 @@ export const getDingTalkDeliveries = async (req: Request, res: Response) => {
|
||||
attempt_count: item.attempt_count,
|
||||
max_attempts: item.max_attempts,
|
||||
next_attempt_at: item.next_attempt_at,
|
||||
provider_type: item.provider_type,
|
||||
dingtalk_task_id: item.dingtalk_task_id,
|
||||
last_error_code: item.last_error_code,
|
||||
last_error: item.last_error_message,
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { getDingTalkNotificationChannelForUser } from '../dingtalkClient'
|
||||
|
||||
test('通知中心灰度名单以外的员工继续使用工作通知', () => {
|
||||
const originalChannel = process.env.DINGTALK_NOTIFICATION_CHANNEL
|
||||
const originalUsers = process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS
|
||||
process.env.DINGTALK_NOTIFICATION_CHANNEL = 'notification_center'
|
||||
process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = 'ding-test-1,ding-test-2'
|
||||
try {
|
||||
assert.equal(getDingTalkNotificationChannelForUser('ding-test-1'), 'notification_center')
|
||||
assert.equal(getDingTalkNotificationChannelForUser('ding-other'), 'work_notice')
|
||||
} finally {
|
||||
if (originalChannel === undefined) delete process.env.DINGTALK_NOTIFICATION_CHANNEL
|
||||
else process.env.DINGTALK_NOTIFICATION_CHANNEL = originalChannel
|
||||
if (originalUsers === undefined) delete process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS
|
||||
else process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = originalUsers
|
||||
}
|
||||
})
|
||||
140
backend/src/services/__tests__/dingtalkClient.robot.test.ts
Normal file
140
backend/src/services/__tests__/dingtalkClient.robot.test.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
DingTalkApiError,
|
||||
getDingTalkConfigStatus,
|
||||
getDingTalkRobotSendResult,
|
||||
sendDingTalkRobotNotification
|
||||
} from '../dingtalkClient'
|
||||
|
||||
const envKeys = [
|
||||
'DINGTALK_APP_KEY',
|
||||
'DINGTALK_APP_SECRET',
|
||||
'DINGTALK_AGENT_ID',
|
||||
'DINGTALK_ROBOT_CODE',
|
||||
'DINGTALK_CORP_ID',
|
||||
'DINGTALK_PUBLIC_BASE_URL',
|
||||
'DINGTALK_SEND_ENABLED',
|
||||
'DINGTALK_NOTIFICATION_CHANNEL'
|
||||
] as const
|
||||
|
||||
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]))
|
||||
const originalFetch = global.fetch
|
||||
|
||||
const configureDingTalk = () => {
|
||||
process.env.DINGTALK_APP_KEY = 'ding-app-key'
|
||||
process.env.DINGTALK_APP_SECRET = 'ding-app-secret'
|
||||
process.env.DINGTALK_AGENT_ID = '10001'
|
||||
process.env.DINGTALK_ROBOT_CODE = 'ding-robot-code'
|
||||
process.env.DINGTALK_CORP_ID = 'ding-corp'
|
||||
process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com'
|
||||
process.env.DINGTALK_SEND_ENABLED = 'true'
|
||||
process.env.DINGTALK_NOTIFICATION_CHANNEL = 'robot_oto'
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
global.fetch = originalFetch
|
||||
for (const key of envKeys) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = originalEnv[key]
|
||||
}
|
||||
})
|
||||
|
||||
test('机器人单聊配置状态要求 RobotCode 且不返回任何密钥值', () => {
|
||||
configureDingTalk()
|
||||
const status = getDingTalkConfigStatus()
|
||||
assert.deepEqual(status, {
|
||||
configured: true,
|
||||
channel: 'robot_oto',
|
||||
send_enabled: true,
|
||||
missing: []
|
||||
})
|
||||
assert.equal(JSON.stringify(status).includes('ding-app-secret'), false)
|
||||
assert.equal(JSON.stringify(status).includes('ding-robot-code'), false)
|
||||
})
|
||||
|
||||
test('机器人单聊使用 batchSend 并返回 processQueryKey', async () => {
|
||||
configureDingTalk()
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = []
|
||||
global.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init })
|
||||
if (String(url).includes('/gettoken?')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, access_token: 'access-token', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
return new Response(JSON.stringify({ processQueryKey: 'query-key-1' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
const taskId = await sendDingTalkRobotNotification({
|
||||
userId: 'ding-user-1',
|
||||
title: '报废审批提醒',
|
||||
content: '有一条报废申请等待审批',
|
||||
actionUrl: '/workflows/disposals/53'
|
||||
})
|
||||
|
||||
assert.equal(taskId, 'query-key-1')
|
||||
const sendCall = calls.find((call) => call.url.includes('/v1.0/robot/oToMessages/batchSend'))
|
||||
assert.ok(sendCall)
|
||||
assert.equal((sendCall.init?.headers as Record<string, string>)['x-acs-dingtalk-access-token'], 'access-token')
|
||||
const body = JSON.parse(String(sendCall.init?.body))
|
||||
assert.equal(body.robotCode, 'ding-robot-code')
|
||||
assert.deepEqual(body.userIds, ['ding-user-1'])
|
||||
assert.equal(body.msgKey, 'sampleActionCard')
|
||||
const msgParam = JSON.parse(body.msgParam)
|
||||
assert.equal(msgParam.singleTitle, '查看详情')
|
||||
assert.equal(
|
||||
msgParam.singleURL,
|
||||
'https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2Fdisposals%2F53'
|
||||
)
|
||||
})
|
||||
|
||||
test('机器人单聊通过 processQueryKey 查询已读状态', async () => {
|
||||
configureDingTalk()
|
||||
global.fetch = (async (url: string | URL | Request) => {
|
||||
assert.ok(String(url).includes('/v1.0/robot/oToMessages/readStatus'))
|
||||
return new Response(JSON.stringify({
|
||||
sendStatus: 'success',
|
||||
messageReadInfoList: [
|
||||
{ userId: 'ding-read', readStatus: 'read' },
|
||||
{ userId: 'ding-unread', readStatus: 'unread' }
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
const result = await getDingTalkRobotSendResult('query-key-1')
|
||||
assert.deepEqual(result, {
|
||||
read_user_id_list: ['ding-read'],
|
||||
unread_user_id_list: ['ding-unread'],
|
||||
delivery_failed: false
|
||||
})
|
||||
})
|
||||
|
||||
test('无效收件人会作为确定性错误停止重试', async () => {
|
||||
configureDingTalk()
|
||||
global.fetch = (async () => new Response(JSON.stringify({
|
||||
invalidStaffIdList: ['ding-invalid']
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})) as typeof fetch
|
||||
|
||||
await assert.rejects(
|
||||
sendDingTalkRobotNotification({
|
||||
userId: 'ding-invalid',
|
||||
title: '通知',
|
||||
content: '内容',
|
||||
actionUrl: '/dashboard'
|
||||
}),
|
||||
(error: unknown) => error instanceof DingTalkApiError &&
|
||||
error.code === 'INVALID_USER' && error.deterministic
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { submitYouyidianNotification } from '../youyidianNotificationClient'
|
||||
|
||||
const originalFetch = global.fetch
|
||||
const originalEnv = {
|
||||
url: process.env.YYD_NOTIFICATION_CENTER_URL,
|
||||
code: process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE,
|
||||
secret: process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET,
|
||||
publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
global.fetch = originalFetch
|
||||
if (originalEnv.url === undefined) delete process.env.YYD_NOTIFICATION_CENTER_URL
|
||||
else process.env.YYD_NOTIFICATION_CENTER_URL = originalEnv.url
|
||||
if (originalEnv.code === undefined) delete process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE
|
||||
else process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = originalEnv.code
|
||||
if (originalEnv.secret === undefined) delete process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET
|
||||
else process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = originalEnv.secret
|
||||
if (originalEnv.publicBaseUrl === undefined) delete process.env.DINGTALK_PUBLIC_BASE_URL
|
||||
else process.env.DINGTALK_PUBLIC_BASE_URL = originalEnv.publicBaseUrl
|
||||
})
|
||||
|
||||
test('固定资产通过独立项目签名提交到由壹点通知中心', async () => {
|
||||
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
|
||||
process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset'
|
||||
process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234'
|
||||
process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com'
|
||||
let captured: { url?: string; init?: RequestInit } = {}
|
||||
global.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
captured = { url: String(url), init }
|
||||
return {
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: async () => ({
|
||||
id: 'notification-1',
|
||||
projectCode: 'fixed_asset',
|
||||
status: 'pending',
|
||||
recipientUserId: 'ding-user-1',
|
||||
title: '【由壹点·固定资产】待审批报废'
|
||||
})
|
||||
} as Response
|
||||
}) as typeof fetch
|
||||
|
||||
const result = await submitYouyidianNotification({
|
||||
idempotencyKey: 'disposal_applied:disposal:53:1',
|
||||
recipientUserId: 'ding-user-1',
|
||||
title: '待审批报废',
|
||||
content: '有一项报废申请等待审批',
|
||||
actionUrl: '/workflows/disposals/53'
|
||||
})
|
||||
|
||||
assert.equal(result.id, 'notification-1')
|
||||
assert.equal(captured.url, 'https://notify.example.com/v1/notifications')
|
||||
const headers = captured.init?.headers as Record<string, string>
|
||||
assert.equal(headers['x-yyd-project-code'], 'fixed_asset')
|
||||
assert.match(headers['x-yyd-signature'], /^[a-f0-9]{64}$/)
|
||||
const body = JSON.parse(String(captured.init?.body))
|
||||
assert.equal(body.actionUrl, 'https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2Fdisposals%2F53')
|
||||
})
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { getDingTalkNotificationChannelForUser } from './dingtalkClient'
|
||||
|
||||
type TransactionClient = Prisma.TransactionClient
|
||||
|
||||
@ -88,6 +89,7 @@ export const createBusinessNotification = async (
|
||||
business_id: input.businessId,
|
||||
recipient_user_id: recipient.id,
|
||||
recipient_dingtalk_userid: recipient.person?.source_userid || null,
|
||||
provider_type: getDingTalkNotificationChannelForUser(recipient.person?.source_userid),
|
||||
payload_json: JSON.stringify({
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { getNotificationCenterConfigStatus } from './youyidianNotificationClient'
|
||||
|
||||
type DingTalkResponse = {
|
||||
errcode?: number
|
||||
errmsg?: string
|
||||
@ -17,28 +19,57 @@ export class DingTalkApiError extends Error {
|
||||
}
|
||||
|
||||
const API_BASE_URL = 'https://oapi.dingtalk.com'
|
||||
const ROBOT_API_BASE_URL = 'https://api.dingtalk.com'
|
||||
const REQUEST_TIMEOUT_MS = 8_000
|
||||
|
||||
export type DingTalkNotificationChannel = 'work_notice' | 'robot_oto' | 'notification_center'
|
||||
|
||||
export const getDingTalkNotificationChannel = (): DingTalkNotificationChannel => {
|
||||
const value = String(process.env.DINGTALK_NOTIFICATION_CHANNEL || 'work_notice').trim().toLowerCase()
|
||||
if (value === 'work_notice' || value === 'robot_oto' || value === 'notification_center') return value
|
||||
throw new Error('DINGTALK_NOTIFICATION_CHANNEL 仅支持 work_notice、robot_oto 或 notification_center')
|
||||
}
|
||||
|
||||
export const getDingTalkNotificationChannelForUser = (dingtalkUserId: string | null | undefined) => {
|
||||
const channel = getDingTalkNotificationChannel()
|
||||
if (channel !== 'notification_center') return channel
|
||||
const testUserIds = Array.from(new Set(
|
||||
String(process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
))
|
||||
if (testUserIds.length === 0) return channel
|
||||
return testUserIds.includes(String(dingtalkUserId || '').trim()) ? 'notification_center' : 'work_notice'
|
||||
}
|
||||
|
||||
const requiredConfig = () => ({
|
||||
appKey: process.env.DINGTALK_APP_KEY || '',
|
||||
appSecret: process.env.DINGTALK_APP_SECRET || '',
|
||||
agentId: process.env.DINGTALK_AGENT_ID || '',
|
||||
robotCode: process.env.DINGTALK_ROBOT_CODE || '',
|
||||
corpId: process.env.DINGTALK_CORP_ID || '',
|
||||
publicBaseUrl: (process.env.DINGTALK_PUBLIC_BASE_URL || '').replace(/\/$/, '')
|
||||
})
|
||||
|
||||
export const getDingTalkConfigStatus = () => {
|
||||
const config = requiredConfig()
|
||||
const channel = getDingTalkNotificationChannel()
|
||||
const missing: string[] = []
|
||||
if (!config.appKey) missing.push('AppKey')
|
||||
if (!config.appSecret) missing.push('AppSecret')
|
||||
if (!config.agentId) missing.push('AgentId')
|
||||
if (!config.corpId) missing.push('CorpId')
|
||||
if (!config.publicBaseUrl) missing.push('PublicBaseUrl')
|
||||
if (channel === 'notification_center') {
|
||||
missing.push(...getNotificationCenterConfigStatus().missing)
|
||||
if (!config.publicBaseUrl) missing.push('PublicBaseUrl')
|
||||
} else {
|
||||
if (!config.appKey) missing.push('AppKey')
|
||||
if (!config.appSecret) missing.push('AppSecret')
|
||||
if (!config.agentId) missing.push('AgentId')
|
||||
if (channel === 'robot_oto' && !config.robotCode) missing.push('RobotCode')
|
||||
if (!config.corpId) missing.push('CorpId')
|
||||
if (!config.publicBaseUrl) missing.push('PublicBaseUrl')
|
||||
}
|
||||
return {
|
||||
configured: Boolean(
|
||||
config.appKey && config.appSecret && config.agentId && config.corpId && config.publicBaseUrl
|
||||
),
|
||||
configured: missing.length === 0,
|
||||
channel,
|
||||
send_enabled: process.env.DINGTALK_SEND_ENABLED === 'true',
|
||||
missing
|
||||
}
|
||||
@ -161,6 +192,113 @@ const buildCardUrl = (actionUrl: string) => {
|
||||
return `dingtalk://dingtalkclient/action/openapp?corpid=${encodeURIComponent(corpId)}&container_type=work_platform&app_id=0_${encodeURIComponent(agentId)}&redirect_type=jump&redirect_url=${encodeURIComponent(entryUrl)}`
|
||||
}
|
||||
|
||||
const buildRobotActionUrl = (actionUrl: string) => {
|
||||
const { publicBaseUrl } = requiredConfig()
|
||||
const safeActionUrl = actionUrl.startsWith('/') && !actionUrl.startsWith('//') ? actionUrl : '/'
|
||||
return `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(safeActionUrl)}`
|
||||
}
|
||||
|
||||
const truncateText = (value: string, maxLength: number) => {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized
|
||||
}
|
||||
|
||||
const stringList = (value: unknown) => Array.isArray(value)
|
||||
? value.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
export const sendDingTalkRobotNotification = async (input: {
|
||||
userId: string
|
||||
title: string
|
||||
content: string
|
||||
actionUrl: string
|
||||
}) => {
|
||||
const config = requiredConfig()
|
||||
if (!config.robotCode) {
|
||||
throw new DingTalkApiError('钉钉机器人单聊未配置 RobotCode', 'CONFIG_INCOMPLETE', true)
|
||||
}
|
||||
const userId = String(input.userId || '').trim()
|
||||
if (!userId) {
|
||||
throw new DingTalkApiError('机器人单聊收件人不能为空', 'RECIPIENT_MISSING', true)
|
||||
}
|
||||
|
||||
const token = await getDingTalkAccessToken()
|
||||
const payload = await requestJson<DingTalkResponse>(
|
||||
`${ROBOT_API_BASE_URL}/v1.0/robot/oToMessages/batchSend`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
body: JSON.stringify({
|
||||
robotCode: config.robotCode,
|
||||
userIds: [userId],
|
||||
msgKey: 'sampleActionCard',
|
||||
msgParam: JSON.stringify({
|
||||
title: truncateText(input.title, 120),
|
||||
text: truncateText(input.content, 4800),
|
||||
singleTitle: '查看详情',
|
||||
singleURL: buildRobotActionUrl(input.actionUrl)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const invalidUserIds = stringList(payload.invalidStaffIdList)
|
||||
const filteredUserIds = stringList(payload.filteredStaffIdList)
|
||||
const flowControlledUserIds = stringList(payload.flowControlledStaffIdList)
|
||||
if (invalidUserIds.includes(userId)) {
|
||||
throw new DingTalkApiError('收件人钉钉 userid 无效', 'INVALID_USER', true)
|
||||
}
|
||||
if (filteredUserIds.includes(userId)) {
|
||||
throw new DingTalkApiError('收件人不在机器人可见范围内', 'FILTERED_USER', true)
|
||||
}
|
||||
if (flowControlledUserIds.includes(userId)) {
|
||||
throw new DingTalkApiError('钉钉机器人单聊发送被限流', 'FLOW_CONTROLLED', false)
|
||||
}
|
||||
|
||||
const processQueryKey = String(payload.processQueryKey || '').trim()
|
||||
if (!processQueryKey) {
|
||||
throw new DingTalkApiError('钉钉未返回机器人单聊任务编号', 'TASK_ID_MISSING', false)
|
||||
}
|
||||
return processQueryKey
|
||||
}
|
||||
|
||||
export const getDingTalkRobotSendResult = async (processQueryKey: string) => {
|
||||
const config = requiredConfig()
|
||||
if (!config.robotCode) {
|
||||
throw new DingTalkApiError('钉钉机器人单聊未配置 RobotCode', 'CONFIG_INCOMPLETE', true)
|
||||
}
|
||||
const normalizedKey = String(processQueryKey || '').trim()
|
||||
if (!normalizedKey) {
|
||||
throw new DingTalkApiError('机器人单聊任务编号不能为空', 'TASK_ID_MISSING', true)
|
||||
}
|
||||
|
||||
const token = await getDingTalkAccessToken()
|
||||
const url = new URL(`${ROBOT_API_BASE_URL}/v1.0/robot/oToMessages/readStatus`)
|
||||
url.searchParams.set('robotCode', config.robotCode)
|
||||
url.searchParams.set('processQueryKey', normalizedKey)
|
||||
const payload = await requestJson<DingTalkResponse>(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { 'x-acs-dingtalk-access-token': token }
|
||||
})
|
||||
|
||||
const readUserIds: string[] = []
|
||||
const unreadUserIds: string[] = []
|
||||
const rows = Array.isArray(payload.messageReadInfoList) ? payload.messageReadInfoList : []
|
||||
for (const row of rows) {
|
||||
const userId = String(row?.userId || '').trim()
|
||||
const readStatus = String(row?.readStatus || '').trim().toLowerCase()
|
||||
if (!userId) continue
|
||||
if (readStatus === 'read') readUserIds.push(userId)
|
||||
if (readStatus === 'unread') unreadUserIds.push(userId)
|
||||
}
|
||||
|
||||
return {
|
||||
read_user_id_list: readUserIds,
|
||||
unread_user_id_list: unreadUserIds,
|
||||
delivery_failed: String(payload.sendStatus || '').trim().toLowerCase().includes('fail')
|
||||
}
|
||||
}
|
||||
|
||||
export const sendDingTalkWorkNotification = async (input: {
|
||||
userId: string
|
||||
title: string
|
||||
|
||||
@ -3,9 +3,16 @@ import type { Prisma } from '@prisma/client'
|
||||
import prisma from '../utils/prisma'
|
||||
import {
|
||||
DingTalkApiError,
|
||||
getDingTalkRobotSendResult,
|
||||
getDingTalkSendResult,
|
||||
sendDingTalkRobotNotification,
|
||||
sendDingTalkWorkNotification
|
||||
} from './dingtalkClient'
|
||||
import {
|
||||
getYouyidianNotificationStatus,
|
||||
NotificationCenterError,
|
||||
submitYouyidianNotification
|
||||
} from './youyidianNotificationClient'
|
||||
import {
|
||||
createBusinessNotification,
|
||||
findActiveUsersByRoles
|
||||
@ -67,7 +74,8 @@ const scheduleDeliveryFailure = async (
|
||||
delivery: NonNullable<Awaited<ReturnType<typeof claimNextDelivery>>>,
|
||||
error: any
|
||||
) => {
|
||||
const deterministic = error instanceof DingTalkApiError && error.deterministic
|
||||
const deterministic = (error instanceof DingTalkApiError && error.deterministic) ||
|
||||
(error instanceof NotificationCenterError && error.deterministic)
|
||||
const exhausted = delivery.attempt_count >= delivery.max_attempts
|
||||
const failed = deterministic || exhausted
|
||||
const delayMinutes = BACKOFF_MINUTES[Math.min(delivery.attempt_count - 1, BACKOFF_MINUTES.length - 1)]
|
||||
@ -96,12 +104,28 @@ const processDelivery = async (
|
||||
content?: string
|
||||
action_url?: string
|
||||
}
|
||||
const taskId = await sendDingTalkWorkNotification({
|
||||
userId: delivery.recipient_dingtalk_userid,
|
||||
title: payload.title || '固定资产通知',
|
||||
content: payload.content || '',
|
||||
actionUrl: payload.action_url || '/'
|
||||
})
|
||||
const taskId = delivery.provider_type === 'notification_center'
|
||||
? (await submitYouyidianNotification({
|
||||
idempotencyKey: delivery.idempotency_key,
|
||||
recipientUserId: delivery.recipient_dingtalk_userid,
|
||||
title: payload.title || '固定资产通知',
|
||||
content: payload.content || '',
|
||||
actionTitle: '查看详情',
|
||||
actionUrl: payload.action_url || '/'
|
||||
})).id
|
||||
: delivery.provider_type === 'robot_oto'
|
||||
? await sendDingTalkRobotNotification({
|
||||
userId: delivery.recipient_dingtalk_userid,
|
||||
title: payload.title || '固定资产通知',
|
||||
content: payload.content || '',
|
||||
actionUrl: payload.action_url || '/'
|
||||
})
|
||||
: await sendDingTalkWorkNotification({
|
||||
userId: delivery.recipient_dingtalk_userid,
|
||||
title: payload.title || '固定资产通知',
|
||||
content: payload.content || '',
|
||||
actionUrl: payload.action_url || '/'
|
||||
})
|
||||
await updateSendingDeliveryIfLeaseOwned(delivery.id, {
|
||||
status: 'sent',
|
||||
dingtalk_task_id: taskId,
|
||||
@ -129,7 +153,40 @@ const refreshOneDeliveryResult = async () => {
|
||||
if (!delivery?.dingtalk_task_id || !delivery.recipient_dingtalk_userid) return
|
||||
|
||||
try {
|
||||
const result = await getDingTalkSendResult(delivery.dingtalk_task_id)
|
||||
if (delivery.provider_type === 'notification_center') {
|
||||
const result = await getYouyidianNotificationStatus(delivery.dingtalk_task_id)
|
||||
if (result.status === 'read') {
|
||||
await prisma.dingTalkDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: { status: 'read', read_at: result.readAt ? new Date(result.readAt) : new Date() }
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.status === 'failed') {
|
||||
await prisma.dingTalkDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
last_error_code: result.lastErrorCode || 'NOTIFICATION_CENTER_FAILED',
|
||||
last_error_message: result.lastErrorMessage || '由壹点通知中心返回发送失败'
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await prisma.dingTalkDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: {
|
||||
status: result.status === 'unread' ? 'unread' : 'sent',
|
||||
sent_at: result.sentAt ? new Date(result.sentAt) : delivery.sent_at,
|
||||
next_attempt_at: new Date(Date.now() + 5 * 60_000)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = delivery.provider_type === 'robot_oto'
|
||||
? await getDingTalkRobotSendResult(delivery.dingtalk_task_id)
|
||||
: await getDingTalkSendResult(delivery.dingtalk_task_id)
|
||||
const includesUser = (value: unknown) =>
|
||||
Array.isArray(value) && value.map(String).includes(delivery.recipient_dingtalk_userid as string)
|
||||
|
||||
@ -141,6 +198,7 @@ const refreshOneDeliveryResult = async () => {
|
||||
return
|
||||
}
|
||||
if (
|
||||
result.delivery_failed === true ||
|
||||
includesUser(result.invalid_user_id_list) || includesUser(result.forbidden_user_id_list) ||
|
||||
includesUser(result.failed_user_id_list)
|
||||
) {
|
||||
|
||||
111
backend/src/services/youyidianNotificationClient.ts
Normal file
111
backend/src/services/youyidianNotificationClient.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
export type NotificationCenterDelivery = {
|
||||
id: string
|
||||
projectCode: string
|
||||
status: 'pending' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed'
|
||||
recipientUserId: string
|
||||
title: string
|
||||
processQueryKey?: string | null
|
||||
lastErrorCode?: string | null
|
||||
lastErrorMessage?: string | null
|
||||
sentAt?: string | null
|
||||
readAt?: string | null
|
||||
}
|
||||
|
||||
export class NotificationCenterError extends Error {
|
||||
constructor(message: string, public readonly deterministic = false) {
|
||||
super(message)
|
||||
this.name = 'NotificationCenterError'
|
||||
}
|
||||
}
|
||||
|
||||
const config = () => ({
|
||||
baseUrl: String(process.env.YYD_NOTIFICATION_CENTER_URL || '').trim().replace(/\/$/, ''),
|
||||
projectCode: String(process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE || '').trim(),
|
||||
secret: String(process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET || '').trim()
|
||||
})
|
||||
|
||||
export const getNotificationCenterConfigStatus = () => {
|
||||
const value = config()
|
||||
const missing: string[] = []
|
||||
if (!value.baseUrl) missing.push('YYD_NOTIFICATION_CENTER_URL')
|
||||
if (!value.projectCode) missing.push('YYD_NOTIFICATION_CENTER_PROJECT_CODE')
|
||||
if (!value.secret) missing.push('YYD_NOTIFICATION_CENTER_PROJECT_SECRET')
|
||||
return { configured: missing.length === 0, missing }
|
||||
}
|
||||
|
||||
const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex')
|
||||
|
||||
const signedHeaders = (method: string, path: string, body: string) => {
|
||||
const value = config()
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = crypto.randomBytes(18).toString('base64url')
|
||||
const canonical = [timestamp, nonce, method.toUpperCase(), path, sha256(body)].join('\n')
|
||||
const signature = crypto.createHmac('sha256', value.secret).update(canonical).digest('hex')
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-yyd-project-code': value.projectCode,
|
||||
'x-yyd-timestamp': timestamp,
|
||||
'x-yyd-nonce': nonce,
|
||||
'x-yyd-signature': signature
|
||||
}
|
||||
}
|
||||
|
||||
const request = async (path: string, method: 'GET' | 'POST', payload?: Record<string, unknown>) => {
|
||||
const value = config()
|
||||
const status = getNotificationCenterConfigStatus()
|
||||
if (!status.configured) throw new NotificationCenterError(`由壹点通知中心配置不完整: ${status.missing.join(', ')}`, true)
|
||||
const body = payload ? JSON.stringify(payload) : ''
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 8_000)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${value.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: signedHeaders(method, path, body),
|
||||
body: body || undefined,
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') throw new NotificationCenterError('由壹点通知中心请求超时')
|
||||
throw new NotificationCenterError(error?.message || '由壹点通知中心网络请求失败')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
const data = await response.json().catch(() => ({})) as Record<string, any>
|
||||
if (!response.ok) {
|
||||
throw new NotificationCenterError(
|
||||
`由壹点通知中心请求失败: ${data.error || response.status}`,
|
||||
response.status >= 400 && response.status < 500 && response.status !== 429
|
||||
)
|
||||
}
|
||||
return data as NotificationCenterDelivery
|
||||
}
|
||||
|
||||
const buildActionUrl = (actionUrl: string) => {
|
||||
const publicBaseUrl = String(process.env.DINGTALK_PUBLIC_BASE_URL || '').trim().replace(/\/$/, '')
|
||||
const safePath = actionUrl.startsWith('/') && !actionUrl.startsWith('//') ? actionUrl : '/'
|
||||
return `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(safePath)}`
|
||||
}
|
||||
|
||||
export const submitYouyidianNotification = (input: {
|
||||
idempotencyKey: string
|
||||
recipientUserId: string
|
||||
title: string
|
||||
content: string
|
||||
actionTitle?: string
|
||||
actionUrl: string
|
||||
}) => request('/v1/notifications', 'POST', {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
recipientUserId: input.recipientUserId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
actionTitle: input.actionTitle || '查看详情',
|
||||
actionUrl: buildActionUrl(input.actionUrl)
|
||||
})
|
||||
|
||||
export const getYouyidianNotificationStatus = (id: string) => {
|
||||
const path = `/v1/notifications/${encodeURIComponent(id)}`
|
||||
return request(path, 'GET')
|
||||
}
|
||||
@ -54,9 +54,15 @@ services:
|
||||
DINGTALK_APP_KEY: ${DINGTALK_APP_KEY:-}
|
||||
DINGTALK_APP_SECRET: ${DINGTALK_APP_SECRET:-}
|
||||
DINGTALK_AGENT_ID: ${DINGTALK_AGENT_ID:-}
|
||||
DINGTALK_ROBOT_CODE: ${DINGTALK_ROBOT_CODE:-}
|
||||
DINGTALK_CORP_ID: ${DINGTALK_CORP_ID:-}
|
||||
DINGTALK_PUBLIC_BASE_URL: ${DINGTALK_PUBLIC_BASE_URL:-}
|
||||
DINGTALK_SEND_ENABLED: ${DINGTALK_SEND_ENABLED:-false}
|
||||
DINGTALK_NOTIFICATION_CHANNEL: ${DINGTALK_NOTIFICATION_CHANNEL:-work_notice}
|
||||
YYD_NOTIFICATION_CENTER_URL: ${YYD_NOTIFICATION_CENTER_URL:-}
|
||||
YYD_NOTIFICATION_CENTER_PROJECT_CODE: ${YYD_NOTIFICATION_CENTER_PROJECT_CODE:-fixed_asset}
|
||||
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: ${YYD_NOTIFICATION_CENTER_PROJECT_SECRET:-}
|
||||
YYD_NOTIFICATION_CENTER_TEST_USER_IDS: ${YYD_NOTIFICATION_CENTER_TEST_USER_IDS:-}
|
||||
DOWNLOADS_DIR: /app/downloads
|
||||
depends_on:
|
||||
mysql:
|
||||
|
||||
434
docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md
Normal file
434
docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md
Normal file
@ -0,0 +1,434 @@
|
||||
# 由壹点统一钉钉单聊通知中心接入指南
|
||||
|
||||
更新日期:2026-07-15
|
||||
|
||||
适用对象:由壹点旗下素材库、产品开发平台、固定资产、脉象、AI 平台及后续新项目的开发 Agent
|
||||
|
||||
通知中心源码:`D:\AI Code\YouYiDianNotificationCenter`
|
||||
|
||||
> 本文是可直接交给其他项目 Agent 的实施契约。文中不包含真实钉钉密钥、项目密钥、员工 `userId`、服务器密码或数据库连接;这些内容必须通过生产环境变量配置,禁止写入 Git、日志或交接文档。
|
||||
|
||||
## 1. 统一结论
|
||||
|
||||
由壹点旗下所有项目共用一个显示名称为“由壹点”的企业内部应用机器人,但业务项目不直接保存或调用该机器人的 `AppKey / AppSecret / RobotCode`。
|
||||
|
||||
所有钉钉单聊统一由独立的“由壹点通知中心”发送。每个业务项目只持有自己的:
|
||||
|
||||
- `projectCode`;
|
||||
- HMAC 项目密钥;
|
||||
- 允许跳转的公网 HTTPS 域名;
|
||||
- 本项目员工的钉钉 `userId`;
|
||||
- 本项目业务消息、幂等键和跳转地址。
|
||||
|
||||
通知中心统一负责:
|
||||
|
||||
- 保管钉钉应用密钥和 `RobotCode`;
|
||||
- 获取并缓存钉钉 `accessToken`;
|
||||
- 发送机器人单聊;
|
||||
- 自动给标题加项目来源,例如 `【由壹点·固定资产】`;
|
||||
- 项目鉴权、请求防重放和项目隔离;
|
||||
- 异步投递、失败重试、幂等和已读状态查询;
|
||||
- 限制按钮只能跳转到该项目登记过的 HTTPS 域名。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[业务项目事件] --> B[项目本地通知记录]
|
||||
B --> C[项目投递 Worker]
|
||||
C -->|HMAC HTTPS| D[由壹点通知中心]
|
||||
D --> E[(通知中心数据库)]
|
||||
D --> F[由壹点机器人]
|
||||
F --> G[员工钉钉单聊]
|
||||
G --> H[已读状态]
|
||||
H --> D
|
||||
D --> C
|
||||
```
|
||||
|
||||
## 2. 钉钉开发者后台由谁负责
|
||||
|
||||
### 2.1 新业务项目通常不需要创建机器人
|
||||
|
||||
只要新项目属于同一企业,并通过通知中心发送消息,就不需要:
|
||||
|
||||
- 新建钉钉机器人;
|
||||
- 复制统一机器人的 `AppKey / AppSecret / RobotCode`;
|
||||
- 申请机器人发送权限;
|
||||
- 自己调用 `/v1.0/robot/oToMessages/batchSend`;
|
||||
- 自己维护钉钉访问令牌。
|
||||
|
||||
新项目只需要由通知中心管理员登记项目配置,然后按本文接口接入。
|
||||
|
||||
### 2.2 统一机器人只在通知中心配置一次
|
||||
|
||||
钉钉开发者后台需要人工确认以下事项:
|
||||
|
||||
1. 机器人显示名称为“由壹点”;
|
||||
2. 使用统一头像和介绍;
|
||||
3. 应用可见范围包含实际收件人;
|
||||
4. 已开通机器人消息发送权限,常见权限标识为 `qyapi_robot_sendmsg`;
|
||||
5. 如控制台单独列出已读状态查询权限,也一并开通;
|
||||
6. 修改机器人、权限或可见范围后,创建并发布应用版本;
|
||||
7. `AppKey / AppSecret / RobotCode` 只写入通知中心生产环境。
|
||||
|
||||
这些后台操作不会因为接入一个新业务项目而重复执行。只有收件人超出现有可见范围、权限发生变化或统一机器人资料需要调整时,才需要再次修改钉钉开发者后台。
|
||||
|
||||
### 2.3 登录应用与通知机器人相互独立
|
||||
|
||||
业务项目现有的钉钉登录应用继续使用自己的 `AppKey / AppSecret / AgentId / CorpId`。不要为了发送通知而替换登录配置。
|
||||
|
||||
- 登录应用:负责免登、身份绑定和获取本项目员工钉钉 `userId`;
|
||||
- 通知中心机器人:只负责统一发送个人单聊通知。
|
||||
|
||||
## 3. 新项目接入前需要提供的信息
|
||||
|
||||
业务项目 Agent 需要先整理:
|
||||
|
||||
| 信息 | 示例 | 说明 |
|
||||
|---|---|---|
|
||||
| 项目代码 | `fixed_asset` | 小写字母、数字和下划线,创建后不要随意修改 |
|
||||
| 项目显示名 | `固定资产` | 通知中心用于生成 `【由壹点·固定资产】` |
|
||||
| 允许跳转域名 | `asset.example.com` | 必须是公网 HTTPS 域名,不能使用 IP、内网地址或任意外链 |
|
||||
| 通知事件 | `workflow_pending` | 说明哪些业务节点需要通知 |
|
||||
| 收件人来源 | 员工钉钉 `userId` | 不能用邮箱、手机号、花名、工号或本地用户 ID 代替 |
|
||||
| 幂等业务键 | `审批单ID + 审批节点 + 收件人` | 同一业务事件重试时必须保持不变 |
|
||||
| 灰度人员 | 1~3 个测试员工 | 上线初期只让这些人员走通知中心 |
|
||||
|
||||
通知中心管理员据此新增项目配置,并向业务项目生产环境安全交付独立项目密钥。不同项目不能共用项目密钥。
|
||||
|
||||
当前已约定项目代码:
|
||||
|
||||
| 项目 | `projectCode` |
|
||||
|---|---|
|
||||
| 素材库 | `material_library` |
|
||||
| 固定资产 | `fixed_asset` |
|
||||
|
||||
## 4. 业务项目环境变量
|
||||
|
||||
每个项目至少增加:
|
||||
|
||||
```dotenv
|
||||
# 默认先保持项目当前旧通知通道,上线灰度时再切换
|
||||
DINGTALK_NOTIFICATION_CHANNEL=work_notice
|
||||
|
||||
# 由壹点通知中心公网 HTTPS 地址
|
||||
YYD_NOTIFICATION_CENTER_URL=https://notify.example.com
|
||||
|
||||
# 本项目独立身份
|
||||
YYD_NOTIFICATION_CENTER_PROJECT_CODE=your_project_code
|
||||
YYD_NOTIFICATION_CENTER_PROJECT_SECRET=
|
||||
|
||||
# 灰度钉钉 userId,多个用英文逗号分隔;不要提交真实值
|
||||
YYD_NOTIFICATION_CENTER_TEST_USER_IDS=
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- `PROJECT_SECRET` 只存在服务器环境变量,不进入前端和 Git;
|
||||
- 浏览器不能直接调用通知中心;
|
||||
- 生产地址必须是 HTTPS;
|
||||
- 切换通知通道和调整灰度名单必须可独立完成;
|
||||
- 通知中心不可用时,不要自动跨通道再发一遍,避免用户收到重复消息。
|
||||
|
||||
## 5. HTTP 接口契约
|
||||
|
||||
### 5.1 创建通知
|
||||
|
||||
```http
|
||||
POST /v1/notifications
|
||||
Content-Type: application/json
|
||||
x-yyd-project-code: <projectCode>
|
||||
x-yyd-timestamp: <Unix 秒时间戳>
|
||||
x-yyd-nonce: <每次请求唯一随机值>
|
||||
x-yyd-signature: <HMAC-SHA256 十六进制小写签名>
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotencyKey": "workflow_pending:order-123:node-2:user-456",
|
||||
"recipientUserId": "DINGTALK_USER_ID",
|
||||
"title": "新的待审批任务",
|
||||
"content": "### ◆ 固定资产待审批\n\n**资产编号** ZC-2026-001\n\n> 请及时处理",
|
||||
"actionTitle": "立即处理",
|
||||
"actionUrl": "https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2F123"
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `idempotencyKey` 必须稳定,不能使用当前时间或随机 UUID;
|
||||
- `recipientUserId` 是一个钉钉员工 `userId`;如需通知多人,由业务项目为每名收件人创建独立投递记录和幂等键;
|
||||
- 标题只写业务标题,不要自己添加 `【由壹点】`;
|
||||
- 以 `【由壹点` 开头的标题会被通知中心拒绝,品牌前缀由中心统一生成;
|
||||
- `actionUrl` 必须属于该项目登记的 HTTPS 域名;
|
||||
- 同一个 `projectCode + idempotencyKey` 重试只会返回原通知,不重复发送。
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "通知中心任务ID",
|
||||
"projectCode": "fixed_asset",
|
||||
"status": "pending",
|
||||
"recipientUserId": "DINGTALK_USER_ID",
|
||||
"title": "【由壹点·固定资产】新的待审批任务"
|
||||
}
|
||||
```
|
||||
|
||||
业务项目应把通知中心任务 `id` 保存到本地投递记录,不能只凭 HTTP 200 标记最终送达。
|
||||
|
||||
### 5.2 查询通知状态
|
||||
|
||||
```http
|
||||
GET /v1/notifications/<notificationId>
|
||||
x-yyd-project-code: <projectCode>
|
||||
x-yyd-timestamp: <Unix 秒时间戳>
|
||||
x-yyd-nonce: <每次请求唯一随机值>
|
||||
x-yyd-signature: <签名>
|
||||
```
|
||||
|
||||
状态按通知中心实际响应处理,业务侧至少映射:
|
||||
|
||||
| 通知中心状态 | 业务项目建议状态 |
|
||||
|---|---|
|
||||
| `pending` / `processing` | 保留待处理,延迟查询 |
|
||||
| `sent` / `unread` | 已发送、未读 |
|
||||
| `read` | 已读,同时保存已读时间 |
|
||||
| `failed` | 失败,保存可脱敏错误摘要 |
|
||||
|
||||
查询只能读取本项目创建的通知。跨项目任务 ID 会被拒绝。
|
||||
|
||||
## 6. HMAC 签名算法
|
||||
|
||||
签名原文按以下顺序拼接,中间使用换行符 `\n`:
|
||||
|
||||
```text
|
||||
TIMESTAMP
|
||||
NONCE
|
||||
HTTP_METHOD
|
||||
REQUEST_PATH
|
||||
SHA256_HEX_OF_RAW_BODY
|
||||
```
|
||||
|
||||
然后计算:
|
||||
|
||||
```text
|
||||
signature = HMAC_SHA256_HEX(projectSecret, canonicalString)
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 方法统一使用大写;
|
||||
- 路径只包含实际 API path,不包含协议、域名和查询字符串;当前接口本身不使用查询字符串;
|
||||
- POST 的 body hash 必须基于实际发送的原始 UTF-8 字符串;
|
||||
- GET 没有正文时,对空字符串计算 SHA-256;
|
||||
- 时间戳使用 Unix 秒;
|
||||
- 每次请求生成新 nonce;
|
||||
- 通知中心只接受允许时间偏差内的请求,并拒绝 nonce 重放。
|
||||
|
||||
TypeScript 参考实现:
|
||||
|
||||
```ts
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const sha256 = (value: string) =>
|
||||
crypto.createHash('sha256').update(value, 'utf8').digest('hex')
|
||||
|
||||
const sign = (input: {
|
||||
method: string
|
||||
path: string
|
||||
timestamp: string
|
||||
nonce: string
|
||||
rawBody: string
|
||||
secret: string
|
||||
}) => {
|
||||
const canonical = [
|
||||
input.timestamp,
|
||||
input.nonce,
|
||||
input.method.toUpperCase(),
|
||||
input.path,
|
||||
sha256(input.rawBody)
|
||||
].join('\n')
|
||||
|
||||
return crypto
|
||||
.createHmac('sha256', input.secret)
|
||||
.update(canonical, 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
```
|
||||
|
||||
不要在日志中打印 `canonicalString`、签名请求头、项目密钥或完整消息正文。
|
||||
|
||||
## 7. 业务项目必须实施的代码结构
|
||||
|
||||
### 7.1 保留本地投递记录
|
||||
|
||||
业务事件发生时,先在本项目数据库创建本地投递记录,再由 Worker 调用通知中心。不要在用户请求接口内同步等待钉钉发送完成。
|
||||
|
||||
本地记录至少包含:
|
||||
|
||||
- 本地投递 ID;
|
||||
- 事件类型和业务键;
|
||||
- 收件人钉钉 `userId`;
|
||||
- 稳定幂等键;
|
||||
- `provider_type`,例如 `work_notice` 或 `notification_center`;
|
||||
- 通知中心任务 ID;
|
||||
- `pending / sent / unread / read / failed`;
|
||||
- 尝试次数、下次重试时间、错误摘要和已读时间。
|
||||
|
||||
已有通知队列、租约、重试和幂等机制的项目应复用现有能力,只增加通知中心 provider,不要重造第二套队列。
|
||||
|
||||
### 7.2 收件人必须来自可靠身份绑定
|
||||
|
||||
通知中心不会根据邮箱、姓名或手机号反查钉钉员工。业务项目必须提前把本地用户与钉钉唯一 `userId` 可靠绑定。
|
||||
|
||||
缺少有效 `userId` 时应:
|
||||
|
||||
1. 不调用通知中心;
|
||||
2. 把本地投递记录标为明确的确定性失败;
|
||||
3. 提示管理员修复人员绑定;
|
||||
4. 禁止猜测姓名或用邮箱代替。
|
||||
|
||||
### 7.3 灰度切换
|
||||
|
||||
推荐通道选择:
|
||||
|
||||
```ts
|
||||
const channelFor = (dingTalkUserId: string) => {
|
||||
const configured = process.env.DINGTALK_NOTIFICATION_CHANNEL || 'work_notice'
|
||||
if (configured !== 'notification_center') return 'work_notice'
|
||||
|
||||
const allowlist = (process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return allowlist.length === 0 || allowlist.includes(dingTalkUserId)
|
||||
? 'notification_center'
|
||||
: 'work_notice'
|
||||
}
|
||||
```
|
||||
|
||||
灰度名单外走旧通道是发布策略;通知中心调用已经开始后,失败时禁止自动改走旧通道补发。
|
||||
|
||||
### 7.4 超时与错误
|
||||
|
||||
- 单次 HTTP 请求建议超时 8 秒;
|
||||
- 连接超时、5xx 和暂时性限流进入延迟重试;
|
||||
- 签名错误、域名不允许、收件人缺失等确定性错误直接失败;
|
||||
- 重试必须复用同一 `idempotencyKey`;
|
||||
- 日志只记录项目代码、本地投递 ID、通知中心任务 ID、状态和脱敏错误;
|
||||
- 不记录 Secret、钉钉令牌或完整个人信息。
|
||||
|
||||
## 8. 项目 Agent 的标准实施步骤
|
||||
|
||||
收到本文后,接入 Agent 按以下顺序执行:
|
||||
|
||||
1. 只读检查项目现有登录、人员绑定、通知入口、队列、数据库和部署方式;
|
||||
2. 列出所有需要通知的业务事件、真实收件人规则和稳定幂等键;
|
||||
3. 确认能够取得员工钉钉 `userId`;
|
||||
4. 向通知中心管理员申请 `projectCode / 项目名 / allowedHost / 项目密钥`;
|
||||
5. 新增独立的通知中心后端客户端和 HMAC 单元测试;
|
||||
6. 复用本地投递表,增加 `provider_type` 与通知中心任务 ID;
|
||||
7. 在 Worker 中增加 `notification_center` 分支和状态轮询;
|
||||
8. 增加环境变量及灰度名单,但默认保持旧通道;
|
||||
9. 跑数据库迁移、类型检查、单元测试和构建;
|
||||
10. 用本地或测试通知中心验证签名、幂等、域名限制和项目隔离;
|
||||
11. 先部署代码但不切全量;
|
||||
12. 只配置 1~3 名测试员工灰度;
|
||||
13. 验证收到单聊、来源标题、按钮跳转、数据库状态及已读回写;
|
||||
14. 扩大灰度,观察无重复投递后再全量切换;
|
||||
15. 保留旧通道一个观察周期,再评估是否下线。
|
||||
|
||||
## 9. 最低测试与发布门禁
|
||||
|
||||
### 9.1 单元和集成测试
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- 签名原文、body hash 和 HMAC 结果固定;
|
||||
- POST 与 GET 空正文签名;
|
||||
- 缺配置时拒绝发送;
|
||||
- 请求超时会结束,不会永久挂起;
|
||||
- 同一幂等键不会生成第二条通知;
|
||||
- 不同项目不能读取彼此任务;
|
||||
- 非 HTTPS 或未登记域名被拒绝;
|
||||
- 标题不会重复添加“由壹点”前缀;
|
||||
- 灰度人员走通知中心,其他人员走旧通道;
|
||||
- 通知中心失败不会自动跨通道重复发送;
|
||||
- `pending / sent / unread / read / failed` 映射正确;
|
||||
- 项目密钥和钉钉凭证不会进入日志、前端包或 Git。
|
||||
|
||||
### 9.2 生产验证
|
||||
|
||||
1. 通知中心 `/health` 显示服务和数据库可用;
|
||||
2. 业务项目先保持旧通道部署成功;
|
||||
3. 只加入开发人员自己的钉钉 `userId`;
|
||||
4. 发送明确标注为测试且不产生真实业务影响的通知;
|
||||
5. 确认消息进入“由壹点”机器人个人单聊,不进入公司群;
|
||||
6. 确认标题包含正确项目来源;
|
||||
7. 确认按钮进入本项目 HTTPS 地址并完成免登;
|
||||
8. 确认本地保存通知中心任务 ID;
|
||||
9. 点击后确认已读状态能够回写;
|
||||
10. 用重复幂等键验证不会收到第二条;
|
||||
11. 观察日志无密钥、令牌和完整个人信息;
|
||||
12. 再扩大到不同部门的 2~3 名测试员工。
|
||||
|
||||
## 10. 明确禁止的做法
|
||||
|
||||
- 不要为每个由壹点项目创建一个同名机器人;
|
||||
- 不要把统一机器人凭证复制到业务项目;
|
||||
- 不要让前端直接调用通知中心或钉钉接口;
|
||||
- 不要使用群机器人 Webhook 代替个人单聊;
|
||||
- 不要用邮箱、手机号、花名或本地用户 ID 冒充钉钉 `userId`;
|
||||
- 不要用随机值或当前时间作为业务幂等键;
|
||||
- 不要在通知中心失败后自动换通道再发一次;
|
||||
- 不要允许任意外部链接作为通知按钮地址;
|
||||
- 不要仅凭 HTTP 200 就认为员工已经收到或已读;
|
||||
- 不要在未发布钉钉应用版本时认为后台权限修改已经生效。
|
||||
|
||||
## 11. 常见故障定位
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
|---|---|
|
||||
| 通知中心返回 401 | 项目代码、项目密钥、时间戳、nonce 和签名原文 |
|
||||
| 返回域名不允许 | `actionUrl` 是否 HTTPS,host 是否已登记在该项目配置中 |
|
||||
| 返回重复请求 | nonce 被复用;重试必须生成新 nonce,但保留相同幂等键 |
|
||||
| 通知一直 pending | 通知中心 Worker、数据库和钉钉访问令牌状态 |
|
||||
| 钉钉返回无权限 | 统一机器人权限、应用版本和机器人所在应用凭证是否匹配 |
|
||||
| 收件人无效或被过滤 | 是否使用真实钉钉 `userId`,应用可见范围是否包含该员工 |
|
||||
| 用户收到两条 | 业务幂等键不稳定、多个 Worker 未正确抢占,或失败后跨通道补发 |
|
||||
| 按钮打不开 | 公网 HTTPS、反向代理、项目免登入口、重定向白名单 |
|
||||
| 能收到但查不到已读 | 是否保存通知中心任务 ID,通知中心已读轮询是否正常 |
|
||||
|
||||
## 12. 当前实现位置
|
||||
|
||||
### 通知中心
|
||||
|
||||
`D:\AI Code\YouYiDianNotificationCenter`
|
||||
|
||||
关键能力和部署方式见该项目 `README.md`、`.env.example`、`docker-compose.yml` 和 `prisma/schema.prisma`。
|
||||
|
||||
### 素材库适配器
|
||||
|
||||
`D:\AI Code\gongxiangp2\lib\youyidian-notification-center.ts`
|
||||
|
||||
素材库通过现有考核调研通知链路调用通知中心,默认仍保持旧通知通道,支持按钉钉 `userId` 灰度。
|
||||
|
||||
### 固定资产适配器
|
||||
|
||||
`D:\AI Code\GuDingZiChan\backend\src\services\youyidianNotificationClient.ts`
|
||||
|
||||
固定资产复用原有 `DingTalkDelivery`、Worker、租约、重试和状态刷新,只新增通知中心 provider。
|
||||
|
||||
## 13. 交给下一个 Agent 的任务模板
|
||||
|
||||
可把下面这段连同本文路径一起交给其他项目 Agent:
|
||||
|
||||
> 请按《由壹点统一钉钉单聊通知中心接入指南》为当前项目接入钉钉个人单聊。先只读排查项目的钉钉身份绑定、通知事件、投递队列、数据库、幂等和部署方式;业务项目不得保存统一机器人的 AppKey、AppSecret 或 RobotCode,不得直接调用钉钉机器人接口。请复用现有通知投递机制,增加 notification_center provider、稳定幂等键、HMAC 客户端、状态轮询和钉钉 userId 灰度名单。首次部署保持旧通道,完成测试后只对指定测试员工灰度,不允许失败后自动跨通道补发。实施前先列出 projectCode、项目显示名、允许跳转的 HTTPS 域名、通知事件、收件人来源和数据库迁移影响;修改后完成单元测试、构建、部署和生产灰度验证,并确保任何密钥都不进入 Git、前端或日志。
|
||||
|
||||
## 14. 关于接口真值
|
||||
|
||||
业务项目只依赖本文定义的通知中心接口,不依赖钉钉开放平台的底层字段。钉钉机器人接口、访问令牌、限流和已读状态的变化统一由通知中心适配,避免每个项目重复维护。
|
||||
|
||||
当前实现已在 2026-07-15 完成本地构建、签名、项目隔离、幂等、域名限制、数据库健康和业务适配器联调。生产地址、项目密钥和灰度人员属于部署配置,应以当前生产环境为准,不写入本文。
|
||||
@ -20,6 +20,7 @@ export interface DingTalkLoginResponse {
|
||||
|
||||
export interface DingTalkDeliveryStatus {
|
||||
configured: boolean
|
||||
channel?: 'robot_oto' | 'work_notice' | 'notification_center'
|
||||
send_enabled?: boolean
|
||||
enabled?: boolean
|
||||
missing?: string[]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user