Files
medical-mall/server/消息推送文档/DELIVERY_E2E_TROUBLESHOOTING_20260310.md
not-like-juvenile 4acbb8ced5 补充方案
2026-03-12 10:36:51 +08:00

17 KiB
Raw Permalink Blame History

物流 Webhook → 通知入库 → 推送到手机联调问题记录2026-03-10

本文记录本次将链路跑通时遇到的阻塞点、根因与修复方式,便于后续环境复现与排障。

目标链路:

  1. webhook-receiver 接收物流回调并落库
  2. DB/服务侧写入 notify_queue
  3. server/notify-worker.js 消费 notify_queue,生成 express_notifications
  4. server/push-server.js 的 consumer 轮询 express_notifications,调用 CLOUD_FUNC_URL 推送到设备 cid

1. 症状notify-worker 报 order not found for waybill

现象

  • notify_queue.process_status=skippedfailed
  • last_error=order not found for waybill

根因

  • public.ml_orders 启用了 RLS。
  • 后端通过 PostgREST 仅使用 apikey(或无法解码的 Bearer访问时auth.uid() 为空RLS policy 不成立,导致对 ml_orders 的查询返回 200 [](看起来像“无数据”,实为被行级安全过滤)。

验证方法

  • 对比SQL Editor 里能查到订单,但经 /rest/v1/ml_orders 查不到。

临时修复(用于快速跑通链路)

ALTER TABLE public.ml_orders DISABLE ROW LEVEL SECURITY;

备注:你当前观察到“把安全策略关上就可以进行推送”,就是因为 notify-worker 能重新读到 ml_orders.user_id/merchant_id 以解析收件人。

恢复建议(生产化方向)

  • 不建议长期关闭 RLS。
  • 推荐做法(本仓库已支持):
    • 保持全局 SUPA_USE_BEARER=false(避免影响其它服务/自托管网关 JWT 配置不一致带来的 PGRST301
    • 仅对 notify-worker 开启 Bearerserver/config.json 设置 NOTIFY_WORKER_SUPA_USE_BEARER=true
    • 确保 SUPA_KEY/SERVICE_ROLE_KEY 是可用的 service_role keyJWT
  • 备选(慎用/止血):通过 RPCSECURITY DEFINER)完成必要查询;避免依赖 DISABLE RLS
    • 执行 SQLpages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql
    • 在数据库中设置 token用于 RPC 显式鉴权):
      • 示例:ALTER ROLE authenticator SET app.notify_worker_token = '<YOUR_LONG_RANDOM_TOKEN>';(按你的 PostgREST 连接角色调整)
    • notify-worker 配置:
      • NOTIFY_WORKER_USE_RLS_SAFE_RPC=true
      • NOTIFY_WORKER_RPC_TOKEN=<YOUR_LONG_RANDOM_TOKEN>

2. 症状:写入 express_notifications 时报 42P10

现象

  • notify_queue.process_status=failed
  • last_error 包含:
    • there is no unique or exclusion constraint matching the ON CONFLICT specification

根因

  • notify-worker / push-server 使用 PostgREST upsert
    • POST /rest/v1/express_notifications?on_conflict=message_id
  • 但数据库侧 express_notifications(message_id) 只有“部分唯一索引”(例如 WHERE message_id IS NOT NULL),无法匹配 ON CONFLICT(message_id),触发 42P10

修复(改为普通唯一索引)

  • 执行脚本:
    • pages/mall/delivery/doc/需求文档/20260310_fix_express_notifications_on_conflict_message_id.sql

核心 SQL摘要

DROP INDEX IF EXISTS public.ux_express_notifications_message_id;
CREATE UNIQUE INDEX IF NOT EXISTS ux_express_notifications_message_id
  ON public.express_notifications(message_id);

修复后验证

  • notify_queue 最新记录不再失败;express_notifications 能正常生成/更新。

3. 症状push-server 启动失败 EADDRINUSE 0.0.0.0:7301

现象

  • node server/push-server.js 直接退出
  • 提示端口占用

根因

  • 7301 端口已有 node 进程占用(重复启动或遗留进程)

排查/处理Windows

netstat -ano | Select-String ":7301 "
Get-Process -Id <PID>
Stop-Process -Id <PID> -Force

4. 症状PGRST301JWT 无法解码)导致访问异常

现象

  • PostgREST 返回 PGRST301 或“JWT cannot be decoded/verified”

根因

  • 在自托管/网关配置不一致时,手动发送 Authorization: Bearer <SUPA_KEY> 可能触发 JWT 解码失败。
  • 本仓库对 Supabase REST 调用默认只发 apikey,仅在显式 SUPA_USE_BEARER=true 时才附加 Bearer。

处理建议

  • 保持 SUPA_USE_BEARER=false(默认)
  • 使用可用的 apikey/service_role key 走 Kong key-auth

如果你的目标是“用 Bearer(service_role JWT) 绕过 RLS”(而不是仅仅避免 401

  • 需要修复自托管配置一致性:确保 PostgREST(rest) 实际加载的 PGRST_JWT_SECRET 与签发 SERVICE_ROLE_KEYJWT_SECRET 完全一致(算法/格式也要匹配),并重启 rest 组件。
  • 在 JWT 未修复前,如需临时止血可参考 RPC 备选方案(慎用):执行 pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql(仅建议用于短期联调/排障)。

4.1 具体怎么修(推荐按这个顺序做)

目标:让 /rest/v1/* 能接受 Authorization: Bearer <SERVICE_ROLE_KEY>,不再返回 401 PGRST301

补充说明(结合你当前的 docker-compose

  • 你贴的 compose 里 rest 使用 PGRST_JWT_SECRET: ${JWT_SECRET},所以如果容器已按该 env 重启生效通常就不是“rest/auth secret 不一致”。
  • 此时最常见根因变成:你正在使用的 SERVICE_ROLE_KEY 并不是用当前这份 JWT_SECRET 签出来的(即 key/secret 不匹配),或者只是改了 .env 但容器没重启仍在用旧 env。
  1. 确认 rest(PostgREST) 当前加载的密钥(不要直接到处打印明文;建议只比对 hash

在 Supabase 机器上Docker 场景示例,容器名因部署不同会不同):

# 1) 找到 PostgREST/rest 容器
docker ps --format "{{.Names}}" | Select-String -Pattern "rest|postgrest" 

# 2) 取 PGRST_JWT_SECRET 的 sha256避免直接输出明文
$restName = (docker ps --format "{{.Names}}" | Select-String -Pattern "rest|postgrest" | Select-Object -First 1).Line.Trim()
docker exec $restName sh -lc 'printf %s "$PGRST_JWT_SECRET" | sha256sum'

# 3) 顺手检查几个容易踩坑的配置项
docker exec $restName sh -lc 'echo PGRST_JWT_SECRET_IS_BASE64=$PGRST_JWT_SECRET_IS_BASE64'
docker exec $restName sh -lc 'echo PGRST_JWT_AUD=$PGRST_JWT_AUD'
  1. 确认 auth(GoTrue) 使用的 JWT_SECRET(同样用 hash 比对):
docker ps --format "{{.Names}}" | Select-String -Pattern "auth|gotrue" 
$authName = (docker ps --format "{{.Names}}" | Select-String -Pattern "auth|gotrue" | Select-Object -First 1).Line.Trim()
docker exec $authName sh -lc 'printf %s "$JWT_SECRET" | sha256sum'

如果这两个 hash 不一致就会出现“auth 签发的 JWT / service_role keyrest 解不开”的典型问题。

如果这两个 hash 一致但仍然 PGRST301,优先做下面两件事:

2.1) 验证你手上的 SERVICE_ROLE_KEY 是否匹配 JWT_SECRET最关键

在你的开发机执行(只在本机跑,不要把 secret 提交到仓库):

# 预期输出Signature VALID
node .\server\verify_supa_token.js <SERVICE_ROLE_KEY_JWT> <JWT_SECRET>
  • 如果是 Signature INVALID:说明当前 SERVICE_ROLE_KEY 不是用这份 JWT_SECRET 签的。
    • 处理方式二选一:
      • JWT_SECRET 改回“当初生成 key 的那份”并重启(影响面大,不推荐在不确定时直接动生产)
      • 重新生成一套新的 ANON_KEY/SERVICE_ROLE_KEY(与当前 JWT_SECRET 匹配并同步更新Kongkey-auth+ 你的后端配置(例如 server/config.json)。

可以使用本仓库脚本从 JWT_SECRET 生成匹配的 keyHS256

$env:JWT_SECRET='<your jwt secret>'
$env:SUPA_JWT_ISSUER='supabase-1'   # 与你现有 key 的 iss 保持一致
# $env:SUPA_JWT_AUD='authenticated' # 如需带 aud 再打开
node .\server\tools\generate-supabase-keys.js

如果你不想在命令行里直接暴露 JWT_SECRET,可以用交互式 PowerShell 脚本(输入隐藏):

powershell -ExecutionPolicy Bypass -File .\server\tools\generate-supabase-keys.ps1 -Issuer 'supabase-1' -Years 5

生成后要做的同步(否则 Kong key-auth 仍然认旧 key

  • 更新 Supabase 的 .env:把 ANON_KEY=SERVICE_ROLE_KEY= 替换为新生成的值
  • 重启 kong推荐同时重启 rest/auth 以减少缓存/配置疑难):
docker compose restart kong rest auth

然后更新本仓库服务端配置(至少 server/config.jsonSUPA_KEY)为新的 SERVICE_ROLE_KEY,再用下面的方式验证。

2.2) 确认容器确实吃到了最新 env改完一定要 restart

# 在 Supabase 机器上docker compose
docker compose restart rest auth kong
  1. 确保 ANON_KEY / SERVICE_ROLE_KEY 是用同一套 JWT_SECRET 生成的
  • 你不能从一个 JWT token 反推出 secret所以通常的做法是
    • 选定一套 JWT_SECRETrest/auth 全部用它)
    • 重新生成 ANON_KEYSERVICE_ROLE_KEY
    • 同步更新 Kong/key-auth 里的 key否则 apikey 会失效)

你可以用本仓库脚本验证“某个 service_role token 是否匹配某个 secret”HS256

# 在你的开发机执行(不要把 secret 提交到仓库)
node .\server\verify_supa_token.js <SERVICE_ROLE_KEY_JWT> <JWT_SECRET>
  1. 修改完配置后一定要重启
  • 至少重启:rest(PostgREST)auth(GoTrue)
  • 如果你改了 ANON_KEY/SERVICE_ROLE_KEY,通常还需要重启 kong(让 key-auth 使用新 key
  1. 验证 Bearer 是否修复
$cfg = Get-Content .\server\config.json -Raw | ConvertFrom-Json
$u = $cfg.SUPA_URL.TrimEnd('/')
$token = $cfg.SUPA_KEY   # 确保这里是 service_role JWT

Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$u/rest/v1/ml_orders?select=id&limit=1" -Headers @{
  apikey = $token
  Authorization = "Bearer $token"
  Accept = 'application/json'
}

也可以用本仓库脚本快速测一次(更直观):

$env:CONFIG_FILE=(Resolve-Path .\server\config.json)
node .\server\tools\test-postgrest-bearer.js
  • 修好后的期望:不再是 401 PGRST301

相关实现:

  • server/push-server.jssupaFetch 逻辑

4.1 症状:503 PGRST002PostgREST schema cache / 数据库不可用)

现象

  • webhook-receiver 返回 502 { ok:false, message:'... HTTP 503 {"code":"PGRST002" ... }' }
  • 或 worker 日志持续刷:
    • HTTP 503 {"code":"PGRST002","message":"Could not query the database for the schema cache. Retrying."}

根因

  • 这不是 RLS 问题。
  • 这是 PostgREST 无法连接到 Postgres(或数据库正在重启/不可用/连接池耗尽/磁盘满等),因此无法构建/刷新 schema cache。

快速验证

# 直连 REST 读一条数据(只要能返回 200/空数组就说明 REST 已恢复)
$cfg=Get-Content .\server\config.json -Raw | ConvertFrom-Json
$u=($cfg.SUPA_URL.TrimEnd('/')) + '/rest/v1/platform_express_waybills?select=id&limit=1'
Invoke-WebRequest -UseBasicParsing -Uri $u -Headers @{ apikey=$cfg.SUPA_KEY; Accept='application/json' } -Method GET

处理建议(按优先级)

  1. 在 Supabase 部署机器上确认 Postgres 是否正常能否连接、磁盘是否满、CPU/内存是否打满)。
  2. 重启 PostgREST/rest 服务(多数情况下会自动恢复 schema cache
  3. 如使用 docker-compose优先重启 db 与 rest容器名因环境不同可能不同
  4. 待 REST 恢复后再重跑本仓库的 3 个 Node 后台(或等待它们自动恢复)。

5. 症状:数据库显示 send_status=success,但手机没弹通知

现象

  • express_notifications.send_status=success
  • 但手机端没有系统通知/横幅

常见原因

  1. 推送内容为空(部分通道/客户端不会展示空内容通知)
  2. 客户端处于前台/透传模式:消息到达但不自动弹系统通知(需要客户端本地展示)
  3. 手机系统通知权限/渠道被关闭、省电策略限制

本次定位与修复

  • notify-worker 生成的 express_notifications 通常只有 event_text_safe可作为标题body/content 可能为空。
  • push-server consumer 调云函数时原先传入的 content 可能为空,导致“云函数返回成功,但不展示”。
  • 已在 server/push-server.js 增加兜底:当 body/content 为空时,使用 title/event_text_safe 作为 content。

验证方法(直接调用云函数对 CID 推送)

  • 观察云函数返回中是否包含类似 successed_online;并对比手机是否实际展示。

6. 症状merchant 侧 send_status=no-targets

现象

  • express_notifications.aud=merchant 的记录 send_status=no-targets
  • last_error=no active devices

根因

  • push_devices 表结构本身包含 merchant_id 字段,但实际设备注册时常见只写入 user_id,导致:
    • notify-worker 能生成 aud=merchant, recipient_id=<merchant_id> 的通知;
    • push-server consumer 查询 push_devices?merchant_id=eq.<recipient_id>&is_active=eq.true 时结果为空;
    • 最终落为 send_status=no-targets

补充:如果你的 push-server 版本仍在用 push_devices?on_conflict=cid 做 upsert也会导致“注册接口返回 ok但 Supabase 表里没写进去”PostgREST 会提示没有匹配的唯一约束)。

处理建议

  • 让商家端也调用注册接口,并在请求体里带上 merchant_id(建议同时传 appid,默认为 default
curl -X POST http://localhost:7301/api/v1/push/register \
  -H 'Content-Type: application/json' \
  -d '{"cid":"<CID>","merchant_id":"<MERCHANT_UUID>","platform":"android","appid":"default"}'
  • 验证是否写入成功:
    • GET /api/v1/push/devices?merchant_id=<MERCHANT_UUID>&active=true(优先读 Supabase
    • 或直接在数据库查:
select id, appid, cid, user_id, merchant_id, is_active, last_seen_at
from public.push_devices
where merchant_id = '<MERCHANT_UUID>'
order by last_seen_at desc nulls last;
  • push-server consumer 对 merchant 的设备查询路径REST
    • push_devices?merchant_id=eq.<recipient_id>&is_active=eq.true

7. 端到端自检清单(最短路径)

  1. 触发 webhook 测试:
  • node pages/mall/delivery/webhook-server/test-send.js
  1. 查队列:
  • notify_queue 最新应为 process_status=queuedlast_error=null
  1. 查通知:
  • express_notifications 最新应生成 aud=useraud=merchant 两条
  • aud=user 通常应推进到 send_status=success
  1. 查设备:
  • push_devices 中对应 user_id/merchant_id 必须存在 is_active=truecid

7.1 Windows 一键启动三服务(建议)

为了保证与联调时一致的效果,推荐用脚本一次性启动 3 个后台进程(并落日志):

  1. 启动(会默认释放 7201/7301 端口占用):
  • powershell -ExecutionPolicy Bypass -File .\server\scripts\start-delivery-backend.ps1
  1. 触发与联调一致的 webhook 测试(打到本机 7201
  • node .\pages\mall\delivery\webhook-server\test-send.js
  1. 停止:
  • powershell -ExecutionPolicy Bypass -File .\server\scripts\stop-delivery-backend.ps1

日志文件默认在仓库根目录:

  • webhook-receiver.log/.err.log
  • notify-worker.log/.err.log
  • push-server.log/.err.log

PID 文件(用于停止/排查进程)在:

  • server/.runtime/delivery-backend.pids.json

查看当前是否仍在运行(示例):

  • Get-Content .\server\.runtime\delivery-backend.pids.json
  • Get-Process -Id <PID>

注意:test-send.js 默认请求 http://localhost:7201/webhook/express/status,所以必须先启动 webhook-receiver。

7.1.1 脚本行为与常见坑PowerShell 5.1

  • PID 文件由 start-delivery-backend.ps1 写入,采用 UTF-8 无 BOM,避免 PS 5.1 下 ConvertFrom-Json 因 BOM/编码细节解析失败。
  • stop-delivery-backend.ps1 会优先按 PID 文件停止进程,并在解析/停止异常时兜底尝试释放端口7201/7301

如果你不先停止就重复启动,通常会出现:

  • EADDRINUSE(端口占用导致启动失败)
  • 多实例 worker/consumer 并发运行,导致重复消费、重复写入、重复推送(排障难度陡增)

如果 test-send.js 返回 { ok: false, message: 'waybill not found' }

  • 说明数据库里没有 platform_express_waybills.tracking_no = 'TEST_YT_20260206_0007' 的运单记录。
  • 处理方式:先在 platform_express_waybills 补一条对应 tracking_no 的运单(并关联到你的 ml_orders),再重试发送。

8. 常用 SQL / 操作片段

8.1 重跑某条队列记录(把 failed/skipped 重置为可再次消费)

UPDATE public.notify_queue
SET processed_at = NULL,
    process_status = NULL,
    last_error = NULL
WHERE id = '<notify_queue.id>';

8.2 检查 message_id 是否存在重复(创建唯一索引前)

SELECT message_id, COUNT(*)
FROM public.express_notifications
WHERE message_id IS NOT NULL
GROUP BY message_id
HAVING COUNT(*) > 1;

8.3 查看 push_devices 是否有绑定

SELECT *
FROM public.push_devices
WHERE user_id = '<user_id>' OR merchant_id = '<merchant_id>'
ORDER BY updated_at DESC
LIMIT 20;