# 物流 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 = '<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. 症状：PGRST301（JWT 无法解码）导致访问异常

**现象**
- 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 key，rest 解不开”的典型问题。

如果这两个 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` 匹配），并同步更新：Kong（key-auth）+ 你的后端配置（例如 `server/config.json`）。

可以使用本仓库脚本从 `JWT_SECRET` 生成匹配的 key（HS256）：

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