docs: design go ingest redesign phase one

This commit is contained in:
lingniu
2026-07-01 21:36:07 +08:00
parent cde55e1ae6
commit d148a4e3af
2 changed files with 1206 additions and 0 deletions

View File

@@ -0,0 +1,720 @@
# Go Vehicle Ingest Redesign Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the first production-capable Go runtime for GB32960, JT808, and Yutong MQTT ingestion with unified Kafka, TDengine, MySQL statistics, and Redis realtime state boundaries.
**Architecture:** Create a new single Go module at `go/vehicle-gateway` and migrate only the useful pieces from the existing `go/ingest-edge` and `go/vehicle-state` prototypes. The gateway produces unified envelopes to Kafka; independent consumers write TDengine history, MySQL daily metrics, and Redis realtime state.
**Tech Stack:** Go 1.26+, Kafka (`segmentio/kafka-go`), Redis (`redis/go-redis`), MySQL (`go-sql-driver/mysql`), TDengine official Go connector, MQTT (`eclipse/paho.mqtt.golang`), standard library TCP.
---
## File Structure
- Create: `go/vehicle-gateway/go.mod`
- Create: `go/vehicle-gateway/cmd/gateway/main.go`
- Create: `go/vehicle-gateway/cmd/history-writer/main.go`
- Create: `go/vehicle-gateway/cmd/stat-writer/main.go`
- Create: `go/vehicle-gateway/cmd/realtime-api/main.go`
- Create: `go/vehicle-gateway/internal/envelope/envelope.go`
- Create: `go/vehicle-gateway/internal/envelope/envelope_test.go`
- Create: `go/vehicle-gateway/internal/protocol/jt808/*`
- Create: `go/vehicle-gateway/internal/protocol/gb32960/*`
- Create: `go/vehicle-gateway/internal/protocol/yutongmqtt/*`
- Create: `go/vehicle-gateway/internal/gateway/*`
- Create: `go/vehicle-gateway/internal/identity/*`
- Create: `go/vehicle-gateway/internal/eventbus/*`
- Create: `go/vehicle-gateway/internal/history/*`
- Create: `go/vehicle-gateway/internal/stats/*`
- Create: `go/vehicle-gateway/internal/realtime/*`
- Create: `go/vehicle-gateway/internal/observability/*`
- Modify: `README.md`
- Modify: `docs/target-architecture.md`
- Create: `deploy/portainer/docker-compose-go.yml`
The existing `go/ingest-edge` and `go/vehicle-state` directories are migration sources only. After phase 1 is verified, remove or mark them superseded.
---
### Task 1: Create Single Go Module
**Files:**
- Create: `go/vehicle-gateway/go.mod`
- Create: `go/vehicle-gateway/internal/observability/logger.go`
- Create: `go/vehicle-gateway/cmd/gateway/main.go`
- [ ] **Step 1: Create module manifest**
Add `go/vehicle-gateway/go.mod`:
```go
module lingniu-vehicle-ingest/go/vehicle-gateway
go 1.26
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/go-sql-driver/mysql v1.9.3
github.com/redis/go-redis/v9 v9.17.2
github.com/segmentio/kafka-go v0.4.49
github.com/taosdata/driver-go/v3 v3.8.1
)
```
- [ ] **Step 2: Add logger helper**
Add `go/vehicle-gateway/internal/observability/logger.go`:
```go
package observability
import (
"log/slog"
"os"
)
func NewLogger(service string) *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})
return slog.New(handler).With("service", service)
}
```
- [ ] **Step 3: Add temporary gateway entrypoint**
Add `go/vehicle-gateway/cmd/gateway/main.go`:
```go
package main
import "lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
func main() {
logger := observability.NewLogger("vehicle-gateway")
logger.Info("vehicle gateway scaffold started")
}
```
- [ ] **Step 4: Verify scaffold builds**
Run:
```bash
cd go/vehicle-gateway
go mod tidy
go test ./...
go build ./cmd/gateway
```
Expected: all commands exit `0`.
---
### Task 2: Define Unified Envelope
**Files:**
- Create: `go/vehicle-gateway/internal/envelope/envelope.go`
- Create: `go/vehicle-gateway/internal/envelope/envelope_test.go`
- [ ] **Step 1: Write envelope tests**
Add `go/vehicle-gateway/internal/envelope/envelope_test.go`:
```go
package envelope
import "testing"
func TestFrameEnvelopeVehicleKeyPrefersVIN(t *testing.T) {
e := FrameEnvelope{Protocol: ProtocolJT808, VIN: "LNBVIN00000000001", Phone: "013307795425"}
if got := e.VehicleKey(); got != "LNBVIN00000000001" {
t.Fatalf("VehicleKey() = %q", got)
}
}
func TestFrameEnvelopeVehicleKeyFallsBackToPhone(t *testing.T) {
e := FrameEnvelope{Protocol: ProtocolJT808, Phone: "013307795425"}
if got := e.VehicleKey(); got != "JT808:013307795425" {
t.Fatalf("VehicleKey() = %q", got)
}
}
func TestFrameEnvelopeEventIDStable(t *testing.T) {
e := FrameEnvelope{
Protocol: ProtocolJT808,
MessageID: "0x0200",
Phone: "013307795425",
Sequence: 1,
EventTimeMS: 1782745114000,
ReceivedAtMS: 1782745114999,
RawHex: "7e02000000ff7e",
}
a := e.StableEventID()
b := e.StableEventID()
if a == "" || a != b {
t.Fatalf("event id must be non-empty and stable: %q %q", a, b)
}
}
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/envelope
```
Expected: fail because `FrameEnvelope` is not defined.
- [ ] **Step 3: Implement envelope**
Add `go/vehicle-gateway/internal/envelope/envelope.go`:
```go
package envelope
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
)
type Protocol string
const (
ProtocolGB32960 Protocol = "GB32960"
ProtocolJT808 Protocol = "JT808"
ProtocolYutongMQTT Protocol = "YUTONG_MQTT"
)
type ParseStatus string
const (
ParseOK ParseStatus = "OK"
ParsePartial ParseStatus = "PARTIAL"
ParseBadFrame ParseStatus = "BAD_FRAME"
)
type FrameEnvelope struct {
EventID string `json:"event_id"`
TraceID string `json:"trace_id"`
Protocol Protocol `json:"protocol"`
MessageID string `json:"message_id"`
Sequence uint16 `json:"sequence"`
VIN string `json:"vin,omitempty"`
VehicleKeyHint string `json:"vehicle_key,omitempty"`
Phone string `json:"phone,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Plate string `json:"plate,omitempty"`
SourceEndpoint string `json:"source_endpoint,omitempty"`
EventTimeMS int64 `json:"event_time_ms"`
ReceivedAtMS int64 `json:"received_at_ms"`
RawHex string `json:"raw_hex,omitempty"`
RawText string `json:"raw_text,omitempty"`
Parsed map[string]any `json:"parsed,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
ParseStatus ParseStatus `json:"parse_status"`
ParseError string `json:"parse_error,omitempty"`
}
func (e FrameEnvelope) VehicleKey() string {
if key := strings.TrimSpace(e.VIN); key != "" {
return key
}
if key := strings.TrimSpace(e.VehicleKeyHint); key != "" {
return key
}
if key := strings.TrimSpace(e.Phone); key != "" {
return string(e.Protocol) + ":" + key
}
if key := strings.TrimSpace(e.DeviceID); key != "" {
return string(e.Protocol) + ":" + key
}
return string(e.Protocol) + ":unknown"
}
func (e FrameEnvelope) StableEventID() string {
if strings.TrimSpace(e.EventID) != "" {
return e.EventID
}
input := fmt.Sprintf("%s|%s|%s|%d|%d|%s",
e.Protocol, e.MessageID, e.VehicleKey(), e.Sequence, e.EventTimeMS, e.RawHex)
sum := sha256.Sum256([]byte(input))
return hex.EncodeToString(sum[:16])
}
func (e FrameEnvelope) MarshalJSONBytes() ([]byte, error) {
if e.EventID == "" {
e.EventID = e.StableEventID()
}
if e.ParseStatus == "" {
e.ParseStatus = ParseOK
}
return json.Marshal(e)
}
```
- [ ] **Step 4: Verify tests pass**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/envelope
```
Expected: pass.
---
### Task 3: Implement JT808 Frame and 0200 Parser
**Files:**
- Create: `go/vehicle-gateway/internal/protocol/jt808/parser.go`
- Create: `go/vehicle-gateway/internal/protocol/jt808/parser_test.go`
- [ ] **Step 1: Add sample frame tests**
Use the production sample provided in the thread:
```text
7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E
```
Expected assertions:
- message id is `0x0200`
- phone keeps normalized BCD string `013307795425`
- sequence is `1`
- `fields.total_mileage_km` is parsed from additional item `0x01`
- latitude, longitude, speed, direction, alarm, status, and device time are present
- [ ] **Step 2: Implement parser**
Implementation rules:
- strip `0x7e` start/end delimiters
- unescape `0x7d 0x02 -> 0x7e`
- unescape `0x7d 0x01 -> 0x7d`
- verify XOR checksum
- parse 2011/2013 common header
- parse BCD phone from 6 bytes and keep both raw and normalized values in `parsed.header`
- parse 0200 fixed body
- parse additional item list as `id,length,value_hex`
- parse additional `0x01` as `total_mileage_km = uint32 / 10`
- [ ] **Step 3: Verify**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/protocol/jt808
```
Expected: pass.
---
### Task 4: Implement GB32960 Frame and Data Unit Parser
**Files:**
- Create: `go/vehicle-gateway/internal/protocol/gb32960/parser.go`
- Create: `go/vehicle-gateway/internal/protocol/gb32960/parser_test.go`
- [ ] **Step 1: Add parser tests**
Tests must cover:
- `##` frame boundary
- command id
- response flag
- 17-byte VIN
- encryption flag
- payload length
- BCC verification
- realtime data command `0x02`
- reissue data command `0x03`
- data unit `0x01` vehicle status fields
- data unit `0x05` position fields
- [ ] **Step 2: Implement parser**
Implementation rules:
- parse header without allocating large temporary buffers
- keep RAW as hex in envelope
- write all recognized data units to `Parsed`
- write core fields to `Fields`
- unsupported data unit stays in `Parsed["unknown_units"]`
- [ ] **Step 3: Verify**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/protocol/gb32960
```
Expected: pass.
---
### Task 5: Implement Kafka Sink
**Files:**
- Create: `go/vehicle-gateway/internal/eventbus/kafka_sink.go`
- Create: `go/vehicle-gateway/internal/eventbus/kafka_sink_test.go`
- [ ] **Step 1: Add sink interface**
Create a sink interface:
```go
package eventbus
import (
"context"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
type Sink interface {
PublishRaw(context.Context, envelope.FrameEnvelope) error
PublishUnified(context.Context, envelope.FrameEnvelope) error
Close() error
}
```
- [ ] **Step 2: Implement Kafka topic routing**
Rules:
- `GB32960 -> vehicle.raw.gb32960.v1`
- `JT808 -> vehicle.raw.jt808.v1`
- `YUTONG_MQTT -> vehicle.raw.yutong-mqtt.v1`
- unified topic is `vehicle.event.unified.v1`
- message key is `env.VehicleKey()`
- [ ] **Step 3: Verify routing with unit tests**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/eventbus
```
Expected: pass.
---
### Task 6: Implement TDengine History Writer
**Files:**
- Create: `go/vehicle-gateway/internal/history/schema.go`
- Create: `go/vehicle-gateway/internal/history/writer.go`
- Create: `go/vehicle-gateway/internal/history/writer_test.go`
- Create: `go/vehicle-gateway/cmd/history-writer/main.go`
- [ ] **Step 1: Add schema bootstrap SQL**
Implement schema strings for:
- database `lingniu_vehicle_ts`
- stable `raw_frames`
- stable `vehicle_locations`
- stable `vehicle_mileage_points`
- [ ] **Step 2: Implement writer**
Rules:
- `AppendRawFrame` always writes one raw row.
- `AppendLocation` writes only when longitude and latitude exist.
- `AppendMileagePoint` writes only when `total_mileage_km` exists.
- child table name is deterministic hash of `protocol + vehicle_key`.
- escape tag values.
- [ ] **Step 3: Use TDengine official driver**
Import WebSocket driver in command:
```go
import _ "github.com/taosdata/driver-go/v3/taosWS"
```
Default driver name:
```text
TDENGINE_DRIVER=taosWS
```
Short-term compatibility:
```text
TDENGINE_DRIVER=taosSql
```
Only use `taosSql` when ECS TDengine WebSocket is not available.
- [ ] **Step 4: Verify**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/history
go build ./cmd/history-writer
```
Expected: pass.
---
### Task 7: Implement MySQL Daily Metric Writer
**Files:**
- Create: `go/vehicle-gateway/internal/stats/schema.go`
- Create: `go/vehicle-gateway/internal/stats/daily_metric.go`
- Create: `go/vehicle-gateway/internal/stats/daily_metric_test.go`
- Create: `go/vehicle-gateway/cmd/stat-writer/main.go`
- [ ] **Step 1: Add schema bootstrap**
Implement `vehicle_daily_metric` schema from the design spec.
- [ ] **Step 2: Add metric derivation tests**
Test cases:
- no `total_mileage_km` produces no metric
- one sample produces:
- `daily_mileage_km = 0`
- `daily_total_mileage_km = sample`
- later larger sample updates:
- `latest_total_mileage_km`
- `daily_mileage_km`
- `daily_total_mileage_km`
- out-of-order smaller sample updates:
- `first_total_mileage_km`
- `daily_mileage_km`
- [ ] **Step 3: Implement MySQL upsert**
Use one table and one idempotent upsert:
```sql
INSERT INTO vehicle_daily_metric
(vin, stat_date, protocol, metric_key, metric_value, metric_unit,
first_total_mileage_km, latest_total_mileage_km, sample_count, calculation_method)
VALUES (?, ?, ?, ?, ?, 'km', ?, ?, 1, 'TOTAL_MILEAGE_DIFF')
ON DUPLICATE KEY UPDATE
first_total_mileage_km = LEAST(first_total_mileage_km, VALUES(first_total_mileage_km)),
latest_total_mileage_km = GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km)),
metric_value = CASE
WHEN metric_key = 'daily_mileage_km'
THEN GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))
- LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
WHEN metric_key = 'daily_total_mileage_km'
THEN GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))
ELSE VALUES(metric_value)
END,
sample_count = sample_count + 1,
updated_at = CURRENT_TIMESTAMP
```
- [ ] **Step 4: Verify**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/stats
go build ./cmd/stat-writer
```
Expected: pass.
---
### Task 8: Implement Redis Realtime API
**Files:**
- Create: `go/vehicle-gateway/internal/realtime/repository.go`
- Create: `go/vehicle-gateway/internal/realtime/repository_test.go`
- Create: `go/vehicle-gateway/internal/realtime/http.go`
- Create: `go/vehicle-gateway/cmd/realtime-api/main.go`
- [ ] **Step 1: Add repository contract**
Methods:
- `Update(ctx, envelope.FrameEnvelope) error`
- `GetMerged(ctx, vin string) (Snapshot, error)`
- `GetProtocol(ctx, vin string, protocol envelope.Protocol) (Snapshot, error)`
- `IsOnline(ctx, vin string) (OnlineStatus, error)`
- [ ] **Step 2: Implement merge logic**
Rules:
- update `vehicle:latest:{vin}:{protocol}`
- update `vehicle:latest:{vin}` with newest fields
- update `vehicle:online:{vin}`
- update sorted set `vehicle:last_seen`
- [ ] **Step 3: Implement HTTP API**
Routes:
- `GET /api/realtime/vehicles/{vin}`
- `GET /api/realtime/vehicles/{vin}/online`
- `GET /api/realtime/vehicles/{vin}/protocols/{protocol}`
- [ ] **Step 4: Verify**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/realtime
go build ./cmd/realtime-api
```
Expected: pass.
---
### Task 9: Wire Gateway Runtime
**Files:**
- Create: `go/vehicle-gateway/internal/gateway/tcp_server.go`
- Create: `go/vehicle-gateway/internal/gateway/mqtt_client.go`
- Modify: `go/vehicle-gateway/cmd/gateway/main.go`
- [ ] **Step 1: Implement TCP server**
Rules:
- one goroutine per accepted connection
- bounded max connections
- read timeout and idle timeout
- protocol-specific frame extractor
- structured peer endpoint
- graceful shutdown on SIGTERM
- [ ] **Step 2: Implement MQTT client**
Rules:
- connect with official production config from environment
- subscribe configured topic list
- convert each message into envelope
- publish raw and unified events
- reconnect with backoff
- [ ] **Step 3: Verify local JSON mode**
Run without Kafka:
```bash
cd go/vehicle-gateway
GB32960_TCP_ADDR=:132960 JT808_TCP_ADDR=:18080 go run ./cmd/gateway
```
Expected: service starts and logs configured listeners.
---
### Task 10: Docker and ECS Deployment
**Files:**
- Create: `go/vehicle-gateway/Dockerfile`
- Create: `deploy/portainer/docker-compose-go.yml`
- Modify: `docs/operations/current-ecs-deployment.md`
- [ ] **Step 1: Add multi-stage Dockerfile**
Build all commands:
- `gateway`
- `history-writer`
- `stat-writer`
- `realtime-api`
- [ ] **Step 2: Add Portainer compose**
Services:
- `go-vehicle-gateway`
- `go-history-writer`
- `go-stat-writer`
- `go-realtime-api`
Each service must include:
- restart policy
- memory limit
- Kafka env
- MySQL/TDengine/Redis env as needed
- logging options
- [ ] **Step 3: Verify Linux build**
Run:
```bash
cd go/vehicle-gateway
GOOS=linux GOARCH=amd64 go build ./cmd/gateway
GOOS=linux GOARCH=amd64 go build ./cmd/history-writer
GOOS=linux GOARCH=amd64 go build ./cmd/stat-writer
GOOS=linux GOARCH=amd64 go build ./cmd/realtime-api
```
Expected: all commands exit `0`.
---
### Task 11: Production Verification
**Files:**
- Create: `docs/operations/go-vehicle-gateway-verification.md`
- [ ] **Step 1: Record test commands**
Document commands to verify:
- gateway process health
- Kafka topic consumption
- TDengine row counts
- MySQL daily metric rows
- Redis realtime lookup
- [ ] **Step 2: Validate real traffic**
Evidence required:
- one real 32960 VIN with RAW, location, mileage point, daily metric, Redis snapshot
- one real JT808 phone/VIN with RAW, location, mileage point, daily metric, Redis snapshot
- one real Yutong MQTT VIN with RAW and Redis snapshot
- [ ] **Step 3: Keep old Java services until evidence is captured**
Only disable Java equivalents after the evidence file contains successful command outputs and timestamps.
---
## Self-Review Checklist
- The plan creates one new Go module instead of extending scattered prototypes.
- The plan covers GB32960, JT808, and Yutong MQTT ingress.
- The plan covers Kafka, TDengine, MySQL, and Redis.
- The plan includes 32960 and 808 daily mileage and daily total mileage.
- The plan includes local and ECS verification.
- The plan does not restore Xinda Push.
- The plan keeps Java services untouched until Go evidence exists.

View File

@@ -0,0 +1,486 @@
# Go 车辆接入重构设计规格
## 目标
使用 Go 重新设计车辆数据接入运行面,让系统回到第一性原则:
- 接入层只负责可靠收包、协议解析、应答、身份解析和投递。
- Kafka 是唯一在线消息通道。
- TDengine 保存高吞吐时序明细和完整 RAW 解析 JSON。
- MySQL 保存低频配置、身份映射和每日统计窄表。
- Redis 保存可重建的准实时状态。
- 32960、JT808、宇通 MQTT 使用同一套数据层接口,避免每个协议各自落库。
第一阶段只做生产链路的骨架和核心数据闭环接收、解析、Kafka、TDengine、MySQL 统计、Redis 实时查询、ECS 验证。历史查询 API 和业务侧复杂分析放在后续阶段。
## 参考原则
本设计只吸收开源项目的边界思想,不复制外部实现代码。
- GB32960 参考 `DarkInno/gb32960-go-sdk`连接管理、Handler、Forwarder、鉴权接口分离。
- JT808 参考 `cuteLittleDevil/go-jt808`:高并发 Go 原生 TCP、协议核心小而可扩展、事件/适配器机制。
- JT808 参考 `fakeyanss/jt808-server-go``FramePayload -> PacketData -> JT808Msg -> Reply` 的分层处理、Gateway 模式、保活和版本兼容。
- TDengine 参考官方数据模型:按数据采集点建立 supertable静态维度做 tags明细数据按子表写入Go 连接优先使用 WebSocket 驱动。
## 非目标
- 第一阶段不继续扩展 Java 服务。
- 第一阶段不做统一大查询 API。
- 第一阶段不恢复信达 Push。
- 第一阶段不把所有协议字段展开成一张超宽 TDengine 表。
- Redis 不作为历史事实源。
- MySQL 不保存高频原始明细。
## 目标目录
新的 Go 运行面放在 `go/vehicle-gateway`,不继续沿用实验性的多 module 分散结构。现有 `go/ingest-edge``go/vehicle-state` 可以作为迁移素材,最终归并或删除。
```text
go/vehicle-gateway/
cmd/
gateway/ # 协议接入进程32960 TCP、JT808 TCP、宇通 MQTT
history-writer/ # Kafka -> TDengine RAW/location/mileage points
stat-writer/ # Kafka -> MySQL 每日统计窄表
realtime-api/ # Redis 准实时查询 API
internal/
config/ # 环境变量、配置文件、校验
gateway/ # TCP/MQTT 生命周期、连接限制、优雅停机
protocol/
gb32960/ # 32960 编解码
jt808/ # 808 编解码
yutongmqtt/ # 宇通 MQTT payload 解析
identity/ # VIN、phone、device_id、plate 绑定
envelope/ # 统一事件模型
eventbus/ # Kafka producer/consumer
history/ # TDengine adapter
stats/ # 每日里程聚合
realtime/ # Redis adapter 与快照合并
observability/ # 日志、metrics、健康检查
```
## 协议接入设计
### GB32960
职责:
- 监听 TCP `32960`
- 支持车辆登入、实时信息上报、补发信息上报、车辆登出、平台登入/登出、心跳、校时。
- 正确返回协议应答。
- 完整解析 2016 数据单元:
- 整车数据
- 驱动电机数据
- 燃料电池数据
- 发动机数据
- 车辆位置数据
- 极值数据
- 报警数据
- 可充电储能装置电压数据
- 可充电储能装置温度数据
- RAW 中保存完整 parsed JSON。
- 关键字段标准化为统一 field
- `speed_kmh`
- `total_mileage_km`
- `soc_percent`
- `longitude`
- `latitude`
- `vehicle_status`
- `charge_status`
### JT808
职责:
- 监听 TCP `808`
- 支持 2011/2013 基础兼容,预留 2019 版本标记。
- 分层处理:
- Frame`0x7e` 包边界、转义、校验。
- Packet消息头、手机号 BCD、流水号、分包。
- Message注册、鉴权、心跳、注销、位置、批量位置、未知上行。
- Reply注册应答、平台通用应答。
- 终端手机号按协议头 BCD 解析,内部同时保留原始 BCD、规范化 phone、去前导零 phone。
- 注册帧和鉴权帧写入 808 registration 表。
- 如果设备直接上报位置帧,也要走 identity 解析,尝试用 phone、device_id、plate 找到 VIN。
- 0200 附加信息必须解析表 27
- `0x01` GPS 总里程DWORD单位 0.1 km。
- 其他已知附加项结构化放入 `parsed_json.additional`
- 标准化字段:
- `speed_kmh`
- `total_mileage_km`
- `longitude`
- `latitude`
- `altitude_m`
- `direction_deg`
- `alarm_flag`
- `status_flag`
### 宇通 MQTT
职责:
- 使用正式 MQTT 配置订阅生产 topic。
- 接收 payload 后解析为统一 envelope。
- 按 payload 中的车辆标识解析 VIN、车牌、设备号。
- 保存完整 payload 和 parsed JSON。
- 标准化字段向 32960/808 对齐:
- `speed_kmh`
- `total_mileage_km`
- `longitude`
- `latitude`
- `soc_percent`
- `vehicle_status`
## 统一 Envelope
所有协议接入后输出同一结构。
```json
{
"event_id": "string",
"trace_id": "string",
"protocol": "GB32960|JT808|YUTONG_MQTT",
"message_id": "string",
"vin": "string",
"vehicle_key": "string",
"phone": "string",
"device_id": "string",
"plate": "string",
"source_endpoint": "ip:port or mqtt://broker/topic",
"event_time_ms": 0,
"received_at_ms": 0,
"raw_hex": "string",
"raw_text": "string",
"parsed": {},
"fields": {
"speed_kmh": 0,
"total_mileage_km": 0,
"longitude": 0,
"latitude": 0
},
"parse_status": "OK|PARTIAL|BAD_FRAME",
"parse_error": ""
}
```
规则:
- `event_id` 使用协议、设备标识、消息流水、事件时间、RAW hash 生成,保证幂等。
- Kafka partition key 优先使用 VIN没有 VIN 时使用 `protocol:phone/device_id`
- `parsed` 保存协议完整结构化字段。
- `fields` 只保存跨协议核心字段,用于统计、位置和实时快照。
## Kafka Topic
生产只支持 Kafka。
| Topic | 内容 | Key |
|---|---|---|
| `vehicle.raw.gb32960.v1` | 32960 完整 RAW envelope | VIN 或 vehicle_key |
| `vehicle.raw.jt808.v1` | 808 完整 RAW envelope | VIN 或 phone |
| `vehicle.raw.yutong-mqtt.v1` | 宇通 MQTT 完整 RAW envelope | VIN 或 vehicle_key |
| `vehicle.event.unified.v1` | 统一轻量事件,用于业务消费 | VIN 或 vehicle_key |
接入层必须先写 RAW topic再写 unified event。后续消费者只依赖 Kafka不直接依赖接入进程内存。
## TDengine 数据库设计
如果现有 TDengine 库不符合目标,可以新建库并迁移。建议库名:
```sql
CREATE DATABASE IF NOT EXISTS lingniu_vehicle_ts KEEP 7300 DURATION 10 BUFFER 256;
```
### raw_frames
保存完整 RAW 和完整 parsed JSON。
```sql
CREATE STABLE IF NOT EXISTS raw_frames (
ts TIMESTAMP,
frame_id NCHAR(64),
event_id NCHAR(64),
message_id INT,
event_time TIMESTAMP,
received_at TIMESTAMP,
raw_size_bytes INT,
raw_hex BINARY(16374),
raw_text BINARY(16374),
parsed_json BINARY(16374),
fields_json BINARY(4096),
parse_status NCHAR(16),
parse_error BINARY(1024),
source_endpoint NCHAR(128)
) TAGS (
protocol NCHAR(32),
vehicle_key NCHAR(64),
vin NCHAR(32),
phone NCHAR(32),
device_id NCHAR(64)
);
```
说明:
- 子表按 `protocol + vehicle_key hash` 创建。
- RAW 查询优先按 tags 和时间范围过滤。
- `parsed_json` 是完整协议字段;查询 API 可选择是否返回,避免默认大字段拖慢。
### vehicle_locations
只保存位置查询核心字段。
```sql
CREATE STABLE IF NOT EXISTS vehicle_locations (
ts TIMESTAMP,
event_id NCHAR(64),
frame_id NCHAR(64),
received_at TIMESTAMP,
longitude DOUBLE,
latitude DOUBLE,
altitude_m DOUBLE,
speed_kmh DOUBLE,
direction_deg INT,
alarm_flag BIGINT,
status_flag BIGINT,
total_mileage_km DOUBLE
) TAGS (
protocol NCHAR(32),
vehicle_key NCHAR(64),
vin NCHAR(32),
phone NCHAR(32),
device_id NCHAR(64)
);
```
### vehicle_mileage_points
保存总里程采样点,用于回放重算和查询。
```sql
CREATE STABLE IF NOT EXISTS vehicle_mileage_points (
ts TIMESTAMP,
event_id NCHAR(64),
frame_id NCHAR(64),
received_at TIMESTAMP,
total_mileage_km DOUBLE,
speed_kmh DOUBLE,
longitude DOUBLE,
latitude DOUBLE
) TAGS (
protocol NCHAR(32),
vehicle_key NCHAR(64),
vin NCHAR(32),
phone NCHAR(32),
device_id NCHAR(64)
);
```
## MySQL 数据库设计
MySQL 只保存配置、身份映射、808 注册鉴权和每日统计窄表。
### vehicle_identity_binding
用于把外部标识定位到 VIN。
```sql
CREATE TABLE vehicle_identity_binding (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
vin VARCHAR(32) NOT NULL,
plate VARCHAR(32) NULL,
phone VARCHAR(32) NULL,
device_id VARCHAR(64) NULL,
remark VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_vin (vin),
KEY idx_plate (plate),
KEY idx_phone (phone),
KEY idx_device_id (device_id)
);
```
### jt808_registration
只记录 808 独有的注册、鉴权和在线身份信息。
```sql
CREATE TABLE jt808_registration (
phone VARCHAR(32) PRIMARY KEY,
vin VARCHAR(32) NULL,
plate VARCHAR(32) NULL,
device_id VARCHAR(64) NULL,
manufacturer_id VARCHAR(32) NULL,
terminal_model VARCHAR(64) NULL,
terminal_id VARCHAR(64) NULL,
auth_code VARCHAR(128) NULL,
source_endpoint VARCHAR(128) NULL,
first_registered_at DATETIME NULL,
latest_registered_at DATETIME NULL,
latest_authed_at DATETIME NULL,
latest_seen_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_vin (vin),
KEY idx_plate (plate),
KEY idx_device_id (device_id)
);
```
### vehicle_daily_metric
32960 和 808 的每日里程、每日总里程都进入同一张窄表。
```sql
CREATE TABLE vehicle_daily_metric (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
vin VARCHAR(32) NOT NULL,
stat_date DATE NOT NULL,
protocol VARCHAR(32) NOT NULL,
metric_key VARCHAR(64) NOT NULL,
metric_value DECIMAL(18,3) NOT NULL,
metric_unit VARCHAR(16) NOT NULL,
first_total_mileage_km DECIMAL(18,3) NULL,
latest_total_mileage_km DECIMAL(18,3) NULL,
sample_count BIGINT NOT NULL DEFAULT 0,
calculation_method VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_daily_metric (vin, stat_date, protocol, metric_key),
KEY idx_stat_date (stat_date),
KEY idx_protocol_metric (protocol, metric_key)
);
```
指标:
- `daily_mileage_km = max(total_mileage_km) - min(total_mileage_km)`
- `daily_total_mileage_km = max(total_mileage_km)`
统计消费者可幂等重放。每个样本 upsert 时只维护当天 `min/max total_mileage_km`,不依赖进程内状态。
## Redis 实时数据设计
Redis 保存可从 Kafka 重建的数据。
| Key | 内容 |
|---|---|
| `vehicle:online:{vin}` | 在线状态、最后活跃时间、协议列表 |
| `vehicle:latest:{vin}` | 合并后的 VIN 最新快照 |
| `vehicle:latest:{vin}:{protocol}` | 单协议最新快照 |
| `vehicle:last_seen` | VIN 到最后接收时间的 sorted set |
合并规则:
- 位置以最新 event_time 为准。
- 总里程取最新上报值,不做递增假设。
- 32960 多帧字段按 field timestamp 合并。
- 808 和 MQTT 不覆盖其他协议独有字段。
- Redis TTL 用于在线判断,不作为数据删除依据。
## 数据流
```mermaid
flowchart LR
source["车辆 / 平台"] --> gateway["Go gateway"]
gateway --> parser["protocol parser"]
parser --> identity["identity resolver"]
identity --> kafkaRaw["Kafka RAW topics"]
identity --> kafkaEvent["Kafka unified event"]
kafkaRaw --> history["history-writer"]
kafkaRaw --> stats["stat-writer"]
kafkaEvent --> realtime["realtime consumer"]
history --> td["TDengine raw_frames / locations / mileage_points"]
stats --> mysql["MySQL vehicle_daily_metric"]
realtime --> redis["Redis latest state"]
```
## 错误处理
- 协议坏帧仍写 RAW topic`parse_status=BAD_FRAME`
- 可部分解析的帧写 `parse_status=PARTIAL`,保留 parse error。
- Kafka 写失败时接入层不确认业务成功;协议是否应答按协议要求处理,但必须打错误日志和 metrics。
- TDengine 写失败不提交 Kafka offset。
- MySQL 统计写失败不提交 Kafka offset。
- Redis 写失败不影响历史和统计,但要通过 metrics 暴露。
- 808 相同 phone 新连接时,旧连接应被替换并记录 latest_seen。
## 部署设计
生产 ECS 运行 Go 二进制或容器:
- `vehicle-gateway`
- `vehicle-history-writer`
- `vehicle-stat-writer`
- `vehicle-realtime-api`
配置来源:
- 环境变量优先。
- Nacos 可作为后续配置中心,但 Go 第一阶段必须能只靠环境变量启动,便于 Portainer 部署和故障恢复。
TDengine 连接:
- 优先 WebSocket `taosWS`
- 若当前 ECS 只开放原生端口,可短期兼容 `taosSql`,但代码配置必须显式标记为兼容模式。
## 验证计划
### 本地验证
- 用样例 808 报文验证:
- phone BCD 解析。
- 0200 经纬度、速度、方向、时间。
- 表 27 `0x01` GPS 总里程。
- 用真实 32960 报文验证:
- 登录应答正确。
- 实时帧和补发帧都能写 RAW。
- 位置和总里程字段进入统一 fields。
- 用宇通 MQTT payload 验证:
- payload 完整保存。
- 核心字段归一化。
- 不连 Kafka 时可输出 JSON log。
- 连接 Kafka 时 RAW topic 可消费到 envelope。
### ECS 验证
- 部署前先旁路接收或短窗口切流。
- 验证端口:
- `32960`
- `808`
- MQTT 订阅连接。
- 验证 Kafka topic 有持续写入。
- 验证 TDengine
- `raw_frames` 有三种协议数据。
- `vehicle_locations` 能按 VIN、协议、时间分页查询。
- `vehicle_mileage_points` 有 32960 和 808 总里程采样。
- 验证 MySQL
- `vehicle_daily_metric` 有 32960 和 808 的 `daily_mileage_km``daily_total_mileage_km`
- 验证 Redis
- 查询 VIN 在线。
- 查询 VIN 合并实时快照。
- 查询 VIN 分协议实时快照。
## 验收标准
- Go gateway 能在 ECS 上接收真实 32960、808、宇通 MQTT 数据。
- 三种协议 RAW 都进入 Kafka。
- 三种协议 RAW 和 parsed JSON 都进入 TDengine。
- 32960 和 808 的位置进入 `vehicle_locations`
- 32960 和 808 的总里程采样进入 `vehicle_mileage_points`
- 32960 和 808 的每日里程、每日总里程进入 MySQL 窄表。
- Redis 可以查询 VIN 在线和实时数据。
- 任一消费者宕机后可通过 Kafka offset 继续恢复。
- 查询和统计验证使用 ECS 上的真实数据完成。
## 实施顺序
1. 创建 `go/vehicle-gateway` 单 module迁移现有 Go 原型中可用的解析、Kafka、TDengine、MySQL、Redis 代码。
2. 先写协议解析单元测试,覆盖 808 样例报文和 32960 核心字段。
3. 完成统一 envelope 和 Kafka sink。
4. 完成 TDengine schema bootstrap 与 history writer。
5. 完成 MySQL schema bootstrap 与 stat writer。
6. 完成 Redis realtime writer/API。
7. 本地启动 gateway用 ECS 转发流量验证。
8. 构建 Linux 镜像,部署 ECS。
9. 按验收标准逐项验证并记录证据。