feat(api): 支持电池分页和安全预测
This commit is contained in:
+238
-12
@@ -5,8 +5,40 @@ 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
|
||||
|
||||
@@ -41,6 +73,130 @@ const sourceColumns = `
|
||||
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<BatteryListSort, string> = {
|
||||
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<PageCursor>
|
||||
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<string, string | number> = {}
|
||||
|
||||
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<BatteryInfo[]> {
|
||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||
`
|
||||
@@ -71,21 +227,91 @@ export async function getBatteryPredictionHistory(mac: string): Promise<BatteryI
|
||||
return rows.map(toBatteryInfo).reverse()
|
||||
}
|
||||
|
||||
export async function getLatestBatteryPerDevice(): Promise<BatteryInfo[]> {
|
||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(`
|
||||
export async function getBatteryPredictionHistories(macAddresses: string[]): Promise<Map<string, BatteryInfo[]>> {
|
||||
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<BatteryInfoMysqlRow[]>(
|
||||
`
|
||||
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<string, BatteryInfo[]>()
|
||||
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<LatestBatteryPage> {
|
||||
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<BatteryInfoMysqlRow[]>(
|
||||
`
|
||||
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<CountMysqlRow[]>(
|
||||
`
|
||||
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<BatteryInfo[]> {
|
||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||
`
|
||||
SELECT ${sourceColumns}
|
||||
FROM ls_battery_info AS current_record
|
||||
WHERE 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)
|
||||
)
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user