消息推送和第三方接收后台搭建,未测试
This commit is contained in:
73
pages/mall/delivery/webhook-server/README.md
Normal file
73
pages/mall/delivery/webhook-server/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Webhook 接收器 — 说明
|
||||
|
||||
路径:`pages/mall/delivery/server/webhook-receiver.js`
|
||||
|
||||
目的:接收承运方或 Mock Server 的 HTTP 回调(POST /webhook/express/status),将原始回文写入 `platform_express_event_raw`,并按项目现有映射更新 `platform_express_waybills` 与写入 `platform_express_tracking_events`。
|
||||
|
||||
环境变量(必须/可选):
|
||||
- `SUPA_URL`:Supabase REST 地址(示例 `http://192.168.1.62:18000`)
|
||||
- `SUPA_KEY`:Supabase service_role 或 anon key(用于 REST 写入)
|
||||
- `WEBHOOK_SECRET`(可选):与第三方共享的 HMAC-SHA256 secret,用于校验 `X-Signature`(签名为 hex)
|
||||
- `PORT`(可选):接收器监听端口,默认 `7201`
|
||||
|
||||
启动(PowerShell):
|
||||
```powershell
|
||||
$env:SUPA_URL='http://192.168.1.62:18000'
|
||||
$env:SUPA_KEY='your_service_role_key'
|
||||
# 可选验签
|
||||
$env:WEBHOOK_SECRET='your-secret'
|
||||
node pages/mall/delivery/server/webhook-receiver.js
|
||||
```
|
||||
|
||||
启动(Linux / macOS / WSL):
|
||||
```bash
|
||||
export SUPA_URL='http://192.168.1.62:18000'
|
||||
export SUPA_KEY='your_service_role_key'
|
||||
export WEBHOOK_SECRET='your-secret' # optional
|
||||
node pages/mall/delivery/server/webhook-receiver.js
|
||||
```
|
||||
|
||||
测试(curl 模拟第三方推送):
|
||||
```bash
|
||||
BODY='{"mailNo":"TEST_123","infoContent":"SENT","remark":"派送中","acceptTime":"2026-02-25 12:00:00","carrier":"YTO"}'
|
||||
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# 如果启用了 WEBHOOK_SECRET,计算签名:
|
||||
SIG=$(printf "%s%s" "$BODY" "$TS" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}')
|
||||
|
||||
curl -i -X POST http://localhost:7201/webhook/express/status \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Timestamp: $TS" \
|
||||
-H "X-Client-Id: mock_carrier" \
|
||||
-H "X-Signature: $SIG" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
预期:接口返回 200 JSON {ok:true}(若未找到对应运单会返回 {ok:false, message:'waybill not found'})。
|
||||
|
||||
验证写入(查看 Supabase):
|
||||
```bash
|
||||
# 示例:列最近 5 条原始回文
|
||||
curl -s -H "apikey: $SUPA_KEY" -H "Authorization: Bearer $SUPA_KEY" \
|
||||
"$SUPA_URL/rest/v1/platform_express_event_raw?select=*&order=received_at.desc&limit=5" | jq .
|
||||
|
||||
# 查看最近轨迹事件
|
||||
curl -s -H "apikey: $SUPA_KEY" -H "Authorization: Bearer $SUPA_KEY" \
|
||||
"$SUPA_URL/rest/v1/platform_express_tracking_events?select=*&order=created_at.desc&limit=5" | jq .
|
||||
```
|
||||
|
||||
与仓库中 Mock 实现的关系:
|
||||
- `pages/mall/delivery/test/mock-service.uts` 已包含写库逻辑(`pushWebhookData`、`bindShipment`、`runScenario`)。新接收器复用了同样的入库思路,但以 HTTP/REST 的形式对外暴露。
|
||||
|
||||
注意事项:
|
||||
- 本接收器通过 Supabase REST API 直接写表,使用 `SUPA_KEY` 时请确保权限与密钥安全(不要将 service_role key 公开到前端)。
|
||||
- 如果你的 Supabase 接口部署在内网,请确保接收器能访问 `SUPA_URL`(网络/防火墙)。
|
||||
- 若需要更严格的验签/重放检测/幂等,请告知,我可以把这些能力加入接收器(例如记录并比对 dedupe_key 或检查 X-Timestamp 时间窗口)。
|
||||
|
||||
下一步建议:
|
||||
- 若你要把第三方直接接到该接收器,请把 `WEBHOOK_SECRET` 与对方约定并启用验签。
|
||||
- 若需要我加重放防护或返回 4xx/5xx 更精确的逻辑,也可继续实现。
|
||||
|
||||
文件位置:
|
||||
- [Webhook 接收器](pages/mall/delivery/server/webhook-receiver.js)
|
||||
|
||||
作者:自动生成(可手动调整)
|
||||
186
pages/mall/delivery/webhook-server/webhook-receiver.js
Normal file
186
pages/mall/delivery/webhook-server/webhook-receiver.js
Normal file
@@ -0,0 +1,186 @@
|
||||
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) })
|
||||
Reference in New Issue
Block a user