Files
order_site/apps/backend/src/services/notification/wpush-service.ts
T
yml2213 042574dea3 继续收紧 TypeScript:ClaimIdentity 契约与 flow 出口类型
- 新增 types/claim-identity,统一 ClaimIdentity / Payload / AdminSummary
- 导出 KuaishouCloudFlow、KuaishouFeifeiFlow 作为 normalize 出口
- 生产路径 JsonObject 统一从 types/json 导入,去掉 rebind as any
- 前后端 claimIdentity 类型字段对齐
2026-07-10 15:56:25 +08:00

195 lines
4.9 KiB
TypeScript

import http from 'node:http'
import https from 'node:https'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
const DEFAULT_WPUSH_ENDPOINT = 'https://api.wpush.cn/api/v1/send'
const DEFAULT_TIMEOUT_MS = 10000
type NodeHttpResponse = {
ok: boolean
status: number
bodyText: string
}
type WpushSendInput = {
recipient?: {
id?: string
name?: string
apiKey?: string
}
title?: string
body?: string
endpoint?: string
}
export async function sendWpushNotification(input: WpushSendInput) {
const apiKey = String(input.recipient?.apiKey || '').trim()
const title = String(input.title || '').trim()
const body = String(input.body || '').trim()
const endpoint = String(input.endpoint || DEFAULT_WPUSH_ENDPOINT).trim() || DEFAULT_WPUSH_ENDPOINT
if (!apiKey) {
throw createHttpError('WPush 接收人缺少 apikey', {
statusCode: 400,
errorCode: 'wpush_api_key_required',
})
}
if (!title && !body) {
throw createHttpError('WPush 通知标题或内容不能为空', {
statusCode: 400,
errorCode: 'wpush_message_required',
})
}
const payload = JSON.stringify({
apikey: apiKey,
title: title || body,
content: body || title,
})
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
try {
const response = await requestViaNodeHttp(new URL(endpoint), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/plain, */*',
'content-length': String(Buffer.byteLength(payload)),
},
body: payload,
signal: controller.signal,
})
const parsed = parseJsonResponse(response.bodyText)
if (!response.ok || isWpushFailure(parsed)) {
throw createHttpError(resolveWpushErrorMessage(parsed, response.bodyText, response.status), {
statusCode: response.ok ? 502 : response.status,
errorCode: 'wpush_send_failed',
context: {
status: response.status,
response: parsed || response.bodyText,
},
})
}
return {
ok: true,
status: response.status,
response: parsed || response.bodyText,
}
} catch (error) {
if (isAbortError(error)) {
throw createHttpError('WPush 通知发送超时', {
statusCode: 503,
errorCode: 'wpush_send_timeout',
cause: error,
})
}
throw error
} finally {
clearTimeout(timer)
}
}
async function requestViaNodeHttp(
url: URL,
{ method, headers, body, signal }: {
method: string
headers: Record<string, string>
body?: string
signal?: AbortSignal
},
): Promise<NodeHttpResponse> {
const transport = url.protocol === 'https:' ? https : http
return new Promise((resolve, reject) => {
const request = transport.request(url, {
method,
headers,
rejectUnauthorized: false,
}, (response) => {
const chunks: Buffer[] = []
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
})
response.on('end', () => {
resolve({
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
status: Number(response.statusCode || 0),
bodyText: Buffer.concat(chunks).toString('utf8'),
})
})
})
request.on('error', reject)
if (signal) {
if (signal.aborted) {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
} else {
signal.addEventListener('abort', () => {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
}, { once: true })
}
}
if (typeof body !== 'undefined') {
request.write(body)
}
request.end()
})
}
function parseJsonResponse(text: string) {
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch {
return null
}
}
function isWpushFailure(parsed: unknown) {
if (!isPlainObject(parsed)) {
return false
}
const success = parsed.success
if (typeof success === 'boolean') {
return success === false
}
const code = Number(parsed.code ?? parsed.status ?? 0)
return Number.isFinite(code) && code !== 0 && code !== 200
}
function resolveWpushErrorMessage(parsed: unknown, responseText: string, status: number) {
if (isPlainObject(parsed)) {
return String(parsed.message || parsed.msg || parsed.error || '').trim() || `WPush 通知发送失败,HTTP ${status}`
}
return String(responseText || '').trim() || `WPush 通知发送失败,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)
}