Files
Wardrobe/server/routes/ota.js
T
jif2.zhang 9a02886553
Docker Build and Push / build-image (push) Successful in 1m54s
Android Build & Publish / build-android (push) Successful in 22m18s
feat(ota): 新增 OTA-only 热更通道; 修底部导航椭圆 FAB/点击高亮, 顶部内缩减半
2026-09-16 11:17:21 +08:00

173 lines
7.0 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 升级包)
//
// 只更新 OTA 升级包 (不重建镜像/APK) 的通道:
// POST /api/ota/admin/bundle (mounted at /api)
// - Header x-ota-token 必须等于环境变量 OTA_ADMIN_TOKEN (CI 从配置中心注入)
// - body = tar.gz 原始字节 (Content-Type: application/gzip)
// - 元数据走 header: x-ota-seq(>当前), x-ota-sha256, x-ota-signature(非空),
// x-ota-version, x-ota-min-bin, x-ota-notes
// - 校验通过后覆盖写 OTA_PACKAGE_DIR/latest.json + wardrobe-ota.tar.gz
// - 进程内每次请求现读文件, 无需重启; 配合 -v wardrobe-ota:/app/ota-package 卷可持久化
import { Router, raw } from 'express'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
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()
}
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 比较, 有新版本就提示用户刷新
// 比对依据: 当前部署的 dist/.build-id (vite 构建时写入), 与浏览器内置 BUILD_ID 同源;
// 当服务端换了新 dist, 老标签页拿到的 build_id 变化 → 提示刷新
router.get('/ota/manifest-web', (_req, res) => {
const latest = readLatest()
if (!latest) return res.json({ available: false })
// 优先用"当前托管 dist"的 build_id (部署时 dist/.build-id 与浏览器页面同一次构建)
let buildId = null
try {
buildId = fs
.readFileSync(path.join(path.resolve(process.cwd(), 'dist'), '.build-id'), 'utf8')
.trim()
} catch { /* dist 未构建或无该文件时忽略 */ }
res.json({
available: true,
version: latest.version,
build_id: buildId || latest.build_id || latest.version,
note: latest.notes || '',
size: latest.bundle_size || 0,
})
})
// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方
// POST /api/ota/admin/bundle - 只更新 OTA 升级包, 不重建镜像/APK
// .gitea/workflows/ota-only.yml 在构建+签名后调用此接口, 直推运行中的容器
router.post(
'/ota/admin/bundle',
raw({ type: 'application/gzip', limit: '32mb' }),
(req, res) => {
const expected = process.env.OTA_ADMIN_TOKEN || ''
const token = String(req.headers['x-ota-token'] || '')
if (!expected || token !== expected) {
return res.status(401).json({ error: 'unauthorized' })
}
const seq = Number(req.headers['x-ota-seq'])
if (!Number.isFinite(seq) || seq <= 0) {
return res.status(400).json({ error: '无效 sequence' })
}
const body = req.body
if (!Buffer.isBuffer(body) || body.length === 0) {
return res.status(400).json({ error: '空 bundle' })
}
const sha = String(req.headers['x-ota-sha256'] || '').trim().toLowerCase()
const h = crypto.createHash('sha256').update(body).digest('hex')
if (!sha || sha !== h) {
return res.status(400).json({ error: sha ? 'sha256 不匹配' : '缺少 sha256' })
}
const signature = String(req.headers['x-ota-signature'] || '').trim()
if (!signature) {
return res.status(400).json({ error: '缺少签名' })
}
const dir = resolveOtaDir()
fs.mkdirSync(dir, { recursive: true })
const latest = readLatest()
if (latest && typeof latest.sequence === 'number' && seq <= latest.sequence) {
return res.status(409).json({ error: `sequence 不高于当前`, current: latest.sequence })
}
const manifest = {
version: String(req.headers['x-ota-version'] || '').trim() || '0.1.0-ota',
sequence: seq,
min_binary_version: String(req.headers['x-ota-min-bin'] || '').trim() || '0.0.0',
bundle: 'wardrobe-ota.tar.gz',
bundle_size: body.length,
bundle_sha256: h,
signature,
notes: String(req.headers['x-ota-notes'] || '').trim(),
pub_date: new Date().toISOString(),
mandatory: false,
build_id: 'seq-' + seq,
}
fs.writeFileSync(path.join(dir, 'wardrobe-ota.tar.gz'), body)
fs.writeFileSync(path.join(dir, 'latest.json'), JSON.stringify(manifest, null, 2))
console.log(`[ota] admin 上传 bundle: seq=${seq} size=${body.length} sha256=${h}`)
res.json({ ok: true, sequence: seq, size: body.length })
}
)
export default router