diff --git a/docker-compose.yml b/docker-compose.yml index fb9e1e0..99d3d42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/lib/__tests__/youyidian-notification-center.test.ts b/lib/__tests__/youyidian-notification-center.test.ts new file mode 100644 index 0000000..cca3243 --- /dev/null +++ b/lib/__tests__/youyidian-notification-center.test.ts @@ -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() + }) +}) diff --git a/lib/dingtalk-client.ts b/lib/dingtalk-client.ts index 3279e13..b544919 100644 --- a/lib/dingtalk-client.ts +++ b/lib/dingtalk-client.ts @@ -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) { diff --git a/lib/env-validation.ts b/lib/env-validation.ts index da75c24..28f8e4b 100644 --- a/lib/env-validation.ts +++ b/lib/env-validation.ts @@ -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', diff --git a/lib/performance-survey-notifications.ts b/lib/performance-survey-notifications.ts index fb05a9f..80216a9 100644 --- a/lib/performance-survey-notifications.ts +++ b/lib/performance-survey-notifications.ts @@ -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({ diff --git a/lib/youyidian-notification-center.ts b/lib/youyidian-notification-center.ts new file mode 100644 index 0000000..ea4bb63 --- /dev/null +++ b/lib/youyidian-notification-center.ts @@ -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 }) => { + 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 + 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 => { + const data = await request('/v1/notifications', { method: 'POST', body: input }) + return { ...(data as NotificationCenterDelivery), raw: data } +} + +export const getYouyidianNotificationStatus = async (id: string): Promise => { + const path = `/v1/notifications/${encodeURIComponent(id)}` + const data = await request(path, { method: 'GET' }) + return { ...(data as NotificationCenterDelivery), raw: data } +}