Files
medical-mall/pages/mall/delivery/webhook-server/webhook-receiver.js
2026-02-25 09:38:01 +08:00

187 lines
6.2 KiB
JavaScript

const express = require('express')
const bodyParser = require('body-parser')
const fetch = require('node-fetch')
const crypto = require('crypto')
const PORT = process.env.PORT || 7201
const SUPA_URL = (process.env.SUPA_URL || process.env.SUPA_URL_OVERRIDE || '').replace(/\/$/, '')
const SUPA_KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || ''
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '' // optional HMAC secret
function supaFetch(path, opts = {}) {
const url = `${SUPA_URL}/rest/v1/${path}`
const headers = Object.assign({}, opts.headers || {}, {
apikey: SUPA_KEY,
Authorization: `Bearer ${SUPA_KEY}`,
Accept: 'application/json'
})
return fetch(url, Object.assign({}, opts, { headers }))
}
function computeSignature(bodyText, ts) {
if (!WEBHOOK_SECRET) return ''
const h = crypto.createHmac('sha256', WEBHOOK_SECRET)
h.update(bodyText + (ts || ''))
return h.digest('hex')
}
async function upsertRaw(payload, tracking_no, carrier, signature_valid) {
try {
const body = {
carrier: carrier || null,
tracking_no: tracking_no || null,
body: payload,
received_at: new Date().toISOString(),
signature_valid: signature_valid
}
const resp = await supaFetch('platform_express_event_raw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
return resp
} catch (e) {
console.warn('upsertRaw error', e)
return null
}
}
async function findWaybillId(tracking_no, order_no) {
try {
if (tracking_no) {
const r = await supaFetch(`platform_express_waybills?tracking_no=eq.${encodeURIComponent(tracking_no)}`)
if (r.ok) {
const data = await r.json()
if (data && data.length > 0) return data[0].id
}
}
if (order_no) {
const r2 = await supaFetch(`platform_express_waybills?order_no=eq.${encodeURIComponent(order_no)}`)
if (r2.ok) {
const data2 = await r2.json()
if (data2 && data2.length > 0) return data2[0].id
}
}
return null
} catch (e) {
console.warn('findWaybillId error', e)
return null
}
}
function mapStatus(inStatus) {
let s = 'IN_TRANSIT'
if (!inStatus) return s
const v = String(inStatus).toUpperCase()
if (['GOT','SEND','TRANSIT'].indexOf(v) > -1) s = 'IN_TRANSIT'
else if (v === 'SENT') s = 'OUT_FOR_DELIVERY'
else if (v === 'PICKUP') s = 'READY_FOR_PICKUP'
else if (v === 'SIGNED' || v === 'DELIVERED') s = 'DELIVERED'
else if (v === 'FAILED' || v === 'EXCEPTION') s = 'EXCEPTION'
else if (v === 'RETURNED') s = 'RETURNED'
else {
const valid = ['ORDER_PLACED','SHIPPED','IN_TRANSIT','OUT_FOR_DELIVERY','READY_FOR_PICKUP','DELIVERED','EXCEPTION','RETURNED']
if (valid.indexOf(v) > -1) s = v
}
return s
}
async function updateWaybill(id, status_code, text) {
try {
const now = new Date().toISOString()
await supaFetch(`platform_express_waybills?id=eq.${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ current_status_code: status_code, current_status_text: text, last_synced_at: now })
})
} catch (e) {
console.warn('updateWaybill error', e)
}
}
async function insertEvent(event) {
try {
await supaFetch('platform_express_tracking_events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
})
} catch (e) { console.warn('insertEvent error', e) }
}
async function start() {
if (!SUPA_URL || !SUPA_KEY) {
console.error('SUPA_URL and SUPA_KEY must be set in env')
process.exit(1)
}
const app = express()
app.use(bodyParser.json({ limit: '1mb' }))
app.post('/webhook/express/status', async (req, res) => {
const ts = req.headers['x-timestamp'] || req.headers['X-TIMESTAMP'] || ''
const sig = req.headers['x-signature'] || req.headers['X-SIGNATURE'] || ''
const cid = req.headers['x-client-id'] || req.headers['X-CLIENT-ID'] || ''
const bodyText = JSON.stringify(req.body || {})
let sigValid = true
if (WEBHOOK_SECRET) {
const calc = computeSignature(bodyText, ts)
sigValid = calc === String(sig)
}
// persist raw
await upsertRaw(req.body || {}, req.body && (req.body.mailNo || req.body.tracking_no), req.body && req.body.carrier, sigValid)
// find waybill
const tracking_no = req.body && (req.body.mailNo || req.body.tracking_no)
const order_no = req.body && (req.body.txLogisticId || req.body.order_no)
const carrier = req.body && req.body.carrier ? req.body.carrier : (req.body && req.body.company || null)
const event_code = req.body && (req.body.infoContent || req.body.status_code || req.body.event_code)
const event_text = req.body && (req.body.remark || req.body.event_text || '')
const waybillId = await findWaybillId(tracking_no, order_no)
if (!waybillId) {
// Waybill not found — respond 200 but inform caller in body.
return res.status(200).json({ ok: false, message: 'waybill not found' })
}
const status_code = mapStatus(event_code)
// update waybill
await updateWaybill(waybillId, status_code, event_text)
// parse event_time
let event_time = new Date().toISOString()
if (req.body && req.body.acceptTime) {
try {
const t = req.body.acceptTime.indexOf('T') > -1 ? req.body.acceptTime : req.body.acceptTime.replace(' ', 'T')
const dt = new Date(t)
if (!isNaN(dt.getTime())) event_time = dt.toISOString()
} catch (e) {}
}
// insert event
const eventPayload = {
waybill_id: waybillId,
carrier: carrier,
tracking_no: tracking_no || null,
event_time: event_time,
event_code: event_code || 'UNKNOWN',
event_text: event_text || '',
status_code: status_code,
raw_payload: req.body || {},
dedupe_key: 'WEBHOOK_' + Date.now()
}
await insertEvent(eventPayload)
return res.json({ ok: true })
})
app.get('/health', (req, res) => res.json({ ok: true }))
app.listen(PORT, '0.0.0.0', () => console.log(`Webhook receiver listening on http://0.0.0.0:${PORT}`))
}
start().catch(e => { console.error('start failed', e); process.exit(1) })