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

416 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 物流 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 keyJWT
- 备选(慎用/止血):通过 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 = '<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摘要
```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 <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_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 <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 场景示例,容器名因部署不同会不同):
```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 keyrest 解不开”的典型问题。
如果这两个 hash **一致但仍然 PGRST301**,优先做下面两件事:
2.1) **验证你手上的 SERVICE_ROLE_KEY 是否匹配 JWT_SECRET最关键**
在你的开发机执行(只在本机跑,不要把 secret 提交到仓库):
```powershell
# 预期输出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
```powershell
$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
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 <SERVICE_ROLE_KEY_JWT> <JWT_SECRET>
```
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=<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`
```bash
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
- 或直接在数据库查:
```sql
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`
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 <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 重置为可再次消费)
```sql
UPDATE public.notify_queue
SET processed_at = NULL,
process_status = NULL,
last_error = NULL
WHERE id = '<notify_queue.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 = '<user_id>' OR merchant_id = '<merchant_id>'
ORDER BY updated_at DESC
LIMIT 20;
```