消息推送
This commit is contained in:
@@ -144,8 +144,12 @@ CREATE INDEX IF NOT EXISTS idx_express_notifications_created_at ON public.expres
|
||||
CREATE INDEX IF NOT EXISTS idx_express_notifications_read_at ON public.express_notifications(read_at);
|
||||
|
||||
-- 若使用 message_id 做幂等(外部系统/队列),则建立唯一索引
|
||||
-- 注意:这里不能使用“部分唯一索引(WHERE message_id IS NOT NULL)”,
|
||||
-- 否则 PostgREST 的 upsert `?on_conflict=message_id` 会触发 42P10:
|
||||
-- "there is no unique or exclusion constraint matching the ON CONFLICT specification"
|
||||
-- 普通 UNIQUE INDEX 仍允许多个 NULL(符合历史兼容)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_express_notifications_message_id
|
||||
ON public.express_notifications(message_id) WHERE message_id IS NOT NULL;
|
||||
ON public.express_notifications(message_id);
|
||||
|
||||
-- =====================================================
|
||||
-- C. 兼容性与外键(若目标表存在则添加外键约束;若不存在则保留字段)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- =====================================================================================
|
||||
-- Add send_status to express_notifications
|
||||
--
|
||||
-- 目的:区分“物流状态(status_code)”与“投递处理状态(send_status)”。
|
||||
-- - status_code:业务/物流状态(SHIPPED/OUT_FOR_DELIVERY/...)
|
||||
-- - send_status:投递状态(null=待发送, processing, retrying, success, failed, no-targets)
|
||||
--
|
||||
-- 创建日期:2026-03-09
|
||||
-- =====================================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.express_notifications
|
||||
ADD COLUMN IF NOT EXISTS send_status VARCHAR(32) NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_express_notifications_send_status
|
||||
ON public.express_notifications(send_status);
|
||||
|
||||
-- 兼容旧实现:历史上 push-server consumer 使用 status_code 存投递状态。
|
||||
-- 迁移后 consumer 改读写 send_status,为避免把旧的 success/failed 等记录当成 pending 再次推送,做一次安全回填。
|
||||
UPDATE public.express_notifications
|
||||
SET send_status = status_code
|
||||
WHERE send_status IS NULL
|
||||
AND status_code IN ('processing', 'retrying', 'success', 'failed', 'no-targets');
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- =====================================================================================
|
||||
-- notify_queue + trigger: platform_express_tracking_events -> notify_queue
|
||||
--
|
||||
-- 目的:把“轨迹事件入库”和“消息生成/推送”解耦。
|
||||
-- - Webhook/轮询/手工写入 tracking_events 后,由触发器把关键事件入队到 notify_queue。
|
||||
-- - 常驻 worker 消费 notify_queue,生成 express_notifications(消息中心/推送任务)。
|
||||
-- - push-server consumer 轮询 express_notifications 并调用 CLOUD_FUNC_URL 进行实际下发。
|
||||
--
|
||||
-- 创建日期:2026-03-09
|
||||
-- =====================================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- 队列表:仅做“轻量入队”,避免触发器做外部 IO
|
||||
CREATE TABLE IF NOT EXISTS public.notify_queue (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
waybill_id UUID NOT NULL,
|
||||
carrier VARCHAR(32) NULL,
|
||||
tracking_no VARCHAR(64) NULL,
|
||||
|
||||
event_id VARCHAR(128) NULL,
|
||||
status_code VARCHAR(32) NOT NULL,
|
||||
event_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
event_text TEXT NULL,
|
||||
source VARCHAR(16) NULL,
|
||||
|
||||
-- 与 platform_express_tracking_events 对齐的幂等键
|
||||
dedupe_key VARCHAR(256) NOT NULL,
|
||||
raw_payload JSONB NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
processed_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
process_status VARCHAR(32) NULL,
|
||||
last_error TEXT NULL,
|
||||
|
||||
CONSTRAINT uk_notify_queue_dedupe UNIQUE (waybill_id, dedupe_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_queue_processed_at ON public.notify_queue(processed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_queue_created_at ON public.notify_queue(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_queue_status_code ON public.notify_queue(status_code);
|
||||
|
||||
-- 触发器函数:入队关键状态事件
|
||||
CREATE OR REPLACE FUNCTION public.notify_new_tracking_event()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $func$
|
||||
DECLARE
|
||||
should_enqueue BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- 推送策略(MVP):只对关键状态入队
|
||||
IF NEW.status_code IN ('SHIPPED','OUT_FOR_DELIVERY','READY_FOR_PICKUP','DELIVERED','EXCEPTION','RETURNED') THEN
|
||||
should_enqueue := TRUE;
|
||||
END IF;
|
||||
|
||||
IF should_enqueue THEN
|
||||
INSERT INTO public.notify_queue(
|
||||
waybill_id,
|
||||
carrier,
|
||||
tracking_no,
|
||||
event_id,
|
||||
status_code,
|
||||
event_time,
|
||||
event_text,
|
||||
source,
|
||||
dedupe_key,
|
||||
raw_payload
|
||||
) VALUES (
|
||||
NEW.waybill_id,
|
||||
NEW.carrier,
|
||||
NEW.tracking_no,
|
||||
NEW.event_id,
|
||||
NEW.status_code,
|
||||
NEW.event_time,
|
||||
NEW.event_text,
|
||||
NEW.source,
|
||||
NEW.dedupe_key,
|
||||
NEW.raw_payload
|
||||
)
|
||||
ON CONFLICT (waybill_id, dedupe_key) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$;
|
||||
|
||||
-- 触发器:tracking_events 写入后入队
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'trigger_notify_new_tracking_event'
|
||||
) THEN
|
||||
CREATE TRIGGER trigger_notify_new_tracking_event
|
||||
AFTER INSERT ON public.platform_express_tracking_events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.notify_new_tracking_event();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- =====================================================================================
|
||||
-- Fix express_notifications upsert conflict target (message_id)
|
||||
-- 目的:修复 notify-worker / push-server 在 upsert 时遇到的 42P10:
|
||||
-- "there is no unique or exclusion constraint matching the ON CONFLICT specification"
|
||||
-- 原因:历史迁移可能创建了部分唯一索引(WHERE message_id IS NOT NULL),
|
||||
-- PostgREST 的 upsert `?on_conflict=message_id` 无法匹配该索引。
|
||||
-- 方案:改为普通 UNIQUE INDEX (message_id)。Postgres UNIQUE 允许多条 NULL,兼容旧数据。
|
||||
-- 创建日期:2026-03-10
|
||||
-- =====================================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) 预检查:是否存在重复的非空 message_id(若存在,创建唯一索引会失败)
|
||||
-- 如有返回结果,请先人工去重后再继续执行后续语句。
|
||||
-- 示例去重策略:保留最新 created_at,其它行将 message_id 置为 NULL 或删除重复行。
|
||||
--
|
||||
-- SELECT message_id, COUNT(*)
|
||||
-- FROM public.express_notifications
|
||||
-- WHERE message_id IS NOT NULL
|
||||
-- GROUP BY message_id
|
||||
-- HAVING COUNT(*) > 1;
|
||||
|
||||
-- 2) 删除历史“部分唯一索引”(如果存在)
|
||||
DROP INDEX IF EXISTS public.ux_express_notifications_message_id;
|
||||
|
||||
-- 3) 创建普通唯一索引,让 `on_conflict=message_id` 正常工作
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_express_notifications_message_id
|
||||
ON public.express_notifications(message_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -10,7 +10,8 @@
|
||||
- `SUPA_USE_BEARER`(可选):是否附加 `Authorization: Bearer <SUPA_KEY>`,默认 `false`。
|
||||
- 在一些自托管 Supabase/Kong(key-auth)环境中,**只需要** `apikey`;如果误加 Bearer 且 key 不是 JWT,可能出现 `PGRST301`("None of the keys was able to decode the JWT")。
|
||||
- `WEBHOOK_SECRET`(可选):与第三方共享的 HMAC-SHA256 secret,用于校验 `X-Signature`(签名为 hex)
|
||||
- `PORT`(可选):接收器监听端口,默认 `7201`
|
||||
- `WEBHOOK_PORT`(可选):接收器监听端口,默认 `7201`(推荐用这个,便于与 push-server 共享同一份 `server/config.json`)
|
||||
- `PORT`(可选):接收器监听端口(兼容旧用法;若共享 `server/config.json` 且其中 `PORT=7301`,会导致端口冲突)
|
||||
|
||||
配置方式(推荐用配置文件,避免与其他服务端口冲突):
|
||||
- **同目录配置文件(推荐)**:在 `webhook-receiver.js` 同目录放置 `webhook.config.json`,启动时会自动读取。
|
||||
|
||||
@@ -26,9 +26,11 @@ const fetch = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : (() => {
|
||||
})())
|
||||
const crypto = require('crypto')
|
||||
|
||||
const PORT = process.env.PORT || 7201
|
||||
// 支持服务专用端口,避免与 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(/\/$/, '')
|
||||
const SUPA_KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || ''
|
||||
// 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 = {}) {
|
||||
@@ -51,6 +53,21 @@ function computeSignature(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 = {
|
||||
@@ -72,10 +89,10 @@ async function upsertRaw(payload, tracking_no, carrier, signature_valid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function findWaybillId(tracking_no, order_no) {
|
||||
async function findWaybill(tracking_no, order_no) {
|
||||
try {
|
||||
if (tracking_no) {
|
||||
const r = await supaFetch(`platform_express_waybills?tracking_no=eq.${encodeURIComponent(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}`)
|
||||
@@ -83,10 +100,10 @@ async function findWaybillId(tracking_no, order_no) {
|
||||
throw err
|
||||
}
|
||||
const data = await r.json()
|
||||
if (data && data.length > 0) return data[0].id
|
||||
if (data && data.length > 0) return data[0]
|
||||
}
|
||||
if (order_no) {
|
||||
const r2 = await supaFetch(`platform_express_waybills?order_no=eq.${encodeURIComponent(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}`)
|
||||
@@ -94,11 +111,11 @@ async function findWaybillId(tracking_no, order_no) {
|
||||
throw err2
|
||||
}
|
||||
const data2 = await r2.json()
|
||||
if (data2 && data2.length > 0) return data2[0].id
|
||||
if (data2 && data2.length > 0) return data2[0]
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
console.warn('findWaybillId error', e && e.message ? e.message : e)
|
||||
console.warn('findWaybill error', e && e.message ? e.message : e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -135,12 +152,23 @@ async function updateWaybill(id, status_code, text) {
|
||||
|
||||
async function insertEvent(event) {
|
||||
try {
|
||||
await supaFetch('platform_express_tracking_events', {
|
||||
// 幂等插入:基于 (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' },
|
||||
headers: { 'Content-Type': 'application/json', Prefer: 'resolution=ignore-duplicates' },
|
||||
body: JSON.stringify(event)
|
||||
})
|
||||
} catch (e) { console.warn('insertEvent error', e) }
|
||||
|
||||
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() {
|
||||
@@ -164,19 +192,24 @@ async function start() {
|
||||
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)
|
||||
// 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 carrier = req.body && req.body.carrier ? req.body.carrier : (req.body && req.body.company || null)
|
||||
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 waybillId = null
|
||||
let waybill = null
|
||||
try {
|
||||
waybillId = await findWaybillId(tracking_no, order_no)
|
||||
waybill = await findWaybill(tracking_no, order_no)
|
||||
} catch (e) {
|
||||
const status = e && e.status ? Number(e.status) : 0
|
||||
if (status === 401 || status === 403) {
|
||||
@@ -184,11 +217,14 @@ async function start() {
|
||||
}
|
||||
return res.status(502).json({ ok: false, message: 'supabase query failed' })
|
||||
}
|
||||
if (!waybillId) {
|
||||
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
|
||||
@@ -205,18 +241,26 @@ async function start() {
|
||||
}
|
||||
|
||||
// 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 || null,
|
||||
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: 'WEBHOOK_' + Date.now()
|
||||
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)' })
|
||||
}
|
||||
await insertEvent(eventPayload)
|
||||
|
||||
return res.json({ ok: true })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user