// 米家客户端单例 + 认证状态 + 扫码流程状态机 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 分钟 TTL, /status 不每次打米家账号服务) let authCache = { status: 'unknown', lastError: '', checkedAt: 0 } 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 (刷新会自动处理 30 天过期) // force=true 忽略 TTL 强查 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 = '' try { if (!api.available) throw new Error('无授权凭证') await api.refreshToken() status = 'ok' } catch (e) { status = 'expired' lastError = e?.message || String(e) } authCache = { status, lastError, checkedAt: now } await persistAccount(status, lastError) return authCache } // ==================== 扫码重授权流程 (模块级单例) ==================== // 状态: idle | waiting(已出二维码等扫码) | failed // 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 } export function getQrState() { return { ...qrState } } export function getQrImageUrl() { return qrState.qrImageUrl || '' } export async function startQrLogin() { if (qrState.running) return getQrState() const api = getApi() qrState.running = true qrState.canceled = false qrState.lastError = '' qrState.qrImageUrl = '' qrState.startedAt = Date.now() ;(async () => { 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) } } finally { qrState.running = false } })() return getQrState() } export function cancelQrLogin() { qrState.canceled = true if (qrState.running) { // 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果 } else if (qrState.state !== 'failed') { qrState.state = 'idle' } return getQrState() }