const express = require('express') const bodyParser = require('body-parser') const cors = require('cors') const fs = require('fs').promises const path = require('path') const fetch = require('node-fetch') const 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 || '' const SUPA_SCHEMA = process.env.SUPA_SCHEMA || 'public' 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}` const headers = Object.assign({}, opts.headers || {}, { apikey: SUPA_KEY, Authorization: `Bearer ${SUPA_KEY}`, Accept: 'application/json' }) try { console.log('supaFetch ->', url) const resp = await fetch(url, Object.assign({}, opts, { headers })) if (!resp.ok) { let txt = '' try { txt = await resp.text() } catch (e) { txt = `` } 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 } } 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 start() { await ensureDataDir() const app = express() app.use(cors()) app.use(bodyParser.json()) app.get('/health', (req, res) => res.json({ ok: true })) // 注册或更新设备 app.post('/api/v1/push/register', async (req, res) => { const { cid, user_id, platform } = 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.platform = platform ?? found.platform found.updated_at = now found.active = true } else { found = { cid, user_id: user_id ?? null, platform: platform ?? 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, platform: found.platform, appid: found.appid || 'default', is_active: true, last_seen_at: found.updated_at } const resp = await supaFetch(`push_devices?on_conflict=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 }) }) // 注销设备(可选移除或置为 inactive) app.post('/api/v1/push/unregister', async (req, res) => { const { cid, user_id } = req.body || {} if (!cid && !user_id) return res.status(400).json({ error: 'cid or user_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) } 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 }) }) } } 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, active } = req.query try { if (SUPA_URL && SUPA_KEY) { const 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 (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) } } // 如果配置了 PUSH_PROXY_URL 则转发到外部推送服务(例如 uni-push2 的服务端 API) const proxyUrl = process.env.PUSH_PROXY_URL || '' const proxyToken = process.env.PUSH_PROXY_TOKEN || '' if (proxyUrl) { try { 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() 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) }) } } // 否则仅记录并返回模拟结果 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 }) }) app.listen(PORT, '0.0.0.0', () => { console.log(`Push server listening on http://0.0.0.0:${PORT}`) }) } start().catch(e => { console.error('启动失败:', e) process.exit(1) })