后端迁移通知服务
This commit is contained in:
+29
-25
@@ -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)
|
||||||
|
}
|
||||||
+8
-8
@@ -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]'
|
||||||
}
|
}
|
||||||
+24
-28
@@ -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
|
||||||
+23
-17
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 个用例通过
|
||||||
|
|
||||||
## 下一步建议
|
## 下一步建议
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user