Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d068fa66b | |||
| 779c9c2338 | |||
| fad890abe1 |
@@ -1,8 +1,12 @@
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/database
|
||||
|
||||
# 默认关闭公开 OpenAPI 文档/规格;仅在受控本地或内网演示环境显式启用。
|
||||
# ENABLE_API_DOCS=false
|
||||
|
||||
# 必填:外部 SoH 预测服务地址
|
||||
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
# SOH_PREDICTION_CACHE_TTL_SECONDS=86400
|
||||
# SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS=300
|
||||
# SOH_PREDICTION_TIMEOUT_MS=10000
|
||||
|
||||
# 可选:日志级别与输出格式
|
||||
|
||||
@@ -40,10 +40,21 @@ DATABASE_URL=mysql://user:password@host:3306/database
|
||||
```bash
|
||||
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
SOH_PREDICTION_CACHE_TTL_SECONDS=86400
|
||||
SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS=300
|
||||
SOH_PREDICTION_TIMEOUT_MS=10000
|
||||
```
|
||||
|
||||
服务端会调用 `${SOH_PREDICTION_API_BASE_URL}/predict`,使用返回的当前健康度、30 天趋势、90 天趋势和风险评分生成看板视图。预测结果会按设备与最新采集记录缓存,默认 24 小时;单台设备预测失败或历史数据不足时,仅该设备显示为“预测不可用”。
|
||||
服务端会调用 `${SOH_PREDICTION_API_BASE_URL}/predict`,使用返回的当前健康度、30 天趋势、90 天趋势和风险评分生成看板视图。预测结果会按设备与最新采集记录缓存,默认 24 小时;单台设备预测失败或历史数据不足时会短暂缓存不可用状态,默认 5 分钟,避免反复打满预测服务,同时仅该设备显示为“预测不可用”。
|
||||
|
||||
## 接口文档
|
||||
|
||||
OpenAPI 文档和规格默认不公开,生产或生产类运行环境应保持默认值:
|
||||
|
||||
```bash
|
||||
ENABLE_API_DOCS=false
|
||||
```
|
||||
|
||||
仅在受控本地开发或内网演示环境显式设置 `ENABLE_API_DOCS=true` 后,才会开放 `http://localhost:3000/api/docs` 与 `http://localhost:3000/api/spec.json`。`/api/rpc` 不受此开关影响。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -57,7 +68,7 @@ bun run dev
|
||||
|
||||
- `http://localhost:3000/`:设备健康运营看板
|
||||
- `http://localhost:3000/batteries`:设备状态明细
|
||||
- `http://localhost:3000/api/docs`:接口文档
|
||||
- `http://localhost:3000/api/docs`:接口文档(需设置 `ENABLE_API_DOCS=true`)
|
||||
|
||||
## 本地演示环境
|
||||
|
||||
@@ -73,6 +84,8 @@ Compose 会启动:
|
||||
- `seed`:初始化本地 `ls_battery_info` 演示数据
|
||||
- `app`:启动应用服务
|
||||
|
||||
Compose 的 `app` 服务会为本地演示显式启用 `ENABLE_API_DOCS=true`;生产部署不应沿用该设置,除非已通过网络边界或上游认证限制访问。
|
||||
|
||||
也可以手动初始化本地数据:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -41,6 +41,7 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=mysql://battery:battery@db:3306/battery_soh
|
||||
- SOH_PREDICTION_API_BASE_URL=http://host.docker.internal:8000
|
||||
- ENABLE_API_DOCS=true
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
|
||||
@@ -4,11 +4,13 @@ import { z } from 'zod'
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
DATABASE_URL: z.url({ protocol: /^mysql$/ }),
|
||||
ENABLE_API_DOCS: z.stringbool().default(false),
|
||||
LOG_DB: z.stringbool().default(false),
|
||||
LOG_FORMAT: z.enum(['pretty', 'json']).optional(),
|
||||
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']).default('info'),
|
||||
SOH_PREDICTION_API_BASE_URL: z.url({ protocol: /^https?$/ }),
|
||||
SOH_PREDICTION_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(86_400),
|
||||
SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(300),
|
||||
SOH_PREDICTION_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
|
||||
},
|
||||
clientPrefix: 'VITE_',
|
||||
|
||||
+17
-14
@@ -4,24 +4,27 @@ import { onError } from '@orpc/server'
|
||||
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { name, version } from '#package'
|
||||
import { env } from '@/env'
|
||||
import { handleValidationError, logError } from '@/server/api/interceptors'
|
||||
import { router } from '@/server/api/routers'
|
||||
|
||||
const handler = new OpenAPIHandler(router, {
|
||||
plugins: [
|
||||
new OpenAPIReferencePlugin({
|
||||
docsProvider: 'scalar',
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
specGenerateOptions: {
|
||||
info: {
|
||||
title: name,
|
||||
version,
|
||||
},
|
||||
},
|
||||
docsPath: '/docs',
|
||||
specPath: '/spec.json',
|
||||
}),
|
||||
],
|
||||
plugins: env.ENABLE_API_DOCS
|
||||
? [
|
||||
new OpenAPIReferencePlugin({
|
||||
docsProvider: 'scalar',
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
specGenerateOptions: {
|
||||
info: {
|
||||
title: name,
|
||||
version,
|
||||
},
|
||||
},
|
||||
docsPath: '/docs',
|
||||
specPath: '/spec.json',
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
interceptors: [onError(logError)],
|
||||
clientInterceptors: [onError(handleValidationError)],
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -72,6 +72,10 @@ const cache = new LRUCache<string, SohPrediction>({
|
||||
max: 5_000,
|
||||
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 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 {
|
||||
batteryId: response.battery_id,
|
||||
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() {
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user