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