112 lines
3.4 KiB
JavaScript
112 lines
3.4 KiB
JavaScript
// 米家客户端单例 + 认证状态 + 扫码流程状态机
|
|
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()
|
|
} |