// 米家客户端单例 + 认证状态 + 扫码流程状态机 import { MijiaAPI } from '@zythum02/mijia-api' import { DiskCache } from '@zythum02/mijia-api/cache/disk-cache' import { prisma } from '../db.js' import { cacheDir } from './cache.js' // 未授权/已过期 (模块内部错误, 路由层做 4xx 映射) export class MihomeAuthError extends Error { constructor(message = '米家未授权或已过期') { super(message) this.name = 'MihomeAuthError' } } let _api = null // 惰性单例: 首次调用时才构造并加载磁盘凭证 export function getApi() { if (!_api) _api = new MijiaAPI(new DiskCache(cacheDir())) return _api } // 认证状态内存缓存 (成功缓存 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 .upsert({ where: { key: 'default' }, create: { key: 'default', status, lastError: lastError || '' }, update: { status, lastError: lastError || '' }, }) .catch((e) => { console.warn('[mihome] 写入授权状态失败(忽略):', e.message) }) } // 校验米家是否可用: 有凭证 + 真打一次云接口确认 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() 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 { return await probing } finally { probing = null } } // ==================== 扫码重授权流程 (模块级单例) ==================== // state: idle | waiting(已出二维码等扫码) | failed // done: 只有真正登录成功才置 true, 前端靠它判断「轮询结束 = 授权成功」 // 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB const qrState = { running: false, done: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 } export function getQrState() { return { ...qrState } } export function getQrImageUrl() { return qrState.qrImageUrl || '' } export async function startQrLogin() { // 已有一轮在跑就直接复用: 同一张二维码继续有效, 不重复开. // 注意: 这里绝不能提前 return 而不清 done —— 上一轮成功时 done=true 会残留, // 前端第一次轮询就拿到 {done:true, running:false}, 立刻误判成"扫码成功"关掉弹窗. if (qrState.running) { const snap = getQrState() return { ...snap, done: false, running: true } } const api = getApi() qrState.running = true qrState.done = false qrState.state = 'idle' qrState.lastError = '' qrState.qrImageUrl = '' qrState.startedAt = Date.now() ;(async () => { // 二维码有效期 120s (库内长轮询 AbortSignal.timeout), 超时就重开一张, // 否则用户扫码慢一点只会看到「扫码流程失败」, 还得手动点刷新 const MAX_RETRY = 2 try { 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 } })() return getQrState() } export function cancelQrLogin() { // 只影响前端是否继续轮询; 后台长轮询没法中断, 让它自然结束 (扫了也照样算成功) if (!qrState.running && qrState.state !== 'failed') qrState.state = 'idle' return getQrState() }