测试文件
This commit is contained in:
86
server/PUSH_SERVER_README.md
Normal file
86
server/PUSH_SERVER_README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
**Push Server - 使用与变更说明**
|
||||
|
||||
简要说明
|
||||
- 该文档记录对 `server/push-server.js` 的修改、运行所需的环境变量、表结构依赖、以及如何把 Supabase 的 cid 与通知通过 dCloud UNI‑PUSH 下发的端到端操作步骤。
|
||||
|
||||
变更要点(代码修改摘要)
|
||||
- supaFetch: 默认仅发送 `apikey`;仅当 `SUPA_USE_BEARER=true` 或 `SUPA_KEY` 看起来像 JWT(包含两处 ".")时,才发送 `Authorization: Bearer`。避免把明文 service key 当作 JWT 发出导致 PostgREST 拒绝。
|
||||
- 新增 endpoint `/api/v1/notifications`:将通知写入 `express_notifications`,根据 `aud` 与 `recipient_id` 查询 `push_devices`,再发送推送(proxy 或 mock),并写回通知状态。
|
||||
- 新增 uni-push adapter `sendToUniPush(targets, notification, payload)`:当设置了 `UNI_PUSH_URL` 时,`/api/v1/push/send` 与 `/api/v1/notifications` 会调用该适配器优先发送到 UNI‑PUSH;否则若设置了 `PUSH_PROXY_URL` 则转发到该 URL。
|
||||
|
||||
新增/修改的接口(简要)
|
||||
- GET `/health` — 健康检查。
|
||||
- POST `/api/v1/push/register` — 注册/更新设备;会写本地 `server/data/push_devices.json`,并尝试 upsert 到 Supabase `push_devices` 表(如果配置了 SUPA_URL + SERVICE_ROLE_KEY)。
|
||||
- POST `/api/v1/push/unregister` — 注销设备(本地并尝试同步 Supabase)。
|
||||
- GET `/api/v1/push/devices` — 列出设备(优先从 Supabase 获取)。
|
||||
- POST `/api/v1/push/send` — 直接按 `cids` 或 `user_id` 发送推送;若 `UNI_PUSH_URL` 存在使用 adapter,否则若 `PUSH_PROXY_URL` 存在转发,默认 mock 返回。
|
||||
- POST `/api/v1/notifications` — 将通知写入 `express_notifications` 并基于 `aud`/`recipient_id` 拉取 `push_devices` 发推送,成功/失败状态写回 `express_notifications.status_code`。
|
||||
|
||||
依赖的数据库表(必须存在)
|
||||
- `public.push_devices`:用于存储设备 cid、user_id/merchant_id、is_active 等(见仓库迁移脚本 `20260224_add_push_devices_and_notifications.sql`)。
|
||||
- `public.express_notifications`:用于保存通知记录与状态(见迁移脚本)。
|
||||
|
||||
关键环境变量(示例与说明)
|
||||
- SUPA_URL — Supabase REST(PostgREST)地址(内部建议 `http://rest:3000`)。
|
||||
- SERVICE_ROLE_KEY 或 SUPA_KEY — 用作 `apikey` 向 PostgREST 请求(不要把明文放到 Authorization 除非该值确为 JWT)。
|
||||
- SUPA_USE_BEARER — (可选) 若为 `true` 则强制发送 Authorization: Bearer <SUPA_KEY>。
|
||||
- UNI_PUSH_URL — (可选) 若设置则使用内置 `sendToUniPush` adapter 直接调用 dCloud uni-push 接口。
|
||||
- UNI_PUSH_APPID / UNI_PUSH_SECRET — adapter 用于构造或鉴权(按你现有 uni-push 接口调整)。
|
||||
- PUSH_PROXY_URL / PUSH_PROXY_TOKEN — 若不使用 adapter,可把此设置为你现有的推送代理 URL 与 token,后端会将 {targets, notification, payload} 转发过去。
|
||||
- PUSH_PROXY_URL 优先级低于 UNI_PUSH_URL:若 UNI_PUSH_URL 存在,优先使用本地 adapter。
|
||||
|
||||
运行与测试(本地示例)
|
||||
1) 安装依赖并启动:
|
||||
```bash
|
||||
cd server
|
||||
npm install express body-parser cors node-fetch
|
||||
SUPA_URL='http://rest:3000' SERVICE_ROLE_KEY='PASTE_SERVICE_ROLE_KEY' node push-server.js
|
||||
```
|
||||
|
||||
2) 健康检查:
|
||||
```bash
|
||||
curl http://localhost:7301/health
|
||||
# 返回 {"ok":true}
|
||||
```
|
||||
|
||||
3) 注册设备(后端会写本地并尝试写 Supabase):
|
||||
```bash
|
||||
curl -X POST http://localhost:7301/api/v1/push/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"cid":"test-cid-1","user_id":"<USER_UUID>","platform":"android"}'
|
||||
```
|
||||
|
||||
4) 按 user 发通知(写入 express_notifications 并触发推送):
|
||||
```bash
|
||||
curl -X POST http://localhost:7301/api/v1/notifications \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"aud":"user","recipient_id":"<USER_UUID>","notification":{"title":"测试","body":"uni-push 测试"}}'
|
||||
```
|
||||
|
||||
5) 直接按 cid 发(跳过 DB):
|
||||
```bash
|
||||
curl -X POST http://localhost:7301/api/v1/push/send \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"cids":["test-cid-1"],"notification":{"title":"hi","body":"msg"}}'
|
||||
```
|
||||
|
||||
UNI‑PUSH 集成注意事项
|
||||
- adapter 当前构造 body 使用 `cidList` 与 `message:{title,content,payload}`。请根据你已经验证成功的 uni-push curl 请求体调整字段名与鉴权 header(可使用 `UNI_PUSH_APPID`、`UNI_PUSH_SECRET`、或 `PUSH_PROXY_TOKEN`)。
|
||||
- 建议:把你成功的 uni-push curl 发给我,我可以把 adapter 的 body/header 精确改成一致格式。
|
||||
|
||||
故障与排查要点
|
||||
- 如果 Supabase 报 401 或 PGRST301:不要把明文 service key 作为 Bearer;使用 `apikey` header,或生成并使用与 `PGRST_JWT_SECRET` 匹配的 JWT。可通过 `docker inspect` 检查容器 env 中的 `PGRST_JWT_SECRET`。
|
||||
- 如果 `/rest/v1/push_devices` 返回 404:确认表名在 `public` schema 中并加载,或调整请求前缀。
|
||||
- 查看 push-server 控制台输出中的 `supaFetch` warn 和 proxy 响应体以获取具体错误信息。
|
||||
|
||||
后续建议(可选实现)
|
||||
- 将 `express_notifications` 增加 `attempts`、`error`、`sent_at` 字段以支持重试与错误记录;可实现后台 worker 或 pg_notify+listener 做可靠投递与重试。
|
||||
- 为 `/api/v1/push/send` 与 `/api/v1/notifications` 添加管理员鉴权(例如 `PUSH_ADMIN_KEY`)以限制谁能发送通知。
|
||||
|
||||
文件位置
|
||||
- 文档:[server/PUSH_SERVER_README.md](server/PUSH_SERVER_README.md)
|
||||
- 代码:[server/push-server.js](server/push-server.js)
|
||||
|
||||
如果需要,我可以:
|
||||
- 把 adapter 的请求体精确匹配你现有成功的 uni-push curl(请把 curl 发来);或
|
||||
- 为通知添加重试/记录字段并实现简单重试机制。
|
||||
@@ -6,5 +6,13 @@
|
||||
"created_at": "2026-02-24T03:48:20.907Z",
|
||||
"updated_at": "2026-02-24T03:48:20.907Z",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"cid": "你的CID",
|
||||
"user_id": "123",
|
||||
"platform": "android",
|
||||
"created_at": "2026-02-25T13:28:58.901Z",
|
||||
"updated_at": "2026-02-25T13:28:58.901Z",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
37
server/insert_supa.js
Normal file
37
server/insert_supa.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const fetch = require('node-fetch')
|
||||
|
||||
const SUPA = process.env.SUPA_URL || 'http://192.168.1.62:18000'
|
||||
const KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || ''
|
||||
|
||||
const payload = [
|
||||
{
|
||||
cid: 'CID_TEST_001',
|
||||
user_id: 'a8e3a568-fc1f-4237-bcc5-5722e2fca0a3',
|
||||
platform: 'android',
|
||||
active: true
|
||||
}
|
||||
]
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const url = `${SUPA.replace(/\/$/, '')}/rest/v1/push_devices`
|
||||
console.log('POST', url)
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: KEY,
|
||||
Authorization: `Bearer ${KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
timeout: 15000
|
||||
})
|
||||
console.log('status', resp.status)
|
||||
const txt = await resp.text()
|
||||
console.log('body:', txt)
|
||||
} catch (e) {
|
||||
console.error('insert error', e && e.message ? e.message : e)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
@@ -2,7 +2,7 @@ const fetch = require('node-fetch')
|
||||
|
||||
const SUPA = process.env.SUPA_URL || process.env.SUPA || 'http://192.168.1.62:18000'
|
||||
// 支持两种环境变量名:SUPA_KEY 或 SERVICE_ROLE_KEY(自托管 .env 里常见)
|
||||
const KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UtMSIsImlhdCI6MTc2OTY3NjQ5OCwiZXhwIjoxOTI3MzU2NDk4fQ.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
|
||||
const KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UtMSIsImlhdCI6MTc3MTk4OTAwNywiZXhwIjoxODAzNTI1MDA3fQ.TBZeJIYEGdcKMMXAAJAka-09c4hO8_e_wyW8V5tkJ2Y';
|
||||
const USER = 'a8e3a568-fc1f-4237-bcc5-5722e2fca0a3';
|
||||
|
||||
(async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ const cors = require('cors')
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
const fetch = require('node-fetch')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const PORT = process.env.PORT || 7301
|
||||
const DATA_DIR = path.join(__dirname, 'data')
|
||||
@@ -42,11 +43,14 @@ async function writeDevices(devices) {
|
||||
|
||||
async function supaFetch(path, opts = {}) {
|
||||
const url = `${SUPA_URL.replace(/\/$/, '')}/rest/v1/${path}`
|
||||
// 默认只发送 apikey。只有在显式要求 Bearer(环境变量 SUPA_USE_BEARER=true)
|
||||
// 或者看到 SUPA_KEY 看起来像 JWT(包含两处 ".")时,才添加 Authorization 头。
|
||||
const headers = Object.assign({}, opts.headers || {}, {
|
||||
apikey: SUPA_KEY,
|
||||
Authorization: `Bearer ${SUPA_KEY}`,
|
||||
Accept: 'application/json'
|
||||
})
|
||||
const sendBearer = (process.env.SUPA_USE_BEARER === 'true') || (SUPA_KEY && (SUPA_KEY.split('.').length === 3))
|
||||
if (sendBearer) headers.Authorization = `Bearer ${SUPA_KEY}`
|
||||
try {
|
||||
console.log('supaFetch ->', url)
|
||||
const resp = await fetch(url, Object.assign({}, opts, { headers }))
|
||||
@@ -71,6 +75,148 @@ async function supaFetch(path, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// 适配并发送到 dCloud uni-push(若设置了 UNI_PUSH_URL)
|
||||
async function sendToUniPush(targets, notification, payload) {
|
||||
const uniUrlRaw = process.env.UNI_PUSH_URL || ''
|
||||
if (!uniUrlRaw) throw new Error('UNI_PUSH_URL not configured')
|
||||
const appid = process.env.UNI_PUSH_APPID || '5fSIfMap289ymbdnkfMJ29'
|
||||
const appkey = process.env.UNI_PUSH_APPKEY || '33z3cfHI6Z9TpzyQXRWu01'
|
||||
const secret = process.env.UNI_PUSH_SECRET || process.env.PUSH_PROXY_TOKEN || 'PkVpjStqwW9BourzryQHc7'
|
||||
const authUrl = process.env.UNI_PUSH_AUTH_URL || 'https://restapi.getui.com/v2/5fSIfMap289ymbdnkfMJ29/auth'
|
||||
|
||||
// 全局缓存 token
|
||||
if (!global.__uniPushTokenCache) global.__uniPushTokenCache = {}
|
||||
|
||||
async function obtainToken() {
|
||||
if (authUrl) {
|
||||
const cache = global.__uniPushTokenCache[authUrl]
|
||||
if (cache && cache.expiresAt && Date.now() < cache.expiresAt - 5000) return cache.token
|
||||
try {
|
||||
// 为了解决 provider 对 timestamp/sign 规则不同的情况,先尝试当前 env 配置;
|
||||
// 若返回 timestamp/sign 错误则自动按常见组合重试(秒/毫秒、md5/hmac、大小写、是否包含 secret)
|
||||
const tried = new Set()
|
||||
|
||||
const timestampUnits = ['ms', 's']
|
||||
const signAlgos = ['md5', 'hmac-sha256']
|
||||
const signCases = ['lower', 'upper']
|
||||
const includeSecretOptions = [true, false]
|
||||
|
||||
// 首选使用环境变量指定的选项(若有)
|
||||
const envUnit = process.env.UNI_PUSH_TIMESTAMP_UNIT || 'ms'
|
||||
const envAlgo = (process.env.UNI_PUSH_SIGN_ALGO || 'md5').toLowerCase()
|
||||
const envCase = (process.env.UNI_PUSH_SIGN_CASE || 'lower').toLowerCase()
|
||||
const envInclude = (process.env.UNI_PUSH_INCLUDE_SECRET === 'true')
|
||||
|
||||
const candidates = []
|
||||
// push env preferred first
|
||||
candidates.push({ unit: envUnit, algo: envAlgo, signCase: envCase, includeSecret: envInclude })
|
||||
for (const u of timestampUnits) for (const a of signAlgos) for (const c of signCases) for (const inc of includeSecretOptions) {
|
||||
const key = `${u}|${a}|${c}|${inc}`
|
||||
if (candidates.find(x => `${x.unit}|${x.algo}|${x.signCase}|${x.includeSecret}` === key)) continue
|
||||
candidates.push({ unit: u, algo: a, signCase: c, includeSecret: inc })
|
||||
}
|
||||
|
||||
for (const cand of candidates) {
|
||||
const { unit, algo, signCase, includeSecret } = cand
|
||||
const stamp = (unit === 's') ? String(Math.floor(Date.now() / 1000)) : String(Date.now())
|
||||
const signAlgo = (algo || 'md5').toLowerCase()
|
||||
let sign = null
|
||||
try {
|
||||
if (secret) {
|
||||
if (signAlgo === 'md5') sign = crypto.createHash('md5').update(String(appkey || '') + stamp + String(secret)).digest('hex')
|
||||
else if (signAlgo === 'hmac-sha256' || signAlgo === 'hmac256') sign = crypto.createHmac('sha256', String(secret)).update(String(appkey || '') + stamp).digest('hex')
|
||||
else sign = crypto.createHash('md5').update(String(appkey || '') + stamp + String(secret)).digest('hex')
|
||||
}
|
||||
if (sign && (signCase === 'upper')) sign = sign.toUpperCase()
|
||||
else if (sign) sign = sign.toLowerCase()
|
||||
} catch (e) {
|
||||
console.warn('生成 sign 失败,继续无 sign 请求', e)
|
||||
}
|
||||
|
||||
const b = {}
|
||||
if (appid) { b.appId = appid; b.appid = appid }
|
||||
if (appkey) { b.appKey = appkey; b.appkey = appkey }
|
||||
b.timestamp = stamp
|
||||
if (sign) b.sign = sign
|
||||
if (includeSecret && secret) { b.appSecret = secret; b.appsecret = secret }
|
||||
|
||||
const tryKey = JSON.stringify(b)
|
||||
if (tried.has(tryKey)) continue
|
||||
tried.add(tryKey)
|
||||
|
||||
// Mask debug copy
|
||||
try {
|
||||
const dbg = Object.assign({}, b)
|
||||
if (dbg.appSecret) dbg.appSecret = '***masked***'
|
||||
console.log('uni-push auth attempt:', { unit, algo: signAlgo, signCase, includeSecret })
|
||||
console.log('uni-push auth body:', JSON.stringify(dbg))
|
||||
console.log('uni-push auth url:', authUrl)
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const resp = await fetch(authUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) })
|
||||
let txtDbg = ''
|
||||
try { txtDbg = await resp.clone().text().catch(() => '') } catch (e) { /* ignore */ }
|
||||
if (txtDbg && txtDbg.length < 2000) console.log('uni-push auth response preview:', txtDbg)
|
||||
else console.log('uni-push auth response status:', resp.status)
|
||||
|
||||
let j = null
|
||||
try { j = await resp.json().catch(() => null) } catch (e) { j = null }
|
||||
const token = (j && (j.token || (j.data && j.data.token) || j.access_token)) || null
|
||||
const expires = (j && (j.expires_in || (j.data && j.data.expires_in))) || 0
|
||||
if (token) {
|
||||
global.__uniPushTokenCache[authUrl] = { token, expiresAt: expires ? (Date.now() + (expires * 1000)) : (Date.now() + 55 * 60 * 1000) }
|
||||
console.log('uni-push auth succeeded with variant:', { unit, algo: signAlgo, signCase, includeSecret })
|
||||
return token
|
||||
}
|
||||
|
||||
// 若 provider 明确提示 timestamp/sign 错误,则继续尝试其他组合,否则可能无需重试
|
||||
const code = j && (j.code || (j.error && j.error.code))
|
||||
const msg = j && (j.msg || j.message || (j.error && j.error.message))
|
||||
if (code && !String(code).match(/20001|10001/)) {
|
||||
// 非 timestamp/sign 类型错误,不再重试
|
||||
console.log('uni-push auth returned non-sign/timestamp error, stopping retries:', code, msg)
|
||||
break
|
||||
}
|
||||
console.log('uni-push auth did not return token, response code/msg:', code, msg)
|
||||
// 否则继续下一种变体
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('uni-push auth failed', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (secret) return secret
|
||||
return null
|
||||
}
|
||||
|
||||
const token = await obtainToken()
|
||||
|
||||
// 支持 {appId} 占位符替换
|
||||
let uniUrl = uniUrlRaw
|
||||
if (appid && uniUrl.includes('{appId}')) uniUrl = uniUrl.replace(/\{appId\}/g, appid)
|
||||
|
||||
console.log('uni-push send: uniUrl=', uniUrl, 'token?', !!token, 'targetsLen=', targets.length)
|
||||
|
||||
const body = {
|
||||
appid: appid || undefined,
|
||||
cidList: targets,
|
||||
message: {
|
||||
title: notification && notification.title,
|
||||
content: notification && notification.body,
|
||||
payload: payload || {}
|
||||
}
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const resp = await fetch(uniUrl, { method: 'POST', headers, body: JSON.stringify(body) })
|
||||
const text = await resp.text().catch(() => '')
|
||||
try { if (text && text.length < 2000) console.log('uni-push send response preview:', text); else console.log('uni-push send response status:', resp.status) } catch (e) { /* ignore */ }
|
||||
return { status: resp.status, body: text }
|
||||
}
|
||||
|
||||
async function getDevicesFromSupabase({ user_id, active } = {}) {
|
||||
let q = 'push_devices'
|
||||
const params = []
|
||||
@@ -220,10 +366,23 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果配置了 PUSH_PROXY_URL 则转发到外部推送服务(例如 uni-push2 的服务端 API)
|
||||
// 推送优先策略:
|
||||
// 1) 若配置了 UNI_PUSH_URL,优先使用本地适配器 sendToUniPush
|
||||
// 2) 否则若配置了 PUSH_PROXY_URL,则转发到 proxy
|
||||
// 3) 否则进入 mock
|
||||
const proxyUrl = process.env.PUSH_PROXY_URL || ''
|
||||
const proxyToken = process.env.PUSH_PROXY_TOKEN || ''
|
||||
if (proxyUrl) {
|
||||
console.log('send handler decision: UNI_PUSH_URL=', process.env.UNI_PUSH_URL, 'PUSH_PROXY_URL=', proxyUrl)
|
||||
console.log('send handler targets count:', targets.length)
|
||||
if (process.env.UNI_PUSH_URL) {
|
||||
try {
|
||||
const result = await sendToUniPush(targets, notification, payload)
|
||||
return res.json({ ok: true, proxied: true, status: result.status, response: result.body })
|
||||
} catch (e) {
|
||||
console.warn('本地 uni-push 适配器推送失败:', e)
|
||||
return res.status(500).json({ ok: false, error: String(e) })
|
||||
}
|
||||
} else if (proxyUrl) {
|
||||
try {
|
||||
const resp = await fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
@@ -239,14 +398,124 @@ async function start() {
|
||||
}
|
||||
|
||||
// 否则仅记录并返回模拟结果
|
||||
console.log('Mock push to', targets.length, 'clients')
|
||||
console.log('notification:', notification)
|
||||
console.log('payload:', payload)
|
||||
return res.json({ ok: true, mocked: true, sent: targets.length })
|
||||
console.log('Mock push to', targets.length, 'clients (no UNI_PUSH_URL and no PUSH_PROXY_URL matched)')
|
||||
try {
|
||||
console.log('notification:', notification ? JSON.stringify(notification, null, 2) : null)
|
||||
} catch (e) {
|
||||
console.log('notification:', notification)
|
||||
}
|
||||
try {
|
||||
console.log('payload:', payload ? JSON.stringify(payload, null, 2) : '{}')
|
||||
} catch (e) {
|
||||
console.log('payload:', payload)
|
||||
}
|
||||
return res.json({ ok: true, mocked: true, sent: targets.length, payload: payload || {} })
|
||||
})
|
||||
|
||||
// 创建通知并基于数据库记录推送(写入 express_notifications 并发送)
|
||||
app.post('/api/v1/notifications', async (req, res) => {
|
||||
const { aud, recipient_id, notification, payload, dedupe_key, message_id, order_id, waybill_id } = req.body || {}
|
||||
if (!aud || !recipient_id) return res.status(400).json({ error: 'aud and recipient_id required' })
|
||||
|
||||
try {
|
||||
// 写入 express_notifications 表并返回记录(使用 return=representation)
|
||||
const body = Object.assign({}, { aud, recipient_id, event_text_safe: (notification && notification.title) || null, payload: payload || null, dedupe_key: dedupe_key || null, message_id: message_id || null, order_id: order_id || null, waybill_id: waybill_id || null, event_time: new Date().toISOString() })
|
||||
const insertResp = await supaFetch(`express_notifications?on_conflict=message_id`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
if (!insertResp.ok) {
|
||||
const txt = await insertResp.text()
|
||||
return res.status(500).json({ ok: false, error: `insert notification failed ${insertResp.status} ${txt}` })
|
||||
}
|
||||
const inserted = await insertResp.json()
|
||||
const record = Array.isArray(inserted) ? inserted[0] : inserted
|
||||
|
||||
// 根据 aud 拉取目标设备
|
||||
let targets = []
|
||||
try {
|
||||
if (SUPA_URL && SUPA_KEY) {
|
||||
if (aud === 'user') {
|
||||
const devices = await getDevicesFromSupabase({ user_id: recipient_id, active: true })
|
||||
targets = devices.map(d => d.cid)
|
||||
} else if (aud === 'merchant') {
|
||||
// 使用 merchant_id 字段查询
|
||||
const resp = await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(recipient_id)}&is_active=eq.true`, { method: 'GET' })
|
||||
if (resp.ok) {
|
||||
const devices = await resp.json()
|
||||
targets = devices.map(d => d.cid)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 本地文件回退
|
||||
const devices = await readDevices()
|
||||
if (aud === 'user') targets = devices.filter(d => String(d.user_id) === String(recipient_id) && d.active).map(d => d.cid)
|
||||
else if (aud === 'merchant') targets = devices.filter(d => String(d.merchant_id) === String(recipient_id) && d.active).map(d => d.cid)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('fetch devices for notification failed:', e)
|
||||
}
|
||||
|
||||
// 进行推送(与 /push/send 相同逻辑:proxy 或 mock)
|
||||
const proxyUrl = process.env.PUSH_PROXY_URL || ''
|
||||
const proxyToken = process.env.PUSH_PROXY_TOKEN || ''
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
// 若配置了 UNI_PUSH_URL,优先使用本地 adapter;否则直接转发到 proxyUrl
|
||||
if (process.env.UNI_PUSH_URL) {
|
||||
try {
|
||||
const result = await sendToUniPush(targets, notification, payload)
|
||||
// 更新通知记录状态
|
||||
if (record && record.id) {
|
||||
try { await supaFetch(`express_notifications?id=eq.${encodeURIComponent(record.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_code: String(result.status), updated_at: new Date().toISOString() }) }) } catch (e) { /* ignore */ }
|
||||
}
|
||||
return res.json({ ok: true, proxied: true, status: result.status, response: result.body })
|
||||
} catch (e) {
|
||||
console.warn('本地 uni-push 适配器推送失败:', e)
|
||||
return res.status(500).json({ ok: false, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, proxyToken ? { Authorization: `Bearer ${proxyToken}` } : {}),
|
||||
body: JSON.stringify({ targets, notification, payload })
|
||||
})
|
||||
const data = await resp.text()
|
||||
// 更新通知记录状态
|
||||
if (record && record.id) {
|
||||
try { await supaFetch(`express_notifications?id=eq.${encodeURIComponent(record.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_code: String(resp.status), updated_at: new Date().toISOString() }) }) } catch (e) { /* ignore */ }
|
||||
}
|
||||
return res.json({ ok: true, proxied: true, status: resp.status, response: data })
|
||||
} catch (e) {
|
||||
console.warn('代理推送失败:', e)
|
||||
return res.status(500).json({ ok: false, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// mock 模式
|
||||
console.log('Notification mock push to', targets.length, 'clients for', aud, recipient_id)
|
||||
console.log('notification:', notification)
|
||||
console.log('payload:', payload)
|
||||
|
||||
if (record && record.id) {
|
||||
try { await supaFetch(`express_notifications?id=eq.${encodeURIComponent(record.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_code: 'mocked', updated_at: new Date().toISOString() }) }) } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return res.json({ ok: true, mocked: true, sent: targets.length, notification_id: record && record.id })
|
||||
} catch (e) {
|
||||
console.warn('create notification failed:', e)
|
||||
return res.status(500).json({ ok: false, error: String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Push server listening on http://0.0.0.0:${PORT}`)
|
||||
console.log('ENV: UNI_PUSH_URL=', process.env.UNI_PUSH_URL)
|
||||
console.log('ENV: UNI_PUSH_AUTH_URL=', process.env.UNI_PUSH_AUTH_URL)
|
||||
console.log('ENV: UNI_PUSH_APPID=', process.env.UNI_PUSH_APPID)
|
||||
console.log('ENV: PUSH_PROXY_URL=', process.env.PUSH_PROXY_URL)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
67
server/verify_supa_token.js
Normal file
67
server/verify_supa_token.js
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
const crypto = require('crypto')
|
||||
|
||||
function b64urlDecode(s) {
|
||||
s = String(s || '')
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (s.length % 4) s += '='
|
||||
return Buffer.from(s, 'base64').toString('utf8')
|
||||
}
|
||||
|
||||
function b64urlEncode(buf) {
|
||||
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
}
|
||||
|
||||
function verifyHS256(token, secret) {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return { ok: false, reason: 'bad token format' }
|
||||
const [h64, p64, s64] = parts
|
||||
const signingInput = `${h64}.${p64}`
|
||||
const expected = b64urlEncode(crypto.createHmac('sha256', secret).update(signingInput).digest())
|
||||
return { ok: expected === s64, expected, actual: s64 }
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log('Usage: node server/verify_supa_token.js <token> [secret]')
|
||||
console.log('Or set env vars: TOKEN and SECRET and run without args')
|
||||
}
|
||||
|
||||
const token = process.argv[2] || process.env.TOKEN
|
||||
const secret = process.argv[3] || process.env.SECRET || process.env.JWT_SECRET || process.env.SERVICE_ROLE_KEY || process.env.SUPA_KEY
|
||||
|
||||
if (!token) {
|
||||
usage()
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
if (!secret) {
|
||||
console.error('No secret provided. Pass as second arg or set SECRET/JWT_SECRET/SERVICE_ROLE_KEY env var')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
const header = JSON.parse(b64urlDecode(parts[0]))
|
||||
const payload = JSON.parse(b64urlDecode(parts[1]))
|
||||
|
||||
console.log('Header:')
|
||||
console.log(JSON.stringify(header, null, 2))
|
||||
console.log('Payload:')
|
||||
console.log(JSON.stringify(payload, null, 2))
|
||||
|
||||
if ((header.alg || '').toUpperCase() !== 'HS256') {
|
||||
console.warn('Warning: token alg is not HS256; this verifier only checks HS256 signatures')
|
||||
}
|
||||
|
||||
const res = verifyHS256(token, secret)
|
||||
if (res.ok) {
|
||||
console.log('\nSignature: VALID (HMAC-SHA256)')
|
||||
} else {
|
||||
console.log('\nSignature: INVALID')
|
||||
console.log('expected:', res.expected)
|
||||
console.log('actual :', res.actual)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('verify error', e && e.message ? e.message : e)
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user