Files
medical-mall/server/push-server.js
2026-02-25 09:38:01 +08:00

257 lines
9.2 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.
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)
})