消息推送
This commit is contained in:
@@ -12,12 +12,14 @@ const path = require('path')
|
||||
const fetch = require('node-fetch')
|
||||
const { spawn } = require('child_process')
|
||||
|
||||
const PORT = process.env.PORT || 7301
|
||||
// 支持服务专用端口,避免与 webhook-receiver 共用 server/config.json 时发生端口冲突
|
||||
const PORT = process.env.PUSH_SERVER_PORT || process.env.PORT || 7301
|
||||
const DATA_DIR = path.join(__dirname, 'data')
|
||||
const DEVICES_FILE = path.join(DATA_DIR, 'push_devices.json')
|
||||
const SUPA_URL = process.env.SUPA_URL || ''
|
||||
// 支持两种环境变量名:SUPA_KEY(旧)和 SERVICE_ROLE_KEY(常用于自托管 .env)
|
||||
const SUPA_KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || ''
|
||||
// Prefer service role key when present (server-side), to avoid RLS issues.
|
||||
const SUPA_KEY = process.env.SERVICE_ROLE_KEY || process.env.SUPA_KEY || ''
|
||||
const SUPA_SCHEMA = process.env.SUPA_SCHEMA || 'public'
|
||||
|
||||
// Consumer 配置
|
||||
@@ -31,6 +33,8 @@ const RETRY_INITIAL_MS = parseInt(process.env.RETRY_INITIAL_MS || '5000', 10)
|
||||
const RETRY_FACTOR = parseInt(process.env.RETRY_FACTOR || '2', 10)
|
||||
const RETRY_MAX_MS = parseInt(process.env.RETRY_MAX_MS || '3600000', 10) // 1 hour
|
||||
|
||||
let warnedMissingSendStatus = false
|
||||
|
||||
async function ensureDataDir() {
|
||||
try {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true })
|
||||
@@ -144,12 +148,18 @@ async function start() {
|
||||
if (!SUPA_URL || !SUPA_KEY) return []
|
||||
try {
|
||||
// 查询:
|
||||
// - pending: status_code IS NULL
|
||||
// - retrying: status_code = 'retrying' 且 (next_attempt_at IS NULL 或 next_attempt_at <= now)
|
||||
// - pending: send_status IS NULL
|
||||
// - retrying: send_status = 'retrying' 且 (next_attempt_at IS NULL 或 next_attempt_at <= now)
|
||||
const now = new Date().toISOString()
|
||||
const q = `express_notifications?or=(status_code.is.null,and(status_code.eq.retrying,or(next_attempt_at.is.null,next_attempt_at.lte.${encodeURIComponent(now)})))&order=created_at.asc&limit=${limit}`
|
||||
const q = `express_notifications?or=(send_status.is.null,and(send_status.eq.retrying,or(next_attempt_at.is.null,next_attempt_at.lte.${encodeURIComponent(now)})))&order=created_at.asc&limit=${limit}`
|
||||
const resp = await supaFetch(q, { method: 'GET' })
|
||||
if (!resp.ok) return []
|
||||
if (!resp.ok) {
|
||||
if (!warnedMissingSendStatus && resp.status === 400) {
|
||||
warnedMissingSendStatus = true
|
||||
console.warn('Consumer query failed. If this mentions missing column "send_status", run migration: pages/mall/delivery/doc/需求文档/20260309_add_express_notifications_send_status.sql')
|
||||
}
|
||||
return []
|
||||
}
|
||||
const data = await resp.json().catch(() => [])
|
||||
return Array.isArray(data) ? data : []
|
||||
} catch (e) {
|
||||
@@ -160,19 +170,23 @@ async function start() {
|
||||
|
||||
async function claimNotification(id) {
|
||||
try {
|
||||
const body = { status_code: 'processing', updated_at: new Date().toISOString() }
|
||||
const body = { send_status: 'processing', updated_at: new Date().toISOString() }
|
||||
// 仅当当前仍为 pending 或 retrying 且到期时,才抢占。
|
||||
// 为了避免在 PATCH 中使用复杂 or= 逻辑树导致匹配失败,这里拆成两次尝试。
|
||||
const now = new Date().toISOString()
|
||||
const attempts = [
|
||||
// pending: status_code IS NULL
|
||||
`express_notifications?id=eq.${encodeURIComponent(id)}&status_code=is.null`,
|
||||
// retrying: status_code='retrying' and next_attempt_at is null or <= now
|
||||
`express_notifications?id=eq.${encodeURIComponent(id)}&status_code=eq.retrying&or=(next_attempt_at.is.null,next_attempt_at.lte.${encodeURIComponent(now)})`
|
||||
// pending: send_status IS NULL
|
||||
`express_notifications?id=eq.${encodeURIComponent(id)}&send_status=is.null`,
|
||||
// retrying: send_status='retrying' and next_attempt_at is null or <= now
|
||||
`express_notifications?id=eq.${encodeURIComponent(id)}&send_status=eq.retrying&or=(next_attempt_at.is.null,next_attempt_at.lte.${encodeURIComponent(now)})`
|
||||
]
|
||||
|
||||
for (const path of attempts) {
|
||||
const resp = await supaFetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' }, body: JSON.stringify(body) })
|
||||
if (!resp.ok && !warnedMissingSendStatus && resp.status === 400) {
|
||||
warnedMissingSendStatus = true
|
||||
console.warn('Consumer claim failed. If this mentions missing column "send_status", run migration: pages/mall/delivery/doc/需求文档/20260309_add_express_notifications_send_status.sql')
|
||||
}
|
||||
if (!resp.ok) continue
|
||||
const j = await resp.json().catch(() => null)
|
||||
if (Array.isArray(j)) {
|
||||
@@ -193,9 +207,13 @@ async function start() {
|
||||
async function updateNotificationStatus(id, status, note) {
|
||||
try {
|
||||
if (!id) return
|
||||
const body = { status_code: String(status), updated_at: new Date().toISOString() }
|
||||
if (note) body.event_text_safe = String(note).substring(0, 2000)
|
||||
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
const body = { send_status: String(status), updated_at: new Date().toISOString() }
|
||||
if (note) body.last_error = String(note).substring(0, 2000)
|
||||
const resp = await supaFetch(`express_notifications?id=eq.${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (!resp.ok && !warnedMissingSendStatus && resp.status === 400) {
|
||||
warnedMissingSendStatus = true
|
||||
console.warn('Consumer status update failed. If this mentions missing column "send_status", run migration: pages/mall/delivery/doc/需求文档/20260309_add_express_notifications_send_status.sql')
|
||||
}
|
||||
} catch (e) { console.warn('updateNotificationStatus error', e) }
|
||||
}
|
||||
|
||||
@@ -233,6 +251,12 @@ async function start() {
|
||||
const notification = claimed.payload && claimed.payload.notification ? claimed.payload.notification : (claimed.event_text_safe ? { title: claimed.event_text_safe, body: '' } : {})
|
||||
const payload = claimed.payload || {}
|
||||
|
||||
// Some push providers (and client notification renderers) may not display a system notification
|
||||
// when content/body is empty, even if the push API returns success.
|
||||
const titleText = (notification && (notification.title || notification.event_text || notification.text)) || claimed.event_text_safe || ''
|
||||
const bodyText = (notification && (notification.body || notification.content)) || ''
|
||||
const contentText = bodyText || titleText || ''
|
||||
|
||||
// build targets similar to /api/v1/notifications
|
||||
let targets = []
|
||||
try {
|
||||
@@ -261,7 +285,7 @@ async function start() {
|
||||
let allOk = true
|
||||
let lastNote = ''
|
||||
if (CLOUD_FUNC_URL) {
|
||||
const calls = await Promise.all(targets.map(cid => invokeCloudFuncForCid(CLOUD_FUNC_URL, PUSH_TOKEN, cid, notification && notification.title, notification && notification.body, payload)))
|
||||
const calls = await Promise.all(targets.map(cid => invokeCloudFuncForCid(CLOUD_FUNC_URL, PUSH_TOKEN, cid, titleText, contentText, payload)))
|
||||
for (const c of calls) {
|
||||
if (!c.ok) {
|
||||
allOk = false
|
||||
@@ -285,7 +309,7 @@ async function start() {
|
||||
if (nextRetry >= MAX_RETRIES) {
|
||||
// mark failed
|
||||
try {
|
||||
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_code: 'failed', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), updated_at: new Date().toISOString() }) })
|
||||
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ send_status: 'failed', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), updated_at: new Date().toISOString() }) })
|
||||
} catch (e) { console.warn('mark failed error', e) }
|
||||
} else {
|
||||
// compute exponential backoff
|
||||
@@ -293,7 +317,7 @@ async function start() {
|
||||
if (delay > RETRY_MAX_MS) delay = RETRY_MAX_MS
|
||||
const nextAt = new Date(Date.now() + delay).toISOString()
|
||||
try {
|
||||
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_code: 'retrying', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), next_attempt_at: nextAt, updated_at: new Date().toISOString() }) })
|
||||
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ send_status: 'retrying', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), next_attempt_at: nextAt, updated_at: new Date().toISOString() }) })
|
||||
} catch (e) { console.warn('schedule retry error', e) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user