257 lines
9.2 KiB
JavaScript
257 lines
9.2 KiB
JavaScript
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 = `<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
|
||
}
|
||
}
|
||
|
||
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)
|
||
})
|