# 物流 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=skipped` 或 `failed` - `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` 查不到。 **临时修复(用于快速跑通链路)** ```sql 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 开启 Bearer:在 `server/config.json` 设置 `NOTIFY_WORKER_SUPA_USE_BEARER=true` - 确保 `SUPA_KEY`/`SERVICE_ROLE_KEY` 是可用的 service_role key(JWT) - 备选(慎用/止血):通过 RPC(`SECURITY DEFINER`)完成必要查询;避免依赖 `DISABLE RLS`。 - 执行 SQL:`pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql` - 在数据库中设置 token(用于 RPC 显式鉴权): - 示例:`ALTER ROLE authenticator SET app.notify_worker_token = '';`(按你的 PostgREST 连接角色调整) - notify-worker 配置: - `NOTIFY_WORKER_USE_RLS_SAFE_RPC=true` - `NOTIFY_WORKER_RPC_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(摘要): ```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)** ```powershell netstat -ano | Select-String ":7301 " Get-Process -Id Stop-Process -Id -Force ``` --- ## 4. 症状:PGRST301(JWT 无法解码)导致访问异常 **现象** - PostgREST 返回 `PGRST301` 或“JWT cannot be decoded/verified” **根因** - 在自托管/网关配置不一致时,手动发送 `Authorization: Bearer ` 可能触发 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_KEY` 的 `JWT_SECRET` 完全一致(算法/格式也要匹配),并重启 rest 组件。 - 在 JWT 未修复前,如需临时止血可参考 RPC 备选方案(慎用):执行 `pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql`(仅建议用于短期联调/排障)。 ### 4.1 具体怎么修(推荐按这个顺序做) > 目标:让 `/rest/v1/*` 能接受 `Authorization: Bearer `,不再返回 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 场景示例,容器名因部署不同会不同): ```powershell # 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' ``` 2) **确认 auth(GoTrue) 使用的 JWT_SECRET**(同样用 hash 比对): ```powershell 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 key,rest 解不开”的典型问题。 如果这两个 hash **一致但仍然 PGRST301**,优先做下面两件事: 2.1) **验证你手上的 SERVICE_ROLE_KEY 是否匹配 JWT_SECRET(最关键)** 在你的开发机执行(只在本机跑,不要把 secret 提交到仓库): ```powershell # 预期输出:Signature VALID node .\server\verify_supa_token.js ``` - 如果是 `Signature INVALID`:说明当前 `SERVICE_ROLE_KEY` 不是用这份 `JWT_SECRET` 签的。 - 处理方式二选一: - 把 `JWT_SECRET` 改回“当初生成 key 的那份”并重启(影响面大,不推荐在不确定时直接动生产) - 重新生成一套新的 `ANON_KEY/SERVICE_ROLE_KEY`(与当前 `JWT_SECRET` 匹配),并同步更新:Kong(key-auth)+ 你的后端配置(例如 `server/config.json`)。 可以使用本仓库脚本从 `JWT_SECRET` 生成匹配的 key(HS256): ```powershell $env: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 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 以减少缓存/配置疑难): ```powershell docker compose restart kong rest auth ``` 然后更新本仓库服务端配置(至少 `server/config.json` 的 `SUPA_KEY`)为新的 `SERVICE_ROLE_KEY`,再用下面的方式验证。 2.2) **确认容器确实吃到了最新 env(改完一定要 restart)** ```powershell # 在 Supabase 机器上(docker compose) docker compose restart rest auth kong ``` 3) **确保 ANON_KEY / SERVICE_ROLE_KEY 是用同一套 JWT_SECRET 生成的** - 你不能从一个 JWT token 反推出 secret,所以通常的做法是: - 选定一套 `JWT_SECRET`(rest/auth 全部用它) - 重新生成 `ANON_KEY` 与 `SERVICE_ROLE_KEY` - 同步更新 Kong/key-auth 里的 key(否则 `apikey` 会失效) 你可以用本仓库脚本验证“某个 service_role token 是否匹配某个 secret”(HS256): ```powershell # 在你的开发机执行(不要把 secret 提交到仓库) node .\server\verify_supa_token.js ``` 4) **修改完配置后一定要重启** - 至少重启:`rest(PostgREST)` 与 `auth(GoTrue)` - 如果你改了 `ANON_KEY/SERVICE_ROLE_KEY`,通常还需要重启 `kong`(让 key-auth 使用新 key) 5) **验证 Bearer 是否修复** ```powershell $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' } ``` 也可以用本仓库脚本快速测一次(更直观): ```powershell $env:CONFIG_FILE=(Resolve-Path .\server\config.json) node .\server\tools\test-postgrest-bearer.js ``` - 修好后的期望:不再是 401 `PGRST301`。 相关实现: - `server/push-server.js` 的 `supaFetch` 逻辑 --- ## 4.1 症状:`503 PGRST002`(PostgREST 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。 **快速验证** ```powershell # 直连 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=` 的通知; - push-server consumer 查询 `push_devices?merchant_id=eq.&is_active=eq.true` 时结果为空; - 最终落为 `send_status=no-targets`。 补充:如果你的 push-server 版本仍在用 `push_devices?on_conflict=cid` 做 upsert,也会导致“注册接口返回 ok,但 Supabase 表里没写进去”(PostgREST 会提示没有匹配的唯一约束)。 **处理建议** - 让商家端也调用注册接口,并在请求体里带上 `merchant_id`(建议同时传 `appid`,默认为 `default`): ```bash curl -X POST http://localhost:7301/api/v1/push/register \ -H 'Content-Type: application/json' \ -d '{"cid":"","merchant_id":"","platform":"android","appid":"default"}' ``` - 验证是否写入成功: - `GET /api/v1/push/devices?merchant_id=&active=true`(优先读 Supabase) - 或直接在数据库查: ```sql select id, appid, cid, user_id, merchant_id, is_active, last_seen_at from public.push_devices where merchant_id = '' order by last_seen_at desc nulls last; ``` - push-server consumer 对 merchant 的设备查询路径(REST)是: - `push_devices?merchant_id=eq.&is_active=eq.true` --- ## 7. 端到端自检清单(最短路径) 1) 触发 webhook 测试: - `node pages/mall/delivery/webhook-server/test-send.js` 2) 查队列: - `notify_queue` 最新应为 `process_status=queued` 且 `last_error=null` 3) 查通知: - `express_notifications` 最新应生成 `aud=user` 与 `aud=merchant` 两条 - `aud=user` 通常应推进到 `send_status=success` 4) 查设备: - `push_devices` 中对应 `user_id`/`merchant_id` 必须存在 `is_active=true` 的 `cid` --- ## 7.1 Windows 一键启动三服务(建议) 为了保证与联调时一致的效果,推荐用脚本一次性启动 3 个后台进程(并落日志): 1) 启动(会默认释放 7201/7301 端口占用): - `powershell -ExecutionPolicy Bypass -File .\server\scripts\start-delivery-backend.ps1` 2) 触发与联调一致的 webhook 测试(打到本机 7201): - `node .\pages\mall\delivery\webhook-server\test-send.js` 3) 停止: - `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 ` 注意:`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 重置为可再次消费) ```sql UPDATE public.notify_queue SET processed_at = NULL, process_status = NULL, last_error = NULL WHERE id = ''; ``` ### 8.2 检查 message_id 是否存在重复(创建唯一索引前) ```sql 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` 是否有绑定 ```sql SELECT * FROM public.push_devices WHERE user_id = '' OR merchant_id = '' ORDER BY updated_at DESC LIMIT 20; ```