From 779c9c23380cfaaaffd89b6d5940473b856ea6bb Mon Sep 17 00:00:00 2001 From: imbytecat Date: Tue, 12 May 2026 00:52:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(prediction):=20=E7=BC=93=E5=AD=98=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E9=A2=84=E6=B5=8B=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/prediction/client.test.ts | 77 ++++++++++++++++++++++++++++ src/server/prediction/client.ts | 42 ++++++++++++--- 2 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 src/server/prediction/client.test.ts diff --git a/src/server/prediction/client.test.ts b/src/server/prediction/client.test.ts new file mode 100644 index 0000000..8f0d0b5 --- /dev/null +++ b/src/server/prediction/client.test.ts @@ -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() + }) +}) diff --git a/src/server/prediction/client.ts b/src/server/prediction/client.ts index 31054d3..26ff2ca 100644 --- a/src/server/prediction/client.ts +++ b/src/server/prediction/client.ts @@ -72,6 +72,10 @@ const cache = new LRUCache({ max: 5_000, ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000, }) +const negativeCache = new LRUCache({ + max: 5_000, + ttl: env.SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS * 1000, +}) const inFlightRequests = new Map>() 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): SohPrediction { +export function normalizePrediction(response: z.infer): SohPrediction { return { batteryId: response.battery_id, mac: response.mac, @@ -149,15 +153,24 @@ function normalizePrediction(response: z.infer) } } +export function isPredictionForBattery(prediction: Pick, battery: BatteryInfo) { + return prediction.batteryId === battery.id && prediction.mac === battery.mac +} + export function isPredictionEnabled() { return true } export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]): Promise { - const request = createPredictionRequest(battery, history) - if (!request) return null - 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) if (cached) return cached const pendingRequest = inFlightRequests.get(cacheKey) @@ -191,24 +204,41 @@ async function requestPrediction( if (!response.ok) { logger.warn('SOH prediction request failed', { mac: battery.mac, status: response.status }) - return null + return cacheNegativePrediction(cacheKey) } const json = await response.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) + negativeCache.delete(cacheKey) return prediction } catch (error) { logger.warn('SOH prediction request errored', { mac: battery.mac, error }) - return null + return cacheNegativePrediction(cacheKey) } finally { clearTimeout(timeout) inFlightRequests.delete(cacheKey) } } +function cacheNegativePrediction(cacheKey: string) { + negativeCache.set(cacheKey, true) + return null +} + export function clearPredictionCache() { cache.clear() + negativeCache.clear() inFlightRequests.clear() }