消息推送
This commit is contained in:
255
server/notify-worker.js
Normal file
255
server/notify-worker.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// notify-worker.js
|
||||
// 常驻队列消费者:从 notify_queue 读取待处理事件,写入 express_notifications。
|
||||
// push-server consumer 将继续轮询 express_notifications 并调用 CLOUD_FUNC_URL 实际下发。
|
||||
//
|
||||
// 配置加载优先级:
|
||||
// 1) 系统环境变量
|
||||
// 2) CONFIG_FILE/CONFIG_PATH
|
||||
// 3) 同目录 notify-worker.config.json
|
||||
// 4) server/.env / server/config.json via server/load-config.js
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const localConfigPath = path.join(__dirname, 'notify-worker.config.json')
|
||||
if (!process.env.CONFIG_FILE && !process.env.CONFIG_PATH && fs.existsSync(localConfigPath)) {
|
||||
process.env.CONFIG_FILE = localConfigPath
|
||||
}
|
||||
|
||||
require('./load-config')
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const fetchImpl = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : (() => {
|
||||
try {
|
||||
return require('node-fetch')
|
||||
} catch (e) {
|
||||
throw new Error("No fetch implementation found. Use Node.js 18+ or install 'node-fetch'.")
|
||||
}
|
||||
})())
|
||||
|
||||
const SUPA_URL = (process.env.SUPA_URL || process.env.SUPA_URL_OVERRIDE || '').replace(/\/$/, '')
|
||||
// Prefer service role key when present (server-side), to avoid RLS issues.
|
||||
const SUPA_KEY = process.env.SERVICE_ROLE_KEY || process.env.SUPA_KEY || ''
|
||||
const SUPA_USE_BEARER = (process.env.SUPA_USE_BEARER === 'true')
|
||||
|
||||
const POLL_MS = Number(process.env.NOTIFY_WORKER_POLL_MS || process.env.WORKER_POLL_MS || 2000)
|
||||
const BATCH_SIZE = Number(process.env.NOTIFY_WORKER_BATCH_SIZE || process.env.WORKER_BATCH_SIZE || 20)
|
||||
const RUN_ONCE = (process.env.RUN_ONCE === 'true' || process.env.RUN_ONCE === '1')
|
||||
|
||||
function supaFetch(restPath, opts = {}) {
|
||||
const url = `${SUPA_URL}/rest/v1/${restPath}`
|
||||
const headers = Object.assign({}, opts.headers || {}, {
|
||||
apikey: SUPA_KEY,
|
||||
Accept: 'application/json'
|
||||
})
|
||||
if (SUPA_USE_BEARER) headers.Authorization = `Bearer ${SUPA_KEY}`
|
||||
return fetchImpl(url, Object.assign({}, opts, { headers }))
|
||||
}
|
||||
|
||||
function sha256Hex(input) {
|
||||
return crypto.createHash('sha256').update(String(input)).digest('hex')
|
||||
}
|
||||
|
||||
function buildMessageId({ aud, waybill_id, dedupe_key }) {
|
||||
const h = sha256Hex(`${aud}|${waybill_id}|${dedupe_key}`)
|
||||
return `evt_${aud}_${h.slice(0, 32)}`
|
||||
}
|
||||
|
||||
async function fetchPendingQueue(limit) {
|
||||
const q = `notify_queue?processed_at=is.null&order=created_at.asc&limit=${encodeURIComponent(String(limit))}`
|
||||
const resp = await supaFetch(q, { method: 'GET' })
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => '')
|
||||
throw new Error(`fetch notify_queue failed: HTTP ${resp.status} ${txt}`)
|
||||
}
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function fetchWaybill(waybillId) {
|
||||
const resp = await supaFetch(`platform_express_waybills?id=eq.${encodeURIComponent(waybillId)}&select=id,order_id,order_no,carrier,tracking_no`, { method: 'GET' })
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => '')
|
||||
throw new Error(`fetch waybill failed: HTTP ${resp.status} ${txt}`)
|
||||
}
|
||||
const rows = await resp.json()
|
||||
return rows && rows[0] ? rows[0] : null
|
||||
}
|
||||
|
||||
async function fetchOrderById(orderId) {
|
||||
const resp = await supaFetch(`ml_orders?id=eq.${encodeURIComponent(orderId)}&select=id,order_no,user_id,merchant_id`, { method: 'GET' })
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => '')
|
||||
throw new Error(`fetch order (id) failed: HTTP ${resp.status} ${txt}`)
|
||||
}
|
||||
const rows = await resp.json()
|
||||
return rows && rows[0] ? rows[0] : null
|
||||
}
|
||||
|
||||
async function fetchOrderByNo(orderNo) {
|
||||
const resp = await supaFetch(`ml_orders?order_no=eq.${encodeURIComponent(orderNo)}&select=id,order_no,user_id,merchant_id`, { method: 'GET' })
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => '')
|
||||
throw new Error(`fetch order (order_no) failed: HTTP ${resp.status} ${txt}`)
|
||||
}
|
||||
const rows = await resp.json()
|
||||
return rows && rows[0] ? rows[0] : null
|
||||
}
|
||||
|
||||
async function upsertExpressNotification(body) {
|
||||
const resp = await supaFetch('express_notifications?on_conflict=message_id', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation,resolution=merge-duplicates'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => '')
|
||||
throw new Error(`insert express_notifications failed: HTTP ${resp.status} ${txt}`)
|
||||
}
|
||||
|
||||
const rows = await resp.json().catch(() => null)
|
||||
return Array.isArray(rows) ? rows[0] : rows
|
||||
}
|
||||
|
||||
async function markQueueProcessed(id, patch) {
|
||||
const resp = await supaFetch(`notify_queue?id=eq.${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(Object.assign({}, patch, { processed_at: new Date().toISOString() }))
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => '')
|
||||
throw new Error(`patch notify_queue failed: HTTP ${resp.status} ${txt}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function processOne(row) {
|
||||
const waybillId = row.waybill_id
|
||||
if (!waybillId) {
|
||||
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'missing waybill_id' })
|
||||
return
|
||||
}
|
||||
|
||||
const waybill = await fetchWaybill(waybillId)
|
||||
if (!waybill) {
|
||||
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'waybill not found' })
|
||||
return
|
||||
}
|
||||
|
||||
let order = null
|
||||
if (waybill.order_id) order = await fetchOrderById(waybill.order_id)
|
||||
if (!order && waybill.order_no) order = await fetchOrderByNo(waybill.order_no)
|
||||
|
||||
if (!order) {
|
||||
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'order not found for waybill' })
|
||||
return
|
||||
}
|
||||
|
||||
const recipients = []
|
||||
if (order.user_id) recipients.push({ aud: 'user', recipient_id: order.user_id })
|
||||
if (order.merchant_id) recipients.push({ aud: 'merchant', recipient_id: order.merchant_id })
|
||||
|
||||
if (recipients.length === 0) {
|
||||
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'no recipient_id resolved from order' })
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
biz: 'express',
|
||||
order_no: order.order_no || waybill.order_no || null,
|
||||
order_id: order.id || waybill.order_id || null,
|
||||
waybill_id: waybill.id,
|
||||
carrier: waybill.carrier || row.carrier || null,
|
||||
tracking_no: waybill.tracking_no || row.tracking_no || null,
|
||||
status_code: row.status_code,
|
||||
event_time: row.event_time,
|
||||
event_id: row.event_id || null
|
||||
}
|
||||
|
||||
// 为每个受众写入一条通知(message_id 按 aud 区分,确保幂等)
|
||||
for (const r of recipients) {
|
||||
const message_id = buildMessageId({ aud: r.aud, waybill_id: waybill.id, dedupe_key: row.dedupe_key })
|
||||
const dedupe_key = `${waybill.id}|${r.aud}|${row.dedupe_key}`.slice(0, 256)
|
||||
|
||||
await upsertExpressNotification({
|
||||
aud: r.aud,
|
||||
recipient_id: r.recipient_id,
|
||||
order_id: order.id || null,
|
||||
waybill_id: waybill.id,
|
||||
tracking_no: waybill.tracking_no || null,
|
||||
carrier: waybill.carrier || null,
|
||||
message_id,
|
||||
event_text_safe: row.event_text || null,
|
||||
status_code: row.status_code,
|
||||
event_time: row.event_time || null,
|
||||
payload,
|
||||
dedupe_key
|
||||
})
|
||||
}
|
||||
|
||||
await markQueueProcessed(row.id, { process_status: 'queued', last_error: null })
|
||||
}
|
||||
|
||||
async function loopOnce() {
|
||||
const rows = await fetchPendingQueue(BATCH_SIZE)
|
||||
if (!rows || rows.length === 0) return { processed: 0 }
|
||||
|
||||
let processed = 0
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await processOne(row)
|
||||
processed += 1
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e)
|
||||
try {
|
||||
await markQueueProcessed(row.id, { process_status: 'failed', last_error: msg.substring(0, 2000) })
|
||||
} catch (patchErr) {
|
||||
console.warn('failed to mark queue row failed:', patchErr)
|
||||
}
|
||||
console.warn('processOne failed:', msg)
|
||||
processed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { processed }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!SUPA_URL || !SUPA_KEY) {
|
||||
console.error('SUPA_URL and SUPA_KEY must be set in env')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('notify-worker starting...')
|
||||
console.log('ENV: SUPA_URL=', SUPA_URL)
|
||||
console.log('ENV: SUPA_USE_BEARER=', SUPA_USE_BEARER)
|
||||
console.log('ENV: POLL_MS=', POLL_MS, 'BATCH_SIZE=', BATCH_SIZE, 'RUN_ONCE=', RUN_ONCE)
|
||||
|
||||
if (RUN_ONCE) {
|
||||
const r = await loopOnce()
|
||||
console.log('notify-worker done (run once):', r)
|
||||
return
|
||||
}
|
||||
|
||||
// 常驻轮询
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
const r = await loopOnce()
|
||||
if (r.processed > 0) console.log('notify-worker processed:', r.processed)
|
||||
} catch (e) {
|
||||
console.warn('notify-worker loop error:', e && e.message ? e.message : e)
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS))
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('notify-worker fatal:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user