diff --git a/docs/V0.1/环境监控模块设计.md b/docs/V0.1/环境监控模块设计.md index 22783ca..d2db4ac 100644 --- a/docs/V0.1/环境监控模块设计.md +++ b/docs/V0.1/环境监控模块设计.md @@ -205,24 +205,27 @@ server/routes/mihome.js # REST 路由 (authRequired) ### 5.2 认证与「重新授权」 -- 启动时 `login()` 自动尝试加载/刷新 `DiskCache` 凭证;成功则 `status=ok`。 -- 失败(30 天过期 / 风控)→ `MihomeAccount.status=expired`,前端弹授权横幅。 +- 启动时加载 `DiskCache` 凭证,并用一次真实云请求(`getHomesList`)确认 token 仍有效; + 只有该请求通过才 `status=ok`。 +- 失败(30 天过期 / 风控 / 无凭证)→ `MihomeAccount.status=expired`,前端弹授权横幅 + 扫码窗。 - 重新授权流程(服务端驱动,前端轮询): ``` POST /api/mihome/reauthorize - └─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码) - └─ 立即返回 { state, qrImageUrl } + └─ 若已有流程在跑 => 直接复用(同一张二维码继续有效) + └─ 否则后台启动 QRlogin 循环(每轮 120s 超时自动续二维码, 最多续 2 次) + └─ 立即返回 { running, done, state, qrImageUrl } GET /api/mihome/reauthorize/status - └─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? } - └─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步 + └─ 返回 { running, done, state: idle|waiting|failed, qrImageUrl?, lastError? } + └─ done=true 表示扫码成功、凭证已落盘、authCache 已置 ok -POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环 +POST /api/mihome/reauthorize/cancel # 人工取消: 只让前端停止轮询, 后台长轮询自然结束 ``` -- `pendingQr` 为模块级单例,防并发(同一时间只允许一个扫码流程)。 -- 前端在弹窗内 `setInterval 3s` 轮询直到 `ok`,成功后关弹窗并刷新列表。 +- `qrState` 为模块级单例,防并发(同一时间只允许一个扫码流程)。 +- 前端在弹窗内 `setInterval 3s` 轮询,遇 `done=true` 关弹窗并刷新列表;`failed` 则停轮询显示错误。 +- 二维码图片接口在 `authRequired` 之后,`` 带不上 JWT,前端必须 `fetch` 后转 `blob:` URL 显示。 ### 5.3 采集器 collector @@ -261,7 +264,7 @@ POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环 | 方法 | 路径 | 说明 | | --- | --- | --- | -| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果) | +| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果);`?force=1` 跳过 5 分钟认证缓存强查一次 | | GET | `/device/:did/history?range=today` | 当天逐小时(见 §5.6);缺省 `range=today` | | GET | `/device/:did/history?range=month` | 近 30 天逐日(日均 + min/max) | | GET | `/device/:did/history?range=year` | 近 52 周逐周(周均值 + min/max,服务端按日档聚合) | @@ -297,7 +300,12 @@ POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环 1. **授权横幅**(`status !== ok`): `米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `` - 3s 轮询 `/reauthorize/status` → 成功自动关闭并刷新。 + 3s 轮询 `/reauthorize/status` → `done=true` 自动关闭并刷新。 + - `status=expired` 且非网络类错误时,进入页面**自动弹出一次**扫码窗(用户关掉后不再重复弹)。 + - 二维码图片接口在 `authRequired` 后面,`` 带不上 JWT,必须用 `fetch` + `Authorization` + 取二进制再转 `blob:` URL(见 `src/api/mihome.js` 的 `fetchMihomeQrImage()`),否则图片 401、弹窗里一片空白。 + - 认证探测不能只看 `api.refreshToken()`:该库在本地凭证字段齐全时直接返回成功而不请求云端, + 所以 `checkAuth()` 必须真打一次 `getHomesList()` 才能发现 30 天 token 过期。 2. **顶部操作**:`立即同步`(POST `/sync`)。 3. **家庭列表**:`antd-mobile ` 按家庭分组;组展开后显示该家庭 下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。 diff --git a/scripts/mihome-login.mjs b/scripts/mihome-login.mjs index 30db201..15463ea 100644 --- a/scripts/mihome-login.mjs +++ b/scripts/mihome-login.mjs @@ -21,15 +21,15 @@ for (;;) { console.log('[米家] 扫码失败: ' + (s.lastError || '未知')) process.exit(2) } - if (s.state === 'idle' && s.running === false && !s.qrImageUrl) { + if (s.done) { + console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/') + process.exit(0) + } + if (!s.running && !s.qrImageUrl) { // 启动即失败(可能直接完成/或没权限) console.log('[米家] 流程结束(无二维码产生), 请检查日志') process.exit(2) } - if (!s.running && s.qrImageUrl) { - console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/') - process.exit(0) - } if (Date.now() - started > 150000) { console.log('[米家] 超时, 已停止; 可重跑.') process.exit(3) diff --git a/server/mihome/client.js b/server/mihome/client.js index a1cebae..201d9c0 100644 --- a/server/mihome/client.js +++ b/server/mihome/client.js @@ -20,8 +20,11 @@ export function getApi() { return _api } -// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务) +// 认证状态内存缓存 (成功缓存 5 分钟; 失败只缓存 30 秒, 网络/服务恢复后能尽快自愈) +const TTL_OK = 5 * 60 * 1000 +const TTL_BAD = 30 * 1000 let authCache = { status: 'unknown', lastError: '', checkedAt: 0 } +let probing = null // 并发合并: 页面首次渲染时 force 探测 + 可见性刷新可能同时进来 function persistAccount(status, lastError) { return prisma.mihomeAccount @@ -35,31 +38,49 @@ function persistAccount(status, lastError) { }) } -// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期) +// 校验米家是否可用: 有凭证 + 真打一次云接口确认 token 没失效 // force=true 忽略 TTL 强查 +// +// 注意: 不能用 api.refreshToken() 当探针. 该库在本地凭证字段齐全时直接返回 +// { tokenRefreshed: false } (见 @zythum02/mijia-api 的 available getter), +// 根本不发请求 —— 于是 30 天 token 过期后它永远返回成功, 前端就永远看不到 +// 「授权已过期」横幅, 也就不会弹扫码. 必须发一次真实业务请求才算验证. export async function checkAuth(force = false) { const now = Date.now() - if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache - const api = getApi() - let status = 'expired' - let lastError = '' + const ttl = authCache.status === 'ok' ? TTL_OK : TTL_BAD + if (!force && now - authCache.checkedAt < ttl) return authCache + if (probing) return probing + probing = (async () => { + const api = getApi() + let status = 'expired' + let lastError = '' + try { + if (!api.available) throw new Error('无授权凭证') + await api.refreshToken() // 凭证真过期时这里会自动走 passToken 刷新 + await api.getHomesList() // 真正打到米家云, token 失效会抛 APIError + status = 'ok' + } catch (e) { + status = 'expired' + lastError = e?.message || String(e) + } + const changed = authCache.status !== status || authCache.lastError !== lastError + authCache = { status, lastError, checkedAt: Date.now() } + // 状态没变就不写库, 避免 30 秒重试把 DB 刷爆 + if (changed) await persistAccount(status, lastError) + return authCache + })() try { - if (!api.available) throw new Error('无授权凭证') - await api.refreshToken() - status = 'ok' - } catch (e) { - status = 'expired' - lastError = e?.message || String(e) + return await probing + } finally { + probing = null } - authCache = { status, lastError, checkedAt: now } - await persistAccount(status, lastError) - return authCache } // ==================== 扫码重授权流程 (模块级单例) ==================== -// 状态: idle | waiting(已出二维码等扫码) | failed +// state: idle | waiting(已出二维码等扫码) | failed +// done: 只有真正登录成功才置 true, 前端靠它判断「轮询结束 = 授权成功」 // 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB -const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 } +const qrState = { running: false, done: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 } export function getQrState() { return { ...qrState } @@ -70,29 +91,45 @@ export function getQrImageUrl() { } export async function startQrLogin() { + // 已有一轮在跑就直接复用: 同一张二维码继续有效, 不重复开 if (qrState.running) return getQrState() const api = getApi() qrState.running = true - qrState.canceled = false + qrState.done = false + qrState.state = 'idle' qrState.lastError = '' qrState.qrImageUrl = '' qrState.startedAt = Date.now() ;(async () => { + // 二维码有效期 120s (库内长轮询 AbortSignal.timeout), 超时就重开一张, + // 否则用户扫码慢一点只会看到「扫码流程失败」, 还得手动点刷新 + const MAX_RETRY = 2 try { - await api.QRlogin(async (info) => { - qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || '' - qrState.state = 'waiting' - }) - if (qrState.canceled) return - qrState.state = 'idle' - authCache = { status: 'ok', lastError: '', checkedAt: Date.now() } - await persistAccount('ok', '') - } catch (e) { - if (qrState.canceled) { - qrState.state = 'idle' - } else { - qrState.state = 'failed' - qrState.lastError = e?.message || String(e) + for (let attempt = 0; ; attempt++) { + try { + await api.QRlogin(async (info) => { + qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || '' + qrState.state = 'waiting' + }) + // 这里不要因为「用户点了取消」就丢弃结果: 弹窗关了但二维码可能已经被扫, + // 凭证库内部已落盘, 丢掉会让前端继续显示已过期 + qrState.state = 'idle' + qrState.done = true + authCache = { status: 'ok', lastError: '', checkedAt: Date.now() } + await persistAccount('ok', '') + return + } catch (e) { + const msg = e?.message || String(e) + const timedOut = /abort|timeout|timed out/i.test(msg) + if (timedOut && attempt < MAX_RETRY) { + qrState.qrImageUrl = '' + qrState.state = 'idle' + continue + } + qrState.state = 'failed' + qrState.lastError = msg + return + } } } finally { qrState.running = false @@ -102,11 +139,7 @@ export async function startQrLogin() { } export function cancelQrLogin() { - qrState.canceled = true - if (qrState.running) { - // 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果 - } else if (qrState.state !== 'failed') { - qrState.state = 'idle' - } + // 只影响前端是否继续轮询; 后台长轮询没法中断, 让它自然结束 (扫了也照样算成功) + if (!qrState.running && qrState.state !== 'failed') qrState.state = 'idle' return getQrState() } \ No newline at end of file diff --git a/server/mihome/index.js b/server/mihome/index.js index 142e41e..db6fa36 100644 --- a/server/mihome/index.js +++ b/server/mihome/index.js @@ -10,8 +10,9 @@ export { getDevice, todayBuckets, monthBuckets, yearBuckets, deviceOverview } fr export { getSpec, ENV_MODELS } from './specs.js' // GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日) -export async function getStatus() { - const authState = await auth.checkAuth() +// force=true 跳过 5 分钟认证缓存强查一次 (用户主动打开页面/刷新时用) +export async function getStatus(force = false) { + const authState = await auth.checkAuth(force) const [homes, devices, account] = await Promise.all([ prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }), prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }), @@ -56,10 +57,13 @@ export async function getStatus() { })), })) + // 以实时探测结果为准: DB 里的 status 只在实时探测还是 unknown 时兜底, + // 否则过期后残留的 ok 会把「已失效」遮掉, 前端永远不出重新授权横幅. + const liveUnknown = !authState.status || authState.status === 'unknown' return { auth: { - status: account?.status || authState.status, - lastError: account?.lastError || authState.lastError, + status: liveUnknown ? account?.status || 'unknown' : authState.status, + lastError: (liveUnknown ? account?.lastError : authState.lastError) || '', checkedAt: authState.checkedAt, }, capability, diff --git a/server/routes/mihome.js b/server/routes/mihome.js index b5133f5..00dcca1 100644 --- a/server/routes/mihome.js +++ b/server/routes/mihome.js @@ -33,8 +33,9 @@ function guard(handler) { } // 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数) -router.get('/status', guard(async (_req, res) => { - res.json(await getStatus()) +// force=1: 跳过后端 5 分钟认证缓存强查 (前端打开页面时用, 保证会话失效能立刻发现) +router.get('/status', guard(async (req, res) => { + res.json(await getStatus(req.query.force === '1')) })) // 历史: range = today(默认) | month | year diff --git a/src/api/mihome.js b/src/api/mihome.js index fd4d047..e7f58ef 100644 --- a/src/api/mihome.js +++ b/src/api/mihome.js @@ -1,9 +1,10 @@ // 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端) -import { api, getApiBase } from './client.js' +import { api, getApiBase, getToken } from './client.js' // 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性 -export async function getMihomeStatus() { - return api('/mihome/status') +// force=true 让后端跳过 5 分钟认证缓存, 立刻回真实授权状态 +export async function getMihomeStatus(force) { + return api('/mihome/status' + (force ? '?force=1' : '')) } // 历史: range = today | month | year @@ -32,8 +33,18 @@ export async function cancelMihomeAuth() { return api('/mihome/reauthorize/cancel', { method: 'POST' }) } -// 二维码图片地址 (服务端带小米 UA 代理, 避免 CDN 拒连) -export function mihomeQrImageUrl() { - const base = getApiBase() // 同源时为空串 - return base + '/api/mihome/reauthorize/qr-image' +// 二维码图片: 后端接口在 authRequired 后面, 而 无法带 Authorization 头, +// 直接塞地址会 401 (二维码位置一片空白). 这里用 fetch 带 JWT 取回二进制, 再转 blob URL 给 . +export async function fetchMihomeQrImage() { + const token = getToken() + const res = await fetch(getApiBase() + '/api/mihome/reauthorize/qr-image', { + headers: token ? { Authorization: 'Bearer ' + token } : {}, + cache: 'no-store', + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || '二维码加载失败 (' + res.status + ')') + } + const blob = await res.blob() + return URL.createObjectURL(blob) } \ No newline at end of file diff --git a/src/pages/monitor/EnvView.jsx b/src/pages/monitor/EnvView.jsx index 5a12eab..eb42649 100644 --- a/src/pages/monitor/EnvView.jsx +++ b/src/pages/monitor/EnvView.jsx @@ -9,7 +9,7 @@ import { import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' import { getMihomeStatus, getMihomeHistory, setMihomeAlias, syncMihome, - startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, mihomeQrImageUrl, + startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, fetchMihomeQrImage, } from '../../api/mihome.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' @@ -21,6 +21,8 @@ const RANGE_OPTIONS = [ const round1 = (v) => (v === null || v === undefined ? null : Math.round(Number(v) * 10) / 10) const pad = (n) => String(n).padStart(2, '0') +// 网络类失败: 不能据此断定米家授权失效 +const NETWORK_ERR_RE = /fetch failed|network|ECONN|ETIMEDOUT|timeout|socket|getaddrinfo|ENOTFOUND|aborted/i export default function EnvView() { const [status, setStatus] = useState(null) @@ -28,13 +30,20 @@ export default function EnvView() { const [syncing, setSyncing] = useState(false) const [refreshTick, setRefreshTick] = useState(0) const [qrOpen, setQrOpen] = useState(false) + const autoPromptedRef = useRef(false) + // 依赖必须为空: 之前依赖 [status] 而成功回调又 setStatus(...), 每次响应都会换掉 load, + // 触发 effect 重新执行 -> 无限重复请求 /status. 首屏/重试用 force 强查, 定时轮询走缓存. const load = useCallback((silent) => { if (!silent) setErr('') - getMihomeStatus() + getMihomeStatus(!silent) .then((d) => { setStatus(d); setErr('') }) - .catch((e) => { if (!silent) { setErr(e.message); if (!status) setStatus({ auth: { status: 'expired' }, homes: [] }) } }) - }, [status]) + .catch((e) => { + if (silent) return + setErr(e.message) + setStatus((prev) => prev || { auth: { status: 'expired' }, homes: [] }) + }) + }, []) useEffect(() => { load(false) @@ -43,6 +52,16 @@ export default function EnvView() { }, [load]) useRefreshOnVisible(() => load(true)) + // 会话失效时直接把扫码弹窗弹出来 (每次挂载只自动弹一次, 用户取消后不再骚扰). + // 网络抖动也会让后端判成 expired, 这类错误不自动弹, 只留横幅提示, 避免误弹. + useEffect(() => { + if (autoPromptedRef.current) return + if (status?.auth?.status !== 'expired') return + if (NETWORK_ERR_RE.test(status.auth.lastError || '')) return + autoPromptedRef.current = true + setQrOpen(true) + }, [status]) + const doSync = async () => { setSyncing(true) try { @@ -253,7 +272,9 @@ function EnvChart({ range, buckets }) { function AuthPopup({ open, onClose, onOk }) { const [state, setState] = useState(null) const timerRef = useRef(null) - const [imgTs, setImgTs] = useState(0) + const [imgUrl, setImgUrl] = useState('') + const imgSigRef = useRef('') + const imgUrlRef = useRef('') const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } } @@ -270,12 +291,9 @@ function AuthPopup({ open, onClose, onOk }) { getMihomeAuthStatus() .then((s) => { setState(s) - setImgTs(Date.now()) // 让二维码 重新拉取 (换码刷新) - if (s.state === 'failed') stopPoll() - if (s.state === 'idle' && s.running === false && s.qrImageUrl) { - stopPoll() - onOk() - } + if (s.state === 'failed') { stopPoll(); return } + // done 只由真正登录成功置位; 轮询结束但没 done(用户取消/超时) 不能当成功 + if (!s.running && s.done) { stopPoll(); onOk() } }) .catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) }) } @@ -289,13 +307,45 @@ function AuthPopup({ open, onClose, onOk }) { return stopPoll }, [open]) + // 二维码变化时才重新拉图 (轮询 3s 一次, 用字符串签名去重, 避免每轮都请求图片) + const qrSig = state?.state === 'waiting' ? state.qrImageUrl || '' : '' + useEffect(() => { + if (!qrSig) { + imgSigRef.current = '' + setImgUrl('') + return + } + if (qrSig === imgSigRef.current) return + imgSigRef.current = qrSig + let alive = true + setImgUrl('') + fetchMihomeQrImage() + .then((url) => { + if (!alive) { URL.revokeObjectURL(url); return } + if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current) + imgUrlRef.current = url + setImgUrl(url) + }) + .catch((e) => { + if (!alive) return + // 图片拉取失败就停轮询, 让用户点「刷新二维码」重来, 否则下一轮状态会把错误盖掉 + stopPoll() + setState({ state: 'failed', lastError: e.message }) + }) + return () => { alive = false } + }, [qrSig]) + + // 卸载时释放 blob, 防止内存泄漏 + useEffect(() => () => { if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current) }, []) + const cancel = async () => { try { await cancelMihomeAuth() } catch { /* ignore */ } stopPoll() onClose() } - const waiting = state?.state === 'waiting' && state?.qrImageUrl + const waiting = state?.state === 'waiting' && !!imgUrl + const loading = state?.state === 'waiting' && !imgUrl const failed = state?.state === 'failed' return ( @@ -303,7 +353,7 @@ function AuthPopup({ open, onClose, onOk }) { 米家扫码授权 {waiting ? ( - + ) : ( {failed ? '扫码流程失败' : '正在生成二维码...'} )} @@ -311,7 +361,7 @@ function AuthPopup({ open, onClose, onOk }) { 打开米家 App → 我的 → 扫码登录,扫码后自动恢复 取消 - start()}>刷新二维码 + start()}>刷新二维码