后端迁移通知服务

This commit is contained in:
yml
2026-05-21 16:31:03 +08:00
parent fb95f7dc55
commit 1c760f1dd3
5 changed files with 95 additions and 78 deletions
@@ -1,26 +1,22 @@
// @ts-check
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
const DEFAULT_TIMEOUT_MS = 10000 const DEFAULT_TIMEOUT_MS = 10000
/** type JsonObject = Record<string, any>
* @typedef {{ type BarkSendInput = {
* serverUrl: string serverUrl?: string
* recipient: { recipient?: {
* id?: string id?: string
* name?: string name?: string
* deviceKey?: string deviceKey?: string
* } }
* title: string title?: string
* body?: string body?: string
* group?: string group?: string
* url?: string url?: string
* }} BarkSendInput }
*/
/** @param {BarkSendInput} input */ export async function sendBarkNotification(input: BarkSendInput) {
export async function sendBarkNotification(input) {
const serverUrl = String(input.serverUrl || 'https://api.day.app').trim().replace(/\/+$/, '') const serverUrl = String(input.serverUrl || 'https://api.day.app').trim().replace(/\/+$/, '')
const deviceKey = String(input.recipient?.deviceKey || '').trim() const deviceKey = String(input.recipient?.deviceKey || '').trim()
const title = String(input.title || '').trim() const title = String(input.title || '').trim()
@@ -78,7 +74,7 @@ export async function sendBarkNotification(input) {
response: parsed || responseText, response: parsed || responseText,
} }
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') { if (isAbortError(error)) {
throw createHttpError('Bark 通知发送超时', { throw createHttpError('Bark 通知发送超时', {
statusCode: 503, statusCode: 503,
errorCode: 'bark_send_timeout', errorCode: 'bark_send_timeout',
@@ -91,7 +87,7 @@ export async function sendBarkNotification(input) {
} }
} }
function buildBarkEndpoint(serverUrl, deviceKey, title, body) { function buildBarkEndpoint(serverUrl: string, deviceKey: string, title: string, body: string) {
const segments = [ const segments = [
serverUrl, serverUrl,
encodeURIComponent(deviceKey), encodeURIComponent(deviceKey),
@@ -105,7 +101,7 @@ function buildBarkEndpoint(serverUrl, deviceKey, title, body) {
return segments.join('/') return segments.join('/')
} }
function parseJsonResponse(text) { function parseJsonResponse(text: string) {
if (!text) { if (!text) {
return null return null
} }
@@ -117,8 +113,8 @@ function parseJsonResponse(text) {
} }
} }
function isBarkFailure(parsed) { function isBarkFailure(parsed: unknown) {
if (!parsed || typeof parsed !== 'object') { if (!isPlainObject(parsed)) {
return false return false
} }
@@ -126,10 +122,18 @@ function isBarkFailure(parsed) {
return Number.isFinite(code) && code !== 0 && code !== 200 return Number.isFinite(code) && code !== 0 && code !== 200
} }
function resolveBarkErrorMessage(parsed, responseText, status) { function resolveBarkErrorMessage(parsed: unknown, responseText: string, status: number) {
if (parsed && typeof parsed === 'object') { if (isPlainObject(parsed)) {
return String(parsed.message || parsed.msg || parsed.error || '').trim() || `Bark 通知发送失败,HTTP ${status}` return String(parsed.message || parsed.msg || parsed.error || '').trim() || `Bark 通知发送失败,HTTP ${status}`
} }
return String(responseText || '').trim() || `Bark 通知发送失败,HTTP ${status}` return String(responseText || '').trim() || `Bark 通知发送失败,HTTP ${status}`
} }
function isAbortError(error: unknown): error is Error {
return error instanceof Error && error.name === 'AbortError'
}
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -1,5 +1,3 @@
// @ts-check
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
@@ -8,6 +6,8 @@ import { PROJECT_ROOT } from '../../config/runtime.js'
const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notification-config.json') const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notification-config.json')
const DEFAULT_BARK_SERVER_URL = 'https://api.day.app' const DEFAULT_BARK_SERVER_URL = 'https://api.day.app'
type JsonObject = Record<string, any>
export function getNotificationConfigFilePath() { export function getNotificationConfigFilePath() {
return NOTIFICATION_CONFIG_FILE_PATH return NOTIFICATION_CONFIG_FILE_PATH
} }
@@ -16,14 +16,14 @@ export function getNotificationConfig() {
return loadNotificationConfigFromFile() return loadNotificationConfigFromFile()
} }
export function saveNotificationConfig(rawValue) { export function saveNotificationConfig(rawValue: unknown) {
const normalized = normalizeNotificationConfig(rawValue) const normalized = normalizeNotificationConfig(rawValue)
fs.mkdirSync(path.dirname(NOTIFICATION_CONFIG_FILE_PATH), { recursive: true }) fs.mkdirSync(path.dirname(NOTIFICATION_CONFIG_FILE_PATH), { recursive: true })
fs.writeFileSync(NOTIFICATION_CONFIG_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') fs.writeFileSync(NOTIFICATION_CONFIG_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized return normalized
} }
export function listEnabledBarkRecipients(config = getNotificationConfig()) { export function listEnabledBarkRecipients(config: JsonObject = getNotificationConfig()) {
if (config.enabled === false || config.channels?.bark?.enabled === false) { if (config.enabled === false || config.channels?.bark?.enabled === false) {
return [] return []
} }
@@ -45,7 +45,7 @@ function loadNotificationConfigFromFile() {
} }
} }
function normalizeNotificationConfig(rawValue) { function normalizeNotificationConfig(rawValue: unknown) {
const source = isPlainObject(rawValue) ? rawValue : {} const source = isPlainObject(rawValue) ? rawValue : {}
const bark = isPlainObject(source.channels?.bark) ? source.channels.bark : {} const bark = isPlainObject(source.channels?.bark) ? source.channels.bark : {}
@@ -63,7 +63,7 @@ function normalizeNotificationConfig(rawValue) {
} }
} }
function normalizeBarkRecipient(rawValue) { function normalizeBarkRecipient(rawValue: unknown) {
if (!isPlainObject(rawValue)) { if (!isPlainObject(rawValue)) {
return null return null
} }
@@ -84,7 +84,7 @@ function normalizeBarkRecipient(rawValue) {
} }
} }
function normalizeBarkServerUrl(value) { function normalizeBarkServerUrl(value: unknown) {
const normalized = String(value || DEFAULT_BARK_SERVER_URL).trim() || DEFAULT_BARK_SERVER_URL const normalized = String(value || DEFAULT_BARK_SERVER_URL).trim() || DEFAULT_BARK_SERVER_URL
return normalized.replace(/\/+$/, '') return normalized.replace(/\/+$/, '')
} }
@@ -102,6 +102,6 @@ function createDefaultNotificationConfig() {
} }
} }
function isPlainObject(value) { function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]' return Object.prototype.toString.call(value) === '[object Object]'
} }
@@ -1,24 +1,20 @@
// @ts-check
import { logWarn } from '../../utils/logger.js' import { logWarn } from '../../utils/logger.js'
import { sendInternalNotification } from './notification-service.js' import { sendInternalNotification } from './notification-service.js'
const DEFAULT_COOLDOWN_MS = 10 * 60 * 1000 const DEFAULT_COOLDOWN_MS = 10 * 60 * 1000
const notificationCooldownMap = new Map() const notificationCooldownMap = new Map<string, number>()
/** type JsonObject = Record<string, any>
* @typedef {{ type InternalNotificationPayload = {
* title: string title: string
* body?: string body?: string
* category?: string category?: string
* url?: string url?: string
* cooldownKey?: string cooldownKey?: string
* cooldownMs?: number cooldownMs?: number
* }} InternalNotificationPayload }
*/
/** @param {InternalNotificationPayload} payload */ export async function notifyInternalSafely(payload: InternalNotificationPayload) {
export async function notifyInternalSafely(payload) {
const cooldownKey = String(payload.cooldownKey || '').trim() const cooldownKey = String(payload.cooldownKey || '').trim()
if (cooldownKey && isNotificationCoolingDown(cooldownKey, payload.cooldownMs)) { if (cooldownKey && isNotificationCoolingDown(cooldownKey, payload.cooldownMs)) {
return { return {
@@ -56,7 +52,7 @@ export function notifyKuaishouCloudAssetNotEnough({
assetBefore = 0, assetBefore = 0,
requiredAsset = 0, requiredAsset = 0,
skuName = '', skuName = '',
} = {}) { }: JsonObject = {}) {
const taskRecord = toRecord(task) const taskRecord = toRecord(task)
const flowRecord = toRecord(flow) const flowRecord = toRecord(flow)
const binding = toRecord(flowRecord.binding) const binding = toRecord(flowRecord.binding)
@@ -83,7 +79,7 @@ export function notifyCloudtentaclesAuthExpired({
errorCode = '', errorCode = '',
message = '', message = '',
cooldownSeconds = 600, cooldownSeconds = 600,
} = {}) { }: JsonObject = {}) {
return notifyInternalSafely({ return notifyInternalSafely({
title: 'cloudtentacles 登录已过期', title: 'cloudtentacles 登录已过期',
body: [ body: [
@@ -101,7 +97,7 @@ export function notifyCloudtentaclesAssetLow({
asset = 0, asset = 0,
threshold = 500, threshold = 500,
cooldownSeconds = 1800, cooldownSeconds = 1800,
} = {}) { }: JsonObject = {}) {
return notifyInternalSafely({ return notifyInternalSafely({
title: '快手 Cloud 余额低于阈值', title: '快手 Cloud 余额低于阈值',
body: [ body: [
@@ -121,7 +117,7 @@ export function notifyOpen91PendingConfig({
productNo = '', productNo = '',
buyNum = 0, buyNum = 0,
reason = '未命中履约配置', reason = '未命中履约配置',
} = {}) { }: JsonObject = {}) {
const orderRecord = toRecord(order) const orderRecord = toRecord(order)
return notifyInternalSafely({ return notifyInternalSafely({
@@ -140,7 +136,7 @@ export function notifyOpen91PendingConfig({
export function notifyKuaishouCloudBindUrlRefreshFailed({ export function notifyKuaishouCloudBindUrlRefreshFailed({
task = {}, task = {},
errorMessage = '', errorMessage = '',
} = {}) { }: JsonObject = {}) {
const taskRecord = toRecord(task) const taskRecord = toRecord(task)
return notifyInternalSafely({ return notifyInternalSafely({
@@ -162,7 +158,7 @@ export function notifyKuaishouCloudConsumeFailed({
shopId = '', shopId = '',
shopName = '', shopName = '',
errorMessage = '', errorMessage = '',
} = {}) { }: JsonObject = {}) {
const taskRecord = toRecord(task) const taskRecord = toRecord(task)
const orderRecord = toRecord(order) const orderRecord = toRecord(order)
@@ -184,7 +180,7 @@ export function notifyTaskAutoManualReview({
task = {}, task = {},
reason = '', reason = '',
source = '', source = '',
} = {}) { }: JsonObject = {}) {
const taskRecord = toRecord(task) const taskRecord = toRecord(task)
return notifyInternalSafely({ return notifyInternalSafely({
@@ -204,7 +200,7 @@ export function notifyClaimRedeemNeedsAttention({
order = {}, order = {},
status = '', status = '',
errorMessage = '', errorMessage = '',
} = {}) { }: JsonObject = {}) {
const taskRecord = toRecord(task) const taskRecord = toRecord(task)
const orderRecord = toRecord(order) const orderRecord = toRecord(order)
@@ -221,7 +217,7 @@ export function notifyClaimRedeemNeedsAttention({
}) })
} }
function formatTaskLine(task) { function formatTaskLine(task: unknown) {
const taskRecord = toRecord(task) const taskRecord = toRecord(task)
return [ return [
`任务:${String(taskRecord.task_no || taskRecord.id || '').trim() || '-'}`, `任务:${String(taskRecord.task_no || taskRecord.id || '').trim() || '-'}`,
@@ -229,13 +225,13 @@ function formatTaskLine(task) {
].join(' / ') ].join(' / ')
} }
function toRecord(value) { function toRecord(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? /** @type {Record<string, unknown>} */ (value) ? value as JsonObject
: {} : {}
} }
function isNotificationCoolingDown(cooldownKey, cooldownMs = DEFAULT_COOLDOWN_MS) { function isNotificationCoolingDown(cooldownKey: unknown, cooldownMs = DEFAULT_COOLDOWN_MS) {
const key = String(cooldownKey || '').trim() const key = String(cooldownKey || '').trim()
if (!key) { if (!key) {
return false return false
@@ -245,7 +241,7 @@ function isNotificationCoolingDown(cooldownKey, cooldownMs = DEFAULT_COOLDOWN_MS
return Date.now() - lastSentAt < Number(cooldownMs || DEFAULT_COOLDOWN_MS) return Date.now() - lastSentAt < Number(cooldownMs || DEFAULT_COOLDOWN_MS)
} }
function markNotificationCooldown(cooldownKey) { function markNotificationCooldown(cooldownKey: unknown) {
const key = String(cooldownKey || '').trim() const key = String(cooldownKey || '').trim()
if (!key) { if (!key) {
return return
@@ -1,22 +1,18 @@
// @ts-check
import { logWarn } from '../../utils/logger.js' import { logWarn } from '../../utils/logger.js'
import { getNotificationConfig, listEnabledBarkRecipients } from './config-service.js' import { getNotificationConfig, listEnabledBarkRecipients } from './config-service.js'
import { sendBarkNotification } from './bark-service.js' import { sendBarkNotification } from './bark-service.js'
/** type JsonObject = Record<string, any>
* @typedef {{ type NotificationInput = {
* title?: string title?: string
* body?: string body?: string
* category?: string category?: string
* url?: string url?: string
* }} NotificationInput }
*/
/** @param {NotificationInput} input */ export async function sendInternalNotification(input: NotificationInput = {}) {
export async function sendInternalNotification(input = {}) {
const config = getNotificationConfig() const config = getNotificationConfig()
const bark = /** @type {{ serverUrl?: string }} */ (config.channels?.bark || {}) const bark = (config.channels?.bark || {}) as { serverUrl?: string }
const recipients = listEnabledBarkRecipients(config) const recipients = listEnabledBarkRecipients(config)
const title = String(input.title || '订单系统通知').trim() || '订单系统通知' const title = String(input.title || '订单系统通知').trim() || '订单系统通知'
const body = String(input.body || '').trim() const body = String(input.body || '').trim()
@@ -54,8 +50,8 @@ export async function sendInternalNotification(input = {}) {
recipient, recipient,
false, false,
error instanceof Error ? error.message : 'Bark 内部通知发送失败', error instanceof Error ? error.message : 'Bark 内部通知发送失败',
Number(error?.statusCode || 0) || 0, isPlainObject(error) ? Number(error.statusCode || 0) || 0 : 0,
error?.context || null, isPlainObject(error) ? error.context || null : null,
) )
} }
})) }))
@@ -70,7 +66,13 @@ export async function sendInternalNotification(input = {}) {
} }
} }
function mapNotificationResult(recipient, ok, errorMessage, status, response) { function mapNotificationResult(
recipient: JsonObject,
ok: boolean,
errorMessage: string,
status: number,
response: unknown,
) {
return { return {
recipientId: String(recipient.id || '').trim(), recipientId: String(recipient.id || '').trim(),
recipientName: String(recipient.name || '').trim(), recipientName: String(recipient.name || '').trim(),
@@ -82,7 +84,7 @@ function mapNotificationResult(recipient, ok, errorMessage, status, response) {
} }
} }
function maskDeviceKey(value) { function maskDeviceKey(value: unknown) {
const normalized = String(value || '').trim() const normalized = String(value || '').trim()
if (!normalized) { if (!normalized) {
return '' return ''
@@ -94,3 +96,7 @@ function maskDeviceKey(value) {
return `${normalized.slice(0, 6)}****${normalized.slice(-6)}` return `${normalized.slice(0, 6)}****${normalized.slice(-6)}`
} }
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
+11
View File
@@ -674,6 +674,17 @@
- `npm run typecheck` - `npm run typecheck`
- `npm run build` - `npm run build`
- `npm test` 共 139 个用例通过 - `npm test` 共 139 个用例通过
165. 通知服务模块迁移到 `.ts`
- `src/services/notification/config-service.ts`
- `src/services/notification/bark-service.ts`
- `src/services/notification/notification-service.ts`
- `src/services/notification/domain-notifications.ts`
166. Bark 通知发送、通知配置读写、内部通知分发、业务域告警与冷却控制已进入 TS 编译链路;Bark input、通知 payload、接收人配置、错误上下文与冷却 map 补齐类型
167. Docker 内验证通过:
- `src/services/admin/platform-config/*.test.js` 共 37 个用例通过
- `npm run typecheck`
- `npm run build`
- `npm test` 共 139 个用例通过
## 下一步建议 ## 下一步建议