fix(prediction): 缓存不可用预测结果

This commit is contained in:
2026-05-12 00:52:43 +08:00
parent fad890abe1
commit 779c9c2338
2 changed files with 113 additions and 6 deletions
+77
View File
@@ -0,0 +1,77 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import type { BatteryInfo } from '@/domain/battery'
import { MYSQL_BOOLEAN, POWER_STATUS, toBatteryInfo } from '@/domain/battery'
import type { normalizePrediction as normalizePredictionType } from './client'
type PredictionClientModule = typeof import('./client')
const battery = toBatteryInfo({
id: 10,
userId: 7,
mac: 'RING-A03',
devModel: '2401-A',
devName: 'RING-A03',
isLowPower: MYSQL_BOOLEAN.FALSE,
powerStatus: POWER_STATUS.FULL,
power: 94,
createTime: '2026-05-10 23:00:00',
remark: null,
})
const predictionResponse = {
battery_id: 10,
mac: 'RING-A03',
now_soh: 91,
month_soh: 89,
trmonth_soh: 84,
risk_score: null,
risk_level: null,
status: null,
model_name: 'xgboost',
cycles_used: 6,
updated_at: '2026-05-11T00:00:00.000Z',
}
let createPredictionRequest: PredictionClientModule['createPredictionRequest']
let isPredictionForBattery: PredictionClientModule['isPredictionForBattery']
let normalizePrediction: typeof normalizePredictionType
beforeAll(async () => {
process.env.DATABASE_URL = 'mysql://user:password@localhost:3306/database'
process.env.SOH_PREDICTION_API_BASE_URL = 'http://127.0.0.1:8000'
const client = await import('./client')
createPredictionRequest = client.createPredictionRequest
isPredictionForBattery = client.isPredictionForBattery
normalizePrediction = client.normalizePrediction
})
describe('prediction client helpers', () => {
test('normalizes prediction response shape without fake fallback values', () => {
const prediction = normalizePrediction(predictionResponse)
expect(prediction).toEqual({
batteryId: 10,
mac: 'RING-A03',
nowSoh: 91,
monthSoh: 89,
trmonthSoh: 84,
riskScore: null,
riskLevel: null,
status: null,
modelName: 'xgboost',
cyclesUsed: 6,
updatedAt: '2026-05-11T00:00:00.000Z',
})
})
test('requires prediction responses to belong to the requested battery', () => {
expect(isPredictionForBattery(normalizePrediction(predictionResponse), battery)).toBe(true)
expect(isPredictionForBattery({ batteryId: 11, mac: battery.mac }, battery as BatteryInfo)).toBe(false)
expect(isPredictionForBattery({ batteryId: battery.id, mac: 'RING-B11' }, battery as BatteryInfo)).toBe(false)
})
test('returns null for history-insufficient prediction requests', () => {
expect(createPredictionRequest(battery, [battery, battery, battery, battery])).toBeNull()
})
})
+36 -6
View File
@@ -72,6 +72,10 @@ const cache = new LRUCache<string, SohPrediction>({
max: 5_000, max: 5_000,
ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000, ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000,
}) })
const negativeCache = new LRUCache<string, true>({
max: 5_000,
ttl: env.SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS * 1000,
})
const inFlightRequests = new Map<string, Promise<SohPrediction | null>>() const inFlightRequests = new Map<string, Promise<SohPrediction | null>>()
const round2 = (value: number) => Math.round(value * 100) / 100 const round2 = (value: number) => Math.round(value * 100) / 100
@@ -133,7 +137,7 @@ export function createPredictionRequest(battery: BatteryInfo, history: BatteryIn
} }
} }
function normalizePrediction(response: z.infer<typeof predictionResponseSchema>): SohPrediction { export function normalizePrediction(response: z.infer<typeof predictionResponseSchema>): SohPrediction {
return { return {
batteryId: response.battery_id, batteryId: response.battery_id,
mac: response.mac, mac: response.mac,
@@ -149,15 +153,24 @@ function normalizePrediction(response: z.infer<typeof predictionResponseSchema>)
} }
} }
export function isPredictionForBattery(prediction: Pick<SohPrediction, 'batteryId' | 'mac'>, battery: BatteryInfo) {
return prediction.batteryId === battery.id && prediction.mac === battery.mac
}
export function isPredictionEnabled() { export function isPredictionEnabled() {
return true return true
} }
export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]): Promise<SohPrediction | null> { export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]): Promise<SohPrediction | null> {
const request = createPredictionRequest(battery, history)
if (!request) return null
const cacheKey = createCacheKey(battery, history) const cacheKey = createCacheKey(battery, history)
if (negativeCache.has(cacheKey)) return null
const request = createPredictionRequest(battery, history)
if (!request) {
negativeCache.set(cacheKey, true)
return null
}
const cached = cache.get(cacheKey) const cached = cache.get(cacheKey)
if (cached) return cached if (cached) return cached
const pendingRequest = inFlightRequests.get(cacheKey) const pendingRequest = inFlightRequests.get(cacheKey)
@@ -191,24 +204,41 @@ async function requestPrediction(
if (!response.ok) { if (!response.ok) {
logger.warn('SOH prediction request failed', { mac: battery.mac, status: response.status }) logger.warn('SOH prediction request failed', { mac: battery.mac, status: response.status })
return null return cacheNegativePrediction(cacheKey)
} }
const json = await response.json() const json = await response.json()
const prediction = normalizePrediction(predictionResponseSchema.parse(json)) const prediction = normalizePrediction(predictionResponseSchema.parse(json))
if (!isPredictionForBattery(prediction, battery)) {
logger.warn('SOH prediction response mismatched requested battery', {
requestedBatteryId: battery.id,
requestedMac: battery.mac,
responseBatteryId: prediction.batteryId,
responseMac: prediction.mac,
})
return cacheNegativePrediction(cacheKey)
}
cache.set(cacheKey, prediction) cache.set(cacheKey, prediction)
negativeCache.delete(cacheKey)
return prediction return prediction
} catch (error) { } catch (error) {
logger.warn('SOH prediction request errored', { mac: battery.mac, error }) logger.warn('SOH prediction request errored', { mac: battery.mac, error })
return null return cacheNegativePrediction(cacheKey)
} finally { } finally {
clearTimeout(timeout) clearTimeout(timeout)
inFlightRequests.delete(cacheKey) inFlightRequests.delete(cacheKey)
} }
} }
function cacheNegativePrediction(cacheKey: string) {
negativeCache.set(cacheKey, true)
return null
}
export function clearPredictionCache() { export function clearPredictionCache() {
cache.clear() cache.clear()
negativeCache.clear()
inFlightRequests.clear() inFlightRequests.clear()
} }