274 lines
10 KiB
JavaScript
274 lines
10 KiB
JavaScript
// 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 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 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()
|
|
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 (best-effort)
|
|
await upsertRaw(
|
|
req.body || {},
|
|
req.body && (req.body.mailNo || req.body.tracking_no),
|
|
req.body && (req.body.carrier || req.body.company),
|
|
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 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) })
|