接入由壹点统一通知中心

This commit is contained in:
chenpei 2026-07-15 17:44:08 +08:00
parent 62ca61d9e2
commit df35d2722f
6 changed files with 208 additions and 28 deletions

View File

@ -61,6 +61,10 @@ services:
- DINGTALK_ROBOT_CODE=${DINGTALK_ROBOT_CODE:-}
- DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL=${DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL:-work_notice}
- DINGTALK_ROBOT_OTO_TEST_USER_IDS=${DINGTALK_ROBOT_OTO_TEST_USER_IDS:-}
- YYD_NOTIFICATION_CENTER_URL=${YYD_NOTIFICATION_CENTER_URL:-}
- YYD_NOTIFICATION_CENTER_PROJECT_CODE=${YYD_NOTIFICATION_CENTER_PROJECT_CODE:-material_library}
- YYD_NOTIFICATION_CENTER_PROJECT_SECRET=${YYD_NOTIFICATION_CENTER_PROJECT_SECRET:-}
- YYD_NOTIFICATION_CENTER_TEST_USER_IDS=${YYD_NOTIFICATION_CENTER_TEST_USER_IDS:-}
# 视频转码配置
- VIDEO_TRANSCODE_CODEC=libx265
- VIDEO_TRANSCODE_PRESET=medium

View File

@ -0,0 +1,44 @@
import { submitYouyidianNotification } from '@/lib/youyidian-notification-center'
describe('由壹点通知中心客户端', () => {
const originalFetch = global.fetch
beforeEach(() => {
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'material_library'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234'
})
afterEach(() => {
global.fetch = originalFetch
})
test('使用项目签名提交通知任务', async () => {
global.fetch = jest.fn(async (_url, init) => ({
ok: true,
status: 202,
json: async () => ({
id: 'notification-1',
projectCode: 'material_library',
status: 'pending',
recipientUserId: 'ding-user-1',
title: '【由壹点·素材库】待填写',
}),
})) as unknown as typeof fetch
const result = await submitYouyidianNotification({
idempotencyKey: 'survey.assignment.created:assignment-1',
recipientUserId: 'ding-user-1',
title: '待填写',
content: '请完成考核调研',
actionTitle: '立即填写',
actionUrl: 'https://sc.jiansun.vip/performance-survey/survey/1',
})
expect(result.id).toBe('notification-1')
const [, init] = (global.fetch as jest.Mock).mock.calls[0]
expect(init.headers['x-yyd-project-code']).toBe('material_library')
expect(init.headers['x-yyd-signature']).toMatch(/^[a-f0-9]{64}$/)
expect(init.headers['x-yyd-nonce']).toBeTruthy()
})
})

View File

@ -34,7 +34,7 @@ export type DingtalkRobotOtoReadResult = {
raw: unknown
}
export type DingtalkPerformanceSurveyNotificationChannel = 'work_notice' | 'robot_oto'
export type DingtalkPerformanceSurveyNotificationChannel = 'work_notice' | 'robot_oto' | 'notification_center'
export type DingtalkWorkNoticeSendResult = {
readUserIds: string[]
@ -200,9 +200,9 @@ export function getDingtalkPerformanceSurveyNotificationChannel(): DingtalkPerfo
const value = String(
process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL || 'work_notice',
).trim().toLowerCase()
if (value === 'work_notice' || value === 'robot_oto') return value
if (value === 'work_notice' || value === 'robot_oto' || value === 'notification_center') return value
throw new Error(
'DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL 仅支持 work_notice 或 robot_oto',
'DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL 仅支持 work_notice、robot_oto 或 notification_center',
)
}
@ -219,11 +219,18 @@ export function getDingtalkPerformanceSurveyNotificationChannelForUser(
dingtalkUserId: string,
): DingtalkPerformanceSurveyNotificationChannel {
const channel = getDingtalkPerformanceSurveyNotificationChannel()
if (channel !== 'robot_oto') return channel
if (channel === 'work_notice') return channel
const testUserIds = getDingtalkRobotOtoTestUserIds()
const testUserIds = channel === 'notification_center'
? Array.from(new Set(
String(process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
))
: getDingtalkRobotOtoTestUserIds()
if (testUserIds.length === 0) return channel
return testUserIds.includes(String(dingtalkUserId || '').trim()) ? 'robot_oto' : 'work_notice'
return testUserIds.includes(String(dingtalkUserId || '').trim()) ? channel : 'work_notice'
}
function truncateText(value: string, maxLength = 4800) {

View File

@ -77,6 +77,10 @@ const OPTIONAL_ENV_VARS = [
'DINGTALK_ROBOT_CODE',
'DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL',
'DINGTALK_ROBOT_OTO_TEST_USER_IDS',
'YYD_NOTIFICATION_CENTER_URL',
'YYD_NOTIFICATION_CENTER_PROJECT_CODE',
'YYD_NOTIFICATION_CENTER_PROJECT_SECRET',
'YYD_NOTIFICATION_CENTER_TEST_USER_IDS',
'REPORT_DB_SERVER',
'REPORT_DB_DATABASE',
'REPORT_DB_USER',

View File

@ -9,6 +9,10 @@ import {
sendDingtalkRobotOtoMessage,
sendDingtalkWorkNotice,
} from '@/lib/dingtalk-client'
import {
getYouyidianNotificationStatus,
submitYouyidianNotification,
} from '@/lib/youyidian-notification-center'
const STATUS = {
PENDING: 'pending',
@ -29,6 +33,7 @@ const READ_STATUS_REFRESH_MS = 5 * 60_000
const NOTIFICATION_PROVIDER = {
WORK_NOTICE: 'work_notice',
ROBOT_OTO: 'robot_oto',
NOTIFICATION_CENTER: 'notification_center',
} as const
type NotificationProvider = typeof NOTIFICATION_PROVIDER[keyof typeof NOTIFICATION_PROVIDER]
@ -614,11 +619,20 @@ export async function processPerformanceSurveyNotification(notificationId: strin
actionUrl: notification.actionUrl,
actionTitle: getNotificationActionTitle(notification.eventType),
}
const result = providerType === NOTIFICATION_PROVIDER.ROBOT_OTO
? await sendDingtalkRobotOtoMessage(input)
: await sendDingtalkWorkNotice(input)
const result = providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER
? await submitYouyidianNotification({
idempotencyKey: `${notification.eventType}:${notification.businessKey}`,
recipientUserId: resolvedDingtalkUserId,
title: notification.title,
content: notification.content,
actionUrl: notification.actionUrl || '',
actionTitle: getNotificationActionTitle(notification.eventType),
})
: providerType === NOTIFICATION_PROVIDER.ROBOT_OTO
? await sendDingtalkRobotOtoMessage(input)
: await sendDingtalkWorkNotice(input)
if (result.skipped) {
if ('skipped' in result && result.skipped) {
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
@ -636,9 +650,11 @@ export async function processPerformanceSurveyNotification(notificationId: strin
status: STATUS.SUCCEEDED,
attemptCount: { increment: 1 },
providerType,
providerMessageId: providerType === NOTIFICATION_PROVIDER.ROBOT_OTO
? ('processQueryKey' in result ? result.processQueryKey || null : null)
: ('taskId' in result ? result.taskId || result.requestId || null : null),
providerMessageId: providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER
? ('id' in result ? result.id || null : null)
: providerType === NOTIFICATION_PROVIDER.ROBOT_OTO
? ('processQueryKey' in result ? result.processQueryKey || null : null)
: ('taskId' in result ? result.taskId || result.requestId || null : null),
readStatus: 'unknown',
readAt: null,
readStatusCheckedAt: null,
@ -715,21 +731,34 @@ async function refreshPerformanceSurveyNotificationReadStatusesInternal(limit =
if (!providerMessageId || !recipientDingtalkUserId) continue
try {
const providerType = notification.providerType === NOTIFICATION_PROVIDER.ROBOT_OTO
? NOTIFICATION_PROVIDER.ROBOT_OTO
: NOTIFICATION_PROVIDER.WORK_NOTICE
const result = providerType === NOTIFICATION_PROVIDER.ROBOT_OTO
? await getDingtalkRobotOtoReadResult(providerMessageId)
: await getDingtalkWorkNoticeSendResult(providerMessageId)
const read = result.readUserIds.includes(recipientDingtalkUserId)
const unread = result.unreadUserIds.includes(recipientDingtalkUserId)
const deliveryFailed = providerType === NOTIFICATION_PROVIDER.WORK_NOTICE
? 'failedUserIds' in result && [
...result.failedUserIds,
...result.invalidUserIds,
...result.forbiddenUserIds,
].includes(recipientDingtalkUserId)
: 'deliveryFailed' in result && result.deliveryFailed
const providerType = notification.providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER
? NOTIFICATION_PROVIDER.NOTIFICATION_CENTER
: notification.providerType === NOTIFICATION_PROVIDER.ROBOT_OTO
? NOTIFICATION_PROVIDER.ROBOT_OTO
: NOTIFICATION_PROVIDER.WORK_NOTICE
let read = false
let unread = false
let deliveryFailed = false
if (providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER) {
const result = await getYouyidianNotificationStatus(providerMessageId)
read = result.status === 'read'
unread = result.status === 'unread'
deliveryFailed = result.status === 'failed'
} else if (providerType === NOTIFICATION_PROVIDER.ROBOT_OTO) {
const result = await getDingtalkRobotOtoReadResult(providerMessageId)
read = result.readUserIds.includes(recipientDingtalkUserId)
unread = result.unreadUserIds.includes(recipientDingtalkUserId)
deliveryFailed = result.deliveryFailed
} else {
const result = await getDingtalkWorkNoticeSendResult(providerMessageId)
read = result.readUserIds.includes(recipientDingtalkUserId)
unread = result.unreadUserIds.includes(recipientDingtalkUserId)
deliveryFailed = [
...result.failedUserIds,
...result.invalidUserIds,
...result.forbiddenUserIds,
].includes(recipientDingtalkUserId)
}
const checkedAt = new Date()
await prisma.performanceSurveyNotification.update({

View File

@ -0,0 +1,92 @@
import crypto from 'crypto'
type NotificationCenterStatus = 'pending' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed'
export type NotificationCenterDelivery = {
id: string
projectCode: string
status: NotificationCenterStatus
recipientUserId: string
title: string
processQueryKey?: string | null
lastErrorCode?: string | null
lastErrorMessage?: string | null
sentAt?: string | null
readAt?: string | null
raw?: unknown
}
const getConfig = () => ({
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 config = getConfig()
const missing: string[] = []
if (!config.baseUrl) missing.push('YYD_NOTIFICATION_CENTER_URL')
if (!config.projectCode) missing.push('YYD_NOTIFICATION_CENTER_PROJECT_CODE')
if (!config.secret) missing.push('YYD_NOTIFICATION_CENTER_PROJECT_SECRET')
return { ready: missing.length === 0, missing }
}
const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex')
const signedHeaders = (input: { method: string; path: string; body: string }) => {
const config = getConfig()
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = crypto.randomBytes(18).toString('base64url')
const canonical = [timestamp, nonce, input.method.toUpperCase(), input.path, sha256(input.body)].join('\n')
const signature = crypto.createHmac('sha256', config.secret).update(canonical).digest('hex')
return {
'Content-Type': 'application/json',
'x-yyd-project-code': config.projectCode,
'x-yyd-timestamp': timestamp,
'x-yyd-nonce': nonce,
'x-yyd-signature': signature,
}
}
const request = async (path: string, init: { method: 'GET' | 'POST'; body?: Record<string, unknown> }) => {
const config = getConfig()
const status = getNotificationCenterConfigStatus()
if (!status.ready) throw new Error(`由壹点通知中心配置不完整: ${status.missing.join(', ')}`)
const body = init.body ? JSON.stringify(init.body) : ''
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8_000)
let response: Response
try {
response = await fetch(`${config.baseUrl}${path}`, {
method: init.method,
headers: signedHeaders({ method: init.method, path, body }),
body: body || undefined,
signal: controller.signal,
})
} finally {
clearTimeout(timeout)
}
const data = await response.json().catch(() => ({})) as Record<string, any>
if (!response.ok) {
throw new Error(`由壹点通知中心请求失败: ${data.error || response.status}`)
}
return data
}
export const submitYouyidianNotification = async (input: {
idempotencyKey: string
recipientUserId: string
title: string
content: string
actionTitle: string
actionUrl: string
}): Promise<NotificationCenterDelivery> => {
const data = await request('/v1/notifications', { method: 'POST', body: input })
return { ...(data as NotificationCenterDelivery), raw: data }
}
export const getYouyidianNotificationStatus = async (id: string): Promise<NotificationCenterDelivery> => {
const path = `/v1/notifications/${encodeURIComponent(id)}`
const data = await request(path, { method: 'GET' })
return { ...(data as NotificationCenterDelivery), raw: data }
}