import mysql, { type Pool, type RowDataPacket } from 'mysql2/promise' import { type BatteryInfo, type BatteryInfoSourceRow, toBatteryInfo } from '@/domain/battery' import { env } from '@/env' const historyLimit = 500 const predictionHistoryLimit = 10 const dashboardLatestLimit = 100 type BatteryInfoMysqlRow = RowDataPacket & BatteryInfoSourceRow type CountMysqlRow = RowDataPacket & { total: number lowPower: number | string | null charging: number | string | null } export type BatteryListSort = 'createdAtDesc' | 'createdAtAsc' | 'powerDesc' | 'powerAsc' export type LatestBatteryPageInput = { pageSize: number cursor?: string search?: string lowPower?: boolean powerStatus?: 0 | 1 | 2 sort?: BatteryListSort } export type LatestBatteryPage = { items: BatteryInfo[] nextCursor: string | null total?: number lowPower?: number charging?: number } type PageCursor = { sort: BatteryListSort createTime: string id: number power?: number } let pool: Pool | undefined function getBatteryPool() { pool ??= mysql.createPool({ uri: env.DATABASE_URL, waitForConnections: true, connectionLimit: 5, namedPlaceholders: true, }) return pool } export async function closeBatteryPool() { if (!pool) return await pool.end() pool = undefined } const sourceColumns = ` id, user_id AS userId, mac, dev_model AS devModel, dev_name AS devName, is_low_power AS isLowPower, power_status AS powerStatus, power, create_time AS createTime, remark ` const normalizedColumns = ` id, userId, mac, devModel, devName, isLowPower, powerStatus, power, createTime, remark ` const latestRecordPredicate = ` NOT EXISTS ( SELECT 1 FROM ls_battery_info AS newer_record WHERE newer_record.mac = current_record.mac AND ( newer_record.create_time > current_record.create_time OR (newer_record.create_time = current_record.create_time AND newer_record.id > current_record.id) ) ) ` const orderByBySort: Record = { createdAtDesc: 'current_record.create_time DESC, current_record.id DESC', createdAtAsc: 'current_record.create_time ASC, current_record.id ASC', powerDesc: 'current_record.power DESC, current_record.create_time DESC, current_record.id DESC', powerAsc: 'current_record.power ASC, current_record.create_time DESC, current_record.id DESC', } function toNumber(value: number | string | null | undefined) { if (value === null || value === undefined) return 0 return Number(value) } function encodeCursor(item: BatteryInfo, sort: BatteryListSort) { const cursor: PageCursor = { sort, createTime: item.createTime, id: item.id, power: sort === 'powerAsc' || sort === 'powerDesc' ? item.power : undefined, } return Buffer.from(JSON.stringify(cursor)).toString('base64url') } function decodeCursor(value: string | undefined, sort: BatteryListSort): PageCursor | null { if (!value) return null try { const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial if (decoded.sort !== sort || typeof decoded.createTime !== 'string' || typeof decoded.id !== 'number') return null if ((sort === 'powerAsc' || sort === 'powerDesc') && typeof decoded.power !== 'number') return null return decoded as PageCursor } catch { return null } } function escapeLike(value: string) { return value.replace(/[\\%_]/g, (match) => `\\${match}`) } function normalizeCursorDateTime(value: string) { return value.includes('T') ? value.slice(0, 19).replace('T', ' ') : value } function createLatestWhere(input: LatestBatteryPageInput, cursor: PageCursor | null) { const clauses = [latestRecordPredicate] const params: Record = {} if (input.search) { clauses.push( "(current_record.mac LIKE :search ESCAPE '\\\\' OR current_record.dev_name LIKE :search ESCAPE '\\\\' OR current_record.dev_model LIKE :search ESCAPE '\\\\')", ) params.search = `%${escapeLike(input.search)}%` } if (input.lowPower !== undefined) { clauses.push('current_record.is_low_power = :lowPower') params.lowPower = input.lowPower ? 'true' : 'false' } if (input.powerStatus !== undefined) { clauses.push('current_record.power_status = :powerStatus') params.powerStatus = input.powerStatus } if (cursor) { params.cursorCreateTime = normalizeCursorDateTime(cursor.createTime) params.cursorId = cursor.id switch (input.sort ?? 'createdAtDesc') { case 'createdAtAsc': clauses.push( '(current_record.create_time > :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id > :cursorId))', ) break case 'powerDesc': params.cursorPower = cursor.power ?? 0 clauses.push( '(current_record.power < :cursorPower OR (current_record.power = :cursorPower AND (current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))))', ) break case 'powerAsc': params.cursorPower = cursor.power ?? 0 clauses.push( '(current_record.power > :cursorPower OR (current_record.power = :cursorPower AND (current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))))', ) break case 'createdAtDesc': clauses.push( '(current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))', ) break } } return { whereSql: clauses.map((clause) => `(${clause})`).join(' AND '), params } } export async function getBatteryHistory(mac: string): Promise { const [rows] = await getBatteryPool().query( ` SELECT ${sourceColumns} FROM ls_battery_info WHERE mac = :mac ORDER BY create_time DESC, id DESC LIMIT :limit `, { mac, limit: historyLimit }, ) return rows.map(toBatteryInfo) } export async function getBatteryPredictionHistory(mac: string): Promise { const [rows] = await getBatteryPool().query( ` SELECT ${sourceColumns} FROM ls_battery_info WHERE mac = :mac ORDER BY create_time DESC, id DESC LIMIT :limit `, { mac, limit: predictionHistoryLimit }, ) return rows.map(toBatteryInfo).reverse() } export async function getBatteryPredictionHistories(macAddresses: string[]): Promise> { if (macAddresses.length === 0) return new Map() const params = Object.fromEntries(macAddresses.map((mac, index) => [`mac${index}`, mac])) const placeholders = macAddresses.map((_, index) => `:mac${index}`).join(', ') const [rows] = await getBatteryPool().query( ` SELECT ${normalizedColumns} FROM ( SELECT ${sourceColumns}, ROW_NUMBER() OVER (PARTITION BY mac ORDER BY create_time DESC, id DESC) AS history_rank FROM ls_battery_info WHERE mac IN (${placeholders}) ) AS ranked_history WHERE ranked_history.history_rank <= :limit ORDER BY ranked_history.mac ASC, ranked_history.createTime ASC, ranked_history.id ASC `, { ...params, limit: predictionHistoryLimit }, ) const histories = new Map() for (const item of rows.map(toBatteryInfo)) { histories.set(item.mac, [...(histories.get(item.mac) ?? []), item]) } return histories } export async function getLatestBatteryPage(input: LatestBatteryPageInput): Promise { const sort = input.sort ?? 'createdAtDesc' const pageSize = Math.min(Math.max(input.pageSize, 1), 100) const cursor = decodeCursor(input.cursor, sort) const { whereSql, params } = createLatestWhere({ ...input, sort, pageSize }, cursor) const countWhere = createLatestWhere({ ...input, sort, pageSize }, null) const queryLimit = pageSize + 1 const [rows] = await getBatteryPool().query( ` SELECT ${sourceColumns} FROM ls_battery_info AS current_record WHERE ${whereSql} ORDER BY ${orderByBySort[sort]} LIMIT :limit `, { ...params, limit: queryLimit }, ) const pageItems = rows.slice(0, pageSize).map(toBatteryInfo) const lastPageItem = pageItems.at(-1) const nextCursor = rows.length > pageSize && lastPageItem ? encodeCursor(lastPageItem, sort) : null const [countRows] = await getBatteryPool().query( ` SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN current_record.is_low_power = 'true' THEN 1 ELSE 0 END), 0) AS lowPower, COALESCE(SUM(CASE WHEN current_record.power_status = 1 THEN 1 ELSE 0 END), 0) AS charging FROM ls_battery_info AS current_record WHERE ${countWhere.whereSql} `, countWhere.params, ) const counts = countRows[0] return { items: pageItems, nextCursor, total: toNumber(counts?.total), lowPower: toNumber(counts?.lowPower), charging: toNumber(counts?.charging), } } export async function getLatestBatteryPerDevice(limit = dashboardLatestLimit): Promise { const [rows] = await getBatteryPool().query( ` SELECT ${sourceColumns} FROM ls_battery_info AS current_record WHERE ${latestRecordPredicate} ORDER BY current_record.create_time DESC, current_record.id DESC LIMIT :limit `, { limit: Math.min(Math.max(limit, 1), dashboardLatestLimit) }, ) return rows.map(toBatteryInfo) }