Files
medical-mall/server/push-server.js
2026-03-12 18:05:32 +08:00

613 lines
28 KiB
JavaScript
Raw 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.
try {
require('./load-config')
} catch (e) {
// no-op if no config file
}
const express = require('express')
const bodyParser = require('body-parser')
const cors = require('cors')
const fs = require('fs').promises
const path = require('path')
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 时发生端口冲突
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
// 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 配置
const CONSUMER_ENABLED = (process.env.ENABLE_CONSUMER === 'true') || (process.env.CONSUMER_ENABLED === 'true')
const CONSUMER_POLL_MS = parseInt(process.env.CONSUMER_POLL_MS || process.env.CONSUMER_POLL_INTERVAL_MS || '2000', 10)
const CLOUD_FUNC_URL = process.env.CLOUD_FUNC_URL || ''
const PUSH_TOKEN = process.env.PUSH_TOKEN || ''
// Retry/backoff config
const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '5', 10)
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 })
try {
await fs.access(DEVICES_FILE)
} catch (e) {
await fs.writeFile(DEVICES_FILE, '[]', 'utf8')
}
} catch (e) {
console.error('无法创建数据目录:', e)
process.exit(1)
}
}
async function readDevices() {
try {
const txt = await fs.readFile(DEVICES_FILE, 'utf8')
return JSON.parse(txt || '[]')
} catch (e) {
return []
}
}
async function writeDevices(devices) {
await fs.writeFile(DEVICES_FILE, JSON.stringify(devices, null, 2), 'utf8')
}
async function supaFetch(path, opts = {}) {
const url = `${SUPA_URL.replace(/\/$/, '')}/rest/v1/${path}`
// 默认只发送 apikey。
// 只有在显式要求 Bearer环境变量 SUPA_USE_BEARER=true才添加 Authorization 头。
// 注意:在一些自托管 Supabase/Kong 场景中apikey 可用于 Kong 的 key-auth
// 但 PostgREST 可能无法解码我们手动附加的 Bearer JWTJWT_SECRET 不一致会触发 PGRST301
const headers = Object.assign({}, opts.headers || {}, {
apikey: SUPA_KEY,
Accept: 'application/json'
})
const sendBearer = (process.env.SUPA_USE_BEARER === 'true')
if (sendBearer) headers.Authorization = `Bearer ${SUPA_KEY}`
try {
console.log('supaFetch ->', url)
const resp = await fetchImpl(url, Object.assign({}, opts, { headers }))
if (!resp.ok) {
let txt = ''
try { txt = await resp.text() } catch (e) { txt = `<unable to read body: ${e}>` }
console.warn('supaFetch failed', resp.status, url, txt)
} else {
// optional: peek at small JSON responses for debugging
try {
const clone = resp.clone()
const maybeText = await clone.text()
if (maybeText && maybeText.length < 2000) console.log('supaFetch response preview:', maybeText)
} catch (e) {
// ignore preview errors
}
}
return resp
} catch (e) {
console.warn('supaFetch exception', e, url)
throw e
}
}
// Cloud-function-only:
// UNI-PUSH adapter has been disabled. Push delivery should be implemented inside the cloud function.
async function sendToUniPush() {
throw new Error('UNI-PUSH adapter disabled (cloud-function-only mode)')
}
async function getDevicesFromSupabase({ user_id, active } = {}) {
let q = 'push_devices'
const params = []
if (user_id) params.push(`user_id=eq.${encodeURIComponent(user_id)}`)
if (active != null) params.push(`is_active=eq.${encodeURIComponent(active ? 'true' : 'false')}`)
const suffix = params.length ? `?${params.join('&')}` : ''
const resp = await supaFetch(`${q}${suffix}`, { method: 'GET' })
if (!resp.ok) {
const txt = await resp.text()
throw new Error(`supabase get devices failed ${resp.status} ${txt}`)
}
const data = await resp.json()
return data
}
async function getMerchantDevicesFromSupabase({ merchant_id, active } = {}) {
let q = 'push_devices'
const params = []
if (merchant_id) params.push(`merchant_id=eq.${encodeURIComponent(merchant_id)}`)
if (active != null) params.push(`is_active=eq.${encodeURIComponent(active ? 'true' : 'false')}`)
const suffix = params.length ? `?${params.join('&')}` : ''
const resp = await supaFetch(`${q}${suffix}`, { method: 'GET' })
if (!resp.ok) {
const txt = await resp.text()
throw new Error(`supabase get merchant devices failed ${resp.status} ${txt}`)
}
const data = await resp.json()
return data
}
async function start() {
await ensureDataDir()
const app = express()
app.use(cors())
app.use(bodyParser.json())
// 部署路由与简单鉴权(若设置了 DEPLOY_BEARER 或 DEPLOY_TOKEN 环境变量)
try {
const deployRouter = require('./routes/deploy')
const DEPLOY_BEARER = process.env.DEPLOY_BEARER || process.env.DEPLOY_TOKEN || process.env.ADMIN_DEPLOY_TOKEN || ''
app.use('/api/v1', (req, res, next) => {
if (req.path === '/deploy-cloudfunc' && DEPLOY_BEARER) {
const auth = req.headers.authorization || ''
if (auth !== `Bearer ${DEPLOY_BEARER}`) return res.status(401).json({ ok: false, error: 'unauthorized' })
}
next()
}, deployRouter)
} catch (e) {
console.warn('deploy route not mounted (missing file?):', e && e.message)
}
app.get('/health', (req, res) => res.json({ ok: true }))
// ---- Consumer: 从 express_notifications 拉取 pending 记录并调用云函数 ----
async function fetchPendingNotifications(limit = 5) {
// 只在配置了 Supabase REST 时工作
if (!SUPA_URL || !SUPA_KEY) return []
try {
// 查询:
// - 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=(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) {
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) {
console.warn('fetchPendingNotifications error', e)
return []
}
}
async function claimNotification(id) {
try {
const body = { send_status: 'processing', updated_at: new Date().toISOString() }
// 仅当当前仍为 pending 或 retrying 且到期时,才抢占。
// 为了避免在 PATCH 中使用复杂 or= 逻辑树导致匹配失败,这里拆成两次尝试。
const now = new Date().toISOString()
const attempts = [
// 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)) {
if (j.length > 0) return j[0]
// debug hint: matched 0 rows
try { console.log('claimNotification: updated 0 rows for', path) } catch (e) {}
continue
}
if (j && j.id) return j
}
return null
} catch (e) {
console.warn('claimNotification error', e)
return null
}
}
async function updateNotificationStatus(id, status, note) {
try {
if (!id) return
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) }
}
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 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 } }
// Treat explicit business-level failures as errors even when HTTP is 2xx.
// Many cloud functions return `{ ok: false, ... }` with HTTP 200.
const businessOk = (() => {
if (!json || typeof json !== 'object') return true
if (json.ok === false) return false
// uniCloud/uni-push demo often returns { errCode: 0|..., errMsg: '...' }
if (typeof json.errCode === 'number') return json.errCode === 0
if (typeof json.errCode === 'string' && json.errCode.trim() !== '') return json.errCode === '0'
return true
})()
return { ok: resp.ok && businessOk, httpOk: resp.ok, status: resp.status, body: json }
} catch (e) {
return { ok: false, error: String(e) }
}
}
async function processNotificationRecord(rec) {
if (!rec || !rec.id) return
// claim the record (set processing) to avoid races
const claimed = await claimNotification(rec.id)
if (!claimed) return // someone else claimed or failed
const aud = claimed.aud
const recipient_id = claimed.recipient_id
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 {
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') {
const resp = await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(recipient_id)}&is_active=eq.true`, { method: 'GET' })
if (resp.ok) {
const devs = await resp.json().catch(() => [])
targets = devs.map(d => d.cid)
}
}
}
} catch (e) {
console.warn('processNotificationRecord fetch devices failed', e)
}
if (!targets || targets.length === 0) {
await updateNotificationStatus(claimed.id, 'no-targets', 'no active devices')
return
}
// Cloud-function-only: always POST per cid to CLOUD_FUNC_URL
let allOk = true
let lastNote = ''
if (CLOUD_FUNC_URL) {
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
const err = c.error ? String(c.error) : ''
const bodyStr = c.body ? JSON.stringify(c.body) : ''
lastNote = `cloudfunc failed: status=${c.status || ''} httpOk=${c.httpOk === true} ${err} ${bodyStr}`.trim().substring(0, 1000)
break
}
}
} else {
allOk = false
lastNote = 'no CLOUD_FUNC_URL configured'
}
if (allOk) {
await updateNotificationStatus(claimed.id, 'success', null)
} else {
// failure -> increment retry_count, set last_error and next_attempt_at or mark failed
const currentRetry = parseInt(claimed.retry_count || 0, 10)
const nextRetry = currentRetry + 1
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({ 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
let delay = RETRY_INITIAL_MS * Math.pow(RETRY_FACTOR, currentRetry)
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({ 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) }
}
}
}
async function consumerOnce() {
try {
const pending = await fetchPendingNotifications(5)
if (!pending || pending.length === 0) return
for (const rec of pending) {
try { await processNotificationRecord(rec) } catch (e) { console.warn('consumer process error', e) }
}
} catch (e) {
console.warn('consumerOnce error', e)
}
}
if (CONSUMER_ENABLED) {
if (!SUPA_URL || !SUPA_KEY) console.warn('Consumer enabled but SUPA_URL/SUPA_KEY not configured; consumer will not run against Supabase.')
else console.log('Notification consumer enabled, poll interval(ms)=', CONSUMER_POLL_MS)
setInterval(consumerOnce, CONSUMER_POLL_MS)
}
// ---- end consumer ----
// 注册或更新设备
app.post('/api/v1/push/register', async (req, res) => {
const { cid, user_id, merchant_id, platform, appid, registration_source } = req.body || {}
if (!cid) return res.status(400).json({ error: 'cid required' })
const devices = await readDevices()
let found = devices.find(d => d.cid === cid)
const now = new Date().toISOString()
if (found) {
found.user_id = user_id ?? found.user_id
found.merchant_id = merchant_id ?? found.merchant_id
found.platform = platform ?? found.platform
found.appid = appid ?? found.appid
found.registration_source = registration_source ?? found.registration_source
found.updated_at = now
found.active = true
} else {
found = {
cid,
user_id: user_id ?? null,
merchant_id: merchant_id ?? null,
platform: platform ?? null,
appid: appid ?? 'default',
registration_source: registration_source ?? null,
created_at: now,
updated_at: now,
active: true
}
devices.push(found)
}
await writeDevices(devices)
// 如果配置了 Supabase则尝试写入 supabase 表(异步记录错误)
if (SUPA_URL && SUPA_KEY) {
(async () => {
try {
const body = {
cid: found.cid,
user_id: found.user_id,
merchant_id: found.merchant_id,
platform: found.platform,
appid: found.appid || 'default',
is_active: true,
last_seen_at: found.updated_at
}
if (found.registration_source) body.registration_source = found.registration_source
const resp = await supaFetch(`push_devices?on_conflict=appid,cid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify(body)
})
if (!resp.ok) {
const txt = await resp.text()
console.warn('Supabase upsert push_device failed:', resp.status, txt)
}
} catch (e) {
console.warn('Supabase upsert exception:', e)
}
})()
}
return res.json({ ok: true, cid, user_id: found.user_id || null, merchant_id: found.merchant_id || null })
})
// 注销设备(可选移除或置为 inactive
app.post('/api/v1/push/unregister', async (req, res) => {
const { cid, user_id, merchant_id } = req.body || {}
if (!cid && !user_id && !merchant_id) return res.status(400).json({ error: 'cid or user_id or merchant_id required' })
let devices = await readDevices()
if (cid) {
devices = devices.map(d => d.cid === cid ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
} else if (user_id) {
devices = devices.map(d => d.user_id === user_id ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
} else if (merchant_id) {
devices = devices.map(d => d.merchant_id === merchant_id ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
}
await writeDevices(devices)
// 同步到 Supabase如果可用
if (SUPA_URL && SUPA_KEY) {
(async () => {
try {
const now = new Date().toISOString()
if (cid) {
await supaFetch(`push_devices?cid=eq.${encodeURIComponent(cid)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ is_active: false, updated_at: now })
})
} else if (user_id) {
await supaFetch(`push_devices?user_id=eq.${encodeURIComponent(user_id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ is_active: false, updated_at: now })
})
} else if (merchant_id) {
await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(merchant_id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ is_active: false, updated_at: now })
})
}
} catch (e) {
console.warn('Supabase unregister exception:', e)
}
})()
}
return res.json({ ok: true })
})
// 列出设备
app.get('/api/v1/push/devices', async (req, res) => {
const { user_id, merchant_id, active } = req.query
try {
if (SUPA_URL && SUPA_KEY) {
let devices = []
if (merchant_id) devices = await getMerchantDevicesFromSupabase({ merchant_id, active: active == null ? undefined : (active === 'true') })
else devices = await getDevicesFromSupabase({ user_id, active: active == null ? undefined : (active === 'true') })
return res.json({ ok: true, total: devices.length, data: devices })
}
let devices = await readDevices()
if (user_id) devices = devices.filter(d => String(d.user_id) === String(user_id))
if (merchant_id) devices = devices.filter(d => String(d.merchant_id) === String(merchant_id))
if (active != null) devices = devices.filter(d => String(!!d.active) === String(active === 'true'))
res.json({ ok: true, total: devices.length, data: devices })
} catch (e) {
console.warn('list devices failed:', e)
res.status(500).json({ ok: false, error: String(e) })
}
})
// 发送推送mock 或代理到真实 provider当环境变量 PUSH_PROXY_URL 设置时会代理请求)
app.post('/api/v1/push/send', async (req, res) => {
const { cids, user_id, notification, payload } = req.body || {}
if ((!cids || cids.length === 0) && !user_id) return res.status(400).json({ error: 'cids or user_id required' })
let targets = []
if (cids && cids.length > 0) targets = cids
else if (user_id) {
if (SUPA_URL && SUPA_KEY) {
try {
const devices = await getDevicesFromSupabase({ user_id, active: true })
targets = devices.map(d => d.cid)
} catch (e) {
console.warn('Supabase query devices failed, falling back to local:', e)
const devices = await readDevices()
targets = devices.filter(d => String(d.user_id) === String(user_id) && d.active).map(d => d.cid)
}
} else {
const devices = await readDevices()
targets = devices.filter(d => String(d.user_id) === String(user_id) && d.active).map(d => d.cid)
}
}
if (!CLOUD_FUNC_URL) return res.status(500).json({ ok: false, error: 'CLOUD_FUNC_URL not configured' })
console.log('send handler (cloud function only) targets count:', targets.length)
const calls = await Promise.all((targets || []).map(cid => invokeCloudFuncForCid(CLOUD_FUNC_URL, PUSH_TOKEN, cid, notification && notification.title, notification && (notification.body || notification.content), payload)))
const allOk = calls.every(c => c && c.ok)
return res.status(allOk ? 200 : 502).json({ ok: allOk, sent: targets.length, results: calls })
})
// 创建通知并基于数据库记录推送(写入 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)
}
// Cloud-function-only:
// - This endpoint writes to express_notifications.
// - Actual push delivery is performed by the consumer that polls express_notifications.
return res.json({ ok: true, queued: true, notification_id: record && record.id, targets: targets.length })
} 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: CLOUD_FUNC_URL configured?', !!process.env.CLOUD_FUNC_URL)
console.log('ENV: ENABLE_CONSUMER=', process.env.ENABLE_CONSUMER)
// // 可选:在启动时自动运行部署脚本以打包并上传云函数
// try {
// const AUTO_DEPLOY = (process.env.AUTO_DEPLOY_ON_START === 'true' || process.env.AUTO_DEPLOY_ON_START === '1')
// const DEPLOY_BEARER = process.env.DEPLOY_BEARER || process.env.DEPLOY_TOKEN || process.env.ADMIN_DEPLOY_TOKEN || ''
// if (AUTO_DEPLOY) {
// if (!DEPLOY_BEARER) {
// console.warn('AUTO_DEPLOY_ON_START enabled but no DEPLOY_BEARER found; skipping auto deploy for safety.')
// } else {
// const argsEnv = process.env.AUTO_DEPLOY_ARGS ? process.env.AUTO_DEPLOY_ARGS.split(' ') : []
// const deployScript = path.join(__dirname, 'tools', 'deploy-cloudfunc.js')
// const nodeArgs = [deployScript].concat(argsEnv)
// console.log('Auto-deploy: spawning node', nodeArgs.join(' '))
// const child = spawn(process.execPath, nodeArgs, { cwd: __dirname, env: Object.assign({}, process.env), stdio: ['ignore', 'pipe', 'pipe'] })
// child.stdout.on('data', d => console.log('[auto-deploy stdout]', d.toString().trim()))
// child.stderr.on('data', d => console.error('[auto-deploy stderr]', d.toString().trim()))
// child.on('exit', (code, signal) => console.log(`Auto-deploy process exited with code=${code} signal=${signal}`))
// child.on('error', e => console.error('Auto-deploy spawn error', e))
// }
// }
// } catch (e) {
// console.warn('auto-deploy runtime error', e)
// }
})
}
start().catch(e => {
console.error('启动失败:', e)
process.exit(1)
})