修复米家会话失效后不弹登录二维码
根因一: 过期检测形同虚设 - @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 注意: 前端需重新构建发布后生效。
This commit is contained in:
+71
-38
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user