Files
iboard/server/routes/mihome.js
T
jif2.zhang 3de002aae8 修复米家会话失效后不弹登录二维码
根因一: 过期检测形同虚设
- @zythum02/mijia-api 的 refreshToken() 在本地凭证字段齐全时直接返回成功,
  根本不发请求, 因此 checkAuth() 永远判定 ok, 过期后既不出横幅也不出扫码入口
- checkAuth() 改为真打一次 getHomesList() 验证 token; 成功缓存 5 分钟,
  失败只缓存 30 秒(便于网络恢复后自愈); 增加并发探测合并; 状态未变不写库
- getStatus() 以实时探测结果为准, DB 里的 status 仅在探测为 unknown 时兜底,
  避免残留的 ok 遮蔽已失效状态

根因二: 二维码图片 401 导致弹窗空白
- /api/mihome/reauthorize/qr-image 在 authRequired 之后, 而 <img src> 无法携带
  Authorization 头, 图片请求被拒, 用户看到的是空白/裂图
- 新增 fetchMihomeQrImage(): 带 JWT fetch 后转 blob URL 给 <img>, 并在卸载/换码时释放

其他修复
- EnvView 的 load 依赖 [status] 且成功回调又 setStatus, 造成 /status 无限重复请求;
  改为空依赖 + 函数式 setStatus; 首屏与重试走 ?force=1 强查
- 会话失效(非网络类错误)时自动弹出扫码窗, 每次挂载仅弹一次
- 扫码状态机新增 done 标记, 只有真正登录成功才算成功, 避免取消后误判
- 二维码 120s 超时自动续码(最多 2 次), 慢扫不再直接失败
- 取消后不再丢弃可能已成功的扫码结果
- scripts/mihome-login.mjs 适配 done 语义
- 同步更新设计文档 §5.2 / §5.4 / §6

注意: 前端需重新构建发布后生效。
2026-10-24 07:26:59 +08:00

94 lines
3.2 KiB
JavaScript

// 环境监控 REST 路由: /api/mihome/* (authRequired)
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import {
getStatus,
getDevice,
todayBuckets,
monthBuckets,
yearBuckets,
syncNow,
startQrLogin,
cancelQrLogin,
getQrState,
getQrImageUrl,
MihomeAuthError,
} from '../mihome/index.js'
import { XIAOMI_APP_UA } from '../mihome/specs.js'
const router = Router()
router.use(authRequired)
// 统一错误边界: 米家授权错误映射为 401, 其余走 500
function guard(handler) {
return async (req, res, next) => {
try {
return await handler(req, res, next)
} catch (e) {
if (e instanceof MihomeAuthError) return res.status(401).json({ error: e.message })
return next(e)
}
}
}
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
// force=1: 跳过后端 5 分钟认证缓存强查 (前端打开页面时用, 保证会话失效能立刻发现)
router.get('/status', guard(async (req, res) => {
res.json(await getStatus(req.query.force === '1'))
}))
// 历史: range = today(默认) | month | year
router.get('/device/:did/history', guard(async (req, res) => {
const device = await getDevice(req.params.did)
if (!device) return res.status(404).json({ error: '设备不存在' })
const range = req.query.range || 'today'
let buckets
if (range === 'month') buckets = await monthBuckets(device)
else if (range === 'year') buckets = await yearBuckets(device)
else buckets = await todayBuckets(device)
res.json({ did: device.did, range: range === 'today' ? 'today' : range, buckets })
}))
// 改别名 (可清空); alias 最长 24 字符
router.put('/device/:did', guard(async (req, res) => {
const device = await getDevice(req.params.did)
if (!device) return res.status(404).json({ error: '设备不存在' })
const alias = typeof req.body?.alias === 'string' ? req.body.alias.trim().slice(0, 24) : ''
const updated = await prisma.mihomeDevice.update({ where: { id: device.id }, data: { alias: alias || null } })
res.json({ did: updated.did, alias: updated.alias || '' })
}))
// 立即同步一次 (小时采样 + 日聚合), 首页「立即同步」
router.post('/sync', guard(async (_req, res) => {
res.json(await syncNow())
}))
// 重授权: 启动扫码 (幂等; 已在跑则返回当前状态)
router.post('/reauthorize', guard(async (_req, res) => {
res.json(await startQrLogin())
}))
// 扫码状态轮询
router.get('/reauthorize/status', guard(async (_req, res) => {
res.json(getQrState())
}))
// 取消扫码
router.post('/reauthorize/cancel', guard(async (_req, res) => {
res.json(cancelQrLogin())
}))
// 代理二维码图片 (带小米 UA, 避免被 CDN 拒)
router.get('/reauthorize/qr-image', guard(async (_req, res) => {
const url = getQrImageUrl()
if (!url) return res.status(404).json({ error: '暂无二维码' })
const r = await fetch(url, { headers: { 'User-Agent': XIAOMI_APP_UA } })
if (!r.ok) throw new Error('二维码拉取失败 ' + r.status)
const buf = Buffer.from(await r.arrayBuffer())
res.set('Content-Type', r.headers.get('content-type') || 'image/png')
res.set('Cache-Control', 'no-store')
res.send(buf)
}))
export default router