消息推送

This commit is contained in:
not-like-juvenile
2026-03-10 16:39:50 +08:00
parent ee9fabd806
commit e67016a6f4
18 changed files with 1176 additions and 49 deletions

View File

@@ -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. 兼容性与外键(若目标表存在则添加外键约束;若不存在则保留字段)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -10,7 +10,8 @@
- `SUPA_USE_BEARER`(可选):是否附加 `Authorization: Bearer <SUPA_KEY>`,默认 `false`
- 在一些自托管 Supabase/Kongkey-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`,启动时会自动读取。

View File

@@ -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 })
})