iboard/server/routes/ota.js
2026-08-07 09:49:26 +08:00

111 lines
3.8 KiB
JavaScript

// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定)
// GET /api/ota/check/:current_sequence
// - 客户端传当前 sequence, 服务端判断是否有更新
// - 有: 200 + manifest JSON
// - 无: 204 No Content
// GET /api/ota/bundle/:filename
// - 下载前端 bundle (tar.gz), 供 manifest.url 引用
// GET /api/ota/manifest-web
// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用)
//
// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放:
// - latest.json (由 scripts/build-ota.mjs 生成)
// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size }
// - <filename> (tar.gz 升级包)
import { Router } from 'express'
import fs from 'node:fs'
import path from 'node:path'
const router = Router()
function resolveOtaDir() {
const dir = process.env.OTA_PACKAGE_DIR
? path.resolve(process.env.OTA_PACKAGE_DIR)
: path.resolve(process.cwd(), 'ota-package')
return dir
}
function readLatest() {
const p = path.join(resolveOtaDir(), 'latest.json')
if (!fs.existsSync(p)) return null
try {
return JSON.parse(fs.readFileSync(p, 'utf8'))
} catch (e) {
console.error('[ota] latest.json 解析失败:', e.message)
return null
}
}
// GET /api/ota/check/:current_sequence
// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式
router.get('/ota/check/:current_sequence', (req, res) => {
const currentSeq = Number(req.params.current_sequence)
if (!Number.isFinite(currentSeq) || currentSeq < 0) {
return res.status(400).json({ error: '无效的 sequence' })
}
const latest = readLatest()
if (!latest) {
// 无可用包, 按约定返回 204
return res.status(204).end()
}
// 不新于当前则 204
if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) {
return res.status(204).end()
}
// 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤)
// 客户端传 binary_version query: 暂不强制, 让客户端兜底
const dir = resolveOtaDir()
const base = process.env.OTA_PUBLIC_BASE || ''
res.json({
version: latest.version,
sequence: latest.sequence,
min_binary_version: latest.min_binary_version || '0.0.0',
url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
signature: latest.signature,
notes: latest.notes || '',
pub_date: latest.pub_date || new Date().toISOString(),
mandatory: !!latest.mandatory,
bundle_size: latest.bundle_size || 0,
})
})
// GET /api/ota/bundle/:filename - 流式下载 bundle
router.get('/ota/bundle/:filename', (req, res) => {
const dir = resolveOtaDir()
// 防越界: 只取 basename
const name = path.basename(req.params.filename)
const p = path.join(dir, name)
if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' })
const stat = fs.statSync(p)
res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Length', stat.size)
res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"')
fs.createReadStream(p).pipe(res)
})
// GET /api/ota/manifest-web - Web 浏览器用的简化版
// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新
router.get('/ota/manifest-web', (_req, res) => {
const latest = readLatest()
if (!latest) return res.json({ available: false })
res.json({
available: true,
version: latest.version,
build_id: latest.build_id || latest.version,
note: latest.notes || '',
size: latest.bundle_size || 0,
})
})
// GET /api/ota/info - 诊断/自检
router.get('/ota/info', (_req, res) => {
const dir = resolveOtaDir()
const latest = readLatest()
res.json({
ota_dir: dir,
latest,
})
})
export default router