feat(mileage): default to instrument-only source

This commit is contained in:
kkfluous
2026-07-23 15:12:36 +08:00
parent b0c8d6a5b8
commit 6b72f0ce3c
4 changed files with 211 additions and 19 deletions

View File

@@ -1,5 +1,8 @@
# OneOS 车辆里程接口需求
当前协议选源联调阻塞项和 OneOS 修改清单见:
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
## 开发原则
1. 新增区间查询接口;车辆里程汇总能力在现有
@@ -39,6 +42,8 @@ POST /api/v1/vehicles/mileage/range/query
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`
- 仅仪表数据:`["GB32960", "MQTT"]`
- 仅 GPS 数据:`["JT808"]`
- BI 页面默认只启用“仪表数据”,即默认发送
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`
- 每辆车每天返回一条记录;

View File

@@ -0,0 +1,183 @@
# OneOS 车辆里程数据源协议改造清单
## 0. 发布后复测结论
复测日期2026-07-23
开放平台发布后BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
`INVALID_REQUEST`
- 成功响应已返回实际使用的 `sourceProtocol`
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200
- “仅仪表数据”结果未出现 `JT808`
- “仅 GPS 数据”结果未出现 `GB32960``MQTT`
- 同一车辆四天区间查询能够随优先级在 `GB32960``JT808`
之间正确切换;
- BI 页面已显示真实“仪表数据”或“GPS数据”不再显示“来源待接口”。
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
| --- | ---: | ---: | ---: | ---: | --- |
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
以下内容保留为接口契约和后续回归验收依据。
## 1. 历史联调结论(发布前)
联调地址:`https://open.d.lnoneos.com`
涉及接口:
- `POST /api/v1/vehicles/mileage/query`
- `POST /api/v1/vehicles/mileage/range/query`
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
| 序号 | 当前表现 | 期望表现 | 影响 |
| --- | --- | --- | --- |
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
## 2. OneOS 必须修改的请求字段
两个接口都需要增加以下可选字段:
```json
{
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
字段规则:
1. 类型为非空字符串数组。
2. 仅允许 `GB32960``MQTT``JT808`,协议名区分以此处定义为准。
3. 数组顺序表示逐车、逐自然日的数据源优先级。
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
6. 非法枚举、空数组或重复协议应返回 HTTP 400并在响应中给出明确的错误信息和 `traceId`
BI 只会发送以下四种组合:
| BI 模式 | `protocolPriority` | 选源规则 |
| --- | --- | --- |
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960再查 MQTT最后查 JT808 |
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808再查 32960最后查 MQTT |
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT禁止使用 JT808 |
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808禁止使用 32960 和 MQTT |
BI 页面默认只启用“仪表数据”,即默认使用
`["GB32960", "MQTT"]`GPS 数据需要用户主动启用。
仪表数据内部优先级固定为 `GB32960 > MQTT`BI 不会发送 `MQTT > GB32960`
## 3. OneOS 必须增加的响应字段
两个接口的每条车辆日里程结果都需要增加:
```json
{
"sourceProtocol": "GB32960"
}
```
字段规则:
1. 有有效里程数据时,只能返回 `GB32960``MQTT``JT808`
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
3. 无有效数据、`status="NO_DATA"` 时返回 `null`
4. `sourceProtocol` 对应关系:
- `GB32960``MQTT`BI 显示为“仪表数据”;
- `JT808`BI 显示为“GPS数据”。
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
响应示例:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "粤A00001F",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
## 4. 逐车逐日选源规则
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
1.`protocolPriority` 从前到后检查数据源。
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
5. 所有已启用协议均无有效数据时,返回:
```json
{
"dailyMileageKm": null,
"sourceProtocol": null,
"status": "NO_DATA"
}
```
6. 真实零里程必须返回:
```json
{
"dailyMileageKm": 0,
"sourceProtocol": "实际采用的协议",
"status": "NORMAL"
}
```
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
## 5. 验收用例
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
| 用例 | 请求优先级 | 必须满足的结果 |
| --- | --- | --- |
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960`sourceProtocol="GB32960"` |
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808`sourceProtocol="JT808"` |
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT80832960 缺失时允许降级到 MQTT |
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT`sourceProtocol="MQTT"` |
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null``sourceProtocol=null``status="NO_DATA"` |
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol``status="NORMAL"` |
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
## 6. 交付和联调要求
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
3. 提供四种合法组合的请求和响应样例。
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。

View File

@@ -27,7 +27,7 @@ const HIGH_MILEAGE_ALERT_KM = 800;
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
const MILEAGE_SOURCE_META = {
instrument: { label: '仪表数据', protocol: '32960 > MQTT' },
instrument: { label: '仪表数据', protocol: null },
gps: { label: 'GPS数据', protocol: 'JT808' },
} as const;
@@ -117,7 +117,9 @@ function SourcePriorityControl({
</span>
<span className="text-left leading-tight">
<span className="block text-[9px] font-black">{meta.label}</span>
<span className="block text-[7px] font-bold opacity-60">{enabled ? meta.protocol : '已停用'}</span>
<span className="block min-h-[9px] text-[7px] font-bold opacity-60">
{enabled ? meta.protocol : '已停用'}
</span>
</span>
</button>
);
@@ -469,7 +471,7 @@ export default function MonitoringView() {
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument', 'gps']);
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
@@ -978,16 +980,18 @@ export default function MonitoringView() {
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
</td>
<td className="px-3 py-2">
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
<div className="flex items-center gap-1.5">
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
<span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
</div>
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
<span
className={`mt-0.5 inline-flex rounded px-1 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
</td>
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
@@ -1495,21 +1499,21 @@ export default function MonitoringView() {
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{v.isOnline ? '在线' : '离线'}
</span>
<span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
</div>
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
<span
className={`mt-0.5 inline-flex rounded px-1 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
<div className="flex items-center gap-1.5 md:hidden">
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>

View File

@@ -2,8 +2,8 @@ export type MileageSourceGroup = 'instrument' | 'gps';
export type OneOsProtocol = 'GB32960' | 'MQTT' | 'JT808';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS';
export const DEFAULT_MILEAGE_SOURCE_PRIORITY: MileageSourceGroup[] = ['instrument', 'gps'];
export const DEFAULT_ONEOS_PROTOCOL_PRIORITY: OneOsProtocol[] = ['GB32960', 'MQTT', 'JT808'];
export const DEFAULT_MILEAGE_SOURCE_PRIORITY: MileageSourceGroup[] = ['instrument'];
export const DEFAULT_ONEOS_PROTOCOL_PRIORITY: OneOsProtocol[] = ['GB32960', 'MQTT'];
export function parseMileageSourcePriority(value?: string): MileageSourceGroup[] {
if (!value) return [...DEFAULT_MILEAGE_SOURCE_PRIORITY];