cangku/backend/src/services/yydNotificationCenterClient.js

117 lines
4.0 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const axios = require('axios');
const {
getYydNotificationCenterRuntimeConfig,
} = require('./yydNotificationCenterSettings');
const NOTIFICATION_PATH = '/v1/notifications';
const DEFAULT_TIMEOUT_MS = 15000;
function normalizeText(value, maxLength = 4800) {
return String(value ?? '').trim().slice(0, maxLength);
}
async function getYydNotificationCenterConfig(options = {}) {
const config = await getYydNotificationCenterRuntimeConfig(options);
return {
baseUrl: normalizeText(config.baseUrl, 1000).replace(/\/+$/, ''),
projectCode: normalizeText(config.projectCode, 120),
projectSecret: normalizeText(config.projectSecret, 1000),
timeoutMs: Math.max(Number(config.timeoutMs) || DEFAULT_TIMEOUT_MS, 3000),
};
}
function isYydNotificationCenterConfigured(config = {}) {
return Boolean(config.baseUrl && config.projectCode && config.projectSecret);
}
function buildYydNotificationSignature({
projectSecret,
timestamp,
nonce,
method = 'POST',
path = NOTIFICATION_PATH,
rawBody = '',
}) {
const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex');
const canonical = `${timestamp}\n${nonce}\n${String(method).toUpperCase()}\n${path}\n${bodyHash}`;
return crypto.createHmac('sha256', projectSecret).update(canonical, 'utf8').digest('hex');
}
function validateNotificationPayload(input = {}) {
const payload = {
idempotencyKey: normalizeText(input.idempotencyKey, 160),
title: normalizeText(input.title, 100),
content: normalizeText(input.content, 4800),
};
const recipientUserId = normalizeText(input.recipientUserId, 160);
const recipientAlias = normalizeText(input.recipientAlias, 50);
if (!payload.idempotencyKey || !payload.title || !payload.content) {
throw new Error('通知缺少幂等键、标题或正文');
}
if (Boolean(recipientUserId) === Boolean(recipientAlias)) {
throw new Error('通知收件人必须且只能提供 recipientUserId 或 recipientAlias');
}
if (recipientUserId) payload.recipientUserId = recipientUserId;
if (recipientAlias) payload.recipientAlias = recipientAlias;
const actionTitle = normalizeText(input.actionTitle, 20);
const actionUrl = normalizeText(input.actionUrl, 2000);
if (actionTitle) payload.actionTitle = actionTitle;
if (actionUrl) payload.actionUrl = actionUrl;
return payload;
}
async function sendYydNotification(input = {}, options = {}) {
const config = options.config || await getYydNotificationCenterConfig();
if (!isYydNotificationCenterConfigured(config)) {
const error = new Error('统一通知中心尚未配置');
error.code = 'YYD_NOTIFICATION_CENTER_NOT_CONFIGURED';
throw error;
}
const payload = validateNotificationPayload(input);
const rawBody = JSON.stringify(payload);
const timestamp = String(options.timestamp || Math.trunc(Date.now() / 1000));
const nonce = String(options.nonce || crypto.randomBytes(16).toString('hex'));
const signature = buildYydNotificationSignature({
projectSecret: config.projectSecret,
timestamp,
nonce,
rawBody,
});
try {
const response = await axios.post(`${config.baseUrl}${NOTIFICATION_PATH}`, rawBody, {
timeout: config.timeoutMs,
headers: {
'Content-Type': 'application/json',
'x-yyd-project-code': config.projectCode,
'x-yyd-timestamp': timestamp,
'x-yyd-nonce': nonce,
'x-yyd-signature': signature,
},
transformRequest: [(body) => body],
});
return response.data;
} catch (error) {
const detail = error?.response?.data;
const wrapped = new Error(
detail?.message || detail?.error || error.message || '统一通知中心请求失败'
);
wrapped.code = detail?.code || error.code || 'YYD_NOTIFICATION_CENTER_REQUEST_FAILED';
wrapped.status = Number(error?.response?.status || 0) || 502;
throw wrapped;
}
}
module.exports = {
NOTIFICATION_PATH,
buildYydNotificationSignature,
getYydNotificationCenterConfig,
isYydNotificationCenterConfigured,
sendYydNotification,
validateNotificationPayload,
};