通知系统-1

This commit is contained in:
yml2213
2026-05-14 17:29:12 +08:00
parent 91d74b3e59
commit b6c493d5e7
22 changed files with 1314 additions and 11 deletions
@@ -0,0 +1,135 @@
// @ts-check
import { createHttpError } from '../../utils/http.js'
const DEFAULT_TIMEOUT_MS = 10000
/**
* @typedef {{
* serverUrl: string
* recipient: {
* id?: string
* name?: string
* deviceKey?: string
* }
* title: string
* body?: string
* group?: string
* url?: string
* }} BarkSendInput
*/
/** @param {BarkSendInput} input */
export async function sendBarkNotification(input) {
const serverUrl = String(input.serverUrl || 'https://api.day.app').trim().replace(/\/+$/, '')
const deviceKey = String(input.recipient?.deviceKey || '').trim()
const title = String(input.title || '').trim()
const body = String(input.body || '').trim()
if (!deviceKey) {
throw createHttpError('Bark 接收人缺少 deviceKey', {
statusCode: 400,
errorCode: 'bark_device_key_required',
})
}
if (!title && !body) {
throw createHttpError('Bark 通知标题或内容不能为空', {
statusCode: 400,
errorCode: 'bark_message_required',
})
}
const endpoint = buildBarkEndpoint(serverUrl, deviceKey, title || body, body)
const params = new URLSearchParams()
params.set('group', String(input.group || '订单系统').trim() || '订单系统')
if (input.url) {
params.set('url', String(input.url).trim())
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
try {
const response = await fetch(`${endpoint}?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json, text/plain, */*',
},
signal: controller.signal,
})
const responseText = await response.text()
const parsed = parseJsonResponse(responseText)
if (!response.ok || isBarkFailure(parsed)) {
throw createHttpError(resolveBarkErrorMessage(parsed, responseText, response.status), {
statusCode: response.ok ? 502 : response.status,
errorCode: 'bark_send_failed',
context: {
status: response.status,
response: parsed || responseText,
},
})
}
return {
ok: true,
status: response.status,
response: parsed || responseText,
}
} catch (error) {
if (error?.name === 'AbortError') {
throw createHttpError('Bark 通知发送超时', {
statusCode: 503,
errorCode: 'bark_send_timeout',
cause: error,
})
}
throw error
} finally {
clearTimeout(timer)
}
}
function buildBarkEndpoint(serverUrl, deviceKey, title, body) {
const segments = [
serverUrl,
encodeURIComponent(deviceKey),
encodeURIComponent(title),
]
if (body) {
segments.push(encodeURIComponent(body))
}
return segments.join('/')
}
function parseJsonResponse(text) {
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch {
return null
}
}
function isBarkFailure(parsed) {
if (!parsed || typeof parsed !== 'object') {
return false
}
const code = Number(parsed.code ?? parsed.status ?? 0)
return Number.isFinite(code) && code !== 0 && code !== 200
}
function resolveBarkErrorMessage(parsed, responseText, status) {
if (parsed && typeof parsed === 'object') {
return String(parsed.message || parsed.msg || parsed.error || '').trim() || `Bark 通知发送失败,HTTP ${status}`
}
return String(responseText || '').trim() || `Bark 通知发送失败,HTTP ${status}`
}
@@ -0,0 +1,107 @@
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../config/runtime.js'
const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notification-config.json')
const DEFAULT_BARK_SERVER_URL = 'https://api.day.app'
export function getNotificationConfigFilePath() {
return NOTIFICATION_CONFIG_FILE_PATH
}
export function getNotificationConfig() {
return loadNotificationConfigFromFile()
}
export function saveNotificationConfig(rawValue) {
const normalized = normalizeNotificationConfig(rawValue)
fs.mkdirSync(path.dirname(NOTIFICATION_CONFIG_FILE_PATH), { recursive: true })
fs.writeFileSync(NOTIFICATION_CONFIG_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
export function listEnabledBarkRecipients(config = getNotificationConfig()) {
if (config.enabled === false || config.channels?.bark?.enabled === false) {
return []
}
return (Array.isArray(config.channels?.bark?.recipients) ? config.channels.bark.recipients : [])
.filter((item) => item.enabled !== false && String(item.deviceKey || '').trim())
}
function loadNotificationConfigFromFile() {
if (!fs.existsSync(NOTIFICATION_CONFIG_FILE_PATH)) {
return createDefaultNotificationConfig()
}
try {
const rawText = fs.readFileSync(NOTIFICATION_CONFIG_FILE_PATH, 'utf8')
return normalizeNotificationConfig(JSON.parse(rawText))
} catch {
return createDefaultNotificationConfig()
}
}
function normalizeNotificationConfig(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {}
const bark = isPlainObject(source.channels?.bark) ? source.channels.bark : {}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
channels: {
bark: {
enabled: typeof bark.enabled === 'boolean' ? bark.enabled : true,
serverUrl: normalizeBarkServerUrl(bark.serverUrl),
recipients: (Array.isArray(bark.recipients) ? bark.recipients : [])
.map((item) => normalizeBarkRecipient(item))
.filter(Boolean),
},
},
}
}
function normalizeBarkRecipient(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const name = String(rawValue.name || '').trim()
const deviceKey = String(rawValue.deviceKey || '').trim()
const id = String(rawValue.id || deviceKey || name || '').trim()
if (!name && !deviceKey) {
return null
}
return {
id,
name,
deviceKey,
enabled: typeof rawValue.enabled === 'boolean' ? rawValue.enabled : true,
}
}
function normalizeBarkServerUrl(value) {
const normalized = String(value || DEFAULT_BARK_SERVER_URL).trim() || DEFAULT_BARK_SERVER_URL
return normalized.replace(/\/+$/, '')
}
function createDefaultNotificationConfig() {
return {
enabled: true,
channels: {
bark: {
enabled: true,
serverUrl: DEFAULT_BARK_SERVER_URL,
recipients: [],
},
},
}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,235 @@
// @ts-check
import { logWarn } from '../../utils/logger.js'
import { sendInternalNotification } from './notification-service.js'
const DEFAULT_COOLDOWN_MS = 10 * 60 * 1000
const notificationCooldownMap = new Map()
/**
* @typedef {{
* title: string
* body?: string
* category?: string
* url?: string
* cooldownKey?: string
* cooldownMs?: number
* }} InternalNotificationPayload
*/
/** @param {InternalNotificationPayload} payload */
export async function notifyInternalSafely(payload) {
const cooldownKey = String(payload.cooldownKey || '').trim()
if (cooldownKey && isNotificationCoolingDown(cooldownKey, payload.cooldownMs)) {
return {
skipped: true,
reason: 'cooldown',
}
}
try {
const result = await sendInternalNotification({
title: payload.title,
body: payload.body,
category: payload.category,
url: payload.url,
})
markNotificationCooldown(cooldownKey)
return result
} catch (error) {
logWarn('[notification/internal]', '内部通知发送失败,已跳过', {
title: payload.title,
category: payload.category,
error,
})
markNotificationCooldown(cooldownKey)
return {
skipped: true,
reason: 'send_failed',
}
}
}
export function notifyKuaishouCloudAssetNotEnough({
task = {},
flow = {},
assetBefore = 0,
requiredAsset = 0,
skuName = '',
} = {}) {
const taskRecord = toRecord(task)
const flowRecord = toRecord(flow)
const binding = toRecord(flowRecord.binding)
return notifyInternalSafely({
title: '快手 Cloud 余额不足',
body: [
formatTaskLine(taskRecord),
`当前余额:${Number(assetBefore || 0)}`,
`最低需要:${Number(requiredAsset || 0)}`,
`商品:${String(skuName || binding.skuName || flowRecord.internalSkuName || '').trim() || '-'}`,
].join('\n'),
category: 'kuaishou_cloud_asset_not_enough',
cooldownKey: [
'kuaishou_cloud_asset_not_enough',
taskRecord.id || '',
binding.skuId || '',
].join(':'),
})
}
export function notifyCloudtentaclesAuthExpired({
pathname = '',
errorCode = '',
message = '',
} = {}) {
return notifyInternalSafely({
title: 'cloudtentacles 登录已过期',
body: [
`接口:${String(pathname || '').trim() || '-'}`,
`错误码:${String(errorCode || '').trim() || '-'}`,
`原因:${String(message || '').trim() || '登录态已失效,请到后台重新登录'}`,
].join('\n'),
category: 'cloudtentacles_auth_expired',
cooldownKey: ['cloudtentacles_auth_expired', pathname, errorCode].join(':'),
})
}
export function notifyOpen91PendingConfig({
order = {},
orderNo = '',
productNo = '',
buyNum = 0,
reason = '未命中履约配置',
} = {}) {
const orderRecord = toRecord(order)
return notifyInternalSafely({
title: '91卡券订单待补配置',
body: [
`订单:${String(orderNo || orderRecord.platform_order_id || '').trim() || '-'}`,
`商品:${String(productNo || '').trim() || '-'}`,
`数量:${Number(buyNum || 0) || 0}`,
`原因:${String(reason || '').trim() || '未命中履约配置'}`,
].join('\n'),
category: 'open91_pending_config',
cooldownKey: ['open91_pending_config', orderNo || orderRecord.platform_order_id || '', productNo].join(':'),
})
}
export function notifyKuaishouCloudBindUrlRefreshFailed({
task = {},
errorMessage = '',
} = {}) {
const taskRecord = toRecord(task)
return notifyInternalSafely({
title: '快手 Cloud 链接刷新失败',
body: [
formatTaskLine(taskRecord),
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
'旧虚拟号已退还,新绑定链接未准备成功。',
].join('\n'),
category: 'kuaishou_cloud_bind_url_refresh_failed',
cooldownKey: ['kuaishou_cloud_bind_url_refresh_failed', taskRecord.id || ''].join(':'),
})
}
export function notifyKuaishouCloudConsumeFailed({
task = {},
order = {},
ticketCodeMasked = '',
shopId = '',
shopName = '',
errorMessage = '',
} = {}) {
const taskRecord = toRecord(task)
const orderRecord = toRecord(order)
return notifyInternalSafely({
title: '快手核销失败,需人工处理',
body: [
formatTaskLine(taskRecord),
`订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`,
`券码:${String(ticketCodeMasked || '').trim() || '-'}`,
`店铺:${String(shopName || shopId || '').trim() || '-'}`,
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
].join('\n'),
category: 'kuaishou_cloud_consume_failed',
cooldownKey: ['kuaishou_cloud_consume_failed', taskRecord.id || ''].join(':'),
})
}
export function notifyTaskAutoManualReview({
task = {},
reason = '',
source = '',
} = {}) {
const taskRecord = toRecord(task)
return notifyInternalSafely({
title: '任务已自动转人工处理',
body: [
formatTaskLine(taskRecord),
`来源:${String(source || '').trim() || '-'}`,
`原因:${String(reason || taskRecord.last_error || '').trim() || '-'}`,
].join('\n'),
category: 'task_auto_manual_review',
cooldownKey: ['task_auto_manual_review', taskRecord.id || '', source].join(':'),
})
}
export function notifyClaimRedeemNeedsAttention({
task = {},
order = {},
status = '',
errorMessage = '',
} = {}) {
const taskRecord = toRecord(task)
const orderRecord = toRecord(order)
return notifyInternalSafely({
title: status === 'waiting_inventory' ? '库存不足,任务等待补货' : '兑换失败,任务需关注',
body: [
formatTaskLine(taskRecord),
`订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`,
`状态:${String(status || taskRecord.task_status || '').trim() || '-'}`,
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
].join('\n'),
category: status === 'waiting_inventory' ? 'task_waiting_inventory' : 'claim_redeem_failed',
cooldownKey: ['claim_redeem_needs_attention', taskRecord.id || '', status].join(':'),
})
}
function formatTaskLine(task) {
const taskRecord = toRecord(task)
return [
`任务:${String(taskRecord.task_no || taskRecord.id || '').trim() || '-'}`,
`平台订单:${String(taskRecord.platform_order_id || '').trim() || '-'}`,
].join(' / ')
}
function toRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value)
? /** @type {Record<string, unknown>} */ (value)
: {}
}
function isNotificationCoolingDown(cooldownKey, cooldownMs = DEFAULT_COOLDOWN_MS) {
const key = String(cooldownKey || '').trim()
if (!key) {
return false
}
const lastSentAt = Number(notificationCooldownMap.get(key) || 0)
return Date.now() - lastSentAt < Number(cooldownMs || DEFAULT_COOLDOWN_MS)
}
function markNotificationCooldown(cooldownKey) {
const key = String(cooldownKey || '').trim()
if (!key) {
return
}
notificationCooldownMap.set(key, Date.now())
}
@@ -0,0 +1,96 @@
// @ts-check
import { logWarn } from '../../utils/logger.js'
import { getNotificationConfig, listEnabledBarkRecipients } from './config-service.js'
import { sendBarkNotification } from './bark-service.js'
/**
* @typedef {{
* title?: string
* body?: string
* category?: string
* url?: string
* }} NotificationInput
*/
/** @param {NotificationInput} input */
export async function sendInternalNotification(input = {}) {
const config = getNotificationConfig()
const bark = /** @type {{ serverUrl?: string }} */ (config.channels?.bark || {})
const recipients = listEnabledBarkRecipients(config)
const title = String(input.title || '订单系统通知').trim() || '订单系统通知'
const body = String(input.body || '').trim()
const category = String(input.category || 'system').trim() || 'system'
if (config.enabled === false) {
return {
enabled: false,
channel: 'bark',
successCount: 0,
failedCount: 0,
skippedCount: recipients.length,
results: [],
}
}
const results = await Promise.all(recipients.map(async (recipient) => {
try {
const result = await sendBarkNotification({
serverUrl: bark.serverUrl,
recipient,
title,
body,
group: `订单系统/${category}`,
url: input.url,
})
return mapNotificationResult(recipient, true, '', result.status, result.response)
} catch (error) {
logWarn('[notification/bark]', 'Bark 内部通知发送失败', {
recipientId: recipient.id,
recipientName: recipient.name,
error,
})
return mapNotificationResult(
recipient,
false,
error instanceof Error ? error.message : 'Bark 内部通知发送失败',
Number(error?.statusCode || 0) || 0,
error?.context || null,
)
}
}))
return {
enabled: true,
channel: 'bark',
successCount: results.filter((item) => item.ok).length,
failedCount: results.filter((item) => !item.ok).length,
skippedCount: 0,
results,
}
}
function mapNotificationResult(recipient, ok, errorMessage, status, response) {
return {
recipientId: String(recipient.id || '').trim(),
recipientName: String(recipient.name || '').trim(),
recipientKeyMasked: maskDeviceKey(recipient.deviceKey),
ok,
status,
errorMessage,
response,
}
}
function maskDeviceKey(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
if (normalized.length <= 10) {
return `${normalized.slice(0, 2)}****${normalized.slice(-2)}`
}
return `${normalized.slice(0, 6)}****${normalized.slice(-6)}`
}