接入由壹点统一通知中心
This commit is contained in:
parent
62ca61d9e2
commit
df35d2722f
@ -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
|
||||
|
||||
44
lib/__tests__/youyidian-notification-center.test.ts
Normal file
44
lib/__tests__/youyidian-notification-center.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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({
|
||||
|
||||
92
lib/youyidian-notification-center.ts
Normal file
92
lib/youyidian-notification-center.ts
Normal 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 }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user