import crypto from 'node:crypto' const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex') const cleanBaseUrl = (value) => { const url = new URL(String(value || '').trim()) if (url.protocol !== 'https:' && url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') { throw new Error('通知中心地址必须使用 HTTPS') } if (url.username || url.password || url.search || url.hash) { throw new Error('通知中心地址不能包含凭据、查询参数或片段') } return url.toString().replace(/\/$/, '') } export class YouYiDianNotificationClient { constructor({ baseUrl, projectCode, projectSecret, fetchImpl = fetch }) { if (!/^[a-z0-9_-]{2,40}$/.test(String(projectCode || ''))) { throw new Error('projectCode 格式不正确') } if (String(projectSecret || '').length < 24) { throw new Error('projectSecret 未配置或长度不足') } this.baseUrl = cleanBaseUrl(baseUrl) this.projectCode = projectCode this.projectSecret = projectSecret this.fetchImpl = fetchImpl } createHeaders({ method, path, body = '' }) { 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', this.projectSecret).update(canonical).digest('hex') return { 'content-type': 'application/json', 'x-yyd-project-code': this.projectCode, 'x-yyd-timestamp': timestamp, 'x-yyd-nonce': nonce, 'x-yyd-signature': signature, } } async request({ method, path, body }) { const rawBody = body === undefined ? '' : JSON.stringify(body) const response = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers: this.createHeaders({ method, path, body: rawBody }), ...(rawBody ? { body: rawBody } : {}), }) const payload = await response.json().catch(() => ({ error: 'INVALID_JSON_RESPONSE' })) if (!response.ok) { const error = new Error(payload.message || payload.error || `通知中心返回 HTTP ${response.status}`) error.status = response.status error.code = payload.error error.payload = payload throw error } return payload } send(input) { return this.request({ method: 'POST', path: '/v1/notifications', body: input, }) } getDelivery(id) { if (!id) throw new Error('投递 id 不能为空') return this.request({ method: 'GET', path: `/v1/notifications/${encodeURIComponent(id)}`, }) } }