// Load configuration into process.env. // Priority: // 1) Real environment variables // 2) CONFIG_FILE/CONFIG_PATH (explicit) // 3) Local file next to this script: webhook.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, 'webhook.config.json') if (!process.env.CONFIG_FILE && !process.env.CONFIG_PATH && fs.existsSync(localConfigPath)) { process.env.CONFIG_FILE = localConfigPath } require('../../../../server/load-config') const express = require('express') const bodyParser = require('body-parser') const fetch = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : (() => { try { // Fallback for older Node versions where fetch is not available. return require('node-fetch') } catch (e) { throw new Error("No fetch implementation found. Use Node.js 18+ or install 'node-fetch'.") } })()) const crypto = require('crypto') // 支持服务专用端口,避免与 push-server 共用 server/config.json 时发生端口冲突 const PORT = process.env.WEBHOOK_PORT || process.env.PORT || 7201 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 WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '' // optional HMAC secret function supaFetch(path, opts = {}) { const url = `${SUPA_URL}/rest/v1/${path}` // Default to apikey only (compatible with self-hosted Supabase/Kong key-auth). // Only attach Authorization: Bearer when explicitly enabled. const headers = Object.assign({}, opts.headers || {}, { apikey: SUPA_KEY, Accept: 'application/json' }) const sendBearer = (process.env.SUPA_USE_BEARER === 'true') if (sendBearer) headers.Authorization = `Bearer ${SUPA_KEY}` 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') } function stableEventDedupeKey({ tracking_no, carrier, status_code, event_time, event_code, event_text }) { // 目的:让 webhook 重复回调(同一事件)不会重复入库,从而避免重复入队/重复通知。 // 注意:优先使用稳定字段组合;不要使用 Date.now() 这类易变字段。 const base = JSON.stringify({ tracking_no: tracking_no || null, carrier: carrier || null, status_code: status_code || null, event_time: event_time || null, event_code: event_code || null, event_text: (event_text || '').trim().slice(0, 200) }) const hex = crypto.createHash('sha256').update(base).digest('hex') return 'WH_' + hex.slice(0, 32) } async function upsertRaw(payload, tracking_no, carrier, signature_valid) { try { const received_at = new Date().toISOString() const minimal = { carrier: carrier || null, tracking_no: tracking_no || null, body: payload, received_at, signature_valid: signature_valid } // Prefer inserting richer metadata when the upgraded schema is present. // If the database schema is older (missing columns), fall back to minimal. const meta = arguments && arguments.length >= 5 ? arguments[4] : null const extended = Object.assign({}, minimal, { source: 'webhook', client_id: meta && meta.client_id ? meta.client_id : null, signature: meta && meta.signature ? meta.signature : null, ts_header: meta && meta.ts_header ? meta.ts_header : null, request_id: meta && meta.request_id ? meta.request_id : null, remote_ip: meta && meta.remote_ip ? meta.remote_ip : null, headers: meta && meta.headers ? meta.headers : null, dedupe_key: meta && meta.dedupe_key ? meta.dedupe_key : null }) const tryInsert = async (row) => { return await supaFetch('platform_express_event_raw', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(row) }) } let resp = await tryInsert(extended) if (resp && !resp.ok) { const txt = await resp.text().catch(() => '') if (resp.status === 400 && /column .* does not exist/i.test(txt)) { resp = await tryInsert(minimal) } } return resp } catch (e) { console.warn('upsertRaw error', e) return null } } async function findWaybill(tracking_no, order_no) { try { if (tracking_no) { const r = await supaFetch(`platform_express_waybills?tracking_no=eq.${encodeURIComponent(tracking_no)}&select=id,carrier,tracking_no,order_no,order_id`) if (!r.ok) { const txt = await r.text().catch(() => '') const err = new Error(`Supabase query failed (tracking_no): HTTP ${r.status} ${txt}`) err.status = r.status throw err } const data = await r.json() if (data && data.length > 0) return data[0] } if (order_no) { const r2 = await supaFetch(`platform_express_waybills?order_no=eq.${encodeURIComponent(order_no)}&select=id,carrier,tracking_no,order_no,order_id`) if (!r2.ok) { const txt2 = await r2.text().catch(() => '') const err2 = new Error(`Supabase query failed (order_no): HTTP ${r2.status} ${txt2}`) err2.status = r2.status throw err2 } const data2 = await r2.json() if (data2 && data2.length > 0) return data2[0] } return null } catch (e) { console.warn('findWaybill error', e && e.message ? e.message : e) throw e } } 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 { // 幂等插入:基于 (waybill_id, dedupe_key) 的唯一约束忽略重复。 const resp = await supaFetch('platform_express_tracking_events?on_conflict=waybill_id,dedupe_key', { method: 'POST', headers: { 'Content-Type': 'application/json', Prefer: 'resolution=ignore-duplicates' }, body: JSON.stringify(event) }) if (!resp.ok) { const txt = await resp.text().catch(() => '') console.warn('insertEvent failed:', `HTTP ${resp.status}`, txt) return { ok: false, status: resp.status, body: txt } } return { ok: true } } catch (e) { console.warn('insertEvent error', e) return { ok: false, status: 0, body: (e && e.message) ? e.message : String(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() // Capture raw body for signature verification. app.use(bodyParser.json({ limit: '1mb', verify: (req, res, buf) => { try { req.rawBody = buf ? buf.toString('utf8') : '' } catch (e) { req.rawBody = '' } } })) app.post('/webhook/express/status', async (req, res) => { const ts = req.headers['x-timestamp'] || '' const sig = req.headers['x-signature'] || '' const cid = req.headers['x-client-id'] || '' const bodyText = (req.rawBody && typeof req.rawBody === 'string' && req.rawBody.length > 0) ? req.rawBody : JSON.stringify(req.body || {}) let sigValid = true if (WEBHOOK_SECRET) { const calc = computeSignature(bodyText, ts) sigValid = calc === String(sig) } // Optional strict mode: reject invalid signature when secret is configured. if (WEBHOOK_SECRET && !sigValid && process.env.WEBHOOK_REJECT_INVALID_SIGNATURE === 'true') { return res.status(401).json({ ok: false, message: 'invalid signature' }) } // persist raw (best-effort) const tracking_no_raw = req.body && (req.body.mailNo || req.body.tracking_no) const carrier_raw = req.body && (req.body.carrier || req.body.company) const dedupe_key_raw = (() => { try { const base = String(bodyText || '') + '|' + String(ts || '') return 'RAW_' + crypto.createHash('sha256').update(base).digest('hex').slice(0, 32) } catch (e) { return null } })() await upsertRaw(req.body || {}, tracking_no_raw, carrier_raw, sigValid, { client_id: String(cid || '') || null, signature: String(sig || '') || null, ts_header: String(ts || '') || null, remote_ip: (req.ip || (req.connection && req.connection.remoteAddress) || null), request_id: null, headers: req.headers || null, dedupe_key: dedupe_key_raw }) // 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 carrierIn = req.body && (req.body.carrier || 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 || '') let waybill = null try { waybill = await findWaybill(tracking_no, order_no) } catch (e) { const status = e && e.status ? Number(e.status) : 0 if (status === 401 || status === 403) { return res.status(502).json({ ok: false, message: 'supabase unauthorized (check SUPA_KEY/SUPA_URL)' }) } return res.status(502).json({ ok: false, message: 'supabase query failed' }) } if (!waybill || !waybill.id) { // Waybill not found — respond 200 but inform caller in body. return res.status(200).json({ ok: false, message: 'waybill not found' }) } const waybillId = waybill.id const carrier = carrierIn || waybill.carrier || null 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 received_at = new Date().toISOString() const dedupe_key = stableEventDedupeKey({ tracking_no, carrier, status_code, event_time, event_code, event_text }) const eventPayload = { waybill_id: waybillId, carrier: carrier, tracking_no: tracking_no || waybill.tracking_no || null, received_at, source: 'webhook', event_id: dedupe_key, event_time: event_time, event_code: event_code || 'UNKNOWN', event_text: event_text || '', status_code: status_code, raw_payload: req.body || {}, dedupe_key } const ins = await insertEvent(eventPayload) if (!ins || ins.ok !== true) { return res.status(200).json({ ok: false, message: 'insert tracking_event failed (see webhook-receiver logs)' }) } 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) })