112 lines
4.1 KiB
TypeScript
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')
|
|
}
|