diff --git a/pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql b/pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql new file mode 100644 index 00000000..3783310c --- /dev/null +++ b/pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql @@ -0,0 +1,85 @@ +-- ===================================================================================== +-- RPC: notify-worker safe recipients lookup (RLS-safe) +-- +-- 背景: +-- - public.ml_orders 已开启 RLS,PostgREST 在未携带可解码 JWT 时,auth.uid() 为 NULL, +-- 直接 SELECT 会被策略过滤为 0 行,导致 notify-worker 报 “order not found for waybill”。 +-- - 在一些自托管场景中,Authorization: Bearer 可能因 JWT_SECRET 不一致被 PostgREST 拒绝(401 PGRST301)。 +-- +-- 方案: +-- - 提供 SECURITY DEFINER 的 RPC:只返回订单的收件人映射(user_id / merchant_id)。 +-- - 通过请求头 x-notify-worker-token 做显式鉴权(避免把表全局 SELECT 放开)。 +-- +-- 使用: +-- - notify-worker 调用 POST /rest/v1/rpc/notify_get_order_recipients +-- 并携带 header: x-notify-worker-token: +-- ===================================================================================== + +BEGIN; + +CREATE OR REPLACE FUNCTION public.notify_get_order_recipients( + p_order_id UUID DEFAULT NULL, + p_order_no TEXT DEFAULT NULL +) +RETURNS TABLE ( + id UUID, + order_no VARCHAR, + user_id UUID, + merchant_id UUID +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $func$ +DECLARE + headers_json JSON; + token TEXT; + expected_token TEXT; +BEGIN + -- 1) 读取请求头 token(PostgREST 会把 headers 放入 GUC request.headers) + expected_token := current_setting('app.notify_worker_token', true); + headers_json := NULLIF(current_setting('request.headers', true), '')::json; + IF headers_json IS NOT NULL THEN + token := headers_json->>'x-notify-worker-token'; + END IF; + + IF expected_token IS NULL OR expected_token = '' THEN + RAISE EXCEPTION 'server misconfigured: app.notify_worker_token is not set'; + END IF; + + IF token IS NULL OR token <> expected_token THEN + RAISE EXCEPTION 'permission denied: invalid x-notify-worker-token'; + END IF; + + -- 2) 参数校验 + IF (p_order_id IS NULL OR p_order_id::text = '') AND (p_order_no IS NULL OR btrim(p_order_no) = '') THEN + RAISE EXCEPTION 'p_order_id or p_order_no must be provided'; + END IF; + + -- 3) 返回映射(SECURITY DEFINER 可绕过 RLS;只返回最小必要字段) + RETURN QUERY + SELECT o.id, o.order_no, o.user_id, o.merchant_id + FROM public.ml_orders o + WHERE (p_order_id IS NOT NULL AND o.id = p_order_id) + OR (p_order_no IS NOT NULL AND o.order_no = p_order_no) + LIMIT 1; +END; +$func$; + +-- 默认收紧:撤销 PUBLIC,按需授予 anon/authenticated/service_role 执行权限。 +REVOKE ALL ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) FROM PUBLIC; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO authenticated; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO service_role; + END IF; +END $$; + +COMMIT; diff --git a/pages/mall/delivery/webhook-server/README.md b/pages/mall/delivery/webhook-server/README.md index 7d5a6a79..f5e8e02a 100644 --- a/pages/mall/delivery/webhook-server/README.md +++ b/pages/mall/delivery/webhook-server/README.md @@ -10,6 +10,7 @@ - `SUPA_USE_BEARER`(可选):是否附加 `Authorization: Bearer `,默认 `false`。 - 在一些自托管 Supabase/Kong(key-auth)环境中,**只需要** `apikey`;如果误加 Bearer 且 key 不是 JWT,可能出现 `PGRST301`("None of the keys was able to decode the JWT")。 - `WEBHOOK_SECRET`(可选):与第三方共享的 HMAC-SHA256 secret,用于校验 `X-Signature`(签名为 hex) +- `WEBHOOK_REJECT_INVALID_SIGNATURE`(可选):若为 `true`,且配置了 `WEBHOOK_SECRET`,则验签失败会直接返回 HTTP 401(默认不拒绝,只记录)。 - `WEBHOOK_PORT`(可选):接收器监听端口,默认 `7201`(推荐用这个,便于与 push-server 共享同一份 `server/config.json`) - `PORT`(可选):接收器监听端口(兼容旧用法;若共享 `server/config.json` 且其中 `PORT=7301`,会导致端口冲突) @@ -85,6 +86,8 @@ curl -i -X POST http://localhost:7201/webhook/express/status \ -d "$BODY" ``` +> 重要:签名计算必须使用**原始请求体文本**(raw body)。接收器也会使用 raw body 进行验签;不要用 JSON 对象 stringify 后的字符串替代。 + 健康检查: - `GET http://localhost:7201/health`(端口以 `PORT` 为准) @@ -125,12 +128,15 @@ Stop-Process -Id 验证写入(查看 Supabase): ```bash # 示例:列最近 5 条原始回文 -curl -s -H "apikey: $SUPA_KEY" -H "Authorization: Bearer $SUPA_KEY" \ +curl -s -H "apikey: $SUPA_KEY" -H "Accept: application/json" \ "$SUPA_URL/rest/v1/platform_express_event_raw?select=*&order=received_at.desc&limit=5" | jq . # 查看最近轨迹事件 -curl -s -H "apikey: $SUPA_KEY" -H "Authorization: Bearer $SUPA_KEY" \ +curl -s -H "apikey: $SUPA_KEY" -H "Accept: application/json" \ "$SUPA_URL/rest/v1/platform_express_tracking_events?select=*&order=created_at.desc&limit=5" | jq . + +# 如果你的环境已确认 Bearer 可用(不会触发 PGRST301),也可以额外加上: +# -H "Authorization: Bearer $SUPA_KEY" ``` 与仓库中 Mock 实现的关系: @@ -146,6 +152,6 @@ curl -s -H "apikey: $SUPA_KEY" -H "Authorization: Bearer $SUPA_KEY" \ - 若需要我加重放防护或返回 4xx/5xx 更精确的逻辑,也可继续实现。 文件位置: -- [Webhook 接收器](pages/mall/delivery/server/webhook-receiver.js) +- [Webhook 接收器](pages/mall/delivery/webhook-server/webhook-receiver.js) 作者:自动生成(可手动调整) diff --git a/server/push-server.js b/server/push-server.js index 5f1b6c83..5a2bedbe 100644 --- a/server/push-server.js +++ b/server/push-server.js @@ -9,7 +9,13 @@ const bodyParser = require('body-parser') const cors = require('cors') const fs = require('fs').promises const path = require('path') -const fetch = require('node-fetch') +const fetchImpl = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : (() => { + try { + return require('node-fetch') + } catch (e) { + throw new Error("No fetch implementation found. Use Node.js 18+ or install 'node-fetch'.") + } +})()) const { spawn } = require('child_process') // 支持服务专用端口,避免与 webhook-receiver 共用 server/config.json 时发生端口冲突 @@ -76,7 +82,7 @@ async function supaFetch(path, opts = {}) { if (sendBearer) headers.Authorization = `Bearer ${SUPA_KEY}` try { console.log('supaFetch ->', url) - const resp = await fetch(url, Object.assign({}, opts, { headers })) + const resp = await fetchImpl(url, Object.assign({}, opts, { headers })) if (!resp.ok) { let txt = '' try { txt = await resp.text() } catch (e) { txt = `` } @@ -220,7 +226,7 @@ async function start() { async function invokeCloudFuncForCid(funcUrl, token, pushCid, title, content, payload) { try { const body = { token: token || PUSH_TOKEN || null, push_clientid: pushCid, title: title || '', content: content || '', payload: payload || {} } - const resp = await fetch(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + const resp = await fetchImpl(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const txt = await resp.text().catch(() => '') let json try { json = JSON.parse(txt) } catch (e) { json = { statusText: txt } } diff --git a/server/消息推送文档/DELIVERY_E2E_TROUBLESHOOTING_20260310.md b/server/消息推送文档/DELIVERY_E2E_TROUBLESHOOTING_20260310.md index 958fe01a..51e7ca11 100644 --- a/server/消息推送文档/DELIVERY_E2E_TROUBLESHOOTING_20260310.md +++ b/server/消息推送文档/DELIVERY_E2E_TROUBLESHOOTING_20260310.md @@ -36,7 +36,13 @@ ALTER TABLE public.ml_orders DISABLE ROW LEVEL SECURITY; - 保持全局 `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/安全视图完成必要查询;避免依赖 `DISABLE RLS`。 +- 备选(慎用/止血):通过 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=` --- @@ -99,6 +105,135 @@ Stop-Process -Id -Force - 保持 `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` 逻辑 @@ -159,11 +294,34 @@ Invoke-WebRequest -UseBasicParsing -Uri $u -Headers @{ apikey=$cfg.SUPA_KEY; Acc - `last_error=no active devices` **根因** -- 设备绑定表 `push_devices` 仅存在 `user_id` 绑定,没有 `merchant_id` 绑定。 +- `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` 维度的设备(或插入对应记录)。 -- push-server consumer 对 merchant 的设备查询路径是: +- 让商家端也调用注册接口,并在请求体里带上 `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` --- diff --git a/server/消息推送文档/DELIVERY_WEBHOOK_PUSH_SERVER_OVERVIEW.md b/server/消息推送文档/DELIVERY_WEBHOOK_PUSH_SERVER_OVERVIEW.md index 7bc5e66d..5238942e 100644 --- a/server/消息推送文档/DELIVERY_WEBHOOK_PUSH_SERVER_OVERVIEW.md +++ b/server/消息推送文档/DELIVERY_WEBHOOK_PUSH_SERVER_OVERVIEW.md @@ -126,13 +126,14 @@ flowchart LR - `PATCH platform_express_waybills`:更新 `current_status_code/current_status_text/last_synced_at` - `INSERT platform_express_tracking_events`:写入归一化事件事实数据(包含 raw_payload、dedupe_key 等) -> 注意:当前实现的 `dedupe_key` 使用 `WEBHOOK_ + Date.now()`,更偏向“留痕与追溯”;如需严格幂等(重复回调不重复入库),建议按文档口径构造稳定 dedupe_key(例如 `tracking_no|event_code|event_time`)。 +> 当前实现的 `dedupe_key` 使用稳定哈希(基于 tracking_no / carrier / status_code / event_time / event_code / event_text 的截断组合),用于保证 webhook 重复回调不会重复写入事件表,从而避免重复入队/重复通知。 ### 3.4 验签(可选) - 配置 `WEBHOOK_SECRET` 后会校验: - `X-Timestamp`、`X-Signature` - - 签名算法:`HMAC-SHA256(secret, bodyText + timestamp)`,输出 hex -- 目前验签失败不会直接拒绝入库(会在 raw 表记录 `signature_valid=false`),可按需要升级为“验签失败直接 4xx”。 + - 签名算法:`HMAC-SHA256(secret, rawBodyText + timestamp)`,输出 hex(务必使用**原始请求体文本**参与计算) +- 默认行为:验签失败不会直接拒绝入库(会在 raw 表记录 `signature_valid=false`)。 +- 可选严格模式:设置 `WEBHOOK_REJECT_INVALID_SIGNATURE=true` 后,验签失败将直接返回 HTTP 401。 ### 3.5 配置与启动方式 接收器会先通过 `server/load-config.js` 把配置注入 `process.env`,优先级是: diff --git a/server/消息推送文档/NOTIFY_WORKER_README.md b/server/消息推送文档/NOTIFY_WORKER_README.md index 1f0a364a..2e97b27e 100644 --- a/server/消息推送文档/NOTIFY_WORKER_README.md +++ b/server/消息推送文档/NOTIFY_WORKER_README.md @@ -86,6 +86,13 @@ - `NOTIFY_WORKER_SUPA_USE_BEARER`(可选,默认 false):仅对 notify-worker 生效,若为 `true` 则发送 `Authorization: Bearer ` - `SUPA_USE_BEARER`(可选,默认 false):全局开关(会影响其它服务;一般不建议在自托管 JWT_SECRET 不一致时开启) +RLS 开启且 Bearer/JWT 不可用时的替代方案(慎用/止血): +- `NOTIFY_WORKER_USE_RLS_SAFE_RPC`(可选,默认 false):若为 `true`,则不再直查 `ml_orders`,改为调用 `rpc/notify_get_order_recipients`(SECURITY DEFINER)获取 `user_id/merchant_id`。 +- `NOTIFY_WORKER_RPC_TOKEN`(当上面为 true 时必需):notify-worker 调用 RPC 时通过请求头 `x-notify-worker-token` 传递;数据库函数会校验该 token。 + +配套 SQL: +- `pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql` + worker 行为: - `NOTIFY_WORKER_POLL_MS`(默认 2000) - `NOTIFY_WORKER_BATCH_SIZE`(默认 20) diff --git a/server/消息推送文档/PUSH_SERVER_README.md b/server/消息推送文档/PUSH_SERVER_README.md index 6f092296..365532ec 100644 --- a/server/消息推送文档/PUSH_SERVER_README.md +++ b/server/消息推送文档/PUSH_SERVER_README.md @@ -21,6 +21,8 @@ 新增/修改的接口(简要) - GET `/health` — 健康检查。 - POST `/api/v1/push/register` — 注册/更新设备;会写本地 `server/data/push_devices.json`,并尝试 upsert 到 Supabase `push_devices` 表(如果配置了 SUPA_URL + SERVICE_ROLE_KEY)。 + - 支持字段:`cid`(必填)、`user_id`(可选)、`merchant_id`(可选)、`platform`(可选)、`appid`(可选,默认 `default`)。 + - 注意:数据库侧 `push_devices` 的唯一约束是 `(appid, cid)`;服务端 upsert 使用 `on_conflict=appid,cid`。 - POST `/api/v1/push/unregister` — 注销设备(本地并尝试同步 Supabase)。 - GET `/api/v1/push/devices` — 列出设备(优先从 Supabase 获取)。 - POST `/api/v1/push/send` — 直接按 `cids` 或 `user_id` 发送推送(仅云函数:对每个 cid POST 到 `CLOUD_FUNC_URL`)。 @@ -76,7 +78,14 @@ curl http://localhost:7301/health ```bash curl -X POST http://localhost:7301/api/v1/push/register \ -H 'Content-Type: application/json' \ - -d '{"cid":"test-cid-1","user_id":"","platform":"android"}' + -d '{"cid":"test-cid-1","user_id":"","platform":"android","appid":"default"}' +``` + +商家端注册(写 merchant_id 维度,避免 `send_status=no-targets`): +```bash +curl -X POST http://localhost:7301/api/v1/push/register \ + -H 'Content-Type: application/json' \ + -d '{"cid":"test-cid-2","merchant_id":"","platform":"android","appid":"default"}' ``` 4) 按 user 发通知(写入 express_notifications 并触发推送): @@ -396,11 +405,11 @@ Auto-deploy process exited with code=0 signal=null ``` - 关键环境变量:`SUPA_URL`、`SUPA_KEY`(Supabase REST)、`CLOUD_FUNC_URL`(云函数 invoke URL)、`PUSH_TOKEN`(云函数鉴权)。 - 行为摘要: - - 轮询 `express_notifications`(status_code IS NULL)并选取记录; - - 通过带过滤的 PATCH 抢占(将 `status_code` 设为 `processing`)以避免并发重复处理; + - 轮询 `express_notifications`(`send_status IS NULL` 或 `send_status='retrying'` 且到期)并选取记录; + - 通过带过滤的 PATCH 抢占(将 `send_status` 设为 `processing`)以避免并发重复处理; - 查询目标设备(`push_devices`),对每个 `cid` 构造 event 并 POST 到 `CLOUD_FUNC_URL`;若未配置 `CLOUD_FUNC_URL` 则本次处理将失败并写入错误原因; - - 根据调用结果回写 `express_notifications.status_code` 为 `success` / `failed` / `no-targets`。 -- 限制与扩展点:当前 consumer 依赖 Supabase REST;尚未在 DB 中新增 `retry_count`/`last_error` 字段(建议在迁移中加入以支持指数退避与重试);为保证高可用建议配合 `FOR UPDATE SKIP LOCKED` 或 Supabase Realtime 优化并发策略。 + - 根据调用结果回写 `express_notifications.send_status` 为 `success` / `failed` / `no-targets`(失败时会写入 `retry_count/last_error/next_attempt_at` 用于退避重试)。 +- 限制与扩展点:当前 consumer 依赖 Supabase REST;并发控制基于“先查后 claim”的方式实现(适合 MVP)。如需更强一致性/高并发,可进一步改造为 DB 端锁(`FOR UPDATE SKIP LOCKED`)或引入队列系统。 文档实践 ---------------------------------