GuDingZiChan/backend/src/services/youyidianNotificationClient.ts
2026-07-15 17:44:08 +08:00

112 lines
4.1 KiB
TypeScript

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')
}