diff --git a/deploy/feichi-ocr/Dockerfile b/deploy/feichi-ocr/Dockerfile new file mode 100644 index 00000000..9b0d3e2d --- /dev/null +++ b/deploy/feichi-ocr/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +RUN pip install --no-cache-dir \ + --index-url https://mirrors.aliyun.com/pypi/simple \ + ddddocr==1.6.1 + +WORKDIR /app +COPY ocr.py /app/ocr.py +USER nobody +ENTRYPOINT ["python", "/app/ocr.py"] diff --git a/deploy/feichi-ocr/ocr.py b/deploy/feichi-ocr/ocr.py new file mode 100644 index 00000000..6be9abf0 --- /dev/null +++ b/deploy/feichi-ocr/ocr.py @@ -0,0 +1,22 @@ +import re +import sys + +import ddddocr + + +def main() -> int: + image = sys.stdin.buffer.read() + if not image: + print("captcha image is empty", file=sys.stderr) + return 2 + result = ddddocr.DdddOcr(show_ad=False).classification(image) + code = re.sub(r"[^0-9A-Za-z]", "", result).upper() + if not re.fullmatch(r"[0-9A-Z]{4}", code): + print(f"invalid OCR result: {code!r}", file=sys.stderr) + return 3 + print(code) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/systemd/lingniu-go-feichi-bridge.env b/deploy/systemd/lingniu-go-feichi-bridge.env new file mode 100644 index 00000000..cfbf7ffd --- /dev/null +++ b/deploy/systemd/lingniu-go-feichi-bridge.env @@ -0,0 +1,15 @@ +FEICHI_BASE_URL=http://mob.fsfeichi.com.cn:8000 +FEICHI_AUTH_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-auth.json +FEICHI_TARGET_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-target.json +FEICHI_TARGET_ADDR=127.0.0.1:32960 +FEICHI_STATE_FILE=/var/lib/lingniu-feichi-bridge/state.json +FEICHI_OCR_IMAGE=lingniu/feichi-captcha-ocr:1.0.0 +FEICHI_LOGIN_MAX_ATTEMPTS=20 +FEICHI_LOGIN_RETRY_SECONDS=1 +FEICHI_POLL_INTERVAL_SECONDS=10 +FEICHI_DISCOVERY_INTERVAL_SECONDS=300 +FEICHI_FETCH_CONCURRENCY=4 +FEICHI_SOURCE_STALE_SECONDS=120 +FEICHI_STALE_REISSUE_ENABLED=true +FEICHI_BACKFILL_ENABLED=false +HEALTH_ADDR=127.0.0.1:20219 diff --git a/deploy/systemd/lingniu-go-feichi-bridge.service b/deploy/systemd/lingniu-go-feichi-bridge.service new file mode 100644 index 00000000..78068b24 --- /dev/null +++ b/deploy/systemd/lingniu-go-feichi-bridge.service @@ -0,0 +1,28 @@ +[Unit] +Description=Lingniu Feichi HTTP to GB/T 32960 Bridge +Documentation=file:/opt/lingniu-go-native/current/docs/ops/feichi-bridge.md +After=network-online.target docker.service lingniu-go-gateway.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +WorkingDirectory=/opt/lingniu-go-native/current +EnvironmentFile=/opt/lingniu-go-native/env/base.env +EnvironmentFile=/opt/lingniu-go-native/env/feichi-bridge.env +Environment=GOMEMLIMIT=256MiB +ExecStart=/opt/lingniu-go-native/current/feichi-bridge +Restart=always +RestartSec=5 +LimitNOFILE=65536 +MemoryLimit=384M +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWriteDirectories=/var/lib/lingniu-feichi-bridge + +[Install] +WantedBy=multi-user.target diff --git a/docs/ops/feichi-bridge.md b/docs/ops/feichi-bridge.md new file mode 100644 index 00000000..97ccccd2 --- /dev/null +++ b/docs/ops/feichi-bridge.md @@ -0,0 +1,160 @@ +# 飞驰 HTTP → GB/T 32960 桥接服务 + +## 目标 + +将飞驰车辆数据平台中的 20 辆 `GB_T32960` 车辆转换为 GB/T 32960.3-2016 标准 TCP 报文,并发送到岭牛内网车辆网关的 32960 端口。 + +服务必须满足: + +- 每 10 秒发现实时增量,不重复转发平台持续返回的最后一帧; +- 使用 `0x05` 完成平台登录,实时数据使用 `0x02`,历史补传使用 `0x03`; +- 每帧收到内网网关成功 ACK 后才推进持久化游标; +- 自动完成 CSRF 初始化、验证码识别、RSA 密码登录和 `R_SESS` 会话维护; +- 源会话返回 401/403 时自动重新登录,并重放一次原 API 请求; +- TCP 断线后重新登录,并用相同原始帧重试一次; +- 重启后从本地游标继续,默认补齐最近 1 小时、每次查询 20 分钟; +- 保留源接口调用、目标 ACK、错误和活跃车辆等 Prometheus 指标; +- 不转发离线车辆反复返回的旧快照。 +- 离线车辆最后快照按源时间仅补发一次 `0x03`,不伪装成实时 `0x02`。 + +## 数据路径 + +```text +飞驰 HTTP API + ├─ 车辆发现 vehicleRealStatuss + ├─ 实时明细 vehicleRealStatussByVId + └─ 历史明细 hisdataQuerys + │ + ▼ +feichi-bridge + 字段映射 → 去重 → 32960 编码 → ACK 后提交游标 + │ + ▼ +内网 vehicle-gateway :32960 +``` + +桥接服务当前编码 V2016 `##` 报文。源平台确认的主要字段如下: + +| 32960 数据 | 飞驰字段 | +|---|---| +| 数据时间 | `2000`(兼容 `9999`) | +| 车速、里程、挡位 | `2201`、`2202`、`2203` | +| 加速/制动踏板 | `2208`、`2209` | +| 运行模式、DCDC | `2213`、`2214` | +| 充电状态、车辆状态 | `2301`、`3201` | +| 总电压、总电流、绝缘电阻 | `2613`、`2614`、`2617` | +| SOC | `7615` | +| 原始经纬度 | `2502`、`2503` | +| 单体电压、温度 | `2003`、`2103` | +| 极值 | `2601`—`2612` | +| 燃料电池主要数据 | `2110`—`2121` | +| 驱动电机复合数据 | `2307`、`2308` | + +定位必须使用 `2502/2503` 原始坐标;`.gd` 字段是页面地图转换坐标,不能写进国标数据。 + +## 自动登录与认证文件 + +服务按照网页端协议执行 `first-login → randCode → login`:读取 `x-api-csrf`/`CSRF`,验证码图片交给 ECS 本机、禁网运行的专用 OCR 容器,密码使用网页端同一 RSA 公钥做 PKCS#1 v1.5 加密,登录成功后保存响应中的 `R_SESS`。OCR 结果不会被直接信任;登录接口是最终校验,错误时会换一张验证码重试。密码、Cookie、验证码图片和响应正文均不写日志。 + +`/opt/lingniu-go-native/secrets/feichi-auth.json`: + +```json +{ + "username": "replace-me", + "password": "replace-me" +} +``` + +文件权限必须为 `0600`。飞驰若提供机器账号、长期 Token 或正式接口鉴权,仍应优先切换到供应商承诺的方式。 + +`/opt/lingniu-go-native/secrets/feichi-target.json`: + +```json +{ + "platformId": "FEICHIBRIDGE00001", + "username": "bridge", + "password": "replace-me" +} +``` + +`platformId` 必须正好 17 字节。目标网关启用 `GB32960_AUTH_MODE=enforce` 时,需在网关凭据文件中登记同一平台账号和密码。 + +## 环境配置 + +`/opt/lingniu-go-native/env/feichi-bridge.env` 最小配置: + +```dotenv +FEICHI_BASE_URL=http://mob.fsfeichi.com.cn:8000 +FEICHI_AUTH_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-auth.json +FEICHI_TARGET_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-target.json +FEICHI_TARGET_ADDR=127.0.0.1:32960 +FEICHI_STATE_FILE=/var/lib/lingniu-feichi-bridge/state.json +HEALTH_ADDR=127.0.0.1:20219 +``` + +可调参数及默认值: + +| 参数 | 默认值 | 说明 | +|---|---:|---| +| `FEICHI_POLL_INTERVAL_SECONDS` | 10 | 实时轮询间隔 | +| `FEICHI_DISCOVERY_INTERVAL_SECONDS` | 300 | 车辆清单刷新间隔 | +| `FEICHI_FETCH_CONCURRENCY` | 4 | 明细接口并发数 | +| `FEICHI_SOURCE_STALE_SECONDS` | 120 | 超过该时间不作为实时帧转发 | +| `FEICHI_STALE_REISSUE_ENABLED` | true | 将离线旧快照去重后仅补发一次为 `0x03` | +| `FEICHI_HTTP_TIMEOUT_SECONDS` | 15 | HTTP 超时 | +| `FEICHI_OCR_IMAGE` | `lingniu/feichi-captcha-ocr:1.0.0` | 本机验证码 OCR 镜像 | +| `FEICHI_OCR_TIMEOUT_SECONDS` | 20 | 单次验证码识别超时 | +| `FEICHI_LOGIN_MAX_ATTEMPTS` | 20 | 单轮自动登录最多验证码次数 | +| `FEICHI_LOGIN_RETRY_SECONDS` | 1 | 验证码/登录失败重试间隔 | +| `FEICHI_TARGET_TIMEOUT_SECONDS` | 10 | TCP 写入和 ACK 超时 | +| `FEICHI_BACKFILL_ENABLED` | true | 是否启用历史补传 | +| `FEICHI_BACKFILL_LOOKBACK_SECONDS` | 3600 | 无游标时回看范围 | +| `FEICHI_BACKFILL_WINDOW_SECONDS` | 1200 | 单次历史查询窗口 | +| `FEICHI_BACKFILL_SAFETY_SECONDS` | 30 | 历史与实时之间的安全延迟 | +| `FEICHI_BACKFILL_INTERVAL_SECONDS` | 3600 | 补传巡检周期 | + +飞驰地址当前是明文 HTTP。部署时应限制服务的出口目的地址,并避免认证文件、Cookie、响应原文进入代理访问日志。 + +## 构建与启动 + +```bash +cd go/vehicle-gateway +go test ./internal/feichibridge ./cmd/feichi-bridge +CGO_ENABLED=0 go build -trimpath -o feichi-bridge ./cmd/feichi-bridge + +docker build -t lingniu/feichi-captcha-ocr:1.0.0 ../../deploy/feichi-ocr +install -d -m 0750 /var/lib/lingniu-feichi-bridge +systemctl daemon-reload +systemctl enable --now lingniu-go-feichi-bridge +``` + +检查: + +```bash +curl -fsS http://127.0.0.1:20219/healthz +curl -fsS http://127.0.0.1:20219/readyz +curl -fsS http://127.0.0.1:20219/metrics | grep vehicle_feichi_bridge +journalctl -u lingniu-go-feichi-bridge -f +jq '.vehicles | to_entries[] | select(.value.last_realtime_ack_at or .value.last_backfill_ack_at) | + {vin: .key, realtime_ack: .value.last_realtime_ack_at, backfill_ack: .value.last_backfill_ack_at}' \ + /var/lib/lingniu-feichi-bridge/state.json +``` + +## 上线验收 + +先选 1 辆在线车灰度 30 分钟,再开放全部 20 辆。验收需要同时满足: + +1. 网关收到一次 `0x05` 登录,后续实时帧为 `0x02`; +2. 同一源时间和同一内容只落一条,重启后无持续重复; +3. 断开目标 TCP 后能重连、重新登录并继续收到 ACK; +4. 使源会话过期后能自动重登;连续登录失败时 `/readyz` 变为 503,游标不前移; +5. 两辆离线车不会把历史最后一帧当实时数据周期发送; +6. 原始经纬度、总压、总流、SOC、单体电压和温度与平台页面抽样一致; +7. 20 辆车连续运行 24 小时后,源/目标计数和平台记录数差异可解释。 + +## 已知边界 + +- 页面接口不是飞驰承诺的稳定开放 API,接口结构升级可能要求同步调整客户端; +- 验证码识别依赖本机 OCR 镜像和飞驰当前验证码样式;样式变化会触发登录失败告警,不会导致未 ACK 数据推进游标; +- 源接口只提供离散快照,无法恢复平台未保存或查询窗口之外的原始上行帧; +- 映射结果是标准 32960 语义报文,不是车辆原始二进制报文的逐字节复制。 diff --git a/docs/ops/vehicle-ingest-runbook.md b/docs/ops/vehicle-ingest-runbook.md index 03eacf48..d9e4b27b 100644 --- a/docs/ops/vehicle-ingest-runbook.md +++ b/docs/ops/vehicle-ingest-runbook.md @@ -49,6 +49,8 @@ Gateway 在 NATS 模式下设置 `FIELDS_DERIVE_FROM_RAW_ENABLED=true`,每帧 stat-writer 默认 `STATS_WORKERS=3`,每个 worker 是同一 consumer group 的独立 Kafka reader。Kafka 按车辆 key 固定分区,因此单车累计里程仍按分区顺序执行,不同分区并行。调整 worker 后同时核对 `vehicle_stat_config{setting="workers"}`、每个 `vehicle_stat_worker_active{worker}`、MySQL 连接数、write p99 和 stat lag;worker 不应超过 fields topic 的有效分区并行度。 +stat-writer 对死锁、锁等待和网络中断等瞬时 MySQL 故障按 `STATS_RETRY_ATTEMPTS` 有限重试,重试耗尽后保持失败关闭并等待存储恢复。只有 `NULL`、数值越界、类型转换、字段过长和 CHECK 约束这类明确的单消息数据错误,才会在同样的有限重试耗尽后写入 `STATS_QUARANTINE_DIR` 并提交该 Kafka 偏移量,避免一条坏消息阻塞整个分区;表结构、权限和未知程序错误不会被隔离。隔离文件包含原 topic、partition、offset、key、value 和错误,默认权限为 `0600`,处理前不得删除。 + history-writer 和 realtime-writer 同样默认分别使用 `HISTORY_WORKERS=3`、`REALTIME_WORKERS=3`。每个 worker 只顺序处理 Kafka 分配给自己的分区,TDengine 表缓存、MySQL 实时节流缓存和车牌缓存均支持并发访问。调整后必须同时核对对应的 `*_config{setting="workers"}`、`*_worker_active`、pending/retry、后端连接数和 Kafka lag。 history-writer 启动会对既有 `vehicle_locations` stable 幂等补齐 `soc_percent DOUBLE`。发布后除 `/readyz` 外,必须抽查一台支持 SOC 的车辆在 TDengine 历史位置中 `soc_percent IS NOT NULL`;如果迁移报错且不是明确的重复列错误,服务应保持未就绪,先处理 TDengine DDL/权限,不能跳过迁移后强启。 @@ -174,6 +176,8 @@ curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_kafka_lag curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_samples_total curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_sources_total curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_last_ +curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_quarantine_total +find /var/lib/lingniu-go-native/stat-writer-quarantine -maxdepth 1 -type f -printf '%f %s bytes\n' curl -fsS http://127.0.0.1:20216/metrics | grep vehicle_realtime_kafka_lag curl -fsS http://127.0.0.1:20217/metrics | grep vehicle_identity_writer_kafka_lag for port in 20212 20213 20216 20217 20200; do diff --git a/go/vehicle-gateway/Dockerfile b/go/vehicle-gateway/Dockerfile index 3e923865..c1d9963f 100644 --- a/go/vehicle-gateway/Dockerfile +++ b/go/vehicle-gateway/Dockerfile @@ -6,6 +6,7 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/gateway ./cmd/gateway \ + && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/feichi-bridge ./cmd/feichi-bridge \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/history-writer ./cmd/history-writer \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/stat-writer ./cmd/stat-writer \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/realtime-api ./cmd/realtime-api @@ -18,6 +19,7 @@ RUN apt-get update \ WORKDIR /app COPY --from=build /out/gateway /app/gateway +COPY --from=build /out/feichi-bridge /app/feichi-bridge COPY --from=build /out/history-writer /app/history-writer COPY --from=build /out/stat-writer /app/stat-writer COPY --from=build /out/realtime-api /app/realtime-api diff --git a/go/vehicle-gateway/cmd/feichi-bridge/main.go b/go/vehicle-gateway/cmd/feichi-bridge/main.go new file mode 100644 index 00000000..5461e5f7 --- /dev/null +++ b/go/vehicle-gateway/cmd/feichi-bridge/main.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/feichibridge" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/health" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability" +) + +type authSecret struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type targetSecret struct { + PlatformID string `json:"platformId"` + Username string `json:"username"` + Password string `json:"password"` +} + +type config struct { + BaseURL string + AuthSecretFile string + TargetSecret string + TargetAddress string + StateFile string + HealthAddress string + HTTPTimeout time.Duration + TargetTimeout time.Duration + OCRImage string + OCRTimeout time.Duration + LoginAttempts int + LoginRetryDelay time.Duration + Service feichibridge.ServiceConfig +} + +func main() { + logger := observability.NewLogger("feichi-bridge") + cfg, err := loadConfig() + if err != nil { + logger.Error("load configuration failed", "error", err) + os.Exit(1) + } + auth, err := readJSONSecret[authSecret](cfg.AuthSecretFile) + if err != nil { + logger.Error("read Feichi API secret failed", "error", err) + os.Exit(1) + } + credentials, err := readJSONSecret[targetSecret](cfg.TargetSecret) + if err != nil { + logger.Error("read GB/T 32960 target secret failed", "error", err) + os.Exit(1) + } + source, err := feichibridge.NewAuthenticatedAPIClient( + cfg.BaseURL, + feichibridge.LoginCredentials{ + Username: auth.Username, Password: auth.Password, + MaxAttempts: cfg.LoginAttempts, RetryDelay: cfg.LoginRetryDelay, + }, + feichibridge.DockerCaptchaSolver{Image: cfg.OCRImage, Timeout: cfg.OCRTimeout}, + cfg.HTTPTimeout, + ) + if err != nil { + logger.Error("build Feichi API client failed", "error", err) + os.Exit(1) + } + state, err := feichibridge.OpenStateStore(cfg.StateFile) + if err != nil { + logger.Error("open bridge state failed", "error", err) + os.Exit(1) + } + target, err := feichibridge.NewTarget(feichibridge.TargetConfig{ + Address: cfg.TargetAddress, PlatformID: credentials.PlatformID, + Username: credentials.Username, Password: credentials.Password, + Timeout: cfg.TargetTimeout, + }, state) + if err != nil { + logger.Error("build GB/T 32960 target failed", "error", err) + os.Exit(1) + } + registry := metrics.NewRegistry() + service, err := feichibridge.NewService(cfg.Service, source, target, state, logger, registry) + if err != nil { + logger.Error("build bridge service failed", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + health.Start(ctx, logger, health.NewServer(cfg.HealthAddress, "feichi-bridge", []health.Check{ + {Name: "bridge", Check: service.Ready}, + }, registry)) + logger.Info("Feichi bridge starting", + "base_url", cfg.BaseURL, + "target_address", cfg.TargetAddress, + "poll_interval", cfg.Service.PollInterval, + "backfill_enabled", cfg.Service.BackfillEnabled, + ) + if strings.HasPrefix(strings.ToLower(cfg.BaseURL), "http://") { + logger.Warn("Feichi source uses clear-text HTTP; deploy only through a controlled egress path") + } + if err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + logger.Error("Feichi bridge stopped unexpectedly", "error", err) + os.Exit(1) + } + logger.Info("Feichi bridge stopped") +} + +func loadConfig() (config, error) { + cfg := config{ + BaseURL: strings.TrimSpace(os.Getenv("FEICHI_BASE_URL")), + AuthSecretFile: env("FEICHI_AUTH_SECRET_FILE", strings.TrimSpace(os.Getenv("FEICHI_AUTH_HEADERS_FILE"))), + TargetSecret: strings.TrimSpace(os.Getenv("FEICHI_TARGET_SECRET_FILE")), + TargetAddress: env("FEICHI_TARGET_ADDR", "127.0.0.1:32960"), + StateFile: env("FEICHI_STATE_FILE", "/var/lib/lingniu-feichi-bridge/state.json"), + HealthAddress: env("HEALTH_ADDR", "127.0.0.1:20219"), + HTTPTimeout: seconds("FEICHI_HTTP_TIMEOUT_SECONDS", 15), + TargetTimeout: seconds("FEICHI_TARGET_TIMEOUT_SECONDS", 10), + OCRImage: env("FEICHI_OCR_IMAGE", "lingniu/feichi-captcha-ocr:1.0.0"), + OCRTimeout: seconds("FEICHI_OCR_TIMEOUT_SECONDS", 20), + LoginAttempts: envInt("FEICHI_LOGIN_MAX_ATTEMPTS", 20), + LoginRetryDelay: seconds("FEICHI_LOGIN_RETRY_SECONDS", 1), + Service: feichibridge.ServiceConfig{ + PollInterval: seconds("FEICHI_POLL_INTERVAL_SECONDS", 10), + DiscoveryInterval: seconds("FEICHI_DISCOVERY_INTERVAL_SECONDS", 300), + BackfillInterval: seconds("FEICHI_BACKFILL_INTERVAL_SECONDS", 3600), + BackfillLookback: seconds("FEICHI_BACKFILL_LOOKBACK_SECONDS", 3600), + BackfillWindow: seconds("FEICHI_BACKFILL_WINDOW_SECONDS", 1200), + BackfillSafetyLag: seconds("FEICHI_BACKFILL_SAFETY_SECONDS", 30), + SourceStaleAfter: seconds("FEICHI_SOURCE_STALE_SECONDS", 120), + FetchConcurrency: envInt("FEICHI_FETCH_CONCURRENCY", 4), + BackfillEnabled: envBool("FEICHI_BACKFILL_ENABLED", true), + StaleReissueEnabled: envBool("FEICHI_STALE_REISSUE_ENABLED", true), + }, + } + var missing []string + if cfg.BaseURL == "" { + missing = append(missing, "FEICHI_BASE_URL") + } + if cfg.AuthSecretFile == "" { + missing = append(missing, "FEICHI_AUTH_SECRET_FILE") + } + if cfg.TargetSecret == "" { + missing = append(missing, "FEICHI_TARGET_SECRET_FILE") + } + if len(missing) > 0 { + return config{}, fmt.Errorf("required configuration missing: %s", strings.Join(missing, ", ")) + } + if cfg.Service.BackfillWindow > 24*time.Hour { + return config{}, errors.New("FEICHI_BACKFILL_WINDOW_SECONDS must not exceed 86400") + } + return cfg, nil +} + +func readJSONSecret[T any](path string) (T, error) { + var value T + encoded, err := os.ReadFile(path) + if err != nil { + return value, err + } + if err := json.Unmarshal(encoded, &value); err != nil { + return value, fmt.Errorf("decode %s: %w", path, err) + } + return value, nil +} + +func env(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func envInt(name string, fallback int) int { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func envBool(name string, fallback bool) bool { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} + +func seconds(name string, fallback int) time.Duration { + return time.Duration(envInt(name, fallback)) * time.Second +} diff --git a/go/vehicle-gateway/cmd/stat-writer/main.go b/go/vehicle-gateway/cmd/stat-writer/main.go index e055835b..e2e32801 100644 --- a/go/vehicle-gateway/cmd/stat-writer/main.go +++ b/go/vehicle-gateway/cmd/stat-writer/main.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "os/signal" + "path/filepath" "sort" "strconv" "strings" @@ -54,6 +55,7 @@ func main() { registry.SetGauge("vehicle_stat_cache_retention_seconds", nil, cfg.CacheRetention.Seconds()) registry.SetGauge("vehicle_stat_cache_cleanup_interval_seconds", nil, cfg.CacheCleanupInterval.Seconds()) registry.SetGauge("vehicle_stat_baseline_miss_ttl_seconds", nil, cfg.BaselineMissTTL.Seconds()) + registry.SetGauge("vehicle_stat_baseline_hit_ttl_seconds", nil, cfg.BaselineHitTTL.Seconds()) registry.SetGauge("vehicle_stat_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers)) health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{ {Name: "mysql", Check: db.PingContext}, @@ -65,6 +67,7 @@ func main() { writer.SetCacheRetention(cfg.CacheRetention) writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval) writer.SetBaselineMissTTL(cfg.BaselineMissTTL) + writer.SetBaselineHitTTL(cfg.BaselineHitTTL) writer.SetMaxCacheEntries(cfg.CacheMaxEntries) if cfg.EnsureSchema { if err := writer.EnsureSchema(ctx); err != nil { @@ -89,14 +92,15 @@ func main() { delay: cfg.RetryDelay, registry: registry, } + quarantiner := fileStatMessageQuarantiner{dir: cfg.QuarantineDir} - logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds()) + logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "baseline_hit_ttl_seconds", cfg.BaselineHitTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds(), "quarantine_dir", cfg.QuarantineDir) var workers sync.WaitGroup for workerID := 1; workerID <= cfg.Workers; workerID++ { workers.Add(1) go func(id int) { defer workers.Done() - runStatConsumer(ctx, logger, registry, appender, cfg, id) + runStatConsumer(ctx, logger, registry, appender, quarantiner, cfg, id) }(workerID) } workers.Wait() @@ -105,7 +109,7 @@ func main() { func runStatConsumer(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) -}, registry *metrics.Registry, appender statAppender, cfg config, workerID int) { +}, registry *metrics.Registry, appender statAppender, quarantiner statMessageQuarantiner, cfg config, workerID int) { reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: cfg.KafkaBrokers, GroupID: cfg.KafkaGroup, @@ -129,7 +133,7 @@ func runStatConsumer(ctx context.Context, logger interface { continue } batch := collectStatBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond) - processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels) + processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, quarantiner, batch, cfg.RetryDelay, workerLabels) } } @@ -155,6 +159,10 @@ type kafkaMessageFetcher interface { FetchMessage(context.Context) (kafka.Message, error) } +type statMessageQuarantiner interface { + Quarantine(context.Context, kafka.Message, error) error +} + type statBatchItem struct { message kafka.Message processed bool @@ -163,6 +171,8 @@ type statBatchItem struct { type statBatchOutcome struct { commitMessages []kafka.Message retryMessages []kafka.Message + failedMessage *kafka.Message + writeErr error } var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} @@ -251,7 +261,12 @@ func processStatBatchForWorker(ctx context.Context, logger interface { addStatMetric(registry, "vehicle_stat_writes_total", message, "error") logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err) committed, commitErr := commitStatProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items) - outcome := statBatchOutcome{retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed)} + failedMessage := message + outcome := statBatchOutcome{ + retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed), + failedMessage: &failedMessage, + writeErr: err, + } if commitErr != nil { outcome.commitMessages = committed } @@ -279,13 +294,13 @@ func processStatBatchReliably(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) { - processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil) + processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, nil, messages, retryDelay, nil) } func processStatBatchReliablyForWorker(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) -}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) { +}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, quarantiner statMessageQuarantiner, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) { defer registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, 0) pending := messages for len(pending) > 0 { @@ -297,6 +312,19 @@ func processStatBatchReliablyForWorker(ctx context.Context, logger interface { } } pending = outcome.retryMessages + if outcome.writeErr != nil && outcome.failedMessage != nil && isQuarantinableStatError(outcome.writeErr) && quarantiner != nil { + failed := *outcome.failedMessage + if err := quarantiner.Quarantine(ctx, failed, outcome.writeErr); err != nil { + logger.Error("stat message quarantine failed", "topic", failed.Topic, "partition", failed.Partition, "offset", failed.Offset, "error", err) + registry.IncCounter("vehicle_stat_quarantine_total", metrics.Labels{"status": "error", "topic": failed.Topic}) + } else { + registry.IncCounter("vehicle_stat_quarantine_total", metrics.Labels{"status": "ok", "topic": failed.Topic}) + logger.Warn("quarantined permanent stat write failure", "topic", failed.Topic, "partition", failed.Partition, "offset", failed.Offset, "error", outcome.writeErr) + if retryStatCommit(ctx, logger, registry, committer, []kafka.Message{failed}, retryDelay) { + pending = messagesExceptStatMessage(pending, failed) + } + } + } registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, float64(len(pending))) if len(pending) == 0 { return @@ -308,6 +336,105 @@ func processStatBatchReliablyForWorker(ctx context.Context, logger interface { } } +type quarantinedStatMessage struct { + QuarantinedAt string `json:"quarantined_at"` + Topic string `json:"topic"` + Partition int `json:"partition"` + Offset int64 `json:"offset"` + Key []byte `json:"key,omitempty"` + Value []byte `json:"value"` + Error string `json:"error"` +} + +type fileStatMessageQuarantiner struct { + dir string +} + +func (q fileStatMessageQuarantiner) Quarantine(ctx context.Context, message kafka.Message, cause error) error { + if err := ctx.Err(); err != nil { + return err + } + dir := strings.TrimSpace(q.dir) + if dir == "" { + return errors.New("stat quarantine directory is empty") + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create quarantine directory: %w", err) + } + record := quarantinedStatMessage{ + QuarantinedAt: time.Now().UTC().Format(time.RFC3339Nano), + Topic: message.Topic, + Partition: message.Partition, + Offset: message.Offset, + Key: message.Key, + Value: message.Value, + Error: cause.Error(), + } + payload, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("marshal quarantine record: %w", err) + } + name := fmt.Sprintf("%s-%d-%d.json", sanitizeStatQuarantineName(message.Topic), message.Partition, message.Offset) + temporary, err := os.CreateTemp(dir, "."+name+"-*") + if err != nil { + return fmt.Errorf("create quarantine record: %w", err) + } + temporaryName := temporary.Name() + removeTemporary := true + defer func() { + _ = temporary.Close() + if removeTemporary { + _ = os.Remove(temporaryName) + } + }() + if err := temporary.Chmod(0o600); err != nil { + return fmt.Errorf("secure quarantine record: %w", err) + } + if _, err := temporary.Write(payload); err != nil { + return fmt.Errorf("write quarantine record: %w", err) + } + if err := temporary.Sync(); err != nil { + return fmt.Errorf("sync quarantine record: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close quarantine record: %w", err) + } + if err := os.Rename(temporaryName, filepath.Join(dir, name)); err != nil { + return fmt.Errorf("publish quarantine record: %w", err) + } + removeTemporary = false + return nil +} + +func sanitizeStatQuarantineName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "unknown-topic" + } + var out strings.Builder + for _, r := range value { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { + out.WriteRune(r) + } else { + out.WriteByte('_') + } + } + return out.String() +} + +func messagesExceptStatMessage(messages []kafka.Message, excluded kafka.Message) []kafka.Message { + out := make([]kafka.Message, 0, len(messages)) + removed := false + for _, message := range messages { + if !removed && message.Topic == excluded.Topic && message.Partition == excluded.Partition && message.Offset == excluded.Offset { + removed = true + continue + } + out = append(out, message) + } + return out +} + func retryStatCommit(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) @@ -447,7 +574,7 @@ func (a retryStatAppender) AppendWithResult(ctx context.Context, env envelope.Fr var err error for attempt := 1; attempt <= attempts; attempt++ { result, err = appendStatEnvelope(ctx, a.delegate, env) - if err == nil || !isTransientMySQLStatError(err) { + if err == nil || (!isTransientMySQLStatError(err) && !isQuarantinableStatError(err)) { return result, err } if attempt == attempts { @@ -512,6 +639,32 @@ func isTransientMySQLStatError(err error) bool { strings.Contains(text, "no route to host") } +func isQuarantinableStatError(err error) bool { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + text := strings.ToLower(strings.TrimSpace(err.Error())) + // Only deterministic row-data failures may advance past a Kafka message + // after the configured finite retries. Schema, permission and unknown + // application errors remain failure-closed so a systemic outage cannot + // silently quarantine an entire stream. + return strings.Contains(text, "error 1048") || + strings.Contains(text, "cannot be null") || + strings.Contains(text, "error 1264") || + strings.Contains(text, "out of range value") || + strings.Contains(text, "error 1265") || + strings.Contains(text, "data truncated") || + strings.Contains(text, "error 1292") || + strings.Contains(text, "incorrect datetime value") || + strings.Contains(text, "error 1366") || + strings.Contains(text, "incorrect decimal value") || + strings.Contains(text, "incorrect integer value") || + strings.Contains(text, "error 1406") || + strings.Contains(text, "data too long for column") || + strings.Contains(text, "error 3819") || + strings.Contains(text, "check constraint") +} + func addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) { if registry == nil { return @@ -686,6 +839,7 @@ type config struct { CacheRetention time.Duration CacheCleanupInterval time.Duration BaselineMissTTL time.Duration + BaselineHitTTL time.Duration CacheMaxEntries int Workers int BatchSize int @@ -694,6 +848,7 @@ type config struct { RetryDelay time.Duration NormalizePlatformSourcesOnStart bool NormalizePlatformSourcesTimeout time.Duration + QuarantineDir string } func (c config) Validate() error { @@ -728,6 +883,7 @@ func loadConfig() config { CacheRetention: time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour, CacheCleanupInterval: time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second, BaselineMissTTL: time.Duration(envInt("STATS_BASELINE_MISS_TTL_SECONDS", 60)) * time.Second, + BaselineHitTTL: time.Duration(envInt("STATS_BASELINE_HIT_TTL_SECONDS", 300)) * time.Second, CacheMaxEntries: envInt("STATS_CACHE_MAX_ENTRIES", 1000000), Workers: envInt("STATS_WORKERS", 3), BatchSize: envInt("STATS_BATCH_SIZE", 200), @@ -736,6 +892,7 @@ func loadConfig() config { RetryDelay: time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond, NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false", NormalizePlatformSourcesTimeout: time.Duration(envInt("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", 30)) * time.Second, + QuarantineDir: env("STATS_QUARANTINE_DIR", "/var/lib/lingniu-go-native/stat-writer-quarantine"), } } diff --git a/go/vehicle-gateway/cmd/stat-writer/main_test.go b/go/vehicle-gateway/cmd/stat-writer/main_test.go index 73c48719..686360cc 100644 --- a/go/vehicle-gateway/cmd/stat-writer/main_test.go +++ b/go/vehicle-gateway/cmd/stat-writer/main_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "os" + "path/filepath" "strings" "testing" "time" @@ -664,6 +666,72 @@ func TestProcessStatBatchReliablyRetriesFailedSuffix(t *testing.T) { } } +func TestProcessStatBatchReliablyQuarantinesPermanentFailureAndContinues(t *testing.T) { + env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} + payload := marshalStatTestFieldsEnvelope(t, env) + appender := &contextCheckingStatAppender{failOnCount: 1, err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")} + committer := &contextCheckingStatCommitter{} + quarantiner := &recordingStatQuarantiner{} + registry := metrics.NewRegistry() + messages := []kafka.Message{ + {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}, + {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: payload}, + } + + processStatBatchReliablyForWorker( + context.Background(), + discardStatLogger{}, + registry, + appender, + committer, + quarantiner, + messages, + time.Nanosecond, + nil, + ) + + if appender.count != 2 { + t.Fatalf("append attempts = %d, want failed message once and following message once", appender.count) + } + if quarantiner.count != 1 || quarantiner.message.Offset != 10 { + t.Fatalf("quarantine = %d/%d, want one message at offset 10", quarantiner.count, quarantiner.message.Offset) + } + if got := committedOffsets(committer.messages); len(got) != 2 || got[0] != 10 || got[1] != 11 { + t.Fatalf("committed offsets = %#v, want [10 11]", got) + } + text := registry.Render() + if !strings.Contains(text, `vehicle_stat_quarantine_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`) { + t.Fatalf("quarantine success metric missing:\n%s", text) + } +} + +func TestFileStatMessageQuarantinerPersistsOriginalKafkaMessage(t *testing.T) { + dir := t.TempDir() + message := kafka.Message{ + Topic: "vehicle.fields.go.jt808.v1", + Partition: 2, + Offset: 5094052, + Key: []byte("vehicle-key"), + Value: []byte(`{"event_id":"poison:fields"}`), + } + wantErr := errors.New("permanent mysql error") + + if err := (fileStatMessageQuarantiner{dir: dir}).Quarantine(context.Background(), message, wantErr); err != nil { + t.Fatalf("Quarantine() error = %v", err) + } + payload, err := os.ReadFile(filepath.Join(dir, "vehicle.fields.go.jt808.v1-2-5094052.json")) + if err != nil { + t.Fatalf("read quarantine record: %v", err) + } + var record quarantinedStatMessage + if err := json.Unmarshal(payload, &record); err != nil { + t.Fatalf("unmarshal quarantine record: %v", err) + } + if record.Topic != message.Topic || record.Partition != message.Partition || record.Offset != message.Offset || string(record.Key) != string(message.Key) || string(record.Value) != string(message.Value) || record.Error != wantErr.Error() { + t.Fatalf("quarantine record = %#v, want original message and error", record) + } +} + func TestProcessStatBatchReliablyRetriesCommitWithoutReappending(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload := marshalStatTestFieldsEnvelope(t, env) @@ -826,6 +894,30 @@ func TestRetryStatAppenderRecordsExhaustedTransientError(t *testing.T) { } } +func TestRetryStatAppenderRetriesQuarantinableDataErrorBeforeIsolation(t *testing.T) { + registry := metrics.NewRegistry() + delegate := &contextCheckingStatAppender{err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")} + appender := retryStatAppender{delegate: delegate, attempts: 3, registry: registry} + + _, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}) + + if err == nil { + t.Fatal("AppendWithResult() error = nil, want exhausted data error") + } + if delegate.count != 3 { + t.Fatalf("append attempts = %d, want configured finite retries", delegate.count) + } + text := registry.Render() + for _, want := range []string{ + `vehicle_stat_write_retries_total{operation="single",status="retry"} 2`, + `vehicle_stat_write_retries_total{operation="single",status="exhausted"} 1`, + } { + if !strings.Contains(text, want) { + t.Fatalf("retry metric missing %s:\n%s", want, text) + } + } +} + func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) { wantErr := errors.New("validation failed") delegate := &contextCheckingStatAppender{err: wantErr} @@ -841,6 +933,34 @@ func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) { } } +func TestIsQuarantinableStatErrorOnlyAcceptsDeterministicRowDataFailures(t *testing.T) { + for _, err := range []error{ + errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null"), + errors.New("Error 1264: Out of range value for column 'daily_mileage_km'"), + errors.New("Error 1265: Data truncated for column 'daily_mileage_km'"), + errors.New("Error 1292: Incorrect datetime value"), + errors.New("Error 1366: Incorrect decimal value"), + errors.New("Error 1406: Data too long for column"), + errors.New("Error 3819: Check constraint is violated"), + } { + if !isQuarantinableStatError(err) { + t.Fatalf("isQuarantinableStatError(%q) = false, want true", err.Error()) + } + } + for _, err := range []error{ + errors.New("Error 1054: Unknown column 'daily_mileage_km'"), + errors.New("Error 1142: INSERT command denied"), + errors.New("validation failed"), + context.Canceled, + context.DeadlineExceeded, + nil, + } { + if isQuarantinableStatError(err) { + t.Fatalf("isQuarantinableStatError(%v) = true, want false", err) + } + } +} + func TestIsTransientMySQLStatError(t *testing.T) { for _, err := range []error{ errors.New("dial tcp 127.0.0.1:3306: connection refused"), @@ -903,6 +1023,9 @@ func TestLoadConfigDefaultsToGoFieldsTopics(t *testing.T) { if cfg.BaselineMissTTL != time.Minute { t.Fatalf("BaselineMissTTL = %s, want 1m", cfg.BaselineMissTTL) } + if cfg.BaselineHitTTL != 5*time.Minute { + t.Fatalf("BaselineHitTTL = %s, want 5m", cfg.BaselineHitTTL) + } if cfg.RetryAttempts != 3 { t.Fatalf("RetryAttempts = %d, want 3", cfg.RetryAttempts) } @@ -965,6 +1088,7 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) { t.Setenv("STATS_CACHE_RETENTION_HOURS", "48") t.Setenv("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", "300") t.Setenv("STATS_BASELINE_MISS_TTL_SECONDS", "45") + t.Setenv("STATS_BASELINE_HIT_TTL_SECONDS", "180") t.Setenv("STATS_CACHE_MAX_ENTRIES", "12345") cfg := loadConfig() @@ -984,6 +1108,9 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) { if cfg.BaselineMissTTL != 45*time.Second { t.Fatalf("BaselineMissTTL = %s, want 45s", cfg.BaselineMissTTL) } + if cfg.BaselineHitTTL != 3*time.Minute { + t.Fatalf("BaselineHitTTL = %s, want 3m", cfg.BaselineHitTTL) + } if cfg.CacheMaxEntries != 12345 { t.Fatalf("CacheMaxEntries = %d, want 12345", cfg.CacheMaxEntries) } @@ -997,6 +1124,7 @@ func TestLoadConfigReadsStatBatchSettings(t *testing.T) { t.Setenv("STATS_RETRY_DELAY_MS", "33") t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "false") t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", "17") + t.Setenv("STATS_QUARANTINE_DIR", "/tmp/stat-writer-quarantine-test") cfg := loadConfig() @@ -1021,6 +1149,9 @@ func TestLoadConfigReadsStatBatchSettings(t *testing.T) { if cfg.NormalizePlatformSourcesTimeout != 17*time.Second { t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 17s", cfg.NormalizePlatformSourcesTimeout) } + if cfg.QuarantineDir != "/tmp/stat-writer-quarantine-test" { + t.Fatalf("QuarantineDir = %q, want env override", cfg.QuarantineDir) + } } type contextCheckingStatAppender struct { @@ -1070,6 +1201,18 @@ type contextCheckingStatCommitter struct { failOnCount int } +type recordingStatQuarantiner struct { + count int + message kafka.Message + err error +} + +func (q *recordingStatQuarantiner) Quarantine(_ context.Context, message kafka.Message, _ error) error { + q.count++ + q.message = message + return q.err +} + func (c *contextCheckingStatCommitter) CommitMessages(ctx context.Context, messages ...kafka.Message) error { c.ctxErr = ctx.Err() c.count++ diff --git a/go/vehicle-gateway/cmd/stats-backfill/main.go b/go/vehicle-gateway/cmd/stats-backfill/main.go index f7e7167d..96b98659 100644 --- a/go/vehicle-gateway/cmd/stats-backfill/main.go +++ b/go/vehicle-gateway/cmd/stats-backfill/main.go @@ -36,6 +36,7 @@ type config struct { Debug bool EventTimeFullScan bool ProgressEvery int64 + BaselineLookback int Location *time.Location } @@ -50,6 +51,7 @@ type rawFrameRow struct { EventTimeMS int64 ReceivedAtMS int64 ParsedJSON string + RawText string } type metricAgg struct { @@ -188,6 +190,9 @@ func main() { } } fields := fieldsForStats(row.Protocol, row.VIN, text) + if len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT { + fields = fieldsForStats(row.Protocol, row.VIN, row.RawText) + } if cfg.Debug && scanned <= 5 { slog.Info("debug raw frame", "protocol", row.Protocol, @@ -427,7 +432,7 @@ func buildLastDiffAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, return aggregates, nil } -func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) { +func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, _ *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) { if aggregates == nil { return 0, fmt.Errorf("aggregates map is nil") } @@ -441,11 +446,6 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, continue } latestHistory := map[sourceHistoryID]dailySourceLast{} - preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0]) - if err != nil { - return added, err - } - rememberLatestSourceRows(latestHistory, preWindow) aggregateRowsByDate := indexAggregateSourceRowsByDate(aggregates, protocol) for _, date := range targetDates { current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date) @@ -656,7 +656,9 @@ func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, proto if predicate := mileageBearingFramePredicate(protocol); predicate != "" { where = append(where, predicate) } - sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(event_time), FIRST(parsed_json), LAST(event_time), LAST(parsed_json), COUNT(*) + sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, + FIRST(event_time), FIRST(parsed_json), FIRST(raw_text), + LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*) FROM %s.raw_frames WHERE %s GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) @@ -668,7 +670,7 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr // LAST aggregates the complete pre-window history once per source so empty // calendar days do not make a backfill fall back to the current day's first // sample. - where := backfillBeforePredicates(date) + where := backfillBeforePredicates(cfg, date) where = append(where, "parse_status = 'OK'", "vin IS NOT NULL", @@ -679,17 +681,31 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr if predicate := mileageBearingFramePredicate(protocol); predicate != "" { where = append(where, predicate) } - sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(event_time), LAST(parsed_json), COUNT(*) + sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, + LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*) FROM %s.raw_frames WHERE %s GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) return querySourceRows(ctx, db, cfg, protocol, sqlText, false) } -func backfillBeforePredicates(eventDateExclusive string) []string { - return []string{ +func backfillBeforePredicates(cfg config, eventDateExclusive string) []string { + lookbackDays := cfg.BaselineLookback + if lookbackDays <= 0 { + lookbackDays = 7 + } + eventDateFrom := shiftDate(eventDateExclusive, -lookbackDays) + where := []string{ + fmt.Sprintf("event_time >= '%s 00:00:00'", quote(eventDateFrom)), fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)), } + if !cfg.EventTimeFullScan { + where = append([]string{ + fmt.Sprintf("ts >= '%s 00:00:00'", quote(previousDate(eventDateFrom))), + fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(eventDateExclusive))), + }, where...) + } + return where } func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) { @@ -775,27 +791,30 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel var sourceEndpoint string var firstTS time.Time var firstParsedJSON string + var firstRawText string var ts time.Time var parsedJSON string + var rawText string var rawSampleCount int64 if includeFirst { - if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &ts, &parsedJSON, &rawSampleCount); err != nil { + if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &firstRawText, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil { return nil, err } } else { - if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawSampleCount); err != nil { + if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil { return nil, err } firstTS = ts firstParsedJSON = parsedJSON + firstRawText = rawText } - latestTotalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, ts, cfg.Location) + latestTotalKM, ok := mileageFromEvidence(protocol, vin, parsedJSON, rawText, ts, cfg.Location) if !ok { continue } firstTotalKM := latestTotalKM if includeFirst { - if parsedFirst, ok := mileageFromParsed(protocol, vin, firstParsedJSON, firstTS, cfg.Location); ok { + if parsedFirst, ok := mileageFromEvidence(protocol, vin, firstParsedJSON, firstRawText, firstTS, cfg.Location); ok { firstTotalKM = parsedFirst } } @@ -852,6 +871,16 @@ func mileageFromParsed(protocol envelope.Protocol, vin string, parsedJSON string return samples[0].TotalMileageKM, true } +func mileageFromEvidence(protocol envelope.Protocol, vin string, parsedJSON string, rawText string, eventTime time.Time, loc *time.Location) (float64, bool) { + if totalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, eventTime, loc); ok { + return totalKM, true + } + if protocol != envelope.ProtocolYutongMQTT || strings.TrimSpace(rawText) == "" { + return 0, false + } + return mileageFromParsed(protocol, vin, rawText, eventTime, loc) +} + func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string { return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "") } @@ -951,6 +980,7 @@ func loadConfig() (config, error) { Debug: envBool("BACKFILL_DEBUG", false), EventTimeFullScan: envBool("BACKFILL_EVENT_TIME_FULL_SCAN", false), ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)), + BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7), Location: loc, }, nil } @@ -1000,7 +1030,7 @@ func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, err where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")") } where = append(where, realtimeMileageFramePredicate()) - sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json + sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json, raw_text FROM %s.raw_frames WHERE %s ORDER BY event_time ASC, ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) @@ -1044,6 +1074,9 @@ func mileageBearingFramePredicate(protocol envelope.Protocol) string { conditions := make([]string, 0, len(tokens)) for _, token := range tokens { conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(token))) + if protocol == envelope.ProtocolYutongMQTT { + conditions = append(conditions, fmt.Sprintf("raw_text LIKE '%%%s%%'", quote(token))) + } } return "(" + strings.Join(conditions, " OR ") + ")" } @@ -1071,10 +1104,10 @@ func mileageSearchTokens(protocol envelope.Protocol) []string { } func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) { - var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed string + var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed, rawText string var messageID int64 var eventTime, receivedAt time.Time - if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed); err != nil { + if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed, &rawText); err != nil { return rawFrameRow{}, err } return rawFrameRow{ @@ -1088,6 +1121,7 @@ func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) { EventTimeMS: eventTime.UnixMilli(), ReceivedAtMS: receivedAt.UnixMilli(), ParsedJSON: parsed, + RawText: rawText, }, nil } @@ -1267,6 +1301,14 @@ func previousDate(date string) string { return parsed.AddDate(0, 0, -1).Format("2006-01-02") } +func shiftDate(date string, days int) string { + parsed, err := time.Parse("2006-01-02", date) + if err != nil { + return date + } + return parsed.AddDate(0, 0, days).Format("2006-01-02") +} + func dateRangeWithPrevious(from string, to string) ([]string, error) { dates, err := dateRange(from, to) if err != nil { diff --git a/go/vehicle-gateway/cmd/stats-backfill/main_test.go b/go/vehicle-gateway/cmd/stats-backfill/main_test.go index 7c45fa79..bcb21297 100644 --- a/go/vehicle-gateway/cmd/stats-backfill/main_test.go +++ b/go/vehicle-gateway/cmd/stats-backfill/main_test.go @@ -233,23 +233,23 @@ func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing. dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc) currentRows := func() *sqlmock.Rows { return sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", + "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }) } previousRows := func() *sqlmock.Rows { return sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", + "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }) } mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(previousRows()) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). - WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, int64(4))) + WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4))) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(currentRows()) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'"). - WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, int64(6))) + WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6))) aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{ TDengineDatabase: "lingniu_vehicle_ts", @@ -342,18 +342,10 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing. WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12"). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}). AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0)) - tdMock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-12 00:00:00'.*protocol = 'YUTONG_MQTT'.*TOTAL_MILEAGE"). - WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", - }).AddRow( - "LMRKH9AC0R1004086", - "", - "LMRKH9AC0R1004086", - "mqtt://yutong/ytforward/shln/4", - previousTS, - `{"data":{"TOTAL_MILEAGE":11500000}}`, - int64(12), - )) + sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") + mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source"). + WithArgs("LMRKH9AC0R1004086", "2026-07-12", "YUTONG_MQTT", sourceKey). + WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(11500.0, previousTS)) aggregates := map[string]*metricAgg{} added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{ @@ -373,7 +365,7 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing. if agg.FirstKM != 11500 || agg.LatestKM != 11578 { t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM) } - if agg.SourceKey != stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") { + if agg.SourceKey != sourceKey { t.Fatalf("source key = %q", agg.SourceKey) } if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" { @@ -410,10 +402,6 @@ func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *tes dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc) dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc) - tdMock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). - WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", - })) for _, date := range []string{"2026-07-09", "2026-07-10"} { mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?"). WithArgs("YUTONG_MQTT", date, date). @@ -483,16 +471,18 @@ func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) { lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", + "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }).AddRow( "LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", firstTS, - `{"yutong_mqtt.data.total_mileage":"65422000"}`, + `{"yutong_mqtt.data.latitude":"30.0"}`, + `{"data":{"TOTAL_MILEAGE":65422000}}`, lastTS, - `{"yutong_mqtt.data.total_mileage":"65423000"}`, + `{"yutong_mqtt.data.latitude":"30.1"}`, + `{"data":{"TOTAL_MILEAGE":65423000}}`, int64(42), )) @@ -526,14 +516,15 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) { lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", + "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }).AddRow( "LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", lastTS, - `{"yutong_mqtt.data.total_mileage":"65377000"}`, + `{"yutong_mqtt.data.latitude":"30.0"}`, + `{"data":{"TOTAL_MILEAGE":65377000}}`, int64(31), )) @@ -556,13 +547,19 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) { } } -func TestBackfillBeforePredicatesSearchesAllEarlierHistory(t *testing.T) { - where := strings.Join(backfillBeforePredicates("2026-07-08"), " AND ") +func TestBackfillBeforePredicatesBoundsHistoryScan(t *testing.T) { + where := strings.Join(backfillBeforePredicates(config{BaselineLookback: 7}, "2026-07-08"), " AND ") if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") { t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where) } - if strings.Contains(where, "event_time >=") || strings.Contains(where, "ts >=") { - t.Fatalf("pre-window predicate must not stop at the previous day: %s", where) + for _, predicate := range []string{ + "event_time >= '2026-07-01 00:00:00'", + "ts >= '2026-06-30 00:00:00'", + "ts < '2026-07-09 00:00:00'", + } { + if !strings.Contains(where, predicate) { + t.Fatalf("pre-window predicate missing %q: %s", predicate, where) + } } } @@ -577,7 +574,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'"). WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", + "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", }).AddRow( "LMRKH9AC7R1004098", "", @@ -585,6 +582,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone "mqtt://yutong/ytforward/shln/4", utcInstant, `{"yutong_mqtt.data.total_mileage":"41249000"}`, + "", int64(1), )) diff --git a/go/vehicle-gateway/internal/feichibridge/captcha.go b/go/vehicle-gateway/internal/feichibridge/captcha.go new file mode 100644 index 00000000..380dedc6 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/captcha.go @@ -0,0 +1,57 @@ +package feichibridge + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + "time" +) + +var captchaPattern = regexp.MustCompile(`^[0-9A-Z]{4}$`) + +type CaptchaSolver interface { + Solve(context.Context, []byte) (string, error) +} + +type DockerCaptchaSolver struct { + Image string + Timeout time.Duration +} + +func (s DockerCaptchaSolver) Solve(ctx context.Context, image []byte) (string, error) { + if len(image) == 0 { + return "", errors.New("captcha image is empty") + } + if strings.TrimSpace(s.Image) == "" { + return "", errors.New("captcha OCR image is required") + } + timeout := s.Timeout + if timeout <= 0 { + timeout = 20 * time.Second + } + solveCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + command := exec.CommandContext( + solveCtx, + "docker", "run", "--rm", "--network=none", "-i", strings.TrimSpace(s.Image), + ) + command.Stdin = bytes.NewReader(image) + var stderr bytes.Buffer + command.Stderr = &stderr + output, err := command.Output() + if err != nil { + if solveCtx.Err() != nil { + return "", fmt.Errorf("captcha OCR timed out: %w", solveCtx.Err()) + } + return "", fmt.Errorf("captcha OCR failed: %w: %s", err, strings.TrimSpace(stderr.String())) + } + code := strings.ToUpper(strings.TrimSpace(string(output))) + if !captchaPattern.MatchString(code) { + return "", fmt.Errorf("captcha OCR returned invalid result %q", code) + } + return code, nil +} diff --git a/go/vehicle-gateway/internal/feichibridge/client.go b/go/vehicle-gateway/internal/feichibridge/client.go new file mode 100644 index 00000000..4fb51b16 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/client.go @@ -0,0 +1,211 @@ +package feichibridge + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +var ErrUnauthorized = errors.New("feichi API session unauthorized") + +type APIClient struct { + baseURL *url.URL + headers http.Header + client *http.Client + login *loginSession +} + +func NewAPIClient(baseURL string, headers map[string]string, timeout time.Duration) (*APIClient, error) { + parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/") + if err != nil { + return nil, fmt.Errorf("parse FEICHI_BASE_URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("FEICHI_BASE_URL must use http or https") + } + if timeout <= 0 { + timeout = 15 * time.Second + } + out := &APIClient{ + baseURL: parsed, + headers: make(http.Header), + client: &http.Client{Timeout: timeout}, + } + for name, value := range headers { + if strings.TrimSpace(name) != "" && strings.TrimSpace(value) != "" { + out.headers.Set(name, value) + } + } + return out, nil +} + +func NewAuthenticatedAPIClient(baseURL string, credentials LoginCredentials, solver CaptchaSolver, timeout time.Duration) (*APIClient, error) { + client, err := NewAPIClient(baseURL, nil, timeout) + if err != nil { + return nil, err + } + login, err := newLoginSession(credentials, solver) + if err != nil { + return nil, err + } + client.login = login + return client, nil +} + +func (c *APIClient) Vehicles(ctx context.Context) ([]Vehicle, error) { + payload := map[string]any{ + "conditions": []map[string]string{ + {"name": "iccid", "value": ""}, + {"name": "powerMode", "value": ""}, + }, + "sort": []map[string]string{{"name": "updateTime", "order": "desc"}}, + "start": 0, + "limit": 100, + } + var envelope collectionEnvelope[Vehicle] + if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/vehicleRealStatuss", payload, &envelope); err != nil { + return nil, err + } + if !apiCodeOK(envelope.Code) { + return nil, apiCodeError(envelope.Code) + } + return envelope.Data, nil +} + +func (c *APIClient) Snapshot(ctx context.Context, vehicleID string) (Snapshot, error) { + path := "api/v1/sys/vehicleRealStatussByVId/" + url.PathEscape(vehicleID) + "/-1" + var envelope objectEnvelope[Snapshot] + if err := c.doJSON(ctx, http.MethodGet, path, nil, &envelope); err != nil { + return Snapshot{}, err + } + if !apiCodeOK(envelope.Code) { + return Snapshot{}, apiCodeError(envelope.Code) + } + return envelope.Data, nil +} + +func (c *APIClient) History(ctx context.Context, vin string, begin, end time.Time) ([]Record, error) { + payload := map[string]any{ + "conditions": []map[string]string{ + {"name": "queryType", "value": "0"}, + {"name": "queryContent", "value": vin}, + {"name": "beginTime", "value": begin.In(shanghai).Format("2006-01-02 15:04:05")}, + {"name": "endTime", "value": end.In(shanghai).Format("2006-01-02 15:04:05")}, + {"name": "hisdataType", "value": "0"}, + }, + "start": 0, + "limit": 100, + } + var envelope collectionEnvelope[historySegment] + if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/hisdataQuerys", payload, &envelope); err != nil { + return nil, err + } + if !apiCodeOK(envelope.Code) { + return nil, apiCodeError(envelope.Code) + } + var records []Record + for _, segment := range envelope.Data { + records = append(records, segment.Subdata...) + } + return records, nil +} + +func (c *APIClient) doJSON(ctx context.Context, method, path string, body any, output any) error { + if c.login != nil { + if err := c.login.ensure(ctx, c); err != nil { + return err + } + } + for attempt := 0; attempt < 2; attempt++ { + _, err := c.rawJSON(ctx, method, path, body, output) + if !errors.Is(err, ErrUnauthorized) || c.login == nil || attempt == 1 { + return err + } + c.login.invalidate() + if err := c.login.ensure(ctx, c); err != nil { + return err + } + } + return ErrUnauthorized +} + +func (c *APIClient) rawJSON(ctx context.Context, method, path string, body any, output any) (http.Header, error) { + endpoint, err := c.baseURL.Parse(path) + if err != nil { + return nil, err + } + var reader io.Reader + if body != nil { + encoded, marshalErr := json.Marshal(body) + if marshalErr != nil { + return nil, marshalErr + } + reader = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + for name, values := range c.headers { + for _, value := range values { + request.Header.Add(name, value) + } + } + if c.login != nil { + if cookie := c.login.cookieHeader(); cookie != "" { + request.Header.Set("Cookie", cookie) + } + if csrf := c.login.csrfHeader(); csrf != "" { + request.Header.Set("x-api-csrf", csrf) + } + } + response, err := c.client.Do(request) + if err != nil { + return nil, fmt.Errorf("feichi %s %s: %w", method, path, err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return response.Header, ErrUnauthorized + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(response.Body, 1024)) + return response.Header, fmt.Errorf("feichi %s %s: HTTP %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(snippet))) + } + encoded, err := io.ReadAll(io.LimitReader(response.Body, 16<<20)) + if err != nil { + return response.Header, fmt.Errorf("read feichi %s: %w", path, err) + } + var status struct { + Code any `json:"code"` + } + if err := json.Unmarshal(encoded, &status); err != nil { + return response.Header, fmt.Errorf("decode feichi %s: %w", path, err) + } + if fmt.Sprint(status.Code) == "401" || fmt.Sprint(status.Code) == "403" { + return response.Header, ErrUnauthorized + } + if output != nil { + if err := json.Unmarshal(encoded, output); err != nil { + return response.Header, fmt.Errorf("decode feichi %s: %w", path, err) + } + } + return response.Header, nil +} + +func apiCodeError(code any) error { + if fmt.Sprint(code) == "401" || fmt.Sprint(code) == "403" { + return ErrUnauthorized + } + return fmt.Errorf("feichi API returned code %v", code) +} diff --git a/go/vehicle-gateway/internal/feichibridge/client_test.go b/go/vehicle-gateway/internal/feichibridge/client_test.go new file mode 100644 index 00000000..70ff0bd1 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/client_test.go @@ -0,0 +1,154 @@ +package feichibridge + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type fixedCaptchaSolver struct { + code string + calls int +} + +func (s *fixedCaptchaSolver) Solve(_ context.Context, image []byte) (string, error) { + s.calls++ + if string(image) != "captcha-image" { + return "", fmt.Errorf("unexpected captcha image") + } + return s.code, nil +} + +func TestAPIClientUsesSessionHeadersAndExpectedEndpoints(t *testing.T) { + var calls int + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + calls++ + if request.Header.Get("Cookie") != "R_SESS=test" || request.Header.Get("x-api-csrf") != "csrf" { + t.Errorf("missing session headers: %#v", request.Header) + } + response.Header().Set("Content-Type", "application/json") + switch { + case request.URL.Path == "/api/v1/sys/vehicleRealStatuss": + _ = json.NewEncoder(response).Encode(map[string]any{ + "code": 200, + "data": []map[string]string{{"vehicleId": "id-1", "vin": "LTEST32960VIN0001"}}, + }) + case strings.Contains(request.URL.Path, "vehicleRealStatussByVId"): + _ = json.NewEncoder(response).Encode(map[string]any{ + "code": 200, + "data": map[string]any{"vehicleId": "id-1", "vin": "LTEST32960VIN0001", "dataItems": map[string]string{"2000": "2026-07-17 12:00:00"}}, + }) + case request.URL.Path == "/api/v1/sys/hisdataQuerys": + _ = json.NewEncoder(response).Encode(map[string]any{ + "code": 200, + "data": []map[string]any{{"subdata": []map[string]string{{"2000": "2026-07-17 11:59:50"}}}}, + }) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, map[string]string{"Cookie": "R_SESS=test", "x-api-csrf": "csrf"}, time.Second) + if err != nil { + t.Fatal(err) + } + vehicles, err := client.Vehicles(context.Background()) + if err != nil || len(vehicles) != 1 { + t.Fatalf("vehicles = %#v, err = %v", vehicles, err) + } + if _, err := client.Snapshot(context.Background(), "id-1"); err != nil { + t.Fatal(err) + } + records, err := client.History(context.Background(), vehicles[0].VIN, time.Now().Add(-time.Hour), time.Now()) + if err != nil || len(records) != 1 { + t.Fatalf("history = %#v, err = %v", records, err) + } + if calls != 3 { + t.Fatalf("calls = %d", calls) + } +} + +func TestAPIClientMapsUnauthorizedEnvelope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _, _ = response.Write([]byte(`{"code":401,"data":[]}`)) + })) + defer server.Close() + client, err := NewAPIClient(server.URL, nil, time.Second) + if err != nil { + t.Fatal(err) + } + if _, err := client.Vehicles(context.Background()); err != ErrUnauthorized { + t.Fatalf("error = %v", err) + } +} + +func TestAuthenticatedAPIClientLogsInAndRetriesExpiredSession(t *testing.T) { + var loginCalls int + var vehicleCalls int + solver := &fixedCaptchaSolver{code: "12WN"} + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/api/v1/first-login": + response.Header().Set("x-api-csrf", "csrf-value") + response.Header().Add("Set-Cookie", "CSRF=csrf-value; Path=/") + _, _ = response.Write([]byte(`{"code":200}`)) + case "/api/v1/login/randCode": + if request.Header.Get("x-api-csrf") != "csrf-value" { + t.Errorf("captcha CSRF = %q", request.Header.Get("x-api-csrf")) + } + src := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("captcha-image")) + _ = json.NewEncoder(response).Encode(map[string]any{"code": 200, "data": map[string]string{"src": src}}) + case "/api/v1/login": + loginCalls++ + var payload map[string]string + _ = json.NewDecoder(request.Body).Decode(&payload) + if payload["username"] != "JXQN0003" || payload["validCode"] != "12WN" { + t.Errorf("login payload = %#v", payload) + } + encrypted, err := base64.StdEncoding.DecodeString(payload["password"]) + if err != nil || len(encrypted) != 128 || payload["password"] == "JXQN0003-" { + t.Errorf("password was not RSA encrypted: len=%d err=%v", len(encrypted), err) + } + _ = json.NewEncoder(response).Encode(map[string]any{ + "code": 200, "data": map[string]string{"token": fmt.Sprintf("token-%d", loginCalls)}, + }) + case "/api/v1/sys/vehicleRealStatuss": + vehicleCalls++ + if vehicleCalls == 1 { + _, _ = response.Write([]byte(`{"code":401,"data":[]}`)) + return + } + if request.Header.Get("Cookie") != "CSRF=csrf-value; R_SESS=token-2" { + t.Errorf("session cookie = %q", request.Header.Get("Cookie")) + } + _, _ = response.Write([]byte(`{"code":200,"data":[]}`)) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + client, err := NewAuthenticatedAPIClient( + server.URL, + LoginCredentials{Username: "JXQN0003", Password: "JXQN0003-", MaxAttempts: 2}, + solver, + time.Second, + ) + if err != nil { + t.Fatal(err) + } + if _, err := client.Vehicles(context.Background()); err != nil { + t.Fatal(err) + } + if loginCalls != 2 || solver.calls != 2 || vehicleCalls != 2 { + t.Fatalf("login=%d solver=%d vehicles=%d", loginCalls, solver.calls, vehicleCalls) + } +} diff --git a/go/vehicle-gateway/internal/feichibridge/encoder.go b/go/vehicle-gateway/internal/feichibridge/encoder.go new file mode 100644 index 00000000..bd116d27 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/encoder.go @@ -0,0 +1,481 @@ +package feichibridge + +import ( + "encoding/binary" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +const ( + CommandRealtime = byte(0x02) + CommandReissue = byte(0x03) + CommandLogin = byte(0x05) +) + +type Encoder struct{} + +func (Encoder) DataFrame(command byte, vin string, at time.Time, record Record) ([]byte, error) { + if command != CommandRealtime && command != CommandReissue { + return nil, fmt.Errorf("unsupported data command 0x%02X", command) + } + body := encodeTime(at) + units := 0 + if unit, ok := wholeVehicleUnit(record); ok { + body = append(body, 0x01) + body = append(body, unit...) + units++ + } + if unit, ok := motorUnit(record); ok { + body = append(body, 0x02) + body = append(body, unit...) + units++ + } + if unit, ok := fuelCellUnit(record); ok { + body = append(body, 0x03) + body = append(body, unit...) + units++ + } + if unit, ok := positionUnit(record); ok { + body = append(body, 0x05) + body = append(body, unit...) + units++ + } + if unit, ok := extremeUnit(record); ok { + body = append(body, 0x06) + body = append(body, unit...) + units++ + } + if unit, ok := alarmUnit(record); ok { + body = append(body, 0x07) + body = append(body, unit...) + units++ + } + for _, unit := range voltageUnits(record) { + body = append(body, 0x08) + body = append(body, unit...) + units++ + } + for _, unit := range temperatureUnits(record) { + body = append(body, 0x09) + body = append(body, unit...) + units++ + } + if units == 0 { + return nil, errors.New("source record has no mappable GB/T 32960 units") + } + return buildFrame('#', command, 0xFE, vin, body) +} + +func LoginFrame(platformID, username, password string, serial uint16, now time.Time) ([]byte, error) { + if len(username) > 12 { + return nil, errors.New("GB/T 32960 platform username exceeds 12 bytes") + } + if len(password) > 20 { + return nil, errors.New("GB/T 32960 platform password exceeds 20 bytes") + } + body := encodeTime(now) + body = binary.BigEndian.AppendUint16(body, serial) + body = appendPaddedASCII(body, username, 12) + body = appendPaddedASCII(body, password, 20) + body = append(body, 0x01) + return buildFrame('#', CommandLogin, 0xFE, platformID, body) +} + +func buildFrame(start, command, response byte, vin string, body []byte) ([]byte, error) { + if len(vin) != 17 { + return nil, fmt.Errorf("GB/T 32960 identifier must be exactly 17 bytes, got %d", len(vin)) + } + if len(body) > math.MaxUint16 { + return nil, errors.New("GB/T 32960 body exceeds 65535 bytes") + } + frame := make([]byte, 24, 25+len(body)) + frame[0], frame[1] = start, start + frame[2], frame[3] = command, response + copy(frame[4:21], vin) + frame[21] = 0x01 + binary.BigEndian.PutUint16(frame[22:24], uint16(len(body))) + frame = append(frame, body...) + frame = append(frame, bcc(frame[2:])) + return frame, nil +} + +func wholeVehicleUnit(record Record) ([]byte, bool) { + if !hasAny(record, "2201", "2202", "3201", "7615") { + return nil, false + } + out := make([]byte, 20) + out[0] = enum(recordValue(record, "3201"), map[string]byte{ + "启动": 1, "启动状态": 1, "行驶": 1, "熄火": 2, "熄火状态": 2, "其他": 3, + }, 0xFE) + out[1] = enum(recordValue(record, "2301"), map[string]byte{ + "停车充电": 1, "行驶充电": 2, "未充电": 3, "未充电状态": 3, "充电完成": 4, + }, 0xFE) + out[2] = enum(recordValue(record, "2213"), map[string]byte{ + "纯电": 1, "纯电动": 1, "混动": 2, "混合动力": 2, "燃油": 3, + }, 0xFE) + putScaledU16(out[3:5], recordValue(record, "2201"), 10, 0) + putScaledU32(out[5:9], recordValue(record, "2202"), 10) + putScaledU16(out[9:11], recordValue(record, "2613"), 10, 0) + putScaledU16(out[11:13], recordValue(record, "2614"), 10, 1000) + out[13] = byteValue(recordValue(record, "7615")) + out[14] = enum(recordValue(record, "2214"), map[string]byte{"工作": 1, "断开": 2}, 0xFE) + out[15] = gearValue(firstField(recordValue(record, "2203"))) + putU16(out[16:18], recordValue(record, "2617")) + out[18] = byteValue(recordValue(record, "2208")) + out[19] = byteValue(recordValue(record, "2209")) + return out, true +} + +func motorUnit(record Record) ([]byte, bool) { + // The source carries every motor as a key:value composite separated by "|". + // Fields are the GB/T 32960 sequence numbers exposed by the platform metadata. + composite := recordValue(record, "2308") + if composite == "" { + return nil, false + } + groups := parseCompositeGroups(composite) + if len(groups) == 0 || len(groups) > 253 { + return nil, false + } + out := []byte{byte(len(groups))} + for index, group := range groups { + out = append(out, byteValue(firstNonempty(group["2302"], strconv.Itoa(index+1)))) + out = append(out, enum(group["2303"], map[string]byte{ + "耗电": 1, "发电": 2, "关闭": 3, "准备": 4, "准备状态": 4, + }, 0xFE)) + out = append(out, tempByte(group["2304"])) + out = binary.BigEndian.AppendUint16(out, offsetU16(group["2305"], 1, 20000)) + out = binary.BigEndian.AppendUint16(out, offsetU16(group["2306"], 10, 2000)) + out = append(out, tempByte(group["2309"])) + out = binary.BigEndian.AppendUint16(out, scaledU16(group["2311"], 10, 0)) + out = binary.BigEndian.AppendUint16(out, scaledU16(group["2312"], 10, 1000)) + } + return out, true +} + +func fuelCellUnit(record Record) ([]byte, bool) { + if !hasAny(record, "2110", "2111", "2112", "2117", "2119") { + return nil, false + } + temperatureGroups := parseSeriesGroups(recordValue(record, "2103")) + var temperatures []string + for _, group := range temperatureGroups { + temperatures = append(temperatures, group.values...) + } + if len(temperatures) > math.MaxUint16 { + temperatures = temperatures[:math.MaxUint16] + } + out := binary.BigEndian.AppendUint16(nil, scaledU16(recordValue(record, "2110"), 10, 0)) + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2111"), 10, 0)) + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2112"), 100, 0)) + out = binary.BigEndian.AppendUint16(out, uint16(len(temperatures))) + for _, value := range temperatures { + out = append(out, tempByte(value)) + } + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2115"), 10, 40)) + out = append(out, byteValue(recordValue(record, "2116"))) + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2117"), 1, 0)) + out = append(out, byteValue(recordValue(record, "2118"))) + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2119"), 10, 0)) + out = append(out, byteValue(recordValue(record, "2120"))) + out = append(out, enum(recordValue(record, "2121"), map[string]byte{"工作": 1, "断开": 2}, 0xFE)) + return out, true +} + +func positionUnit(record Record) ([]byte, bool) { + longitude, lonOK := floatValue(recordValue(record, "2502")) + latitude, latOK := floatValue(recordValue(record, "2503")) + if !lonOK || !latOK || longitude < 0 || latitude < 0 { + return nil, false + } + status := byte(0) + text := strings.TrimSpace(recordValue(record, "2501")) + if text != "" && (strings.Contains(text, "无效") || text == "1") { + status = 1 + } + out := []byte{status} + out = binary.BigEndian.AppendUint32(out, uint32(math.Round(longitude*1_000_000))) + out = binary.BigEndian.AppendUint32(out, uint32(math.Round(latitude*1_000_000))) + return out, true +} + +func extremeUnit(record Record) ([]byte, bool) { + if !hasAny(record, "2601", "2602", "2603", "2604", "2605", "2606", "2607", "2608", "2609", "2610", "2611", "2612") { + return nil, false + } + out := []byte{ + byteValue(recordValue(record, "2601")), + byteValue(recordValue(record, "2602")), + } + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2603"), 1000, 0)) + out = append(out, byteValue(recordValue(record, "2604")), byteValue(recordValue(record, "2605"))) + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2606"), 1000, 0)) + out = append(out, + byteValue(recordValue(record, "2607")), + byteValue(recordValue(record, "2608")), + tempByte(recordValue(record, "2609")), + byteValue(recordValue(record, "2610")), + byteValue(recordValue(record, "2611")), + tempByte(recordValue(record, "2612")), + ) + return out, true +} + +func alarmUnit(record Record) ([]byte, bool) { + levelRaw := recordValue(record, "2900", "2901") + var general uint32 + for bit := 0; bit < 32; bit++ { + value := recordValue(record, strconv.Itoa(2901+bit)) + if truthy(value) { + general |= 1 << bit + } + } + if strings.TrimSpace(levelRaw) == "" && general == 0 { + return nil, false + } + out := []byte{byteValue(levelRaw)} + out = binary.BigEndian.AppendUint32(out, general) + out = append(out, 0, 0, 0, 0) + return out, true +} + +func voltageUnits(record Record) [][]byte { + raw := recordValue(record, "2003", "batteryVoltages") + groups := parseSeriesGroups(raw) + var units [][]byte + for _, group := range groups { + for start := 0; start < len(group.values); start += 255 { + end := start + 255 + if end > len(group.values) { + end = len(group.values) + } + out := []byte{1, byte(group.id)} + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2613"), 10, 0)) + out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2614"), 10, 1000)) + out = binary.BigEndian.AppendUint16(out, uint16(len(group.values))) + out = binary.BigEndian.AppendUint16(out, uint16(start+1)) + out = append(out, byte(end-start)) + for _, value := range group.values[start:end] { + out = binary.BigEndian.AppendUint16(out, scaledU16(value, 1000, 0)) + } + units = append(units, out) + } + } + return units +} + +func temperatureUnits(record Record) [][]byte { + groups := parseSeriesGroups(recordValue(record, "2103", "batteryTemperatures")) + var units [][]byte + for _, group := range groups { + for start := 0; start < len(group.values); start += math.MaxUint16 { + end := start + math.MaxUint16 + if end > len(group.values) { + end = len(group.values) + } + out := []byte{1, byte(group.id)} + out = binary.BigEndian.AppendUint16(out, uint16(end-start)) + for _, value := range group.values[start:end] { + out = append(out, tempByte(value)) + } + units = append(units, out) + } + } + return units +} + +func parseCompositeGroups(raw string) []map[string]string { + var groups []map[string]string + for _, encoded := range strings.Split(raw, "|") { + group := map[string]string{} + for _, field := range strings.Split(encoded, ",") { + key, value, found := strings.Cut(field, ":") + if found { + group[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + if len(group) > 0 { + groups = append(groups, group) + } + } + return groups +} + +type seriesGroup struct { + id int + values []string +} + +func parseSeriesGroups(raw string) []seriesGroup { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var groups []seriesGroup + for index, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ';' || r == '|' }) { + group := seriesGroup{id: index + 1} + if before, after, found := strings.Cut(part, ":"); found { + if parsed, err := strconv.Atoi(strings.TrimSpace(before)); err == nil && parsed > 0 && parsed <= 255 { + group.id = parsed + } + part = after + } + for _, value := range strings.FieldsFunc(part, func(r rune) bool { + return r == '_' || r == ',' || r == ' ' || r == '[' || r == ']' + }) { + if strings.TrimSpace(value) != "" { + group.values = append(group.values, strings.TrimSpace(value)) + } + } + if len(group.values) > 0 { + groups = append(groups, group) + } + } + return groups +} + +func recordValue(record Record, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(record[key]); value != "" && value != "--" && value != "null" { + return value + } + } + return "" +} + +func firstField(value string) string { + fields := strings.Fields(value) + if len(fields) == 0 { + return "" + } + return fields[0] +} + +func firstNonempty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func hasAny(record Record, keys ...string) bool { return recordValue(record, keys...) != "" } + +func floatValue(raw string) (float64, bool) { + raw = strings.TrimSpace(strings.TrimSuffix(raw, "%")) + if raw == "" { + return 0, false + } + value, err := strconv.ParseFloat(raw, 64) + return value, err == nil && !math.IsNaN(value) && !math.IsInf(value, 0) +} + +func byteValue(raw string) byte { + value, ok := floatValue(raw) + if !ok || value < 0 || value > 253 { + return 0xFE + } + return byte(math.Round(value)) +} + +func tempByte(raw string) byte { + value, ok := floatValue(raw) + if !ok || value < -40 || value > 210 { + return 0xFE + } + return byte(math.Round(value + 40)) +} + +func putU16(out []byte, raw string) { binary.BigEndian.PutUint16(out, scaledU16(raw, 1, 0)) } + +func putScaledU16(out []byte, raw string, scale, offset float64) { + binary.BigEndian.PutUint16(out, scaledU16(raw, scale, offset)) +} + +func putScaledU32(out []byte, raw string, scale float64) { + value, ok := floatValue(raw) + if !ok || value < 0 || value*scale > math.MaxUint32-2 { + binary.BigEndian.PutUint32(out, 0xFFFFFFFE) + return + } + binary.BigEndian.PutUint32(out, uint32(math.Round(value*scale))) +} + +func scaledU16(raw string, scale, offset float64) uint16 { + value, ok := floatValue(raw) + encoded := (value + offset) * scale + if !ok || encoded < 0 || encoded > math.MaxUint16-2 { + return 0xFFFE + } + return uint16(math.Round(encoded)) +} + +func offsetU16(raw string, scale, offset float64) uint16 { + return scaledU16(raw, scale, offset) +} + +func enum(raw string, values map[string]byte, missing byte) byte { + raw = strings.TrimSpace(raw) + if raw == "" { + return missing + } + if numeric, err := strconv.ParseUint(raw, 10, 8); err == nil { + return byte(numeric) + } + for label, value := range values { + if raw == label || strings.Contains(raw, label) { + return value + } + } + return missing +} + +func gearValue(raw string) byte { + raw = strings.ToUpper(strings.TrimSpace(raw)) + switch raw { + case "P", "P档": + return 15 + case "R", "R档": + return 13 + case "N", "N档": + return 0 + case "D", "D档": + return 14 + } + raw = strings.TrimSuffix(raw, "档") + raw = strings.TrimPrefix(raw, "D") + return byteValue(raw) +} + +func truthy(raw string) bool { + raw = strings.ToLower(strings.TrimSpace(raw)) + return raw != "" && raw != "0" && raw != "false" && raw != "无" && raw != "正常" +} + +func appendPaddedASCII(out []byte, value string, width int) []byte { + start := len(out) + out = append(out, make([]byte, width)...) + copy(out[start:start+width], value) + return out +} + +func encodeTime(value time.Time) []byte { + local := value.In(shanghai) + return []byte{ + byte(local.Year() - 2000), byte(local.Month()), byte(local.Day()), + byte(local.Hour()), byte(local.Minute()), byte(local.Second()), + } +} + +func bcc(value []byte) byte { + var result byte + for _, current := range value { + result ^= current + } + return result +} diff --git a/go/vehicle-gateway/internal/feichibridge/encoder_test.go b/go/vehicle-gateway/internal/feichibridge/encoder_test.go new file mode 100644 index 00000000..8543b8fa --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/encoder_test.go @@ -0,0 +1,93 @@ +package feichibridge + +import ( + "encoding/binary" + "fmt" + "strings" + "testing" + "time" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960" +) + +func TestDataFrameMapsFeichiFields(t *testing.T) { + at := time.Date(2026, 7, 17, 14, 5, 6, 0, shanghai) + record := Record{ + "2000": "2026-07-17 14:05:06", + "2201": "42.3", "2202": "12345.6", "2203": "D 0 0", + "2208": "35", "2209": "0", "2213": "纯电", + "2214": "工作", "2301": "未充电状态", "3201": "启动状态", + "2613": "550.2", "2614": "-20.5", "2617": "2200", "7615": "78", + "2501": "有效定位", "2502": "113.2644", "2503": "23.1291", + "2601": "1", "2602": "8", "2603": "3.955", + "2604": "1", "2605": "26", "2606": "3.821", + "2607": "1", "2608": "3", "2609": "48", + "2610": "1", "2611": "9", "2612": "37", + "2003": "1:3.900_3.901_3.902", + "2103": "1:35_36_37", + } + frame, err := (Encoder{}).DataFrame(CommandRealtime, "LTEST32960VIN0001", at, record) + if err != nil { + t.Fatal(err) + } + env, err := gb32960.ParseFrame(frame, at.UnixMilli(), "test") + if err != nil { + t.Fatal(err) + } + if env.MessageID != "0x02" || env.VIN != "LTEST32960VIN0001" { + t.Fatalf("unexpected envelope: %#v", env) + } + if got := env.Fields["speed_kmh"]; got != 42.3 { + t.Fatalf("speed = %#v", got) + } + if got := env.Fields["total_mileage_km"]; got != 12345.6 { + t.Fatalf("mileage = %#v", got) + } + if got := env.Fields["soc_percent"]; got != 78 { + t.Fatalf("soc = %#v", got) + } + if got := env.Fields["longitude"]; got != 113.2644 { + t.Fatalf("longitude = %#v", got) + } +} + +func TestDataFrameSplitsMoreThan255CellVoltages(t *testing.T) { + values := make([]string, 288) + for index := range values { + values[index] = fmt.Sprintf("%.3f", 3.5+float64(index)/1000) + } + frame, err := (Encoder{}).DataFrame(CommandReissue, "LTEST32960VIN0002", time.Now(), Record{ + "2003": "1:" + strings.Join(values, "_"), + "2613": "530", + "2614": "10", + }) + if err != nil { + t.Fatal(err) + } + body := frame[24 : len(frame)-1] + if got := strings.Count(string(body), string([]byte{0x08})); got < 2 { + t.Fatalf("expected at least two voltage units, got %d", got) + } + if binary.BigEndian.Uint16(frame[22:24]) != uint16(len(body)) { + t.Fatal("body length mismatch") + } + if _, err := gb32960.ParseFrame(frame, time.Now().UnixMilli(), "test"); err != nil { + t.Fatal(err) + } +} + +func TestLoginFrameLayout(t *testing.T) { + frame, err := LoginFrame("FEICHIBRIDGE00001", "bridge", "secret", 7, time.Date(2026, 7, 17, 1, 2, 3, 0, shanghai)) + if err != nil { + t.Fatal(err) + } + if frame[2] != CommandLogin || frame[3] != 0xFE { + t.Fatalf("unexpected login header: %x", frame[:4]) + } + if got := binary.BigEndian.Uint16(frame[30:32]); got != 7 { + t.Fatalf("serial = %d", got) + } + if got, want := frame[len(frame)-1], bcc(frame[2:len(frame)-1]); got != want { + t.Fatalf("BCC = %x, want %x", got, want) + } +} diff --git a/go/vehicle-gateway/internal/feichibridge/login.go b/go/vehicle-gateway/internal/feichibridge/login.go new file mode 100644 index 00000000..ec1f150c --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/login.go @@ -0,0 +1,209 @@ +package feichibridge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +const feichiPublicKey = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOwmFtEk1oJxDU0NI4kVO0Jx0X +nt+Abx+JKGUzcRzPwjUkd5z9Ice5rh87CCmj0XjZ5pPac6TtA3f0v5FqiK/kjQY5 +XMLti4weJ4dcp1/q1O7PCYxRX8WgetGxwsjxGn+uoZOZkclN1PFS4wRnKEso6+G/ +e60QGB29cZoo4jZnZwIDAQAB +-----END PUBLIC KEY-----` + +type LoginCredentials struct { + Username string + Password string + MaxAttempts int + RetryDelay time.Duration +} + +type loginSession struct { + credentials LoginCredentials + solver CaptchaSolver + mu sync.Mutex + ready atomic.Bool + csrf atomic.Value + token atomic.Value +} + +type firstLoginEnvelope struct { + Code any `json:"code"` +} + +type captchaPayload struct { + Src string `json:"src"` +} + +type loginPayload struct { + Token string `json:"token"` +} + +func newLoginSession(credentials LoginCredentials, solver CaptchaSolver) (*loginSession, error) { + credentials.Username = strings.TrimSpace(credentials.Username) + if credentials.Username == "" || credentials.Password == "" { + return nil, errors.New("Feichi username and password are required") + } + if solver == nil { + return nil, errors.New("captcha solver is required") + } + if credentials.MaxAttempts <= 0 { + credentials.MaxAttempts = 20 + } + if credentials.RetryDelay <= 0 { + credentials.RetryDelay = time.Second + } + session := &loginSession{credentials: credentials, solver: solver} + session.csrf.Store("") + session.token.Store("") + return session, nil +} + +func (s *loginSession) invalidate() { + s.ready.Store(false) +} + +func (s *loginSession) cookieHeader() string { + var cookies []string + if csrf := s.csrf.Load().(string); csrf != "" { + cookies = append(cookies, "CSRF="+csrf) + } + if token := s.token.Load().(string); token != "" { + cookies = append(cookies, "R_SESS="+token) + } + return strings.Join(cookies, "; ") +} + +func (s *loginSession) csrfHeader() string { + return s.csrf.Load().(string) +} + +func (s *loginSession) ensure(ctx context.Context, client *APIClient) error { + if s.ready.Load() { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.ready.Load() { + return nil + } + var lastErr error + for attempt := 1; attempt <= s.credentials.MaxAttempts; attempt++ { + if err := s.loginAttempt(ctx, client); err == nil { + s.ready.Store(true) + return nil + } else { + lastErr = err + } + if attempt < s.credentials.MaxAttempts { + timer := time.NewTimer(s.credentials.RetryDelay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + } + return fmt.Errorf("Feichi automatic login failed after %d attempts: %w", s.credentials.MaxAttempts, lastErr) +} + +func (s *loginSession) loginAttempt(ctx context.Context, client *APIClient) error { + s.ready.Store(false) + s.token.Store("") + var first firstLoginEnvelope + headers, err := client.rawJSON(ctx, http.MethodGet, "api/v1/first-login", nil, &first) + if err != nil { + return fmt.Errorf("initialize login session: %w", err) + } + if !apiCodeOK(first.Code) { + return apiCodeError(first.Code) + } + csrf := strings.TrimSpace(headers.Get("x-api-csrf")) + if csrf == "" { + for _, rawCookie := range headers.Values("Set-Cookie") { + cookie, parseErr := http.ParseSetCookie(rawCookie) + if parseErr == nil && cookie.Name == "CSRF" { + csrf = cookie.Value + break + } + } + } + if csrf == "" { + return errors.New("first-login response did not provide CSRF") + } + s.csrf.Store(csrf) + + var captcha objectEnvelope[captchaPayload] + if _, err := client.rawJSON(ctx, http.MethodGet, "api/v1/login/randCode", nil, &captcha); err != nil { + return fmt.Errorf("request captcha: %w", err) + } + if !apiCodeOK(captcha.Code) { + return apiCodeError(captcha.Code) + } + encodedImage := captcha.Data.Src + if comma := strings.IndexByte(encodedImage, ','); comma >= 0 { + encodedImage = encodedImage[comma+1:] + } + image, err := base64.StdEncoding.DecodeString(encodedImage) + if err != nil { + return fmt.Errorf("decode captcha image: %w", err) + } + validCode, err := s.solver.Solve(ctx, image) + if err != nil { + return err + } + encrypted, err := encryptFeichiPassword(s.credentials.Password) + if err != nil { + return err + } + var login objectEnvelope[loginPayload] + if _, err := client.rawJSON(ctx, http.MethodPost, "api/v1/login", map[string]string{ + "username": s.credentials.Username, + "password": encrypted, + "validCode": validCode, + }, &login); err != nil { + return fmt.Errorf("submit login: %w", err) + } + if !apiCodeOK(login.Code) { + return apiCodeError(login.Code) + } + token := strings.TrimSpace(login.Data.Token) + if token == "" { + return errors.New("login succeeded without R_SESS token") + } + s.token.Store(token) + return nil +} + +func encryptFeichiPassword(password string) (string, error) { + block, _ := pem.Decode([]byte(feichiPublicKey)) + if block == nil { + return "", errors.New("decode Feichi RSA public key") + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("parse Feichi RSA public key: %w", err) + } + publicKey, ok := parsed.(*rsa.PublicKey) + if !ok { + return "", errors.New("Feichi public key is not RSA") + } + encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, []byte(password)) + if err != nil { + return "", fmt.Errorf("encrypt Feichi password: %w", err) + } + return base64.StdEncoding.EncodeToString(encrypted), nil +} diff --git a/go/vehicle-gateway/internal/feichibridge/model.go b/go/vehicle-gateway/internal/feichibridge/model.go new file mode 100644 index 00000000..4ac10ae5 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/model.go @@ -0,0 +1,94 @@ +package feichibridge + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60) + +type Vehicle struct { + VehicleID string `json:"vehicleId"` + VIN string `json:"vin"` + OnlineStatus any `json:"onlineStatus"` + UpdateTime string `json:"updateTime"` + RuleTypeName string `json:"ruleTypeName"` +} + +type Snapshot struct { + VehicleID string `json:"vehicleId"` + VIN string `json:"vin"` + DataItems map[string]string `json:"dataItems"` +} + +type Record map[string]string + +type collectionEnvelope[T any] struct { + Code any `json:"code"` + Data []T `json:"data"` +} + +type objectEnvelope[T any] struct { + Code any `json:"code"` + Data T `json:"data"` +} + +type historySegment struct { + Subdata []Record `json:"subdata"` +} + +func apiCodeOK(code any) bool { + switch value := code.(type) { + case nil: + return true + case float64: + return value == 0 || value == 200 + case string: + return value == "" || value == "0" || value == "200" + default: + return false + } +} + +func recordTime(record Record) (time.Time, error) { + for _, key := range []string{"2000", "9999", "dataTime", "updateTime", "time"} { + if raw := strings.TrimSpace(record[key]); raw != "" { + return parseSourceTime(raw) + } + } + return time.Time{}, fmt.Errorf("source record has no timestamp") +} + +func parseSourceTime(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + for _, layout := range []string{ + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + time.RFC3339Nano, + } { + if layout == time.RFC3339Nano { + if parsed, err := time.Parse(layout, raw); err == nil { + return parsed, nil + } + continue + } + if parsed, err := time.ParseInLocation(layout, raw, shanghai); err == nil { + return parsed, nil + } + } + if unixMS, err := strconv.ParseInt(raw, 10, 64); err == nil { + if unixMS < 10_000_000_000 { + return time.Unix(unixMS, 0).In(shanghai), nil + } + return time.UnixMilli(unixMS).In(shanghai), nil + } + return time.Time{}, fmt.Errorf("unsupported source timestamp %q", raw) +} + +func canonicalHash(record Record) string { + encoded, _ := json.Marshal(record) + return hashBytes(encoded) +} diff --git a/go/vehicle-gateway/internal/feichibridge/service.go b/go/vehicle-gateway/internal/feichibridge/service.go new file mode 100644 index 00000000..5d6f05a7 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/service.go @@ -0,0 +1,469 @@ +package feichibridge + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + "time" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" +) + +type Source interface { + Vehicles(context.Context) ([]Vehicle, error) + Snapshot(context.Context, string) (Snapshot, error) + History(context.Context, string, time.Time, time.Time) ([]Record, error) +} + +type FrameTarget interface { + Connect(context.Context) error + Send(context.Context, []byte) error + Close() error + LastACK() time.Time +} + +type ServiceConfig struct { + PollInterval time.Duration + DiscoveryInterval time.Duration + BackfillInterval time.Duration + BackfillLookback time.Duration + BackfillWindow time.Duration + BackfillSafetyLag time.Duration + SourceStaleAfter time.Duration + FetchConcurrency int + BackfillEnabled bool + StaleReissueEnabled bool +} + +type Service struct { + config ServiceConfig + source Source + target FrameTarget + state *StateStore + encoder Encoder + logger *slog.Logger + metrics *metrics.Registry + + mu sync.RWMutex + vehicles []Vehicle + lastSourceSuccess time.Time + lastError error +} + +func NewService(config ServiceConfig, source Source, target FrameTarget, state *StateStore, logger *slog.Logger, registry *metrics.Registry) (*Service, error) { + if source == nil || target == nil || state == nil { + return nil, errors.New("source, target, and state are required") + } + if config.PollInterval <= 0 { + config.PollInterval = 10 * time.Second + } + if config.DiscoveryInterval <= 0 { + config.DiscoveryInterval = 5 * time.Minute + } + if config.BackfillInterval <= 0 { + config.BackfillInterval = time.Hour + } + if config.BackfillLookback <= 0 { + config.BackfillLookback = time.Hour + } + if config.BackfillWindow <= 0 { + config.BackfillWindow = 20 * time.Minute + } + if config.BackfillSafetyLag <= 0 { + config.BackfillSafetyLag = 30 * time.Second + } + if config.SourceStaleAfter <= 0 { + config.SourceStaleAfter = 2 * time.Minute + } + if config.FetchConcurrency <= 0 { + config.FetchConcurrency = 4 + } + if logger == nil { + logger = slog.Default() + } + return &Service{ + config: config, source: source, target: target, state: state, + logger: logger, metrics: registry, + }, nil +} + +func (s *Service) Run(ctx context.Context) error { + if err := s.target.Connect(ctx); err != nil { + s.setError(err) + return fmt.Errorf("connect GB/T 32960 target: %w", err) + } + if err := s.discover(ctx); err != nil { + s.setError(err) + return fmt.Errorf("initial vehicle discovery: %w", err) + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + s.discoveryLoop(ctx) + }() + go func() { + defer wg.Done() + s.realtimeLoop(ctx) + }() + if s.config.BackfillEnabled { + wg.Add(1) + go func() { + defer wg.Done() + s.backfillLoop(ctx) + }() + } + <-ctx.Done() + _ = s.target.Close() + wg.Wait() + return nil +} + +func (s *Service) Ready(context.Context) error { + s.mu.RLock() + lastSourceSuccess := s.lastSourceSuccess + lastErr := s.lastError + s.mu.RUnlock() + maxSourceAge := max(3*s.config.PollInterval, time.Minute) + if lastSourceSuccess.IsZero() || time.Since(lastSourceSuccess) > maxSourceAge { + if lastErr != nil { + return fmt.Errorf("source unavailable: %w", lastErr) + } + return errors.New("source has not completed a successful request") + } + lastACK := s.target.LastACK() + if lastACK.IsZero() || time.Since(lastACK) > max(3*s.config.DiscoveryInterval, 10*time.Minute) { + return errors.New("GB/T 32960 target has no recent ACK") + } + return nil +} + +func (s *Service) discoveryLoop(ctx context.Context) { + ticker := time.NewTicker(s.config.DiscoveryInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.discover(ctx); err != nil { + s.recordFailure("discover", err) + } + } + } +} + +func (s *Service) realtimeLoop(ctx context.Context) { + s.pollRealtime(ctx) + ticker := time.NewTicker(s.config.PollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.pollRealtime(ctx) + } + } +} + +func (s *Service) backfillLoop(ctx context.Context) { + s.runBackfill(ctx) + ticker := time.NewTicker(s.config.BackfillInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.runBackfill(ctx) + } + } +} + +func (s *Service) discover(ctx context.Context) error { + start := time.Now() + vehicles, err := s.source.Vehicles(ctx) + s.observeAPI("vehicles", start, err) + if err != nil { + return err + } + filtered := make([]Vehicle, 0, len(vehicles)) + for _, vehicle := range vehicles { + vehicle.VIN = strings.TrimSpace(vehicle.VIN) + vehicle.VehicleID = strings.TrimSpace(vehicle.VehicleID) + if len(vehicle.VIN) != 17 || vehicle.VehicleID == "" { + continue + } + if vehicle.RuleTypeName != "" && !strings.Contains(strings.ToUpper(vehicle.RuleTypeName), "32960") { + continue + } + filtered = append(filtered, vehicle) + } + sort.Slice(filtered, func(i, j int) bool { return filtered[i].VIN < filtered[j].VIN }) + s.mu.Lock() + s.vehicles = filtered + s.lastSourceSuccess = time.Now() + s.lastError = nil + s.mu.Unlock() + if s.metrics != nil { + s.metrics.SetGauge("vehicle_feichi_bridge_vehicles", nil, float64(len(filtered))) + } + s.logger.Info("feichi vehicles discovered", "count", len(filtered)) + return nil +} + +type fetchedSnapshot struct { + vehicle Vehicle + record Record + at time.Time + hash string + err error + duration time.Duration +} + +func (s *Service) pollRealtime(ctx context.Context) { + vehicles := s.vehicleSnapshot() + jobs := make(chan Vehicle) + results := make(chan fetchedSnapshot, len(vehicles)) + var wg sync.WaitGroup + for worker := 0; worker < min(s.config.FetchConcurrency, len(vehicles)); worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for vehicle := range jobs { + start := time.Now() + snapshot, err := s.source.Snapshot(ctx, vehicle.VehicleID) + result := fetchedSnapshot{vehicle: vehicle, duration: time.Since(start), err: err} + if err == nil { + result.record = Record(snapshot.DataItems) + result.at, result.err = recordTime(result.record) + result.hash = canonicalHash(result.record) + } + results <- result + } + }() + } + for _, vehicle := range vehicles { + jobs <- vehicle + } + close(jobs) + wg.Wait() + close(results) + + var snapshots []fetchedSnapshot + for result := range results { + s.observeAPIWithDuration("snapshot", result.duration, result.err) + if result.err != nil { + s.recordFailure("snapshot", fmt.Errorf("VIN %s: %w", result.vehicle.VIN, result.err)) + continue + } + snapshots = append(snapshots, result) + s.markSourceSuccess() + } + sort.Slice(snapshots, func(i, j int) bool { + if snapshots[i].at.Equal(snapshots[j].at) { + return snapshots[i].vehicle.VIN < snapshots[j].vehicle.VIN + } + return snapshots[i].at.Before(snapshots[j].at) + }) + for _, snapshot := range snapshots { + if time.Since(snapshot.at) > s.config.SourceStaleAfter { + if s.config.StaleReissueEnabled { + s.reissueStaleSnapshot(ctx, snapshot) + } + continue + } + current := s.state.Vehicle(snapshot.vehicle.VIN) + if snapshot.at.Before(current.LastRealtimeTime) || + (snapshot.at.Equal(current.LastRealtimeTime) && snapshot.hash == current.LastRealtimeHash) { + continue + } + frame, err := s.encoder.DataFrame(CommandRealtime, snapshot.vehicle.VIN, snapshot.at, snapshot.record) + if err != nil { + s.recordFailure("encode", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err)) + continue + } + if err := s.target.Send(ctx, frame); err != nil { + s.recordFrame(CommandRealtime, "error", snapshot.vehicle.VIN, snapshot.at) + s.recordFailure("send", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err)) + continue + } + if err := s.state.CommitRealtime(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil { + s.recordFailure("state", err) + continue + } + s.recordFrame(CommandRealtime, "acked", snapshot.vehicle.VIN, snapshot.at) + } +} + +func (s *Service) reissueStaleSnapshot(ctx context.Context, snapshot fetchedSnapshot) { + current := s.state.Vehicle(snapshot.vehicle.VIN) + if snapshot.at.Before(current.LastSnapshotReissueTime) || + (snapshot.at.Equal(current.LastSnapshotReissueTime) && snapshot.hash == current.LastSnapshotReissueHash) { + return + } + frame, err := s.encoder.DataFrame(CommandReissue, snapshot.vehicle.VIN, snapshot.at, snapshot.record) + if err != nil { + s.recordFailure("encode_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err)) + return + } + if err := s.target.Send(ctx, frame); err != nil { + s.recordFrame(CommandReissue, "error", snapshot.vehicle.VIN, snapshot.at) + s.recordFailure("send_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err)) + return + } + if err := s.state.CommitSnapshotReissue(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil { + s.recordFailure("state", err) + return + } + s.recordFrame(CommandReissue, "acked", snapshot.vehicle.VIN, snapshot.at) +} + +func (s *Service) runBackfill(ctx context.Context) { + for _, vehicle := range s.vehicleSnapshot() { + if ctx.Err() != nil { + return + } + if err := s.backfillVehicle(ctx, vehicle); err != nil { + s.recordFailure("backfill", fmt.Errorf("VIN %s: %w", vehicle.VIN, err)) + } + } +} + +func (s *Service) backfillVehicle(ctx context.Context, vehicle Vehicle) error { + end := time.Now().Add(-s.config.BackfillSafetyLag) + cursor := s.state.Vehicle(vehicle.VIN).BackfillCursor + if cursor.IsZero() { + cursor = end.Add(-s.config.BackfillLookback) + } + for cursor.Before(end) { + windowEnd := cursor.Add(s.config.BackfillWindow) + if windowEnd.After(end) { + windowEnd = end + } + start := time.Now() + records, err := s.source.History(ctx, vehicle.VIN, cursor, windowEnd) + s.observeAPI("history", start, err) + if err != nil { + return err + } + sort.Slice(records, func(i, j int) bool { + left, _ := recordTime(records[i]) + right, _ := recordTime(records[j]) + return left.Before(right) + }) + committed := cursor + for _, record := range records { + at, err := recordTime(record) + if err != nil || !at.After(cursor) || at.After(windowEnd) { + continue + } + frame, err := s.encoder.DataFrame(CommandReissue, vehicle.VIN, at, record) + if err != nil { + return err + } + if err := s.target.Send(ctx, frame); err != nil { + s.recordFrame(CommandReissue, "error", vehicle.VIN, at) + return err + } + if err := s.state.CommitBackfill(vehicle.VIN, at); err != nil { + return err + } + s.recordFrame(CommandReissue, "acked", vehicle.VIN, at) + committed = at + } + if !committed.After(cursor) || committed.Before(windowEnd) { + if err := s.state.AdvanceBackfillCursor(vehicle.VIN, windowEnd); err != nil { + return err + } + } + cursor = windowEnd + s.markSourceSuccess() + } + return nil +} + +func (s *Service) vehicleSnapshot() []Vehicle { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]Vehicle(nil), s.vehicles...) +} + +func (s *Service) markSourceSuccess() { + s.mu.Lock() + s.lastSourceSuccess = time.Now() + s.lastError = nil + s.mu.Unlock() + if s.metrics != nil { + metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_source_last_success_unix_seconds", nil) + } +} + +func (s *Service) setError(err error) { + s.mu.Lock() + s.lastError = err + s.mu.Unlock() +} + +func (s *Service) recordFailure(operation string, err error) { + if err == nil { + return + } + s.setError(err) + if s.metrics != nil { + s.metrics.IncCounter("vehicle_feichi_bridge_errors_total", metrics.Labels{"operation": operation}) + } + s.logger.Error("feichi bridge operation failed", "operation", operation, "error", err) +} + +func (s *Service) observeAPI(operation string, start time.Time, err error) { + s.observeAPIWithDuration(operation, time.Since(start), err) +} + +func (s *Service) observeAPIWithDuration(operation string, duration time.Duration, err error) { + if s.metrics == nil { + return + } + status := "ok" + if err != nil { + status = "error" + } + s.metrics.IncCounter("vehicle_feichi_bridge_api_requests_total", metrics.Labels{"operation": operation, "status": status}) + s.metrics.ObserveHistogram( + "vehicle_feichi_bridge_api_request_duration_seconds", + metrics.Labels{"operation": operation}, + []float64{0.1, 0.25, 0.5, 1, 2, 5}, + duration.Seconds(), + ) +} + +func (s *Service) recordFrame(command byte, status, vin string, sourceTime time.Time) { + if s.metrics != nil { + s.metrics.IncCounter("vehicle_feichi_bridge_frames_total", metrics.Labels{ + "command": fmt.Sprintf("0x%02X", command), + "status": status, + }) + } + if status == "acked" { + if s.metrics != nil { + metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_target_last_ack_unix_seconds", nil) + s.metrics.SetGauge( + "vehicle_feichi_bridge_vehicle_last_ack_unix_seconds", + metrics.Labels{"vin": vin, "command": fmt.Sprintf("0x%02X", command)}, + float64(time.Now().Unix()), + ) + } + s.logger.Info( + "GB/T 32960 vehicle frame acknowledged", + "vin", vin, + "command", fmt.Sprintf("0x%02X", command), + "source_time", sourceTime, + ) + } +} diff --git a/go/vehicle-gateway/internal/feichibridge/service_test.go b/go/vehicle-gateway/internal/feichibridge/service_test.go new file mode 100644 index 00000000..de775a62 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/service_test.go @@ -0,0 +1,123 @@ +package feichibridge + +import ( + "context" + "errors" + "log/slog" + "path/filepath" + "sync" + "testing" + "time" +) + +type fakeSource struct { + vehicles []Vehicle + record Record +} + +func (f *fakeSource) Vehicles(context.Context) ([]Vehicle, error) { + return append([]Vehicle(nil), f.vehicles...), nil +} + +func (f *fakeSource) Snapshot(context.Context, string) (Snapshot, error) { + return Snapshot{DataItems: map[string]string(f.record)}, nil +} + +func (f *fakeSource) History(context.Context, string, time.Time, time.Time) ([]Record, error) { + return nil, nil +} + +type fakeTarget struct { + mu sync.Mutex + frames [][]byte + sendErr error + lastACK time.Time +} + +func (f *fakeTarget) Connect(context.Context) error { return nil } +func (f *fakeTarget) Close() error { return nil } +func (f *fakeTarget) LastACK() time.Time { return f.lastACK } +func (f *fakeTarget) Send(_ context.Context, frame []byte) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.sendErr != nil { + return f.sendErr + } + f.frames = append(f.frames, append([]byte(nil), frame...)) + f.lastACK = time.Now() + return nil +} + +func TestRealtimeCommitsOnlyAfterACKAndDeduplicates(t *testing.T) { + now := time.Now().In(shanghai).Truncate(time.Second) + source := &fakeSource{ + vehicles: []Vehicle{{ + VehicleID: "id-1", VIN: "LTEST32960VIN0001", RuleTypeName: "GB_T32960", + }}, + record: Record{"2000": now.Format("2006-01-02 15:04:05"), "2201": "10"}, + } + target := &fakeTarget{} + store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + service, err := NewService(ServiceConfig{ + SourceStaleAfter: time.Minute, + FetchConcurrency: 1, + }, source, target, store, slog.Default(), nil) + if err != nil { + t.Fatal(err) + } + if err := service.discover(context.Background()); err != nil { + t.Fatal(err) + } + service.pollRealtime(context.Background()) + service.pollRealtime(context.Background()) + if len(target.frames) != 1 { + t.Fatalf("frames = %d, want 1", len(target.frames)) + } + if got := store.Vehicle("LTEST32960VIN0001"); !got.LastRealtimeTime.Equal(now) { + t.Fatalf("committed state = %#v", got) + } + + source.record = Record{"2000": now.Add(time.Second).Format("2006-01-02 15:04:05"), "2201": "11"} + target.sendErr = errors.New("target down") + service.pollRealtime(context.Background()) + if got := store.Vehicle("LTEST32960VIN0001"); !got.LastRealtimeTime.Equal(now) { + t.Fatalf("cursor advanced without ACK: %#v", got) + } +} + +func TestStaleSnapshotIsReissuedOnlyOnce(t *testing.T) { + at := time.Now().In(shanghai).Add(-24 * time.Hour).Truncate(time.Second) + vin := "LTEST32960VIN0002" + source := &fakeSource{ + vehicles: []Vehicle{{VehicleID: "id-2", VIN: vin, RuleTypeName: "GB_T32960"}}, + record: Record{"2000": at.Format("2006-01-02 15:04:05"), "2201": "0"}, + } + target := &fakeTarget{} + store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + service, err := NewService(ServiceConfig{ + SourceStaleAfter: time.Minute, + FetchConcurrency: 1, + StaleReissueEnabled: true, + }, source, target, store, slog.Default(), nil) + if err != nil { + t.Fatal(err) + } + if err := service.discover(context.Background()); err != nil { + t.Fatal(err) + } + service.pollRealtime(context.Background()) + service.pollRealtime(context.Background()) + if len(target.frames) != 1 || target.frames[0][2] != CommandReissue { + t.Fatalf("frames = %d command = %#v", len(target.frames), target.frames) + } + state := store.Vehicle(vin) + if !state.LastSnapshotReissueTime.Equal(at) || state.LastSnapshotReissueACKAt.IsZero() { + t.Fatalf("reissue state = %#v", state) + } +} diff --git a/go/vehicle-gateway/internal/feichibridge/state.go b/go/vehicle-gateway/internal/feichibridge/state.go new file mode 100644 index 00000000..adeb6d17 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/state.go @@ -0,0 +1,160 @@ +package feichibridge + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +type VehicleState struct { + LastRealtimeTime time.Time `json:"last_realtime_time,omitempty"` + LastRealtimeHash string `json:"last_realtime_hash,omitempty"` + LastRealtimeACKAt time.Time `json:"last_realtime_ack_at,omitempty"` + LastSnapshotReissueTime time.Time `json:"last_snapshot_reissue_time,omitempty"` + LastSnapshotReissueHash string `json:"last_snapshot_reissue_hash,omitempty"` + LastSnapshotReissueACKAt time.Time `json:"last_snapshot_reissue_ack_at,omitempty"` + BackfillCursor time.Time `json:"backfill_cursor,omitempty"` + LastBackfillACKAt time.Time `json:"last_backfill_ack_at,omitempty"` +} + +func (s *StateStore) CommitSnapshotReissue(vin string, at time.Time, hash string) error { + s.mu.Lock() + defer s.mu.Unlock() + current := s.state.Vehicles[vin] + current.LastSnapshotReissueTime = at + current.LastSnapshotReissueHash = hash + current.LastSnapshotReissueACKAt = time.Now() + s.state.Vehicles[vin] = current + return s.saveLocked() +} + +type persistentState struct { + Version int `json:"version"` + PlatformSerial uint16 `json:"platform_serial"` + Vehicles map[string]VehicleState `json:"vehicles"` +} + +type StateStore struct { + mu sync.Mutex + path string + state persistentState +} + +func OpenStateStore(path string) (*StateStore, error) { + store := &StateStore{ + path: path, + state: persistentState{ + Version: 1, + Vehicles: map[string]VehicleState{}, + }, + } + encoded, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return store, nil + } + if err != nil { + return nil, fmt.Errorf("read bridge state: %w", err) + } + if err := json.Unmarshal(encoded, &store.state); err != nil { + return nil, fmt.Errorf("decode bridge state: %w", err) + } + if store.state.Vehicles == nil { + store.state.Vehicles = map[string]VehicleState{} + } + return store, nil +} + +func (s *StateStore) Vehicle(vin string) VehicleState { + s.mu.Lock() + defer s.mu.Unlock() + return s.state.Vehicles[vin] +} + +func (s *StateStore) CommitRealtime(vin string, at time.Time, hash string) error { + s.mu.Lock() + defer s.mu.Unlock() + current := s.state.Vehicles[vin] + current.LastRealtimeTime = at + current.LastRealtimeHash = hash + current.LastRealtimeACKAt = time.Now() + s.state.Vehicles[vin] = current + return s.saveLocked() +} + +func (s *StateStore) CommitBackfill(vin string, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + current := s.state.Vehicles[vin] + current.BackfillCursor = at + current.LastBackfillACKAt = time.Now() + s.state.Vehicles[vin] = current + return s.saveLocked() +} + +func (s *StateStore) AdvanceBackfillCursor(vin string, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + current := s.state.Vehicles[vin] + current.BackfillCursor = at + s.state.Vehicles[vin] = current + return s.saveLocked() +} + +func (s *StateStore) NextPlatformSerial() (uint16, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.state.PlatformSerial++ + if s.state.PlatformSerial == 0 { + s.state.PlatformSerial = 1 + } + if err := s.saveLocked(); err != nil { + return 0, err + } + return s.state.PlatformSerial, nil +} + +func (s *StateStore) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil { + return fmt.Errorf("create bridge state directory: %w", err) + } + encoded, err := json.MarshalIndent(s.state, "", " ") + if err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(s.path), ".state-*.json") + if err != nil { + return err + } + tempName := temp.Name() + defer os.Remove(tempName) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(encoded); err != nil { + temp.Close() + return err + } + if err := temp.Sync(); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, s.path); err != nil { + return fmt.Errorf("replace bridge state: %w", err) + } + return nil +} + +func hashBytes(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} diff --git a/go/vehicle-gateway/internal/feichibridge/state_test.go b/go/vehicle-gateway/internal/feichibridge/state_test.go new file mode 100644 index 00000000..503563e7 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/state_test.go @@ -0,0 +1,38 @@ +package feichibridge + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestStateStorePersistsAcknowledgedCursor(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "state.json") + store, err := OpenStateStore(path) + if err != nil { + t.Fatal(err) + } + at := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + if err := store.CommitRealtime("LTEST32960VIN0001", at, "hash"); err != nil { + t.Fatal(err) + } + if err := store.CommitBackfill("LTEST32960VIN0001", at.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + reopened, err := OpenStateStore(path) + if err != nil { + t.Fatal(err) + } + got := reopened.Vehicle("LTEST32960VIN0001") + if !got.LastRealtimeTime.Equal(at) || got.LastRealtimeHash != "hash" { + t.Fatalf("state = %#v", got) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("state mode = %o", info.Mode().Perm()) + } +} diff --git a/go/vehicle-gateway/internal/feichibridge/target.go b/go/vehicle-gateway/internal/feichibridge/target.go new file mode 100644 index 00000000..e924fc86 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/target.go @@ -0,0 +1,193 @@ +package feichibridge + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "strings" + "sync" + "time" +) + +type TargetConfig struct { + Address string + PlatformID string + Username string + Password string + Timeout time.Duration +} + +type Target struct { + mu sync.Mutex + config TargetConfig + state *StateStore + dialer net.Dialer + conn net.Conn + lastACK time.Time +} + +func NewTarget(config TargetConfig, state *StateStore) (*Target, error) { + if strings.TrimSpace(config.Address) == "" { + return nil, errors.New("GB/T 32960 target address is required") + } + if len(config.PlatformID) != 17 { + return nil, fmt.Errorf("target platform ID must be exactly 17 bytes") + } + if state == nil { + return nil, errors.New("state store is required") + } + if config.Timeout <= 0 { + config.Timeout = 10 * time.Second + } + return &Target{ + config: config, + state: state, + dialer: net.Dialer{Timeout: config.Timeout, KeepAlive: 30 * time.Second}, + }, nil +} + +func (t *Target) Send(ctx context.Context, frame []byte) error { + t.mu.Lock() + defer t.mu.Unlock() + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + if err := t.ensureConnected(ctx); err != nil { + lastErr = err + t.closeLocked() + continue + } + if err := t.sendAndACK(ctx, frame); err != nil { + lastErr = err + t.closeLocked() + continue + } + t.lastACK = time.Now() + return nil + } + return fmt.Errorf("send GB/T 32960 frame after reconnect: %w", lastErr) +} + +func (t *Target) Connect(ctx context.Context) error { + t.mu.Lock() + defer t.mu.Unlock() + if err := t.ensureConnected(ctx); err != nil { + t.closeLocked() + return err + } + return nil +} + +func (t *Target) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.conn == nil { + return nil + } + err := t.conn.Close() + t.conn = nil + return err +} + +func (t *Target) LastACK() time.Time { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastACK +} + +func (t *Target) ensureConnected(ctx context.Context) error { + if t.conn != nil { + return nil + } + conn, err := t.dialer.DialContext(ctx, "tcp", t.config.Address) + if err != nil { + return fmt.Errorf("dial target %s: %w", t.config.Address, err) + } + t.conn = conn + serial, err := t.state.NextPlatformSerial() + if err != nil { + return fmt.Errorf("allocate platform login serial: %w", err) + } + login, err := LoginFrame(t.config.PlatformID, t.config.Username, t.config.Password, serial, time.Now()) + if err != nil { + return err + } + if err := t.sendAndACK(ctx, login); err != nil { + return fmt.Errorf("GB/T 32960 platform login: %w", err) + } + t.lastACK = time.Now() + return nil +} + +func (t *Target) sendAndACK(ctx context.Context, frame []byte) error { + if t.conn == nil { + return errors.New("target connection is closed") + } + deadline := time.Now().Add(t.config.Timeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if err := t.conn.SetDeadline(deadline); err != nil { + return err + } + if err := writeFull(t.conn, frame); err != nil { + return fmt.Errorf("write target frame: %w", err) + } + response, err := readFrame(t.conn) + if err != nil { + return fmt.Errorf("read target ACK: %w", err) + } + if response[2] != frame[2] { + return fmt.Errorf("target ACK command mismatch: got 0x%02X want 0x%02X", response[2], frame[2]) + } + if response[3] != 0x01 { + return fmt.Errorf("target rejected command 0x%02X with response 0x%02X", response[2], response[3]) + } + if string(response[4:21]) != string(frame[4:21]) { + return errors.New("target ACK identifier mismatch") + } + return nil +} + +func (t *Target) closeLocked() { + if t.conn != nil { + _ = t.conn.Close() + t.conn = nil + } +} + +func readFrame(reader io.Reader) ([]byte, error) { + header := make([]byte, 24) + if _, err := io.ReadFull(reader, header); err != nil { + return nil, err + } + if (header[0] != '#' || header[1] != '#') && (header[0] != '$' || header[1] != '$') { + return nil, fmt.Errorf("bad GB/T 32960 ACK start %q", header[:2]) + } + bodyLength := int(binary.BigEndian.Uint16(header[22:24])) + tail := make([]byte, bodyLength+1) + if _, err := io.ReadFull(reader, tail); err != nil { + return nil, err + } + frame := append(header, tail...) + if got, want := bcc(frame[2:len(frame)-1]), frame[len(frame)-1]; got != want { + return nil, fmt.Errorf("bad GB/T 32960 ACK BCC: got 0x%02X want 0x%02X", got, want) + } + return frame, nil +} + +func writeFull(writer io.Writer, value []byte) error { + for len(value) > 0 { + written, err := writer.Write(value) + if err != nil { + return err + } + if written == 0 { + return io.ErrShortWrite + } + value = value[written:] + } + return nil +} diff --git a/go/vehicle-gateway/internal/feichibridge/target_test.go b/go/vehicle-gateway/internal/feichibridge/target_test.go new file mode 100644 index 00000000..5b6e38b9 --- /dev/null +++ b/go/vehicle-gateway/internal/feichibridge/target_test.go @@ -0,0 +1,83 @@ +package feichibridge + +import ( + "context" + "net" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestTargetLogsInAndWaitsForACK(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var commands []byte + var mu sync.Mutex + done := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + done <- acceptErr + return + } + defer conn.Close() + for count := 0; count < 2; count++ { + frame, readErr := readFrame(conn) + if readErr != nil { + done <- readErr + return + } + mu.Lock() + commands = append(commands, frame[2]) + mu.Unlock() + body := []byte(nil) + if len(frame) >= 31 { + body = append(body, frame[24:30]...) + } + ack, buildErr := buildFrame('#', frame[2], 0x01, string(frame[4:21]), body) + if buildErr != nil { + done <- buildErr + return + } + if writeErr := writeFull(conn, ack); writeErr != nil { + done <- writeErr + return + } + } + done <- nil + }() + + store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + target, err := NewTarget(TargetConfig{ + Address: listener.Addr().String(), PlatformID: "FEICHIBRIDGE00001", + Username: "bridge", Password: "secret", Timeout: time.Second, + }, store) + if err != nil { + t.Fatal(err) + } + frame, err := (Encoder{}).DataFrame(CommandRealtime, "LTEST32960VIN0001", time.Now(), Record{"2201": "1"}) + if err != nil { + t.Fatal(err) + } + if err := target.Send(context.Background(), frame); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(commands) != 2 || commands[0] != CommandLogin || commands[1] != CommandRealtime { + t.Fatalf("commands = %x", commands) + } + if target.LastACK().IsZero() { + t.Fatal("last ACK was not recorded") + } +} diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser.go b/go/vehicle-gateway/internal/protocol/gb32960/parser.go index 93917ff4..219ffd60 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser.go @@ -151,16 +151,23 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim fields[envelope.FieldSOCPercent] = unit["soc_percent"] cursor += size case 0x05: - if len(body[cursor:]) < 9 { + size := 9 + if version == "V2025" { + size = 10 + } + if len(body[cursor:]) < size { units = append(units, map[string]any{"type": "0x05", "error": "truncated"}) return eventTime, units } - unit := parsePositionData(body[cursor : cursor+9]) + unit := parsePositionData(version, body[cursor:cursor+size]) units = append(units, map[string]any{"type": "0x05", "name": "position", "value": unit}) fields["position_status"] = unit["position_status"] + if coordinateSystem, ok := unit["coordinate_system"]; ok { + fields["coordinate_system"] = coordinateSystem + } fields[envelope.FieldLongitude] = unit["longitude"] fields[envelope.FieldLatitude] = unit["latitude"] - cursor += 9 + cursor += size case 0x02: if len(body[cursor:]) < 1 { units = append(units, map[string]any{"type": "0x02", "error": "truncated"}) @@ -364,21 +371,27 @@ func parsePlatformLogin(body []byte) map[string]any { } func parseVehicleData(data []byte) map[string]any { - return map[string]any{ + out := map[string]any{ "vehicle_status": int(data[0]), "charge_status": int(data[1]), "running_mode": int(data[2]), - "speed_kmh": scaledU16(data[3:5], 10), - "total_mileage_km": scaledU32(data[5:9], 10), - "total_voltage_v": scaledU16(data[9:11], 10), - "total_current_a": scaledU16WithOffset(data[11:13], 10, -1000), - "soc_percent": int(data[13]), + "speed_kmh": nullableScaledU16(data[3:5], 10), + "total_mileage_km": nullableScaledU32(data[5:9], 10), + "total_voltage_v": nullableScaledU16(data[9:11], 10), + "total_current_a": nullableScaledU16WithOffset(data[11:13], 10, -1000), + "soc_percent": nullableByte(data[13]), "dc_dc_status": int(data[14]), "gear": int(data[15]), - "insulation_kohm": binary.BigEndian.Uint16(data[16:18]), - "accelerator_pct": int(data[18]), - "brake_pct": int(data[19]), + "insulation_kohm": nullableU16(data[16:18]), } + // GB/T 32960.3-2025 removes the 2016 accelerator and brake bytes from + // the 0x01 vehicle unit. Keep them only when the frame actually carries + // the 20-byte 2016 payload. + if len(data) >= 20 { + out["accelerator_pct"] = nullableByte(data[18]) + out["brake_pct"] = nullableByte(data[19]) + } + return out } func parseDriveMotorData(data []byte) map[string]any { @@ -389,12 +402,12 @@ func parseDriveMotorData(data []byte) map[string]any { motors = append(motors, map[string]any{ "serial_no": int(data[cursor]), "state": int(data[cursor+1]), - "controller_temperature_c": int(data[cursor+2]) - 40, - "speed_rpm": int(binary.BigEndian.Uint16(data[cursor+3:cursor+5])) - 20000, - "torque_nm": scaledIntWithOffset(int64(binary.BigEndian.Uint16(data[cursor+5:cursor+7])), 10, -20000), - "motor_temperature_c": int(data[cursor+7]) - 40, - "controller_voltage_v": scaledU16(data[cursor+8:cursor+10], 10), - "controller_current_a": scaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000), + "controller_temperature_c": nullableTempOffset40(data[cursor+2]), + "speed_rpm": nullableU16WithOffset(data[cursor+3:cursor+5], -20000), + "torque_nm": nullableScaledU16WithOffset(data[cursor+5:cursor+7], 10, -20000), + "motor_temperature_c": nullableTempOffset40(data[cursor+7]), + "controller_voltage_v": nullableScaledU16(data[cursor+8:cursor+10], 10), + "controller_current_a": nullableScaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000), }) cursor += 12 } @@ -406,34 +419,45 @@ func parseDriveMotorData(data []byte) map[string]any { func parseFuelCellData(data []byte) map[string]any { probeCount := int(binary.BigEndian.Uint16(data[6:8])) - probes := make([]int, 0, probeCount) + probes := make([]any, 0, probeCount) cursor := 8 for i := 0; i < probeCount; i++ { - probes = append(probes, int(data[cursor])-40) + probes = append(probes, nullableTempOffset40(data[cursor])) cursor++ } out := map[string]any{ - "fuel_cell_voltage_v": scaledU16(data[0:2], 10), - "fuel_cell_current_a": scaledU16(data[2:4], 10), - "hydrogen_consumption_kg_per_100km": scaledU16(data[4:6], 100), + "fuel_cell_voltage_v": nullableScaledU16(data[0:2], 10), + "fuel_cell_current_a": nullableScaledU16(data[2:4], 10), + "hydrogen_consumption_kg_per_100km": nullableScaledU16(data[4:6], 100), "temperature_probe_count": probeCount, "temperature_probe_values_c": probes, - "max_hydrogen_temperature_c": scaledU16WithOffset(data[cursor:cursor+2], 10, -40), + "max_hydrogen_temperature_c": nullableScaledU16WithOffset(data[cursor:cursor+2], 10, -40), "max_hydrogen_temperature_probe_id": int(data[cursor+2]), - "max_hydrogen_concentration_fraction": scaledU16(data[cursor+3:cursor+5], 1_000_000), "max_hydrogen_concentration_probe_id": int(data[cursor+5]), - "max_hydrogen_pressure_mpa": scaledU16(data[cursor+6:cursor+8], 10), + "max_hydrogen_pressure_mpa": nullableScaledU16(data[cursor+6:cursor+8], 10), "max_hydrogen_pressure_probe_id": int(data[cursor+8]), "dc_dc_status": int(data[cursor+9]), } + if concentration := nullableU16(data[cursor+3 : cursor+5]); concentration != nil { + raw := concentration.(int) + out["max_hydrogen_concentration_fraction"] = float64(raw) / 1_000_000 + out["max_hydrogen_concentration_ppm"] = raw + out["max_hydrogen_concentration_percent"] = float64(raw) / 10_000 + } else { + out["max_hydrogen_concentration_fraction"] = nil + out["max_hydrogen_concentration_ppm"] = nil + out["max_hydrogen_concentration_percent"] = nil + } return out } func parseEngineData(data []byte) map[string]any { return map[string]any{ "engine_status": int(data[0]), - "crank_speed_rpm": binary.BigEndian.Uint16(data[1:3]), - "fuel_rate": binary.BigEndian.Uint16(data[3:5]), + "crank_speed_rpm": nullableU16(data[1:3]), + // The supplied authoritative reference does not define this field's + // business unit or scale, so preserve the decoded protocol integer. + "fuel_rate": nullableU16(data[3:5]), } } @@ -632,11 +656,13 @@ func parseGdFCStackData(data []byte) map[string]any { "frame_cell_count": frameCellCount, } cursor += 22 + frameVoltages := make([]float64, 0, frameCellCount) maxCell := 0.0 minCell := 0.0 seen := false for c := 0; c < frameCellCount; c++ { voltage := scaledU16(data[cursor:cursor+2], 1000) + frameVoltages = append(frameVoltages, voltage) if !seen || voltage > maxCell { maxCell = voltage } @@ -650,6 +676,7 @@ func parseGdFCStackData(data []byte) map[string]any { summary["frame_max_cell_voltage_v"] = maxCell summary["frame_min_cell_voltage_v"] = minCell } + summary["frame_cell_voltages_v"] = frameVoltages summaries = append(summaries, summary) } return map[string]any{ @@ -760,6 +787,13 @@ func nullableU16WithOffset(data []byte, offset int) any { return int(value) + offset } +func nullableByte(value byte) any { + if value == 0xfe || value == 0xff { + return nil + } + return int(value) +} + func scaledU16(data []byte, divisor int64) float64 { return scaledInt(int64(binary.BigEndian.Uint16(data)), divisor) } @@ -791,6 +825,14 @@ func nullableScaledU16(data []byte, divisor int64) any { return scaledInt(int64(value), divisor) } +func nullableScaledU32(data []byte, divisor int64) any { + value := binary.BigEndian.Uint32(data) + if value == 0xfffffffe || value == 0xffffffff { + return nil + } + return scaledInt(int64(value), divisor) +} + func nullableScaledU16WithOffset(data []byte, divisor int64, offset int64) any { value := binary.BigEndian.Uint16(data) if value == 0xfffe || value == 0xffff { @@ -806,12 +848,16 @@ func nullableTempOffset40(value byte) any { return int(value) - 40 } -func parsePositionData(data []byte) map[string]any { - return map[string]any{ - "position_status": int(data[0]), - "longitude": float64(binary.BigEndian.Uint32(data[1:5])) / 1_000_000, - "latitude": float64(binary.BigEndian.Uint32(data[5:9])) / 1_000_000, +func parsePositionData(version string, data []byte) map[string]any { + offset := 1 + out := map[string]any{"position_status": int(data[0])} + if version == "V2025" { + out["coordinate_system"] = int(data[1]) + offset = 2 } + out["longitude"] = float64(binary.BigEndian.Uint32(data[offset:offset+4])) / 1_000_000 + out["latitude"] = float64(binary.BigEndian.Uint32(data[offset+4:offset+8])) / 1_000_000 + return out } func indexStart(data []byte) int { diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go index fb176df8..0e8c4dc1 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go @@ -222,6 +222,13 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin if units[2]["name"] != "fuel_cell" { t.Fatalf("unexpected fuel cell unit: %#v", units[2]) } + fuelCell, ok := units[2]["value"].(map[string]any) + if !ok { + t.Fatalf("fuel cell payload missing: %#v", units[2]) + } + if _, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok { + t.Fatalf("hydrogen concentration ppm missing: %#v", fuelCell["max_hydrogen_concentration_ppm"]) + } if units[9]["name"] != "gd_fc_stack" { t.Fatalf("unexpected gd stack unit: %#v", units[9]) } @@ -237,6 +244,69 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin } } +func TestParseFuelCellDataExposesHydrogenConcentrationInReferencePercent(t *testing.T) { + data := make([]byte, 18) + data[11] = 0x01 + data[12] = 0xf4 + fuelCell := parseFuelCellData(data) + if concentration, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok || concentration != 500 { + t.Fatalf("hydrogen concentration ppm = %#v, want int(500)", fuelCell["max_hydrogen_concentration_ppm"]) + } + if fraction, ok := fuelCell["max_hydrogen_concentration_fraction"].(float64); !ok || math.Abs(fraction-0.0005) > 0.0000001 { + t.Fatalf("hydrogen concentration fraction = %#v, want 0.0005", fuelCell["max_hydrogen_concentration_fraction"]) + } + if percent, ok := fuelCell["max_hydrogen_concentration_percent"].(float64); !ok || math.Abs(percent-0.05) > 0.0000001 { + t.Fatalf("hydrogen concentration percent = %#v, want 0.05", fuelCell["max_hydrogen_concentration_percent"]) + } +} + +func TestParseFuelCellDataTreatsProtocolInvalidMarkersAsMissing(t *testing.T) { + data := make([]byte, 18) + for index := range data { + data[index] = 0xff + } + data[6], data[7] = 0, 0 + fuelCell := parseFuelCellData(data) + for _, key := range []string{"fuel_cell_voltage_v", "fuel_cell_current_a", "hydrogen_consumption_kg_per_100km", "max_hydrogen_temperature_c", "max_hydrogen_concentration_percent", "max_hydrogen_pressure_mpa"} { + if fuelCell[key] != nil { + t.Fatalf("%s should be nil for protocol invalid marker: %#v", key, fuelCell[key]) + } + } +} + +func TestParseV2025VehicleAndPositionUsesCoordinateSystemByte(t *testing.T) { + body := []byte{0x1a, 0x07, 0x11, 0x0a, 0x00, 0x00, 0x01} + body = append(body, + 0x01, 0x03, 0x01, + 0x00, 0x64, + 0x00, 0x00, 0x03, 0xe8, + 0x13, 0x88, + 0x27, 0x10, + 80, 0x01, 0x00, + 0x03, 0xe8) + body = append(body, + 0x05, + 0x00, 0x02, + 0x07, 0x36, 0x50, 0x40, + 0x01, 0xd2, 0x4f, 0x00) + fields := map[string]any{} + _, units := parseDataBody("V2025", body, fields) + if len(units) != 2 { + t.Fatalf("units = %#v", units) + } + vehicle := units[0]["value"].(map[string]any) + if _, exists := vehicle["accelerator_pct"]; exists { + t.Fatalf("2025 vehicle unit must not invent removed pedal fields: %#v", vehicle) + } + position := units[1]["value"].(map[string]any) + if position["coordinate_system"] != 2 { + t.Fatalf("coordinate system = %#v", position["coordinate_system"]) + } + if longitude := position["longitude"].(float64); math.Abs(longitude-121) > 0.000001 { + t.Fatalf("longitude = %v", longitude) + } +} + func TestScaledDecimalFieldsDoNotLeakBinaryFloatTails(t *testing.T) { unit := parseDriveMotorData([]byte{ 0x01, diff --git a/go/vehicle-gateway/internal/realtime/kv_test.go b/go/vehicle-gateway/internal/realtime/kv_test.go index 1fbf773f..97aae517 100644 --- a/go/vehicle-gateway/internal/realtime/kv_test.go +++ b/go/vehicle-gateway/internal/realtime/kv_test.go @@ -6,6 +6,8 @@ import ( "testing" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/yutongmqtt" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry" ) func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) { @@ -157,6 +159,37 @@ func TestBuildFieldsEnvelopeCopiesSourceMetadata(t *testing.T) { } } +func TestBuildFieldsEnvelopeKeepsYutongJSONNumberMileage(t *testing.T) { + env, err := yutongmqtt.ParseMessage( + "yutong", + "/ytforward/shln/1", + []byte(`{"device":"LMRKH9AC2R1004106","time":"2026-07-17 11:50:13.021","data":{"LATITUDE":30.466027,"LONGITUDE":103.96096,"METER_SPEED":0,"TOTAL_MILEAGE":77081000}}`), + 1784260213051, + ) + if err != nil { + t.Fatalf("ParseMessage() error = %v", err) + } + if !EnsureParsedFields(&env) { + t.Fatal("EnsureParsedFields() should compute ingress fields") + } + fieldsEnv, ok := BuildFieldsEnvelope(env) + if !ok { + t.Fatal("BuildFieldsEnvelope() should emit Yutong fields") + } + for _, field := range []string{ + "yutong_mqtt.data.total_mileage", + "yutong_mqtt.root.data.total_mileage", + } { + if got := fieldsEnv.Fields[field]; got != "77081000" { + t.Fatalf("%s = %#v, want 77081000", field, got) + } + } + mileageKM, found := telemetry.TotalMileageKM(fieldsEnv.Protocol, fieldsEnv.Fields) + if !found || mileageKM != 77081 { + t.Fatalf("TotalMileageKM() = %.3f, %v; want 77081, true", mileageKM, found) + } +} + func TestBuildFieldsEnvelopeDropsNonPositiveTotalMileage(t *testing.T) { fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, diff --git a/go/vehicle-gateway/internal/realtime/repository.go b/go/vehicle-gateway/internal/realtime/repository.go index 2693d62d..af05ef79 100644 --- a/go/vehicle-gateway/internal/realtime/repository.go +++ b/go/vehicle-gateway/internal/realtime/repository.go @@ -825,12 +825,27 @@ func positiveNumber(value any) bool { return typed > 0 case int: return typed > 0 + case int8: + return typed > 0 + case int16: + return typed > 0 + case int32: + return typed > 0 case int64: return typed > 0 + case uint: + return typed > 0 + case uint8: + return typed > 0 case uint16: return typed > 0 case uint32: return typed > 0 + case uint64: + return typed > 0 + case json.Number: + parsed, err := typed.Float64() + return err == nil && parsed > 0 case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) return err == nil && parsed > 0 diff --git a/go/vehicle-gateway/internal/stats/daily_metric.go b/go/vehicle-gateway/internal/stats/daily_metric.go index 8b491a01..cdfa6ef7 100644 --- a/go/vehicle-gateway/internal/stats/daily_metric.go +++ b/go/vehicle-gateway/internal/stats/daily_metric.go @@ -18,6 +18,7 @@ const ( defaultCacheRetention = 72 * time.Hour defaultCacheCleanupInterval = 10 * time.Minute defaultBaselineMissTTL = time.Minute + defaultBaselineHitTTL = 5 * time.Minute defaultMaxCacheEntries = 1000000 ) @@ -34,6 +35,7 @@ type Writer struct { cacheRetention time.Duration cacheCleanupInterval time.Duration baselineMissTTL time.Duration + baselineHitTTL time.Duration maxCacheEntries int lastCacheCleanup time.Time lastCacheCleanupStats cacheCleanupStats @@ -122,6 +124,7 @@ func NewWriter(exec Execer, loc *time.Location) *Writer { cacheRetention: defaultCacheRetention, cacheCleanupInterval: defaultCacheCleanupInterval, baselineMissTTL: defaultBaselineMissTTL, + baselineHitTTL: defaultBaselineHitTTL, maxCacheEntries: defaultMaxCacheEntries, lastTotalMileage: map[string]float64{}, lastSourceSeen: map[string]time.Time{}, @@ -182,6 +185,15 @@ func (w *Writer) SetBaselineMissTTL(ttl time.Duration) { w.baselineMissTTL = ttl } +func (w *Writer) SetBaselineHitTTL(ttl time.Duration) { + w.mu.Lock() + defer w.mu.Unlock() + if ttl < 0 { + ttl = 0 + } + w.baselineHitTTL = ttl +} + func (w *Writer) SetMaxCacheEntries(maxEntries int) { w.mu.Lock() defer w.mu.Unlock() @@ -517,8 +529,12 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa now := time.Now() w.mu.Lock() if cached, ok := w.baselineCache[cacheKey]; ok { - missExpired := !cached.found && (w.baselineMissTTL == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= w.baselineMissTTL) - if !missExpired { + ttl := w.baselineMissTTL + if cached.found { + ttl = w.baselineHitTTL + } + expired := ttl == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= ttl + if !expired { w.mu.Unlock() return cached.baseline, cached.found, nil } @@ -570,6 +586,16 @@ func (w *Writer) cacheBaseline(candidate SourceMileageSample, cacheKey string, e return } w.mu.Lock() + if previous, ok := w.baselineCache[cacheKey]; ok && + previous.found && entry.found && + previous.baseline.LatestTotalKM == entry.baseline.LatestTotalKM && + previous.baseline.LatestEventTime.Equal(entry.baseline.LatestEventTime) && + !previous.cachedAt.IsZero() { + // markBaselineWritten runs for every accepted sample. Preserve the + // original cache age when the durable day-boundary baseline has not + // changed, otherwise an active vehicle would keep stale history forever. + entry.cachedAt = previous.cachedAt + } if entry.cachedAt.IsZero() { entry.cachedAt = time.Now() } diff --git a/go/vehicle-gateway/internal/stats/daily_metric_test.go b/go/vehicle-gateway/internal/stats/daily_metric_test.go index ad048597..dc54b5d9 100644 --- a/go/vehicle-gateway/internal/stats/daily_metric_test.go +++ b/go/vehicle-gateway/internal/stats/daily_metric_test.go @@ -1091,6 +1091,91 @@ func TestWriterRetriesExpiredMissingPreviousDayBaseline(t *testing.T) { } } +func TestWriterRefreshesExpiredFoundPreviousDayBaseline(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer db.Close() + + loc := time.FixedZone("Asia/Shanghai", 8*3600) + writer := NewWriter(db, loc) + writer.SetBaselineHitTTL(time.Minute) + candidate := SourceMileageSample{ + VIN: "LMRKH9AC6R1004111", + StatDate: "2026-07-17", + Protocol: envelope.ProtocolYutongMQTT, + SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong", + } + cacheKey := mileageCacheKey(MetricSample{ + VIN: candidate.VIN, + Protocol: candidate.Protocol, + StatDate: candidate.StatDate, + SourceKey: candidate.SourceKey, + }) + staleTime := time.Date(2026, 7, 12, 4, 12, 20, 0, loc) + writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{ + baseline: sourceBaseline{LatestTotalKM: 90778, LatestEventTime: staleTime}, + found: true, + cachedAt: time.Now().Add(-2 * time.Minute), + } + + refreshedTime := time.Date(2026, 7, 16, 23, 59, 59, 0, loc) + mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`). + WithArgs(candidate.VIN, candidate.StatDate, "YUTONG_MQTT", candidate.SourceKey). + WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}). + AddRow(91302.0, refreshedTime)) + + baseline, found, err := writer.previousBaseline(context.Background(), candidate) + if err != nil || !found { + t.Fatalf("previousBaseline() found=%v error=%v, want refreshed baseline", found, err) + } + if baseline.LatestTotalKM != 91302 || !baseline.LatestEventTime.Equal(refreshedTime) { + t.Fatalf("baseline = %+v, want nearest refreshed baseline", baseline) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } +} + +func TestWriterMarkBaselineKeepsCacheAgeForUnchangedBoundary(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer db.Close() + + loc := time.FixedZone("Asia/Shanghai", 8*3600) + writer := NewWriter(db, loc) + candidate := SourceMileageSample{ + VIN: "LMRKH9AC6R1004111", + StatDate: "2026-07-17", + Protocol: envelope.ProtocolYutongMQTT, + SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong", + FirstTotalKM: 91302, + FirstEventTime: time.Date(2026, 7, 16, 23, 59, 59, 0, loc), + } + sample := MetricSample{ + VIN: candidate.VIN, + Protocol: candidate.Protocol, + StatDate: candidate.StatDate, + SourceKey: candidate.SourceKey, + } + cacheKey := mileageCacheKey(sample) + cachedAt := time.Now().Add(-4 * time.Minute) + writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{ + baseline: sourceBaseline{LatestTotalKM: candidate.FirstTotalKM, LatestEventTime: candidate.FirstEventTime}, + found: true, + cachedAt: cachedAt, + } + + writer.markBaselineWritten(sample, candidate) + + if got := writer.baselineCache[cacheKey].cachedAt; !got.Equal(cachedAt) { + t.Fatalf("cachedAt = %s, want unchanged %s", got, cachedAt) + } +} + func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { diff --git a/go/vehicle-gateway/internal/stats/source_mileage.go b/go/vehicle-gateway/internal/stats/source_mileage.go index cb5ea063..97684299 100644 --- a/go/vehicle-gateway/internal/stats/source_mileage.go +++ b/go/vehicle-gateway/internal/stats/source_mileage.go @@ -337,10 +337,30 @@ func projectDailyMileageWithExec(ctx context.Context, exec Execer, vin string, s return err } +const upsertSourceStatDateStartSQL = `CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)` + +const upsertSourcePreferIncomingFirstSQL = `( + first_event_time IS NULL + OR ( + first_event_time < ` + upsertSourceStatDateStartSQL + ` + AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + ` + AND VALUES(first_event_time) >= first_event_time + ) + OR ( + first_event_time >= ` + upsertSourceStatDateStartSQL + ` + AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + ` + ) + OR ( + first_event_time >= ` + upsertSourceStatDateStartSQL + ` + AND VALUES(first_event_time) >= ` + upsertSourceStatDateStartSQL + ` + AND VALUES(first_event_time) <= first_event_time + ) + )` + const upsertSourceMergedFirstTotalSQL = `CASE WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0 THEN VALUES(first_total_mileage_km) - WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time + WHEN ` + upsertSourcePreferIncomingFirstSQL + ` THEN VALUES(first_total_mileage_km) ELSE first_total_mileage_km END` @@ -354,7 +374,7 @@ const upsertSourceMergedLatestTotalSQL = `CASE END` const upsertSourceMergedFirstEventSQL = `CASE - WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time + WHEN ` + upsertSourcePreferIncomingFirstSQL + ` THEN VALUES(first_event_time) ELSE first_event_time END` @@ -490,6 +510,8 @@ WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ? AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `') + AND s.first_total_mileage_km IS NOT NULL + AND s.latest_total_mileage_km IS NOT NULL AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) @@ -558,6 +580,8 @@ WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ? AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `') + AND s.first_total_mileage_km IS NOT NULL + AND s.latest_total_mileage_km IS NOT NULL AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) diff --git a/go/vehicle-gateway/internal/stats/source_mileage_test.go b/go/vehicle-gateway/internal/stats/source_mileage_test.go index db1882f1..43014eff 100644 --- a/go/vehicle-gateway/internal/stats/source_mileage_test.go +++ b/go/vehicle-gateway/internal/stats/source_mileage_test.go @@ -187,7 +187,9 @@ func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) { for _, want := range []string{ "first_total_mileage_km = CASE", + "VALUES(first_event_time) >= first_event_time", "VALUES(first_event_time) <= first_event_time", + "VALUES(first_event_time) < CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)", "latest_total_mileage_km = CASE", "VALUES(latest_event_time) >= latest_event_time", "daily_mileage_km = CASE", @@ -334,6 +336,22 @@ func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) { } } +func TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing.T) { + for name, query := range map[string]string{ + "insert": normalizePlatformSourceMileageInsertSQL, + "delete": normalizePlatformSourceMileageDeleteSQL, + } { + for _, predicate := range []string{ + "s.first_total_mileage_km IS NOT NULL", + "s.latest_total_mileage_km IS NOT NULL", + } { + if !strings.Contains(query, predicate) { + t.Fatalf("%s query missing incomplete-total guard %q:\n%s", name, predicate, query) + } + } + } +} + func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) { db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) if err != nil { diff --git a/go/vehicle-gateway/scripts/install-stats-backfill-timer.sh b/go/vehicle-gateway/scripts/install-stats-backfill-timer.sh index 99cfad1c..f466a625 100755 --- a/go/vehicle-gateway/scripts/install-stats-backfill-timer.sh +++ b/go/vehicle-gateway/scripts/install-stats-backfill-timer.sh @@ -5,6 +5,7 @@ HOST="" REMOTE_ROOT="/opt/lingniu-go-native" DAYS_BACK=1 WINDOW_DAYS=3 +BASELINE_LOOKBACK_DAYS=7 PROTOCOLS="GB32960,JT808,YUTONG_MQTT" ON_CALENDAR="*-*-* 01:30:00" DRY_RUN="false" @@ -19,6 +20,9 @@ Options: --remote-root DIR Remote install root. Defaults to /opt/lingniu-go-native. --days-back N Target end date relative to local day. Defaults to 1, meaning yesterday. --window-days N Number of days to recalculate ending at days-back. Defaults to 3. + --baseline-lookback-days N + Maximum days searched before the target window for a mileage baseline. + Defaults to 7. --protocols LIST Comma-separated protocols. Defaults to GB32960,JT808,YUTONG_MQTT. --on-calendar SPEC systemd OnCalendar value. Defaults to "*-*-* 01:30:00". --dry-run Install timer in dry-run mode. @@ -44,6 +48,10 @@ while [[ $# -gt 0 ]]; do WINDOW_DAYS="${2:-}" shift 2 ;; + --baseline-lookback-days) + BASELINE_LOOKBACK_DAYS="${2:-}" + shift 2 + ;; --protocols) PROTOCOLS="${2:-}" shift 2 @@ -86,11 +94,17 @@ case "$WINDOW_DAYS" in exit 2 ;; esac +case "$BASELINE_LOOKBACK_DAYS" in + ''|*[!0-9]*|0) + echo "--baseline-lookback-days must be a positive integer" >&2 + exit 2 + ;; +esac remote_cmd="" printf -v remote_cmd \ - "REMOTE_ROOT=%q DAYS_BACK=%q WINDOW_DAYS=%q PROTOCOLS=%q ON_CALENDAR=%q DRY_RUN=%q bash -s" \ - "$REMOTE_ROOT" "$DAYS_BACK" "$WINDOW_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN" + "REMOTE_ROOT=%q DAYS_BACK=%q WINDOW_DAYS=%q BASELINE_LOOKBACK_DAYS=%q PROTOCOLS=%q ON_CALENDAR=%q DRY_RUN=%q bash -s" \ + "$REMOTE_ROOT" "$DAYS_BACK" "$WINDOW_DAYS" "$BASELINE_LOOKBACK_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN" ssh "$HOST" "$remote_cmd" <<'REMOTE' set -euo pipefail @@ -110,6 +124,7 @@ Environment=BACKFILL_METHOD=last_diff Environment=BACKFILL_DRY_RUN=${DRY_RUN} Environment=BACKFILL_DAYS_BACK=${DAYS_BACK} Environment=BACKFILL_WINDOW_DAYS=${WINDOW_DAYS} +Environment=BACKFILL_BASELINE_LOOKBACK_DAYS=${BASELINE_LOOKBACK_DAYS} Environment=BACKFILL_PROTOCOLS=${PROTOCOLS} ExecStart=${REMOTE_ROOT}/current/stats-backfill TimeoutStartSec=45min diff --git a/vehicle-data-platform/apps/api/internal/app/auth.go b/vehicle-data-platform/apps/api/internal/app/auth.go index 9b5b6d3b..140fbeeb 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth.go +++ b/vehicle-data-platform/apps/api/internal/app/auth.go @@ -253,6 +253,8 @@ func requiredRole(r *http.Request) string { path := r.URL.Path if r.Method == http.MethodPost { switch path { + case "/api/v2/auth/logout": + return "viewer" case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports": return "viewer" case "/api/v2/alerts/notifications/read": diff --git a/vehicle-data-platform/apps/api/internal/app/auth_test.go b/vehicle-data-platform/apps/api/internal/app/auth_test.go index 0ba515b4..f1f798e1 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_test.go @@ -147,6 +147,15 @@ func TestAPIAuthSessionAndDisabledMode(t *testing.T) { } } +func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) { + if role := requiredRole(httptest.NewRequest(http.MethodPost, "/api/v2/auth/logout", nil)); role != "viewer" { + t.Fatalf("logout should allow authenticated customers, required role=%s", role) + } + if role := requiredRole(httptest.NewRequest(http.MethodPut, "/api/v2/auth/password", nil)); role != "viewer" { + t.Fatalf("password change should allow authenticated customers, required role=%s", role) + } +} + func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) { cases := []config.Config{ {AuthMode: "enforce"}, diff --git a/vehicle-data-platform/apps/api/internal/platform/access_store.go b/vehicle-data-platform/apps/api/internal/platform/access_store.go index a4f2aa67..8470203e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/access_store.go @@ -65,7 +65,7 @@ func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceR rows, err := s.db.QueryContext(ctx, `SELECT v.vin, COALESCE(NULLIF(s.plate, ''), NULLIF(b.plate, ''), '') AS plate, -COALESCE(NULLIF(b.oem, ''), '') AS oem, +COALESCE(NULLIF(p.brand_name, ''), NULLIF(b.oem, ''), '') AS oem, COALESCE(p.model_name, '') AS model_name, COALESCE(p.company_name, '') AS company_name, COALESCE(s.protocol, '') AS protocol, @@ -145,7 +145,7 @@ LIMIT ?`, accessEvidenceLimit+1) } func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error { - s.accessSchemaOnce.Do(func() { + return s.accessSchema.ensure(func() error { statements := []string{ `CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config ( id TINYINT NOT NULL PRIMARY KEY, @@ -170,17 +170,16 @@ func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error { } for _, statement := range statements { if _, err := s.db.ExecContext(ctx, statement); err != nil { - s.accessSchemaErr = err - return + return err } } defaults := defaultAccessThresholds(time.Now()) protocols, _ := json.Marshal(defaults.Protocols) - _, s.accessSchemaErr = s.db.ExecContext(ctx, `INSERT IGNORE INTO vehicle_access_threshold_config + _, err := s.db.ExecContext(ctx, `INSERT IGNORE INTO vehicle_access_threshold_config (id, version, default_threshold_sec, delay_threshold_sec, long_offline_sec, protocol_overrides_json, updated_by) VALUES (1, ?, ?, ?, ?, ?, ?)`, defaults.Version, defaults.DefaultThresholdSec, defaults.DelayThresholdSec, defaults.LongOfflineSec, string(protocols), defaults.UpdatedBy) + return err }) - return s.accessSchemaErr } func (s *ProductionStore) AccessThresholds(ctx context.Context) (AccessThresholdConfig, error) { diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go b/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go index ffdc5e54..602bba0f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go @@ -439,7 +439,7 @@ func loadActiveAlertEvents(ctx context.Context, tx *sql.Tx) (map[string][]alertA } func (s *ProductionStore) alertEvaluationEvidence(ctx context.Context) ([]alertEvaluationEvidence, error) { - rows, err := s.db.QueryContext(ctx, `SELECT l.vin,COALESCE(NULLIF(l.plate,''),NULLIF(b.plate,''),''),l.protocol,COALESCE(b.oem,''),COALESCE(p.model_name,''),COALESCE(p.company_name,''),COALESCE(l.speed_kmh,0),COALESCE(l.soc_percent,0),COALESCE(l.alarm_flag,0),COALESCE(l.longitude,0),COALESCE(l.latitude,0),COALESCE(l.event_id,''),COALESCE(DATE_FORMAT(l.event_time,'%Y-%m-%d %H:%i:%s.%f'),''),COALESCE(DATE_FORMAT(l.received_at,'%Y-%m-%d %H:%i:%s.%f'),''),GREATEST(0,TIMESTAMPDIFF(SECOND,l.updated_at,NOW())),TIMESTAMPDIFF(SECOND,l.event_time,l.received_at),COALESCE(CONCAT(l.longitude,',',l.latitude),'') FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin=l.vin LEFT JOIN vehicle_profile p ON p.vin=l.vin WHERE l.vin<>'' ORDER BY l.updated_at DESC LIMIT ?`, alertEvaluationVehicleLimit+1) + rows, err := s.db.QueryContext(ctx, `SELECT l.vin,COALESCE(NULLIF(l.plate,''),NULLIF(b.plate,''),''),l.protocol,COALESCE(NULLIF(p.brand_name,''),NULLIF(b.oem,''),''),COALESCE(p.model_name,''),COALESCE(p.company_name,''),COALESCE(l.speed_kmh,0),COALESCE(l.soc_percent,0),COALESCE(l.alarm_flag,0),COALESCE(l.longitude,0),COALESCE(l.latitude,0),COALESCE(l.event_id,''),COALESCE(DATE_FORMAT(l.event_time,'%Y-%m-%d %H:%i:%s.%f'),''),COALESCE(DATE_FORMAT(l.received_at,'%Y-%m-%d %H:%i:%s.%f'),''),GREATEST(0,TIMESTAMPDIFF(SECOND,l.updated_at,NOW())),TIMESTAMPDIFF(SECOND,l.event_time,l.received_at),COALESCE(CONCAT(l.longitude,',',l.latitude),'') FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin=l.vin LEFT JOIN vehicle_profile p ON p.vin=l.vin WHERE l.vin<>'' ORDER BY l.updated_at DESC LIMIT ?`, alertEvaluationVehicleLimit+1) if err != nil { return nil, err } diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_store.go b/vehicle-data-platform/apps/api/internal/platform/alert_store.go index 7fca1e69..bd36cb81 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_store.go @@ -10,14 +10,13 @@ import ( ) func (s *ProductionStore) ensureAlertSchema(ctx context.Context) error { - s.alertSchemaOnce.Do(func() { + return s.alertSchema.ensure(func() error { var count int - s.alertSchemaErr = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule`).Scan(&count) - if s.alertSchemaErr != nil { - s.alertSchemaErr = fmt.Errorf("alert center schema unavailable; apply deploy/migrations/002_alert_center.sql: %w", s.alertSchemaErr) + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule`).Scan(&count); err != nil { + return fmt.Errorf("alert center schema unavailable; apply deploy/migrations/002_alert_center.sql: %w", err) } + return nil }) - return s.alertSchemaErr } func (s *ProductionStore) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) { diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go b/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go index 8d3ee430..b28d9af4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go @@ -367,7 +367,7 @@ func loadAlertStreamVehicleMetadata(ctx context.Context, tx *sql.Tx, records []A selects[i] = "SELECT ? AS vin" args[i] = vin } - query := `SELECT v.vin,COALESCE(MAX(NULLIF(b.plate,'')),''),COALESCE(MAX(b.oem),''),COALESCE(MAX(p.model_name),''),COALESCE(MAX(p.company_name),'') FROM (` + strings.Join(selects, " UNION ALL ") + `) v LEFT JOIN vehicle_identity_binding b ON b.vin=v.vin LEFT JOIN vehicle_profile p ON p.vin=v.vin GROUP BY v.vin` + query := `SELECT v.vin,COALESCE(MAX(NULLIF(b.plate,'')),''),COALESCE(MAX(NULLIF(p.brand_name,'')),MAX(NULLIF(b.oem,'')),''),COALESCE(MAX(p.model_name),''),COALESCE(MAX(p.company_name),'') FROM (` + strings.Join(selects, " UNION ALL ") + `) v LEFT JOIN vehicle_identity_binding b ON b.vin=v.vin LEFT JOIN vehicle_profile p ON p.vin=v.vin GROUP BY v.vin` rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return nil, err diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_test.go b/vehicle-data-platform/apps/api/internal/platform/alert_test.go index c878ee2b..86be8b06 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "regexp" @@ -15,6 +16,33 @@ import ( "github.com/DATA-DOG/go-sqlmock" ) +func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := NewProductionStore(db, nil, "") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := store.ensureAlertSchema(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled schema probe, got %v", err) + } + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + if err := store.ensureAlertSchema(t.Context()); err != nil { + t.Fatalf("expected retry to succeed, got %v", err) + } + if err := store.ensureAlertSchema(t.Context()); err != nil { + t.Fatalf("expected successful probe to stay cached, got %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + type configuredMetricStore struct { *MockStore definitions []MetricDefinition diff --git a/vehicle-data-platform/apps/api/internal/platform/gb32960_reference.go b/vehicle-data-platform/apps/api/internal/platform/gb32960_reference.go new file mode 100644 index 00000000..1fbc8a0f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/gb32960_reference.go @@ -0,0 +1,227 @@ +package platform + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// gb32960FieldReference is the platform copy of the authoritative Chinese +// terminology supplied for GB/T 32960 and the Guangdong fuel-cell extension. +// It enriches protocol values at read time without changing the stored RAW +// evidence or replacing the parser's stable source-field keys. +type gb32960FieldReference struct { + Label string + Unit string + Description string + ValueMappings []MetricValueMapping +} + +func enumMappings(values ...string) []MetricValueMapping { + result := make([]MetricValueMapping, 0, len(values)/3) + for index := 0; index+2 < len(values); index += 3 { + result = append(result, MetricValueMapping{Value: values[index], Label: values[index+1], Description: values[index+2]}) + } + return result +} + +var gb32960PositionMappings = func() []MetricValueMapping { + result := make([]MetricValueMapping, 0, 8) + for value := 0; value < 8; value++ { + parts := []string{"有效定位", "北纬", "东经"} + if value&1 != 0 { + parts[0] = "无效定位" + } + if value&2 != 0 { + parts[1] = "南纬" + } + if value&4 != 0 { + parts[2] = "西经" + } + result = append(result, MetricValueMapping{ + Value: strconv.Itoa(value), + Label: strings.Join(parts, " · "), + Description: "bit0 表示定位有效性,bit1 表示纬度方向,bit2 表示经度方向。", + }) + } + return result +}() + +var gb32960FieldReferences = map[string]gb32960FieldReference{ + "gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃料电池等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃料电池", "燃料电池驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"}, + "gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"}, + "gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"}, + "gb32960.vehicle.total_current_a": {Label: "总电流", Unit: "A", Description: "动力系统总电流。"}, + "gb32960.vehicle.soc_percent": {Label: "SOC", Unit: "%", Description: "动力电池荷电状态。"}, + "gb32960.vehicle.dc_dc_status": {Label: "DC/DC 状态", Description: "DC/DC 工作状态编码。", ValueMappings: enumMappings("1", "工作", "DC/DC 处于工作状态。", "2", "断开", "DC/DC 处于断开状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.vehicle.gear": {Label: "挡位原始值", Description: "挡位、驱动力和制动力组合原始编码。"}, + "gb32960.vehicle.insulation_kohm": {Label: "绝缘电阻", Unit: "kΩ", Description: "高压系统绝缘电阻。"}, + "gb32960.vehicle.accelerator_pct": {Label: "加速踏板行程", Unit: "%", Description: "2016 版字段,加速踏板开度。"}, + "gb32960.vehicle.brake_pct": {Label: "制动踏板状态", Unit: "%", Description: "2016 版字段,制动踏板状态或开度。"}, + + "gb32960.drive_motor.serial_no": {Label: "驱动电机序号", Description: "电机编号。"}, + "gb32960.drive_motor.state": {Label: "驱动电机状态", Description: "电机工作状态编码。", ValueMappings: enumMappings("1", "耗电", "驱动电机耗电。", "2", "发电", "驱动电机发电。", "3", "关闭", "驱动电机关闭。", "4", "准备", "驱动电机准备。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.drive_motor.controller_temperature_c": {Label: "控制器温度", Unit: "℃", Description: "驱动电机控制器温度。"}, + "gb32960.drive_motor.speed_rpm": {Label: "转速", Unit: "rpm", Description: "驱动电机转速。"}, + "gb32960.drive_motor.torque_nm": {Label: "转矩", Unit: "N·m", Description: "驱动电机输出转矩。"}, + "gb32960.drive_motor.motor_temperature_c": {Label: "电机温度", Unit: "℃", Description: "驱动电机温度。"}, + "gb32960.drive_motor.controller_voltage_v": {Label: "控制器输入电压", Unit: "V", Description: "电机控制器直流母线输入电压。"}, + "gb32960.drive_motor.controller_current_a": {Label: "控制器直流母线电流", Unit: "A", Description: "电机控制器直流母线电流。"}, + + "gb32960.fuel_cell.fuel_cell_voltage_v": {Label: "燃料电池电压", Unit: "V", Description: "燃料电池系统输出电压。"}, + "gb32960.fuel_cell.fuel_cell_current_a": {Label: "燃料电池电流", Unit: "A", Description: "燃料电池系统输出电流。"}, + "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km": {Label: "氢耗", Unit: "kg/100km", Description: "燃料电池氢气消耗率。"}, + "gb32960.fuel_cell.temperature_probe_values_c": {Label: "探针温度", Unit: "℃", Description: "燃料电池温度探针列表。"}, + "gb32960.fuel_cell.max_hydrogen_temperature_c": {Label: "氢系统最高温度", Unit: "℃", Description: "氢系统最高温度。"}, + "gb32960.fuel_cell.max_hydrogen_concentration_percent": {Label: "最高氢浓度", Unit: "%", Description: "氢气浓度最高值。"}, + "gb32960.fuel_cell.max_hydrogen_pressure_mpa": {Label: "最高氢压力", Unit: "MPa", Description: "氢系统最高压力。"}, + "gb32960.fuel_cell.dc_dc_status": {Label: "高压 DC/DC 状态", Description: "燃料电池高压 DC/DC 状态。"}, + "gb32960.fuel_cell.max_hydrogen_temperature_probe_id": {Label: "最高氢温探针编号", Description: "检测到氢系统最高温度的探针编号。"}, + "gb32960.fuel_cell.max_hydrogen_concentration_probe_id": {Label: "最高氢浓度探针编号", Description: "检测到最高氢浓度的探针编号。"}, + "gb32960.fuel_cell.max_hydrogen_pressure_probe_id": {Label: "最高氢压探针编号", Description: "检测到最高氢压力的探针编号。"}, + + "gb32960.position.position_status": {Label: "定位状态标志", Description: "定位有效性及经纬度方向标志。", ValueMappings: gb32960PositionMappings}, + "gb32960.position.coordinate_system": {Label: "坐标系", Description: "2025 版新增坐标系编码。", ValueMappings: enumMappings("1", "WGS84", "WGS84 坐标系。", "2", "GCJ-02", "GCJ-02 坐标系。", "3", "其他", "其他坐标系。")}, + "gb32960.position.longitude": {Label: "经度", Unit: "°", Description: "车辆经度。"}, + "gb32960.position.latitude": {Label: "纬度", Unit: "°", Description: "车辆纬度。"}, + + "gb32960.alarm.max_alarm_level": {Label: "最高报警等级", Description: "当前最高报警等级。", ValueMappings: enumMappings("0", "无故障", "当前无报警故障。", "1", "一级故障", "一级报警故障。", "2", "二级故障", "二级报警故障。", "3", "三级故障", "三级报警故障。")}, + "gb32960.alarm.general_alarm_flag": {Label: "通用报警标志", Description: "通用报警位图。"}, + "gb32960.alarm.battery_faults": {Label: "可充电储能装置故障码", Description: "动力电池相关故障码列表。"}, + "gb32960.alarm.motor_faults": {Label: "驱动电机故障码", Description: "驱动电机相关故障码列表。"}, + "gb32960.alarm.engine_faults": {Label: "发动机故障码", Description: "发动机相关故障码列表。"}, + "gb32960.alarm.other_faults": {Label: "其他故障码", Description: "其他故障码列表。"}, + + "gb32960.gd_fc_stack.stack_count": {Label: "电堆数量", Description: "本包包含的电堆数量。"}, + "gb32960.gd_fc_stack.engine_work_state": {Label: "发动机工作状态", Description: "燃料电池发动机工作状态。"}, + "gb32960.gd_fc_stack.stack_water_outlet_temp_c": {Label: "电堆出水温度", Unit: "℃", Description: "电堆冷却水出口温度。"}, + "gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa": {Label: "氢气入口压力", Unit: "kPa", Description: "电堆氢气入口压力。"}, + "gb32960.gd_fc_stack.air_inlet_pressure_kpa": {Label: "空气入口压力", Unit: "kPa", Description: "电堆空气入口压力。"}, + "gb32960.gd_fc_stack.air_inlet_temp_c": {Label: "空气入口温度", Unit: "℃", Description: "电堆空气入口温度。"}, + "gb32960.gd_fc_stack.max_cell_voltage_v": {Label: "单体最高电压", Unit: "V", Description: "本电堆单体最高电压。"}, + "gb32960.gd_fc_stack.min_cell_voltage_v": {Label: "单体最低电压", Unit: "V", Description: "本电堆单体最低电压。"}, + "gb32960.gd_fc_stack.avg_cell_voltage_v": {Label: "单体平均电压", Unit: "V", Description: "本电堆单体平均电压。"}, + "gb32960.gd_fc_stack.cell_count": {Label: "单体总数", Description: "电堆单体总数量。"}, + "gb32960.gd_fc_stack.frame_cell_start": {Label: "本帧单体起始序号", Description: "当前分段电压起始单体序号。"}, + "gb32960.gd_fc_stack.frame_cell_count": {Label: "本帧单体数量", Description: "当前分段包含的单体数量。"}, + "gb32960.gd_fc_stack.frame_cell_voltages_v": {Label: "本帧单体电压列表", Unit: "V", Description: "当前分段的单体电压数组;未跨帧合并时不代表完整电堆快照。"}, + + "gb32960.gd_fc_dcdc.input_voltage_v": {Label: "输入电压", Unit: "V", Description: "DC/DC 输入电压。"}, + "gb32960.gd_fc_dcdc.input_current_a": {Label: "输入电流", Unit: "A", Description: "DC/DC 输入电流。"}, + "gb32960.gd_fc_dcdc.output_voltage_v": {Label: "输出电压", Unit: "V", Description: "DC/DC 输出电压。"}, + "gb32960.gd_fc_dcdc.output_current_a": {Label: "输出电流", Unit: "A", Description: "DC/DC 输出电流。"}, + "gb32960.gd_fc_dcdc.controller_temp_c": {Label: "控制器温度", Unit: "℃", Description: "DC/DC 控制器温度。"}, + + "gb32960.gd_fc_air_conditioner.status": {Label: "空调状态", Description: "空调工作状态。", ValueMappings: enumMappings("0", "关闭", "空调关闭。", "1", "启动", "空调启动。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.gd_fc_air_conditioner.power_kw": {Label: "空调功率", Unit: "kW", Description: "空调消耗功率。"}, + "gb32960.gd_fc_air_conditioner.compressor_input_voltage_v": {Label: "压缩机输入电压", Unit: "V", Description: "空调压缩机输入电压。"}, + + "gb32960.gd_fc_vehicle_info.collision_alarm": {Label: "碰撞报警", Description: "碰撞报警状态。", ValueMappings: enumMappings("0", "无碰撞报警", "当前无碰撞报警。", "1", "有碰撞报警", "当前有碰撞报警。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")}, + "gb32960.gd_fc_vehicle_info.ambient_temp_c": {Label: "环境温度", Unit: "℃", Description: "车辆环境温度。"}, + "gb32960.gd_fc_vehicle_info.ambient_pressure_kpa": {Label: "环境压力", Unit: "kPa", Description: "车辆环境压力。"}, + "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg": {Label: "车载氢量", Unit: "kg", Description: "当前车载氢气质量。"}, +} + +func canonicalGB32960ReferenceKey(sourceField string) string { + sourceField = strings.ToLower(strings.TrimSpace(sourceField)) + if !strings.HasPrefix(sourceField, "gb32960.") { + return sourceField + } + if strings.HasPrefix(sourceField, "gb32960.drive_motor.") { + return "gb32960.drive_motor." + lastSourcePart(sourceField) + } + if strings.HasPrefix(sourceField, "gb32960.gd_fc_stack.") { + return "gb32960.gd_fc_stack." + lastSourcePart(sourceField) + } + return sourceField +} + +func lastSourcePart(sourceField string) string { + if index := strings.LastIndexByte(sourceField, '.'); index >= 0 && index+1 < len(sourceField) { + return sourceField[index+1:] + } + return sourceField +} + +func gb32960ReferenceForSource(sourceField string) (gb32960FieldReference, bool) { + reference, ok := gb32960FieldReferences[canonicalGB32960ReferenceKey(sourceField)] + if !ok { + return gb32960FieldReference{}, false + } + reference.ValueMappings = append([]MetricValueMapping(nil), reference.ValueMappings...) + return reference, true +} + +func protocolDisplayValue(sourceField string, value any) string { + reference, ok := gb32960ReferenceForSource(sourceField) + if !ok || len(reference.ValueMappings) == 0 || value == nil { + return "" + } + raw := metricMappingValue(value) + for _, mapping := range reference.ValueMappings { + if mapping.Value == raw { + return fmt.Sprintf("%s(%s)", mapping.Label, raw) + } + } + return fmt.Sprintf("未知状态(%s)", raw) +} + +func metricMappingValue(value any) string { + switch typed := value.(type) { + case json.Number: + return typed.String() + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 32) + case int: + return strconv.Itoa(typed) + case int8: + return strconv.FormatInt(int64(typed), 10) + case int16: + return strconv.FormatInt(int64(typed), 10) + case int32: + return strconv.FormatInt(int64(typed), 10) + case int64: + return strconv.FormatInt(typed, 10) + case uint: + return strconv.FormatUint(uint64(typed), 10) + case uint8: + return strconv.FormatUint(uint64(typed), 10) + case uint16: + return strconv.FormatUint(uint64(typed), 10) + case uint32: + return strconv.FormatUint(uint64(typed), 10) + case uint64: + return strconv.FormatUint(typed, 10) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func enrichMetricDefinitions(definitions []MetricDefinition) []MetricDefinition { + result := make([]MetricDefinition, len(definitions)) + copy(result, definitions) + for index := range result { + definition := &result[index] + sourceField := definition.SourceFields["GB32960"] + reference, ok := gb32960ReferenceForSource(sourceField) + if !ok { + continue + } + if len(definition.Protocols) == 1 && strings.EqualFold(definition.Protocols[0], "GB32960") { + definition.Label = reference.Label + definition.Description = reference.Description + definition.Unit = reference.Unit + } + definition.ValueMappings = reference.ValueMappings + } + return result +} diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 058b2ae4..8d74303a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -84,13 +84,13 @@ func TestHandlerV2MonitorSummaryAndMap(t *testing.T) { } } -func TestHandlerRejectsInvalidMonitorBounds(t *testing.T) { +func TestHandlerIgnoresInvalidOptionalMonitorBounds(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) request := httptest.NewRequest(http.MethodGet, "/api/v2/monitor/map?zoom=12&bounds=105,31,103,29", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) - if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "MONITOR_BOUNDS_INVALID") { - t.Fatalf("unexpected invalid bounds response: status=%d body=%s", response.Code, response.Body.String()) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"mode"`) || strings.Contains(response.Body.String(), "MONITOR_BOUNDS_INVALID") { + t.Fatalf("optional invalid bounds must degrade to an unbounded map response: status=%d body=%s", response.Code, response.Body.String()) } } diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index dad15869..783982c8 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -46,7 +46,7 @@ func NewMockStore() *MockStore { sourceProviders: map[string]string{}, sourcePolicyRemarks: map[string]string{}, profiles: map[string]VehicleProfile{ - "LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"}, + "LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", BrandName: "飞驰", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"}, }, locations: []RealtimeLocationRow{ {VIN: vehicles[0].VIN, Plate: vehicles[0].Plate, Protocol: vehicles[0].Protocol, Longitude: 113.2644, Latitude: 23.1291, SpeedKmh: 42.5, SOCPercent: 76.2, TotalMileageKm: 48798.9, LastSeen: vehicles[0].LastSeen}, @@ -350,7 +350,7 @@ func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input Vehi if exists { version = current.Version + 1 } - profile := VehicleProfile{VIN: vin, ModelName: input.ModelName, VehicleType: input.VehicleType, CompanyName: input.CompanyName, OperationStatus: input.OperationStatus, AccessProvider: input.AccessProvider, FirstAccessAt: input.FirstAccessAt, RuntimeSeconds: input.RuntimeSeconds, SourceSystem: "manual", Version: version, UpdatedBy: input.Actor, UpdatedAt: time.Now().Format(time.RFC3339)} + profile := VehicleProfile{VIN: vin, BrandName: input.BrandName, ModelName: input.ModelName, VehicleType: input.VehicleType, CompanyName: input.CompanyName, OperationStatus: input.OperationStatus, AccessProvider: input.AccessProvider, FirstAccessAt: input.FirstAccessAt, RuntimeSeconds: input.RuntimeSeconds, SourceSystem: "manual", Version: version, UpdatedBy: input.Actor, UpdatedAt: time.Now().Format(time.RFC3339)} m.profiles[vin] = profile return profile, nil } @@ -394,7 +394,7 @@ func (m *MockStore) SyncVehicleProfiles(_ context.Context, request VehicleProfil continue } m.profiles[item.VIN] = VehicleProfile{ - VIN: item.VIN, ModelName: item.ModelName, VehicleType: item.VehicleType, + VIN: item.VIN, BrandName: item.BrandName, ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName, OperationStatus: item.OperationStatus, AccessProvider: item.AccessProvider, FirstAccessAt: item.FirstAccessAt, RuntimeSeconds: item.RuntimeSeconds, SourceSystem: request.SourceSystem, diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 3b543fed..eb9de6b8 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -206,17 +206,24 @@ type MetricCatalog struct { } type MetricDefinition struct { - Key string `json:"key"` - Label string `json:"label"` - Description string `json:"description"` - Unit string `json:"unit"` - Category string `json:"category"` - ValueType string `json:"valueType"` - Protocols []string `json:"protocols"` - SourceFields map[string]string `json:"sourceFields"` - Searchable bool `json:"searchable"` - Chartable bool `json:"chartable"` - Alertable bool `json:"alertable"` + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + Unit string `json:"unit"` + Category string `json:"category"` + ValueType string `json:"valueType"` + Protocols []string `json:"protocols"` + SourceFields map[string]string `json:"sourceFields"` + ValueMappings []MetricValueMapping `json:"valueMappings,omitempty"` + Searchable bool `json:"searchable"` + Chartable bool `json:"chartable"` + Alertable bool `json:"alertable"` +} + +type MetricValueMapping struct { + Value string `json:"value"` + Label string `json:"label"` + Description string `json:"description,omitempty"` } type HistoryDataCategory struct { @@ -225,12 +232,14 @@ type HistoryDataCategory struct { } type HistoryMetricDefinition struct { - Key string `json:"key"` - Label string `json:"label"` - Unit string `json:"unit"` - Category string `json:"category"` - ValueType string `json:"valueType"` - DefaultVisible bool `json:"defaultVisible"` + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Unit string `json:"unit"` + Category string `json:"category"` + ValueType string `json:"valueType"` + ValueMappings []MetricValueMapping `json:"valueMappings,omitempty"` + DefaultVisible bool `json:"defaultVisible"` } type HistoryDataResponse struct { @@ -940,6 +949,7 @@ type VehicleDetail struct { type VehicleProfile struct { VIN string `json:"vin"` + BrandName string `json:"brandName"` ModelName string `json:"modelName"` VehicleType string `json:"vehicleType"` CompanyName string `json:"companyName"` @@ -958,6 +968,7 @@ type VehicleProfile struct { } type VehicleProfileInput struct { + BrandName string `json:"brandName"` ModelName string `json:"modelName"` VehicleType string `json:"vehicleType"` CompanyName string `json:"companyName"` @@ -971,6 +982,7 @@ type VehicleProfileInput struct { type VehicleProfileSyncItem struct { VIN string `json:"vin"` + BrandName string `json:"brandName"` ModelName string `json:"modelName"` VehicleType string `json:"vehicleType"` CompanyName string `json:"companyName"` @@ -1337,6 +1349,7 @@ type LatestTelemetryValue struct { Category string `json:"category"` ValueType string `json:"valueType"` Value any `json:"value"` + DisplayValue string `json:"displayValue,omitempty"` Protocol string `json:"protocol"` SourceEndpoint string `json:"sourceEndpoint,omitempty"` FrameID string `json:"frameId"` diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 8c8b9013..457ca9c2 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -7,26 +7,21 @@ import ( "net/url" "strconv" "strings" - "sync" "time" ) type ProductionStore struct { - db *sql.DB - tdengine *sql.DB - tdDatabase string - redisOnline redisOnlineKeyCounter - capacityCheck capacityChecker - alertStreamGroup string - alertStreamMode string - accessSchemaOnce sync.Once - accessSchemaErr error - alertSchemaOnce sync.Once - alertSchemaErr error - profileSchemaOnce sync.Once - profileSchemaErr error - reconciliationSchemaOnce sync.Once - reconciliationSchemaErr error + db *sql.DB + tdengine *sql.DB + tdDatabase string + redisOnline redisOnlineKeyCounter + capacityCheck capacityChecker + alertStreamGroup string + alertStreamMode string + accessSchema schemaReadyGate + alertSchema schemaReadyGate + profileSchema schemaReadyGate + reconciliationSchema schemaReadyGate } type redisOnlineKeyCounter interface { diff --git a/vehicle-data-platform/apps/api/internal/platform/profile.go b/vehicle-data-platform/apps/api/internal/platform/profile.go index e1da74d3..fdfbacd6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/profile.go +++ b/vehicle-data-platform/apps/api/internal/platform/profile.go @@ -100,6 +100,7 @@ func normalizeVehicleProfileSyncRequest(request VehicleProfileSyncRequest) Vehic item := &request.Items[index] item.VIN = strings.ToUpper(strings.TrimSpace(item.VIN)) normalized := normalizeVehicleProfileInput(vehicleProfileSyncInput(*item, request.Actor)) + item.BrandName = normalized.BrandName item.ModelName = normalized.ModelName item.VehicleType = normalized.VehicleType item.CompanyName = normalized.CompanyName @@ -149,7 +150,7 @@ func validateVehicleProfileSyncRequest(request VehicleProfileSyncRequest) error func vehicleProfileSyncInput(item VehicleProfileSyncItem, actor string) VehicleProfileInput { return VehicleProfileInput{ - ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName, + BrandName: item.BrandName, ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName, OperationStatus: item.OperationStatus, AccessProvider: item.AccessProvider, FirstAccessAt: item.FirstAccessAt, RuntimeSeconds: item.RuntimeSeconds, Actor: actor, } @@ -172,7 +173,7 @@ func vehicleProfileSyncDecision(current VehicleProfile, exists bool, request Veh } func vehicleProfileSyncFieldsEqual(current VehicleProfile, item VehicleProfileSyncItem) bool { - return current.ModelName == item.ModelName && current.VehicleType == item.VehicleType && + return current.BrandName == item.BrandName && current.ModelName == item.ModelName && current.VehicleType == item.VehicleType && current.CompanyName == item.CompanyName && current.OperationStatus == item.OperationStatus && current.AccessProvider == item.AccessProvider && current.FirstAccessAt == item.FirstAccessAt && equalOptionalInt64(current.RuntimeSeconds, item.RuntimeSeconds) @@ -221,6 +222,7 @@ func validateProfileVIN(vin string) error { } func normalizeVehicleProfileInput(input VehicleProfileInput) VehicleProfileInput { + input.BrandName = strings.TrimSpace(input.BrandName) input.ModelName = strings.TrimSpace(input.ModelName) input.VehicleType = strings.TrimSpace(input.VehicleType) input.CompanyName = strings.TrimSpace(input.CompanyName) @@ -240,7 +242,7 @@ func validateVehicleProfileInput(input VehicleProfileInput) error { max int name string }{ - {input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"}, + {input.BrandName, 128, "品牌"}, {input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"}, {input.CompanyName, 128, "所属企业"}, {input.AccessProvider, 128, "接入服务商"}, } for _, field := range lengths { @@ -278,7 +280,10 @@ func parseVehicleProfileTime(value string) (time.Time, error) { } func decorateVehicleProfile(profile VehicleProfile) VehicleProfile { - missing := make([]string, 0, 7) + missing := make([]string, 0, 8) + if profile.BrandName == "" { + missing = append(missing, "brandName") + } if profile.ModelName == "" { missing = append(missing, "modelName") } @@ -301,7 +306,7 @@ func decorateVehicleProfile(profile VehicleProfile) VehicleProfile { missing = append(missing, "runtimeSeconds") } profile.MissingFields = missing - profile.Completeness = (7 - len(missing)) * 100 / 7 + profile.Completeness = (8 - len(missing)) * 100 / 8 if profile.OperationStatus == "" { profile.OperationStatus = "unknown" } diff --git a/vehicle-data-platform/apps/api/internal/platform/profile_store.go b/vehicle-data-platform/apps/api/internal/platform/profile_store.go index 4b7d9e24..372f50a9 100644 --- a/vehicle-data-platform/apps/api/internal/platform/profile_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/profile_store.go @@ -8,17 +8,19 @@ import ( "time" ) -const vehicleProfileSelect = `SELECT vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by,updated_at FROM vehicle_profile ` +const vehicleProfileSelect = `SELECT vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by,updated_at FROM vehicle_profile ` func (s *ProductionStore) ensureProfileSchema(ctx context.Context) error { - s.profileSchemaOnce.Do(func() { + return s.profileSchema.ensure(func() error { var present int - s.profileSchemaErr = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`).Scan(&present) - if s.profileSchemaErr == nil && present != 2 { - s.profileSchemaErr = sql.ErrNoRows + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`).Scan(&present); err != nil { + return err } + if present != 2 { + return sql.ErrNoRows + } + return nil }) - return s.profileSchemaErr } func (s *ProductionStore) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, bool, error) { @@ -48,7 +50,7 @@ func (s *ProductionStore) SaveVehicleProfile(ctx context.Context, vin string, in if input.Version != 0 { return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_NOT_FOUND", Message: "待更新车辆档案不存在"} } - _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`, vin, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor) + _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`, vin, input.BrandName, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor) current = 0 } else if err != nil { return VehicleProfile{}, err @@ -56,7 +58,7 @@ func (s *ProductionStore) SaveVehicleProfile(ctx context.Context, vin string, in if input.Version != current { return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_VERSION_CONFLICT", Message: "车辆档案已被其他用户更新,请刷新后重试"} } - result, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_profile SET model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system='manual',source_version='',synced_at=NULL,version=version+1,updated_by=? WHERE vin=? AND version=?`, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor, vin, current) + result, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_profile SET brand_name=?,model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system='manual',source_version='',synced_at=NULL,version=version+1,updated_by=? WHERE vin=? AND version=?`, input.BrandName, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor, vin, current) err = updateErr if err == nil { rows, _ := result.RowsAffected() @@ -154,10 +156,10 @@ func (s *ProductionStore) SyncVehicleProfiles(ctx context.Context, request Vehic firstAccess := nullableVehicleProfileTime(item.FirstAccessAt) if itemResult.Status == profileSyncCreated { - _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?)`, item.VIN, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor) + _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?)`, item.VIN, item.BrandName, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor) } else { var updateResult sql.Result - updateResult, err = tx.ExecContext(ctx, `UPDATE vehicle_profile SET model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system=?,source_version=?,synced_at=?,version=version+1,updated_by=? WHERE vin=? AND version=?`, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor, item.VIN, current.Version) + updateResult, err = tx.ExecContext(ctx, `UPDATE vehicle_profile SET brand_name=?,model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system=?,source_version=?,synced_at=?,version=version+1,updated_by=? WHERE vin=? AND version=?`, item.BrandName, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor, item.VIN, current.Version) if err == nil { rowsAffected, rowsErr := updateResult.RowsAffected() if rowsErr != nil { @@ -207,7 +209,7 @@ func scanVehicleProfile(scanner vehicleProfileScanner) (VehicleProfile, error) { var firstAccess, syncedAt sql.NullTime var runtime sql.NullInt64 var updatedAt time.Time - err := scanner.Scan(&profile.VIN, &profile.ModelName, &profile.VehicleType, &profile.CompanyName, &profile.OperationStatus, &profile.AccessProvider, &firstAccess, &runtime, &profile.SourceSystem, &profile.SourceVersion, &syncedAt, &profile.Version, &profile.UpdatedBy, &updatedAt) + err := scanner.Scan(&profile.VIN, &profile.BrandName, &profile.ModelName, &profile.VehicleType, &profile.CompanyName, &profile.OperationStatus, &profile.AccessProvider, &firstAccess, &runtime, &profile.SourceSystem, &profile.SourceVersion, &syncedAt, &profile.Version, &profile.UpdatedBy, &updatedAt) if err != nil { return VehicleProfile{}, err } diff --git a/vehicle-data-platform/apps/api/internal/platform/profile_test.go b/vehicle-data-platform/apps/api/internal/platform/profile_test.go index 0ddec18b..29b2cdf3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/profile_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/profile_test.go @@ -26,7 +26,7 @@ func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) { if err != nil { t.Fatal(err) } - if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 7 { + if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 8 { t.Fatalf("expected explicit empty profile, got %+v", empty) } } @@ -34,7 +34,7 @@ func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) { func TestSaveVehicleProfileCreatesAndUsesOptimisticVersion(t *testing.T) { service := NewService(NewMockStore()) runtime := int64(3600) - input := VehicleProfileInput{ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime, Actor: "admin-a"} + input := VehicleProfileInput{BrandName: "现代", ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime, Actor: "admin-a"} profile, err := service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", input) if err != nil { t.Fatal(err) @@ -174,7 +174,7 @@ func TestVehicleProfileHandlersReadAndUpdate(t *testing.T) { t.Fatalf("empty profile read status=%d body=%s", read.Code, read.Body.String()) } update := httptest.NewRecorder() - body := bytes.NewBufferString(`{"modelName":"氢燃料重卡","vehicleType":"重卡","companyName":"示范物流","operationStatus":"active","accessProvider":"车厂平台","firstAccessAt":"2026-07-01T08:30","runtimeSeconds":3600,"version":0}`) + body := bytes.NewBufferString(`{"brandName":"现代","modelName":"氢燃料重卡","vehicleType":"重卡","companyName":"示范物流","operationStatus":"active","accessProvider":"车厂平台","firstAccessAt":"2026-07-01T08:30","runtimeSeconds":3600,"version":0}`) handler.ServeHTTP(update, httptest.NewRequest(http.MethodPut, "/api/v2/vehicles/LNXNEGRR7SR318212/profile", body)) if update.Code != http.StatusOK { t.Fatalf("profile update status=%d body=%s", update.Code, update.Body.String()) @@ -216,12 +216,12 @@ func TestProductionVehicleProfileCreateIsTransactionalAndAudited(t *testing.T) { mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM vehicle_profile WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"version"})) - mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "admin-a").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`)).WithArgs("VIN001", "现代", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "admin-a").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "admin-a", "create", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() - columns := []string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"} - mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=?`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows(columns).AddRow("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", now, runtime, "manual", "", nil, 1, "admin-a", now)) - profile, err := store.SaveVehicleProfile(context.Background(), "VIN001", VehicleProfileInput{ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: now.Format(time.RFC3339), RuntimeSeconds: &runtime, Actor: "admin-a"}) + columns := []string{"vin", "brand_name", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"} + mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=?`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows(columns).AddRow("VIN001", "现代", "示范车型", "重卡", "示范物流", "active", "车厂平台", now, runtime, "manual", "", nil, 1, "admin-a", now)) + profile, err := store.SaveVehicleProfile(context.Background(), "VIN001", VehicleProfileInput{BrandName: "现代", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: now.Format(time.RFC3339), RuntimeSeconds: &runtime, Actor: "admin-a"}) if err != nil { t.Fatal(err) } @@ -243,13 +243,13 @@ func TestProductionVehicleProfileSyncCreatesAndAuditsInOneTransaction(t *testing runtime := int64(3600) request := VehicleProfileSyncRequest{ SourceSystem: "oem-tsp", SourceVersion: "snapshot-1", Actor: "sync-admin", - Items: []VehicleProfileSyncItem{{VIN: "VIN001", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}}, + Items: []VehicleProfileSyncItem{{VIN: "VIN001", BrandName: "现代", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}}, } mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(`SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IN (?)`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001")) - mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"})) - mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "oem-tsp", "snapshot-1", sqlmock.AnyArg(), "sync-admin").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin", "brand_name", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"})) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?)`)).WithArgs("VIN001", "现代", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "oem-tsp", "snapshot-1", sqlmock.AnyArg(), "sync-admin").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "sync-admin", "sync_created", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() result, err := store.SyncVehicleProfiles(context.Background(), request) diff --git a/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go b/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go index 2a53fcbe..648c62e6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go @@ -42,18 +42,17 @@ type reconciliationExisting struct { } func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error { - s.reconciliationSchemaOnce.Do(func() { + return s.reconciliationSchema.ensure(func() error { var count int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_reconciliation_run','vehicle_reconciliation_issue','vehicle_reconciliation_action')`).Scan(&count); err != nil { - s.reconciliationSchemaErr = err - return + return err } if count != 3 { - s.reconciliationSchemaErr = fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql") + return fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql") } + return nil }) - return s.reconciliationSchemaErr } func (s *ProductionStore) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) { diff --git a/vehicle-data-platform/apps/api/internal/platform/schema_gate.go b/vehicle-data-platform/apps/api/internal/platform/schema_gate.go new file mode 100644 index 00000000..5f1a6b0a --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/schema_gate.go @@ -0,0 +1,23 @@ +package platform + +import "sync" + +// schemaReadyGate serializes schema probes and caches only a successful result. +// Request cancellation and transient database failures must remain retryable. +type schemaReadyGate struct { + mu sync.Mutex + ready bool +} + +func (gate *schemaReadyGate) ensure(check func() error) error { + gate.mu.Lock() + defer gate.mu.Unlock() + if gate.ready { + return nil + } + if err := check(); err != nil { + return err + } + gate.ready = true + return nil +} diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index ea872434..5a9d6438 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -608,9 +608,13 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values zoom = 20 } provinceMode := zoom <= monitorProvinceMaxZoom - bounds, hasBounds, err := parseMonitorBounds(query.Get("bounds")) - if err != nil { - return MonitorMapResponse{}, err + bounds, hasBounds, boundsErr := parseMonitorBounds(query.Get("bounds")) + if boundsErr != nil { + // The viewport is an optional performance hint. Wide, wrapped or + // intermediate map frames can briefly exceed WGS-84 bounds; falling + // back to the bounded unfiltered response keeps the map usable. + bounds = monitorBounds{} + hasBounds = false } result := MonitorMapResponse{ Mode: "clusters", @@ -1602,6 +1606,63 @@ func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[ return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery) } +type trackSourceCandidate struct { + Protocol string + Page Page[HistoryLocationRow] + Raw []HistoryLocationRow + Clean []HistoryLocationRow + Quality TrackQuality +} + +var trackCandidateProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"} + +func cloneURLValues(values url.Values) url.Values { + next := make(url.Values, len(values)) + for key, items := range values { + next[key] = append([]string(nil), items...) + } + return next +} + +func sortedHistoryLocations(items []HistoryLocationRow) []HistoryLocationRow { + points := append([]HistoryLocationRow(nil), items...) + sort.SliceStable(points, func(i, j int) bool { + left, leftOK := parseVehicleServiceTime(points[i].DeviceTime) + right, rightOK := parseVehicleServiceTime(points[j].DeviceTime) + if leftOK && rightOK && !left.Equal(right) { + return left.Before(right) + } + return points[i].DeviceTime < points[j].DeviceTime + }) + return points +} + +func (s *Service) trackSourceCandidates(ctx context.Context, query url.Values, requestedProtocol string) ([]trackSourceCandidate, error) { + protocols := trackCandidateProtocols + if requestedProtocol != "" { + protocols = []string{requestedProtocol} + } + candidates := make([]trackSourceCandidate, 0, len(protocols)) + for _, protocol := range protocols { + sourceQuery := cloneURLValues(query) + sourceQuery.Set("protocol", protocol) + page, err := s.store.HistoryLocationsFromTDengine(ctx, sourceQuery) + if err != nil { + return nil, err + } + raw := sortedHistoryLocations(page.Items) + clean, quality := analyzeTrackPoints(raw) + candidates = append(candidates, trackSourceCandidate{ + Protocol: protocol, + Page: page, + Raw: raw, + Clean: clean, + Quality: quality, + }) + } + return candidates, nil +} + func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPlaybackResponse, error) { keyword := strings.TrimSpace(firstNonEmpty(query.Get("keyword"), query.Get("vin"))) if keyword == "" { @@ -1627,37 +1688,41 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla } resolvedQuery.Set("limit", "5000") resolvedQuery.Set("offset", "0") - page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery) + requestedProtocol := strings.ToUpper(strings.TrimSpace(query.Get("protocol"))) + candidates, err := s.trackSourceCandidates(ctx, resolvedQuery, requestedProtocol) if err != nil { return TrackPlaybackResponse{}, err } - rawPoints := append([]HistoryLocationRow(nil), page.Items...) - sort.SliceStable(rawPoints, func(i, j int) bool { - left, leftOK := parseVehicleServiceTime(rawPoints[i].DeviceTime) - right, rightOK := parseVehicleServiceTime(rawPoints[j].DeviceTime) - if leftOK && rightOK && !left.Equal(right) { - return left.Before(right) + allCleanPoints := make([]HistoryLocationRow, 0) + for _, candidate := range candidates { + allCleanPoints = append(allCleanPoints, candidate.Clean...) + } + points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(allCleanPoints, requestedProtocol) + selected := trackSourceCandidate{Protocol: selectedProtocol, Clean: []HistoryLocationRow{}} + for _, candidate := range candidates { + if strings.EqualFold(candidate.Protocol, selectedProtocol) { + selected = candidate + break } - return rawPoints[i].DeviceTime < rawPoints[j].DeviceTime - }) - cleanPoints, quality := analyzeTrackPoints(rawPoints) - points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(cleanPoints, query.Get("protocol")) + } + quality := selected.Quality quality.SelectedProtocol = selectedProtocol quality.AlternateSourcePoints = alternateSourcePoints quality.ValidPoints = len(points) applyTrackSequenceQuality(points, &quality) + truncated := selected.Page.Total > len(selected.Raw) result := TrackPlaybackResponse{ Points: []HistoryLocationRow{}, Events: []TrackPlaybackEvent{}, - Sources: summarizeTrackSources(cleanPoints), + Sources: summarizeTrackSources(allCleanPoints), Segments: []TrackSegment{}, Stops: []TrackStop{}, - Total: page.Total, - Truncated: page.Total > len(rawPoints), + Total: selected.Page.Total, + Truncated: truncated, Quality: quality, AsOf: time.Now().UTC().Format(time.RFC3339), } - result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), 0, result.Truncated, false) + result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), 0, result.Truncated, false) applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints) if len(points) == 0 { return result, nil @@ -1696,7 +1761,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla for index := range result.Stops { result.Stops[index].SampledIndex = nearestSampledIndex((result.Stops[index].startIndex+result.Stops[index].endIndex)/2, sampledIndexes) } - result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled) + result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), len(result.Points), result.Truncated, result.Sampled) applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints) result.Coverage.ActualStart = result.Summary.StartTime result.Coverage.ActualEnd = result.Summary.EndTime @@ -1755,9 +1820,13 @@ type metricCatalogStore interface { func (s *Service) metricDefinitions(ctx context.Context) ([]MetricDefinition, error) { if store, ok := s.store.(metricCatalogStore); ok { - return store.MetricDefinitions(ctx) + definitions, err := store.MetricDefinitions(ctx) + if err != nil { + return nil, err + } + return enrichMetricDefinitions(definitions), nil } - return metricDefinitions(), nil + return enrichMetricDefinitions(metricDefinitions()), nil } func (s *Service) MetricCatalog(ctx context.Context) (MetricCatalog, error) { @@ -1865,7 +1934,8 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin if unified { key = definition.Key } - if previous, exists := candidates[key]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) { + candidateKey := normalizedProtocol + "\x00" + key + if previous, exists := candidates[candidateKey]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) { continue } label, unit := latestTelemetryRawMetadata(sourceField) @@ -1873,6 +1943,11 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin valueType := "dynamic" description := "" value := rawValue + if reference, ok := gb32960ReferenceForSource(sourceField); ok { + label = reference.Label + unit = reference.Unit + description = reference.Description + } if unified { label = definition.Label unit = definition.Unit @@ -1880,11 +1955,18 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin valueType = definition.ValueType description = definition.Description value = normalizeTelemetryValue(rawValue, definition.ValueType) + if reference, ok := gb32960ReferenceForSource(sourceField); ok { + label = reference.Label + unit = reference.Unit + description = reference.Description + } + label, description = protocolTelemetryMetadata(normalizedProtocol, key, label, description) } - candidates[key] = latestTelemetryCandidate{ + displayValue := protocolDisplayValue(sourceField, value) + candidates[candidateKey] = latestTelemetryCandidate{ value: LatestTelemetryValue{ Key: key, SourceField: sourceField, Label: label, Description: description, Unit: unit, - Category: category, ValueType: valueType, Value: value, Protocol: normalizedProtocol, + Category: category, ValueType: valueType, Value: value, DisplayValue: displayValue, Protocol: normalizedProtocol, SourceEndpoint: frame.SourceEndpoint, FrameID: frame.ID, DeviceTime: frame.DeviceTime, ServerTime: frame.ServerTime, Quality: quality, QualityReason: qualityReason, FreshnessSeconds: freshness, DataDelaySeconds: delay, }, @@ -1896,6 +1978,9 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin response.Values = append(response.Values, candidate.value) } sort.Slice(response.Values, func(i, j int) bool { + if left, right := telemetryProtocolRank(response.Values[i].Protocol), telemetryProtocolRank(response.Values[j].Protocol); left != right { + return left < right + } left, right := telemetryCategoryRank(response.Values[i].Category), telemetryCategoryRank(response.Values[j].Category) if left != right { return left < right @@ -1915,10 +2000,24 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin } response.Categories[index].Count++ } - response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; selected %d newest scalar values by unified metric/source field", len(frames), len(response.Values)) + response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; retained %d newest scalar values independently by protocol and metric/source field", len(frames), len(response.Values)) return response } +func protocolTelemetryMetadata(protocol, key, label, description string) (string, string) { + if key != "total_mileage_km" { + return label, description + } + switch protocol { + case "JT808": + return "GPS 总里程", "JT/T 808 定位终端累计的 GPS 里程,不等同于车辆仪表盘里程" + case "GB32960", "YUTONG_MQTT": + return "仪表盘总里程", "车辆总线或车厂平台上报的仪表盘累计里程" + default: + return label, description + } +} + func telemetrySourceLookupKey(protocol, sourceField string) string { return strings.ToUpper(strings.TrimSpace(protocol)) + "\x00" + strings.TrimSpace(sourceField) } @@ -2036,6 +2135,10 @@ func normalizeTelemetryCategory(category string) string { return "location" case "quality": return "quality" + case "fuel-cell", "fuel_cell", "fuelcell", "hydrogen": + return "fuel-cell" + case "motor", "engine": + return "motor" default: return "extension" } @@ -2046,7 +2149,7 @@ func telemetrySourceCategory(sourceField string) string { switch { case strings.Contains(normalized, "alarm") || strings.Contains(normalized, "fault") || strings.Contains(normalized, "warning"): return "alarm" - case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack"): + case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack") || strings.Contains(normalized, "hydrogen"): return "fuel-cell" case strings.Contains(normalized, "motor") || strings.Contains(normalized, "engine") || strings.Contains(normalized, "rpm"): return "motor" @@ -2070,6 +2173,15 @@ func telemetryCategoryRank(category string) int { return 99 } +func telemetryProtocolRank(protocol string) int { + for index, candidate := range canonicalVehicleProtocols { + if protocol == candidate { + return index + } + } + return len(canonicalVehicleProtocols) +} + func telemetryCategoryLabel(category string) string { labels := map[string]string{"vehicle": "整车数据", "motor": "驱动电机", "fuel-cell": "燃料电池", "battery": "电池", "location": "定位", "alarm": "报警", "quality": "数据质量", "extension": "扩展字段"} if label := labels[category]; label != "" { @@ -2086,9 +2198,28 @@ func metricDefinitions() []MetricDefinition { {Key: "alarm_active", Label: "协议告警位", Description: "协议报文是否携带活动告警", Unit: "", Category: "safety", ValueType: "boolean", Protocols: []string{"GB32960", "JT808"}, SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag", "JT808": "jt808.location.alarm_flag"}, Searchable: true, Chartable: false, Alertable: true}, {Key: "freshness_sec", Label: "离线时长", Description: "最新数据距当前时间的秒数", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.updated_at"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "data_delay_sec", Label: "数据延迟", Description: "服务接收时间与设备事件时间的差值", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "received_at-event_time"}, Searchable: true, Chartable: true, Alertable: true}, - {Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong.vehicle.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false}, + {Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程;JT808 为 GPS 里程,其余为仪表盘里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong_mqtt.data.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false}, {Key: "longitude", Label: "经度", Description: "WGS84/协议归一化经度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.longitude"}, Searchable: true, Chartable: false, Alertable: false}, {Key: "latitude", Label: "纬度", Description: "WGS84/协议归一化纬度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.latitude"}, Searchable: true, Chartable: false, Alertable: false}, + {Key: "vehicle_status", Label: "车辆状态", Description: "GB/T 32960 整车状态码", Unit: "", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.vehicle_status"}, Searchable: true, Chartable: false, Alertable: true}, + {Key: "charge_status", Label: "充电状态", Description: "GB/T 32960 充电状态码", Unit: "", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.charge_status"}, Searchable: true, Chartable: false, Alertable: true}, + {Key: "total_voltage_v", Label: "总电压", Description: "动力电池总电压", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "total_current_a", Label: "总电流", Description: "动力电池总电流", Unit: "A", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_current_a"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "insulation_kohm", Label: "绝缘电阻", Description: "整车绝缘电阻", Unit: "kΩ", Category: "safety", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.insulation_kohm"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "fuel_cell_voltage_v", Label: "燃料电池电压", Description: "燃料电池系统输出电压", Unit: "V", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "fuel_cell_current_a", Label: "燃料电池电流", Description: "燃料电池系统输出电流", Unit: "A", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_current_a"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "hydrogen_consumption_kg_per_100km", Label: "百公里氢耗", Description: "燃料电池系统百公里氢气消耗量", Unit: "kg/100km", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Description: "氢气浓度最高值", Unit: "%", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "hydrogen_pressure_mpa", Label: "最高氢气压力", Description: "燃料电池系统氢气压力探针的最高读数", Unit: "MPa", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_pressure_mpa"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "hydrogen_temperature_c", Label: "最高氢气温度", Description: "燃料电池系统氢气温度探针的最高读数", Unit: "℃", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_temperature_c"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "engine_speed_rpm", Label: "发动机转速", Description: "发动机曲轴转速", Unit: "rpm", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.engine.crank_speed_rpm"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "max_cell_voltage_v", Label: "单体最高电压", Description: "动力电池单体电压最高值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "min_cell_voltage_v", Label: "单体最低电压", Description: "动力电池单体电压最低值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "max_battery_temperature_c", Label: "电池最高温度", Description: "动力电池温度探针最高值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_temp_c"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "min_battery_temperature_c", Label: "电池最低温度", Description: "动力电池温度探针最低值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_temp_c"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "gnss_satellite_count", Label: "GNSS 卫星数", Description: "JT/T 808 定位终端参与定位的卫星数量", Unit: "颗", Category: "location", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.gnss_satellite_count"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "network_signal_strength", Label: "通信信号强度", Description: "JT/T 808 终端无线通信信号强度", Unit: "", Category: "quality", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.network_signal_strength"}, Searchable: true, Chartable: true, Alertable: true}, + {Key: "fuel_l", Label: "油量", Description: "JT/T 808 终端上报的车辆油量", Unit: "L", Category: "vehicle", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.fuel_l"}, Searchable: true, Chartable: true, Alertable: true}, } } @@ -2593,19 +2724,51 @@ func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDat sort.Strings(keys) for _, key := range keys { label, unit := rawMetricMetadata(key) - result = append(result, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7}) + metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7} + if reference, ok := gb32960ReferenceForSource(key); ok { + metric.Label = reference.Label + " · GB32960" + metric.Description = reference.Description + metric.Unit = reference.Unit + metric.ValueMappings = reference.ValueMappings + } + result = append(result, metric) } return result } func rawMetricMetadata(key string) (string, string) { + if reference, ok := gb32960ReferenceForSource(key); ok { + return reference.Label + " · GB32960", reference.Unit + } parts := strings.Split(key, ".") tail := parts[len(parts)-1] protocol := strings.ToUpper(parts[0]) - labels := map[string]string{"speed_kmh": "速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压", "soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "current_a": "电流", "temperature_c": "温度"} + labels := map[string]string{ + "speed_kmh": "速度", "recorder_speed_kmh": "行驶记录仪速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压", + "soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "direction_deg": "方向角", "altitude_m": "海拔", + "current_a": "电流", "temperature_c": "温度", "vehicle_status": "车辆状态", "charge_status": "充电状态", "running_mode": "运行模式", + "total_voltage_v": "总电压", "total_current_a": "总电流", "dc_dc_status": "DC/DC 状态", "gear": "挡位", "insulation_kohm": "绝缘电阻", + "accelerator_pct": "加速踏板开度", "brake_pct": "制动踏板状态", "state": "工作状态", "controller_temperature_c": "控制器温度", + "speed_rpm": "转速", "torque_nm": "转矩", "motor_temperature_c": "驱动电机温度", "controller_voltage_v": "控制器输入电压", + "controller_current_a": "控制器母线电流", "fuel_cell_voltage_v": "燃料电池电压", "fuel_cell_current_a": "燃料电池电流", + "hydrogen_consumption_kg_per_100km": "百公里氢耗", "temperature_probe_count": "温度探针数量", "max_hydrogen_temperature_c": "最高氢气温度", + "max_hydrogen_temperature_probe_id": "最高氢温探针编号", "max_hydrogen_concentration_fraction": "最高氢气浓度占比", + "max_hydrogen_concentration_percent": "最高氢浓度", "max_hydrogen_concentration_ppm": "最高氢浓度原始值", "max_hydrogen_concentration_probe_id": "最高氢浓度探针编号", + "max_hydrogen_pressure_mpa": "最高氢气压力", "max_hydrogen_pressure_probe_id": "最高氢压探针编号", + "engine_status": "发动机状态", "crank_speed_rpm": "发动机转速", "fuel_rate": "燃料消耗率", + "max_voltage_subsystem_no": "最高电压子系统编号", "max_voltage_cell_no": "最高电压单体编号", "max_voltage_v": "单体最高电压", + "min_voltage_subsystem_no": "最低电压子系统编号", "min_voltage_cell_no": "最低电压单体编号", "min_voltage_v": "单体最低电压", + "max_temp_subsystem_no": "最高温度子系统编号", "max_temp_probe_no": "最高温度探针编号", "max_temp_c": "电池最高温度", + "min_temp_subsystem_no": "最低温度子系统编号", "min_temp_probe_no": "最低温度探针编号", "min_temp_c": "电池最低温度", + "max_alarm_level": "最高告警等级", "general_alarm_flag": "通用告警标志", "alarm_flag": "告警标志", "status_flag": "状态标志", + "fuel_l": "油量", "manual_alarm_event_id": "人工确认告警事件编号", "carriage_temperature_c": "车厢温度", + "network_signal_strength": "通信信号强度", "gnss_satellite_count": "GNSS 卫星数", "device_time": "设备时间", + "hydrogen_mass_kg": "储氢质量", "gd_fc_vehicle_hydrogen_mass_kg": "储氢质量", "stack_temp_c": "电堆温度", + "gd_fc_demo_stack_temp_c": "电堆温度", "controller_temp_c": "控制器温度", "gd_fc_dcdc_controller_temp_c": "燃料电池 DC/DC 控制器温度", + } label := labels[tail] if label == "" { - label = strings.ReplaceAll(tail, "_", " ") + label = humanizeRawMetricTail(tail) } if protocol != "" { label += " · " + protocol @@ -2614,6 +2777,8 @@ func rawMetricMetadata(key string) (string, string) { switch { case strings.HasSuffix(tail, "_kmh"): unit = "km/h" + case strings.HasSuffix(tail, "_kg_per_100km"): + unit = "kg/100km" case strings.HasSuffix(tail, "_km"): unit = "km" case strings.HasSuffix(tail, "_percent"): @@ -2624,10 +2789,63 @@ func rawMetricMetadata(key string) (string, string) { unit = "A" case strings.HasSuffix(tail, "_temperature_c") || strings.HasSuffix(tail, "_c"): unit = "℃" + case strings.HasSuffix(tail, "_rpm"): + unit = "rpm" + case strings.HasSuffix(tail, "_nm"): + unit = "N·m" + case strings.HasSuffix(tail, "_mpa"): + unit = "MPa" + case strings.HasSuffix(tail, "_kohm"): + unit = "kΩ" + case strings.HasSuffix(tail, "_ppm"): + unit = "ppm" + case strings.HasSuffix(tail, "_kg"): + unit = "kg" + case strings.HasSuffix(tail, "_deg"): + unit = "°" + case strings.HasSuffix(tail, "_m"): + unit = "m" + case strings.HasSuffix(tail, "_l"): + unit = "L" } return label, unit } +func humanizeRawMetricTail(tail string) string { + tokenLabels := map[string]string{ + "max": "最高", "min": "最低", "avg": "平均", "total": "总", "vehicle": "车辆", "battery": "电池", + "fuel": "燃料", "cell": "电池", "hydrogen": "氢气", "motor": "电机", "engine": "发动机", "controller": "控制器", + "stack": "电堆", "inlet": "入口", "outlet": "出口", "pressure": "压力", "temperature": "温度", "temp": "温度", + "voltage": "电压", "current": "电流", "speed": "速度", "mileage": "里程", "status": "状态", "count": "数量", + "probe": "探针", "serial": "序号", "subsystem": "子系统", "signal": "信号", "strength": "强度", "alarm": "告警", + "level": "等级", "rate": "速率", "mass": "质量", "consumption": "消耗量", "capacity": "容量", "soc": "SOC", + } + var builder strings.Builder + for _, token := range strings.Split(tail, "_") { + if label := tokenLabels[token]; label != "" { + builder.WriteString(label) + } else if token != "" && !isRawMetricUnitToken(token) { + if builder.Len() > 0 { + builder.WriteByte(' ') + } + builder.WriteString(strings.ToUpper(token)) + } + } + if builder.Len() == 0 { + return strings.ReplaceAll(tail, "_", " ") + } + return builder.String() +} + +func isRawMetricUnitToken(token string) bool { + switch token { + case "kmh", "km", "v", "a", "c", "rpm", "nm", "mpa", "kohm", "ppm", "kg", "pct", "percent", "deg", "m", "l": + return true + default: + return false + } +} + func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) { principal, ok := PrincipalFromContext(ctx) if !ok { @@ -3170,7 +3388,12 @@ func historyExportColumns(category string, requested []string) []HistoryMetricDe continue } label, unit := rawMetricMetadata(key) - selected = append(selected, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true}) + metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true} + if reference, ok := gb32960ReferenceForSource(key); ok { + metric.Description = reference.Description + metric.ValueMappings = reference.ValueMappings + } + selected = append(selected, metric) known[key] = true } return selected @@ -3200,7 +3423,11 @@ func historyExportRecord(row HistoryDataRow, columns []HistoryMetricDefinition) record = append(record, "") continue } - record = append(record, fmt.Sprint(value)) + if display := protocolDisplayValue(column.Key, value); display != "" { + record = append(record, display) + } else { + record = append(record, fmt.Sprint(value)) + } } return record } @@ -3368,27 +3595,37 @@ func analyzeTrackPoints(raw []HistoryLocationRow) ([]HistoryLocationRow, TrackQu func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol string) ([]HistoryLocationRow, string, int) { requestedProtocol = strings.ToUpper(strings.TrimSpace(requestedProtocol)) - counts := map[string]int{} - latest := map[string]time.Time{} + grouped := map[string][]HistoryLocationRow{} for _, point := range points { protocol := strings.ToUpper(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN")) - counts[protocol]++ - if observedAt, ok := parseVehicleServiceTime(point.DeviceTime); ok && observedAt.After(latest[protocol]) { - latest[protocol] = observedAt - } + grouped[protocol] = append(grouped[protocol], point) } selected := requestedProtocol if selected == "" { - protocols := make([]string, 0, len(counts)) - for protocol := range counts { + protocols := make([]string, 0, len(grouped)) + for protocol := range grouped { protocols = append(protocols, protocol) } sort.Slice(protocols, func(i, j int) bool { - if counts[protocols[i]] != counts[protocols[j]] { - return counts[protocols[i]] > counts[protocols[j]] + left := trackSourceSelectionScore(grouped[protocols[i]]) + right := trackSourceSelectionScore(grouped[protocols[j]]) + if left.HasMovement != right.HasMovement { + return left.HasMovement } - if !latest[protocols[i]].Equal(latest[protocols[j]]) { - return latest[protocols[i]].After(latest[protocols[j]]) + if left.DurationSeconds != right.DurationSeconds { + return left.DurationSeconds > right.DurationSeconds + } + if left.CoordinateChanges != right.CoordinateChanges { + return left.CoordinateChanges > right.CoordinateChanges + } + if math.Abs(left.DistanceKm-right.DistanceKm) > 0.001 { + return left.DistanceKm > right.DistanceKm + } + if left.MovingPoints != right.MovingPoints { + return left.MovingPoints > right.MovingPoints + } + if len(grouped[protocols[i]]) != len(grouped[protocols[j]]) { + return len(grouped[protocols[i]]) > len(grouped[protocols[j]]) } return protocols[i] < protocols[j] }) @@ -3396,13 +3633,48 @@ func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol str selected = protocols[0] } } - result := make([]HistoryLocationRow, 0, counts[selected]) - for _, point := range points { - if strings.EqualFold(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"), selected) { - result = append(result, point) + result := grouped[selected] + return result, selected, len(points) - len(result) +} + +type trackSourceScore struct { + HasMovement bool + DurationSeconds int64 + CoordinateChanges int + MovingPoints int + DistanceKm float64 +} + +func trackSourceSelectionScore(points []HistoryLocationRow) trackSourceScore { + score := trackSourceScore{} + if len(points) == 0 { + return score + } + if start, startOK := parseVehicleServiceTime(points[0].DeviceTime); startOK { + if end, endOK := parseVehicleServiceTime(points[len(points)-1].DeviceTime); endOK && end.After(start) { + score.DurationSeconds = int64(end.Sub(start).Seconds()) } } - return result, selected, len(points) - len(result) + for index, point := range points { + if point.SpeedKmh > trackStopSpeedKmh { + score.MovingPoints++ + } + if index == 0 || !validTrackCoordinate(points[index-1]) || !validTrackCoordinate(point) { + continue + } + previousTime, previousOK := parseVehicleServiceTime(points[index-1].DeviceTime) + currentTime, currentOK := parseVehicleServiceTime(point.DeviceTime) + if !previousOK || !currentOK || !currentTime.After(previousTime) || currentTime.Sub(previousTime) > time.Duration(trackGapThresholdSeconds)*time.Second { + continue + } + distance := haversineKm(points[index-1].Latitude, points[index-1].Longitude, point.Latitude, point.Longitude) + score.DistanceKm += distance + if distance >= 0.03 { + score.CoordinateChanges++ + } + } + score.HasMovement = score.CoordinateChanges >= 2 || score.DistanceKm >= 0.5 || score.MovingPoints >= 3 + return score } func applyTrackSequenceQuality(points []HistoryLocationRow, quality *TrackQuality) { diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index f4fae55b..a0de848b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -445,6 +445,31 @@ func TestTrackPrimarySourcePreventsParallelProtocolsFromBecomingOneRoute(t *test } } +func TestTrackPrimarySourcePrefersUsefulMovementOverHighFrequencyStaticHeartbeat(t *testing.T) { + points := make([]HistoryLocationRow, 0, 120) + for index := 0; index < 100; index++ { + points = append(points, HistoryLocationRow{ + DeviceTime: time.Date(2026, 7, 16, 8, 0, index, 0, time.UTC).Format(time.RFC3339), + Protocol: "YUTONG_MQTT", + Longitude: 121.400000, + Latitude: 31.200000, + }) + } + for index := 0; index < 20; index++ { + points = append(points, HistoryLocationRow{ + DeviceTime: time.Date(2026, 7, 16, 8, index, 0, 0, time.UTC).Format(time.RFC3339), + Protocol: "JT808", + Longitude: 121.400000 + float64(index)*0.002, + Latitude: 31.200000 + float64(index)*0.001, + SpeedKmh: 36, + }) + } + selected, protocol, alternate := selectTrackPrimarySource(points, "") + if protocol != "JT808" || len(selected) != 20 || alternate != 100 { + t.Fatalf("moving source must beat static heartbeat volume: protocol=%s selected=%d alternate=%d", protocol, len(selected), alternate) + } +} + func TestTrackSegmentsExposeStopsAndDataGapsWithoutClaimingIgnition(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1}, @@ -487,6 +512,23 @@ func TestMonitorBoundsAreValidatedAndApplied(t *testing.T) { } } +func TestMonitorMapIgnoresInvalidOptionalBounds(t *testing.T) { + vehicles := Page[VehicleRealtimeRow]{ + Items: []VehicleRealtimeRow{{ + VIN: "LTEST000000000001", Plate: "粤A00001", Online: true, LocationAvailable: true, + Longitude: 113.2, Latitude: 23.1, LastSeen: "2026-07-17T15:41:00+08:00", + }}, + Total: 1, + } + result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"12"}, "bounds": {"105,31,103,29"}}) + if err != nil { + t.Fatalf("optional invalid bounds must not fail the map: %v", err) + } + if result.Total != 1 || len(result.Points) != 1 { + t.Fatalf("invalid bounds should fall back to the bounded unfiltered map: %+v", result) + } +} + func TestMonitorQueryKeepsServerOwnedKeywordAndMotionStatus(t *testing.T) { query := normalizeMonitorQuery(url.Values{"keyword": {"沪A"}, "status": {"driving"}}) if query.Get("vin") != "沪A" || query.Get("limit") != "10000" { @@ -921,13 +963,14 @@ func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testi definitions := []MetricDefinition{ {Key: "speed_kmh", Label: "速度", Description: "车辆最新行驶速度", Unit: "km/h", Category: "driving", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}}, {Key: "alarm_active", Label: "协议告警位", Unit: "", Category: "safety", ValueType: "boolean", SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag"}}, + {Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Unit: "%", Category: "fuel-cell", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}}, } frames := []RawFrameRow{ - {ID: "new", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", SourceEndpoint: "gateway-a", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 42.5, "gb32960.alarm.general_alarm_flag": float64(1), "vendor.custom_temperature_c": 31.2}}, + {ID: "new", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", SourceEndpoint: "gateway-a", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 42.5, "gb32960.alarm.general_alarm_flag": float64(1), "gb32960.fuel_cell.max_hydrogen_concentration_percent": 0.032, "vendor.custom_temperature_c": 31.2}}, {ID: "old", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:20:00", ServerTime: "2026-07-14 09:20:01", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 10.0}}, } response := buildLatestTelemetryResponse("VIN001", frames, definitions, now) - if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 3 || len(response.Categories) != 3 { + if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 4 || len(response.Categories) != 4 { t.Fatalf("unexpected response: %+v", response) } byKey := map[string]LatestTelemetryValue{} @@ -940,11 +983,58 @@ func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testi if alarm := byKey["alarm_active"]; alarm.Value != true || alarm.Category != "alarm" { t.Fatalf("catalog boolean should be normalized: %+v", alarm) } + if hydrogen := byKey["hydrogen_concentration_percent"]; hydrogen.Value != 0.032 || hydrogen.Category != "fuel-cell" || hydrogen.Unit != "%" { + t.Fatalf("fuel-cell catalog metric should retain its business category: %+v", hydrogen) + } if extension := byKey["vendor.custom_temperature_c"]; extension.Category != "extension" || extension.Unit != "℃" || extension.Protocol != "GB32960" { t.Fatalf("manufacturer extension should retain source semantics: %+v", extension) } } +func TestGB32960ReferenceFormatsStatusAndHistoryMetadata(t *testing.T) { + if display := protocolDisplayValue("gb32960.vehicle.vehicle_status", float64(1)); display != "启动(1)" { + t.Fatalf("vehicle status display = %q", display) + } + if display := protocolDisplayValue("gb32960.position.position_status", 6); display != "有效定位 · 南纬 · 西经(6)" { + t.Fatalf("position status display = %q", display) + } + columns := mergeDiscoveredRawMetrics(historyRawMetrics(), []HistoryDataRow{{Values: map[string]any{ + "gb32960.drive_motor.motors.motor_1.state": 2, + }}}) + for _, column := range columns { + if column.Key == "gb32960.drive_motor.motors.motor_1.state" { + if column.Label != "驱动电机状态 · GB32960" || len(column.ValueMappings) == 0 || column.ValueMappings[1].Label != "发电" { + t.Fatalf("unexpected protocol reference column: %+v", column) + } + return + } + } + t.Fatalf("GB32960 reference column missing: %+v", columns) +} + +func TestBuildLatestTelemetryResponseRetainsSameMetricAcrossProtocols(t *testing.T) { + now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) + definitions := []MetricDefinition{{ + Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric", + SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km"}, + }} + frames := []RawFrameRow{ + {ID: "gb", VIN: "VIN001", Protocol: "GB32960", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.total_mileage_km": 1000.0}}, + {ID: "jt", VIN: "VIN001", Protocol: "JT808", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"jt808.location.total_mileage_km": 980.0}}, + } + response := buildLatestTelemetryResponse("VIN001", frames, definitions, now) + if len(response.Values) != 2 { + t.Fatalf("same unified metric from two protocols must be retained independently: %+v", response.Values) + } + byProtocol := map[string]LatestTelemetryValue{} + for _, value := range response.Values { + byProtocol[value.Protocol] = value + } + if byProtocol["GB32960"].Label != "仪表盘总里程" || byProtocol["JT808"].Label != "GPS 总里程" { + t.Fatalf("mileage semantics were not preserved: %+v", byProtocol) + } +} + func TestLatestTelemetryQualityDistinguishesStaleAndWarnings(t *testing.T) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) stale, reason, freshness, delay := latestTelemetryQuality(RawFrameRow{DeviceTime: "2026-07-14 09:19:59", ServerTime: "2026-07-14 09:20:00", ParseStatus: "ok"}, now) diff --git a/vehicle-data-platform/apps/web/scripts/verify-build.mjs b/vehicle-data-platform/apps/web/scripts/verify-build.mjs index 1f07add1..ba743e6f 100644 --- a/vehicle-data-platform/apps/web/scripts/verify-build.mjs +++ b/vehicle-data-platform/apps/web/scripts/verify-build.mjs @@ -6,9 +6,12 @@ const assets = readdirSync(assetsDirectory); const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.test(name)); const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name)); const monitorAssets = assets.filter((name) => /^MonitorPage-[\w-]+\.js$/.test(name)); +const vehicleAssets = assets.filter((name) => /^VehiclePage-[\w-]+\.js$/.test(name)); +const profileSyncAssets = assets.filter((name) => /^VehicleProfileSyncPanel-[\w-]+\.js$/.test(name)); const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8'); const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8'); const builtIndex = readFileSync(resolve(process.cwd(), 'dist/index.html'), 'utf8'); +const builtSemiPrototype = readFileSync(resolve(process.cwd(), 'dist/semi-prototype.html'), 'utf8'); const releaseManifest = readFileSync(resolve(process.cwd(), 'dist/.release-assets'), 'utf8').split(/\r?\n/).filter(Boolean); if (excelAssets.length !== 1) { @@ -20,6 +23,9 @@ if (mileageWorkers.length !== 1) { if (monitorAssets.length !== 1) { throw new Error(`production build must contain exactly one monitor page asset, found ${monitorAssets.length}: ${monitorAssets.join(', ') || 'none'}`); } +if (vehicleAssets.length !== 1 || profileSyncAssets.length !== 1) { + throw new Error(`vehicle profile sync must remain one deferred route asset: vehicle=${vehicleAssets.join(', ') || 'none'} sync=${profileSyncAssets.join(', ') || 'none'}`); +} if (runtimeConfig !== safeRuntimeTemplate) { throw new Error('production dist/app-config.js must match the credential-free runtime template'); } @@ -33,6 +39,12 @@ if (!mainAsset || !assets.includes(mainAsset) || !readFileSync(resolve(assetsDirectory, mainAsset), 'utf8').includes('vehicle-platform:ready')) { throw new Error('production entry must retain the React boot-ready handshake'); } +const semiPrototypeAsset = builtSemiPrototype.match(/]+type="module"[^>]+src="\/assets\/([^"/]+\.js)"/)?.[1]; +if (!builtSemiPrototype.includes('Semi UI 原型 · 羚牛车辆数据中台') + || !semiPrototypeAsset + || !assets.includes(semiPrototypeAsset)) { + throw new Error('production build must contain the standalone Semi UI prototype entry and asset'); +} if (releaseManifest.length !== assets.length || releaseManifest.some((name, index) => name !== [...assets].sort()[index])) { throw new Error('production dist/.release-assets must list every generated asset exactly once in sorted order'); } @@ -60,5 +72,14 @@ const monitorBytes = statSync(resolve(assetsDirectory, monitorAssets[0])).size; if (monitorBytes > 35_000) { throw new Error(`monitor critical-path asset exceeds 35000 bytes: ${monitorBytes}`); } +const vehicleBytes = statSync(resolve(assetsDirectory, vehicleAssets[0])).size; +if (vehicleBytes > 30_000) { + throw new Error(`vehicle detail critical-path asset exceeds 30000 bytes: ${vehicleBytes}`); +} +const profileSyncBytes = statSync(resolve(assetsDirectory, profileSyncAssets[0])).size; +const vehicleSource = readFileSync(resolve(assetsDirectory, vehicleAssets[0]), 'utf8'); +if (!vehicleSource.includes(profileSyncAssets[0])) { + throw new Error('vehicle profile sync asset must be loaded only through the vehicle page dynamic import'); +} const qrBytes = statSync(resolve(assetsDirectory, qrAssets[0])).size; -process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1 boot_recovery=1\n`); +process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} vehicle_bytes=${vehicleBytes} profile_sync_deferred=1 profile_sync_bytes=${profileSyncBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1 boot_recovery=1\n`); diff --git a/vehicle-data-platform/apps/web/semi-prototype.html b/vehicle-data-platform/apps/web/semi-prototype.html new file mode 100644 index 00000000..42c9405f --- /dev/null +++ b/vehicle-data-platform/apps/web/semi-prototype.html @@ -0,0 +1,13 @@ + + + + + + + Semi UI 原型 · 羚牛车辆数据中台 + + +
+ + + diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index b6bb1a67..e7bb46a1 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -261,13 +261,14 @@ export interface HistoryMetricCatalog { } export interface MetricCatalog { metrics: MetricDefinition[]; asOf: string; } +export interface MetricValueMapping { value: string; label: string; description?: string; } export interface MetricDefinition { key: string; label: string; description: string; unit: string; category: string; valueType: 'numeric' | 'boolean'; - protocols: string[]; sourceFields: Record; searchable: boolean; chartable: boolean; alertable: boolean; + protocols: string[]; sourceFields: Record; valueMappings?: MetricValueMapping[]; searchable: boolean; chartable: boolean; alertable: boolean; } export interface HistoryDataCategory { key: 'location' | 'raw' | 'mileage' | string; label: string; } -export interface HistoryMetricDefinition { key: string; label: string; unit: string; category: string; valueType: string; defaultVisible: boolean; } +export interface HistoryMetricDefinition { key: string; label: string; description?: string; unit: string; category: string; valueType: string; valueMappings?: MetricValueMapping[]; defaultVisible: boolean; } export interface HistoryDataRow { id: string; vin: string; plate: string; protocol: string; deviceTime: string; serverTime: string; quality: string; qualityReason: string; evidenceId?: string; values: Record; } export interface HistoryDataSummary { resultRows: number; vehicleCount: number; sources: string[]; queryDurationMs: number; } export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; } @@ -447,6 +448,7 @@ export interface VehicleDetail { export interface VehicleProfile { vin: string; + brandName: string; modelName: string; vehicleType: string; companyName: string; @@ -465,6 +467,7 @@ export interface VehicleProfile { } export interface VehicleProfileInput { + brandName?: string; modelName: string; vehicleType: string; companyName: string; @@ -477,6 +480,7 @@ export interface VehicleProfileInput { export interface VehicleProfileSyncItem { vin: string; + brandName?: string; modelName: string; vehicleType: string; companyName: string; @@ -768,7 +772,7 @@ export interface RawFrameRow { export interface LatestTelemetryCategory { key: string; label: string; count: number; } export interface LatestTelemetryValue { key: string; sourceField: string; label: string; description?: string; unit: string; category: string; - valueType: string; value: unknown; protocol: string; sourceEndpoint?: string; frameId: string; + valueType: string; value: unknown; displayValue?: string; protocol: string; sourceEndpoint?: string; frameId: string; deviceTime: string; serverTime: string; quality: 'good' | 'stale' | 'warning'; qualityReason: string; freshnessSeconds: number; dataDelaySeconds?: number; } diff --git a/vehicle-data-platform/apps/web/src/main.tsx b/vehicle-data-platform/apps/web/src/main.tsx index c05d7a35..44dc0b4f 100644 --- a/vehicle-data-platform/apps/web/src/main.tsx +++ b/vehicle-data-platform/apps/web/src/main.tsx @@ -1,7 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { AppV2 } from './v2/AppV2'; +import './v2/styles/semi-theme.scss'; import './v2/styles/v2.css'; +import './v2/styles/workspace.css'; + +if ('scrollRestoration' in window.history) { + window.history.scrollRestoration = 'manual'; +} ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/vehicle-data-platform/apps/web/src/semi-prototype/main.tsx b/vehicle-data-platform/apps/web/src/semi-prototype/main.tsx new file mode 100644 index 00000000..3ba841b7 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/semi-prototype/main.tsx @@ -0,0 +1,320 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import ReactDOM from 'react-dom/client'; +import { + Avatar, + Button, + Card, + DatePicker, + Layout, + Nav, + Pagination, + Progress, + Select, + SideSheet, + Space, + Table, + Tag, + TagInput, + Typography +} from '@douyinfe/semi-ui'; +import { + IconAlarm, + IconBarChartHStroked, + IconBox, + IconDownload, + IconFilter, + IconHelpCircle, + IconHome, + IconMapPin, + IconRefresh, + IconSearch, + IconSetting, + IconUser +} from '@douyinfe/semi-icons'; +import '@douyinfe/semi-theme-default/scss/index.scss'; +import './prototype.css'; + +const { Header, Sider, Content } = Layout; +const { Title, Text } = Typography; + +type MileageRow = { + key: string; + plate: string; + vin: string; + protocol: 'GB32960' | 'JT808' | 'YUTONG_MQTT'; + d1: number; + d2: number; + d3: number; + d4: number; + d5: number; + d6: number; + d7: number; + total: number; +}; + +const mileageRows: MileageRow[] = [ + { key: '1', plate: '粤A·6K21F', vin: 'LHGCM82633A004352', protocol: 'GB32960', d1: 42.6, d2: 86.4, d3: 73.2, d4: 112.8, d5: 69.3, d6: 91.7, d7: 62.8, total: 538.8 }, + { key: '2', plate: '粤A·3M82Q', vin: 'LHGCM82633A004718', protocol: 'JT808', d1: 126.3, d2: 94.8, d3: 105.4, d4: 88.7, d5: 132.6, d6: 116.9, d7: 103.2, total: 767.9 }, + { key: '3', plate: '粤A·8P16D', vin: 'LHGCM82633A004966', protocol: 'YUTONG_MQTT', d1: 38.1, d2: 44.9, d3: 0, d4: 59.4, d5: 66.8, d6: 31.2, d7: 48.6, total: 289.0 }, + { key: '4', plate: '粤A·1T53L', vin: 'LHGCM82633A005104', protocol: 'GB32960', d1: 76.8, d2: 103.7, d3: 92.5, d4: 81.4, d5: 109.2, d6: 98.6, d7: 87.1, total: 649.3 }, + { key: '5', plate: '粤A·9R20H', vin: 'LHGCM82633A005339', protocol: 'JT808', d1: 54.4, d2: 61.8, d3: 48.9, d4: 70.6, d5: 83.4, d6: 77.3, d7: 65.7, total: 462.1 }, + { key: '6', plate: '粤A·5V79C', vin: 'LHGCM82633A005581', protocol: 'GB32960', d1: 144.2, d2: 118.4, d3: 136.5, d4: 129.7, d5: 152.3, d6: 97.8, d7: 121.6, total: 900.5 }, + { key: '7', plate: '粤A·2X41N', vin: 'LHGCM82633A005846', protocol: 'YUTONG_MQTT', d1: 21.5, d2: 35.2, d3: 40.6, d4: 28.3, d5: 51.7, d6: 46.4, d7: 37.9, total: 261.6 }, + { key: '8', plate: '粤A·7B66K', vin: 'LHGCM82633A006093', protocol: 'GB32960', d1: 89.7, d2: 97.6, d3: 84.3, d4: 110.5, d5: 101.9, d6: 93.8, d7: 108.4, total: 686.2 } +]; + +const navigation = [ + { itemKey: 'monitor', text: '全局监控', icon: }, + { itemKey: 'vehicles', text: '车辆查询', icon: }, + { itemKey: 'tracks', text: '轨迹回放', icon: }, + { itemKey: 'history', text: '历史数据', icon: }, + { itemKey: 'statistics', text: '里程查询', icon: }, + { itemKey: 'alerts', text: '告警中心', icon: }, + { itemKey: 'access', text: '接入管理', icon: }, + { itemKey: 'users', text: '账号管理', icon: } +]; + +function useMobile() { + const [mobile, setMobile] = useState(() => window.matchMedia('(max-width: 767px)').matches); + useEffect(() => { + const media = window.matchMedia('(max-width: 767px)'); + const update = () => setMobile(media.matches); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + return mobile; +} + +function Filters({ mobile, onDone }: { mobile: boolean; onDone?: () => void }) { + return ( +
+
+ 车辆范围 + +
+
+ 开始日期 + +
+
+ 结束日期 + +
+
+ 数据源策略 + setDraftToken(event.target.value)} placeholder="Bearer token" /> : <> - - - } - {error ? {error} : null} - - - 登录即表示你同意遵守平台数据安全规范 - + +
+ 羚牛智能 + 登录车辆数据中台 + {legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'} + {legacyMode ? : <> + + + } + {error ? {error} : null} + + + 登录即表示你同意遵守平台数据安全规范 +
+
; } return {children}; diff --git a/vehicle-data-platform/apps/web/src/v2/domain/history.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/history.test.ts index 86329a4b..f991402a 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/history.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/history.test.ts @@ -10,6 +10,7 @@ describe('history domain', () => { it('formats units without fabricating missing values', () => { expect(formatHistoryValue(undefined)).toBe('—'); expect(formatHistoryValue(42.5, { unit: 'km/h' } as never)).toBe('42.5 km/h'); + expect(formatHistoryValue(1, { valueMappings: [{ value: '1', label: '启动' }] } as never)).toBe('启动(1)'); }); it('builds only numeric series with at least two evidence points', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/domain/history.ts b/vehicle-data-platform/apps/web/src/v2/domain/history.ts index d81f410d..a06ca0a1 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/history.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/history.ts @@ -24,6 +24,9 @@ export function parseHistoryKeywords(value: string) { export function formatHistoryValue(value: unknown, metric?: HistoryMetricDefinition) { if (value == null || value === '') return '—'; + const raw = typeof value === 'number' && Number.isInteger(value) ? String(value) : String(value); + const mapped = metric?.valueMappings?.find((item) => item.value === raw); + if (mapped) return `${mapped.label}(${mapped.value})`; const formatted = typeof value === 'number' ? formatZhNumber(value, 6) : String(value); return metric?.unit ? `${formatted} ${metric.unit}` : formatted; } diff --git a/vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts index cc751cea..aa20da97 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts @@ -1,6 +1,6 @@ import type { VehicleRealtimeRow } from '../../api/types'; import { describe, expect, it } from 'vitest'; -import { formatNumber, statusLabel, vehicleStatus } from './monitor'; +import { formatNumber, normalizeMonitorBounds, statusLabel, vehicleStatus } from './monitor'; function vehicle(overrides: Partial = {}): VehicleRealtimeRow { return { @@ -37,4 +37,11 @@ describe('monitor domain', () => { expect(formatNumber(12560)).toBe('12,560'); expect(statusLabel('driving')).toBe('行驶'); }); + + it('normalizes valid WGS-84 viewport bounds and drops unsafe map ranges', () => { + expect(normalizeMonitorBounds('113,22,114,24')).toBe('113.000000,22.000000,114.000000,24.000000'); + expect(normalizeMonitorBounds('105,31,103,29')).toBe(''); + expect(normalizeMonitorBounds('-181,0,181,1')).toBe(''); + expect(normalizeMonitorBounds('113,22,Infinity,24')).toBe(''); + }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts b/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts index f82743f6..ac855270 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/monitor.ts @@ -34,3 +34,12 @@ export function relativeFreshness(value: string) { export function formatNumber(value: number, maximumFractionDigits = 0) { return formatZhNumber(value, maximumFractionDigits); } + +export function normalizeMonitorBounds(value?: string | null) { + if (!value) return ''; + const coordinates = value.split(',').map(Number); + if (coordinates.length !== 4 || coordinates.some((coordinate) => !Number.isFinite(coordinate))) return ''; + const [west, south, east, north] = coordinates; + if (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north) return ''; + return coordinates.map((coordinate) => coordinate.toFixed(6)).join(','); +} diff --git a/vehicle-data-platform/apps/web/src/v2/domain/profileSync.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/profileSync.test.ts index 73f56d74..173d5323 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/profileSync.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/profileSync.test.ts @@ -3,15 +3,15 @@ import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from './profi describe('parseVehicleProfileSyncCSV', () => { it('parses quoted values, BOM, CRLF and normalizes VIN/status', () => { - const rows = parseVehicleProfileSyncCSV(`\uFEFF${vehicleProfileSyncCSVHeader}\r\nvin001,"车型,一",重卡,示范物流,ACTIVE,车厂平台,2026-07-01T08:30:00+08:00,3600\r\n`); - expect(rows).toEqual([expect.objectContaining({ vin: 'VIN001', modelName: '车型,一', operationStatus: 'active', runtimeSeconds: 3600 })]); + const rows = parseVehicleProfileSyncCSV(`\uFEFF${vehicleProfileSyncCSVHeader}\r\nvin001,宇通,"车型,一",重卡,示范物流,ACTIVE,车厂平台,2026-07-01T08:30:00+08:00,3600\r\n`); + expect(rows).toEqual([expect.objectContaining({ vin: 'VIN001', brandName: '宇通', modelName: '车型,一', operationStatus: 'active', runtimeSeconds: 3600 })]); }); it('rejects duplicate VINs and malformed source rows before upload', () => { - const duplicate = `${vehicleProfileSyncCSVHeader}\nVIN001,,,,unknown,,,\nvin001,,,,unknown,,,`; + const duplicate = `${vehicleProfileSyncCSVHeader}\nVIN001,,,,,unknown,,,\nvin001,,,,,unknown,,,`; expect(() => parseVehicleProfileSyncCSV(duplicate)).toThrow(/VIN 重复/); - expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,,,,active,,,1.5`)).toThrow(/累计运行秒数/); + expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,,,,,active,,,1.5`)).toThrow(/累计运行秒数/); expect(() => parseVehicleProfileSyncCSV('vin,modelName\nVIN001,车型')).toThrow(/表头/); - expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,"车型"x,,,active,,,`)).toThrow(/引号结束/); + expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,宇通,"车型"x,,,active,,,`)).toThrow(/引号结束/); }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts b/vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts index 09544d61..04d0e863 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts @@ -1,6 +1,6 @@ import type { VehicleProfileSyncItem } from '../../api/types'; -const headers = ['vin', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const; +const headers = ['vin', 'brandName', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const; const allowedStatuses = new Set(['', 'unknown', 'active', 'inactive', 'maintenance', 'retired']); export const vehicleProfileSyncCSVHeader = headers.join(','); @@ -18,7 +18,7 @@ export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem return dataRows.map((row, index) => { const line = index + 2; if (row.length !== headers.length) throw new Error(`CSV 第 ${line} 行列数不正确`); - const [rawVIN, modelName, vehicleType, companyName, rawStatus, accessProvider, firstAccessAt, rawRuntime] = row.map((value) => value.trim()); + const [rawVIN, brandName, modelName, vehicleType, companyName, rawStatus, accessProvider, firstAccessAt, rawRuntime] = row.map((value) => value.trim()); const vin = rawVIN.toUpperCase(); if (!vin || vin.length > 32) throw new Error(`CSV 第 ${line} 行 VIN 无效`); if (seen.has(vin)) throw new Error(`CSV 第 ${line} 行 VIN 重复:${vin}`); @@ -28,7 +28,7 @@ export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem const runtimeSeconds = rawRuntime === '' ? null : Number(rawRuntime); if (runtimeSeconds !== null && (!Number.isSafeInteger(runtimeSeconds) || runtimeSeconds < 0)) throw new Error(`CSV 第 ${line} 行累计运行秒数无效`); return { - vin, modelName, vehicleType, companyName, + vin, brandName, modelName, vehicleType, companyName, operationStatus: (operationStatus || 'unknown') as VehicleProfileSyncItem['operationStatus'], accessProvider, firstAccessAt, runtimeSeconds }; diff --git a/vehicle-data-platform/apps/web/src/v2/domain/telemetry.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/telemetry.test.ts index 0f352c7e..0f42c51c 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/telemetry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/telemetry.test.ts @@ -6,6 +6,7 @@ describe('latest telemetry presentation', () => { expect(formatTelemetryValue(42.567)).toBe('42.57'); expect(formatTelemetryValue(true)).toBe('是'); expect(formatTelemetryValue(null)).toBe('—'); + expect(formatTelemetryValue(1, '启动(1)')).toBe('启动(1)'); }); it('translates server quality states', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts b/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts index 60d90c64..18891b51 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts @@ -1,6 +1,7 @@ import { formatZhNumber } from './formatters'; -export function formatTelemetryValue(value: unknown) { +export function formatTelemetryValue(value: unknown, displayValue?: string) { + if (displayValue) return displayValue; if (typeof value === 'number' && Number.isFinite(value)) { return formatZhNumber(value, 2); } diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts index 1bb9ccc4..8b0f489b 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts @@ -18,7 +18,13 @@ describe('monitor query params', () => { it('drops stale viewport bounds for a direct vehicle search', () => { const viewport = { zoom: 13, bounds: '103,29,105,31' }; expect(monitorMapQueryParams({ keyword: '粤A1', protocol: '', status: '' }, viewport).has('bounds')).toBe(false); - expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe(viewport.bounds); + expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe('103.000000,29.000000,105.000000,31.000000'); + }); + + it('omits wrapped or out-of-range viewport hints instead of failing the map query', () => { + for (const bounds of ['105,31,103,29', '-181,0,181,1', '103,29,Infinity,31']) { + expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, { zoom: 5, bounds }).has('bounds')).toBe(false); + } }); it('normalizes pasted plates, removes duplicates, and sends one batch parameter', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts index 070ca8f6..1b5d9c17 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts @@ -2,6 +2,7 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { api } from '../../api/client'; import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types'; +import { normalizeMonitorBounds } from '../domain/monitor'; import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; export type MonitorFilters = { @@ -116,7 +117,8 @@ export function monitorQueryParams(filters: MonitorFilters, limit: number) { export function monitorMapQueryParams(filters: MonitorFilters, viewport: MonitorViewport) { const params = monitorQueryParams(filters, 10_000); params.set('zoom', String(viewport.zoom)); - if (viewport.bounds && parseMonitorSearchTerms(filters.keyword).length === 0) params.set('bounds', viewport.bounds); + const bounds = normalizeMonitorBounds(viewport.bounds); + if (bounds && parseMonitorSearchTerms(filters.keyword).length === 0) params.set('bounds', bounds); return params; } @@ -203,6 +205,15 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, gcTime: QUERY_MEMORY.highVolumeGcTime, ...LIVE_QUERY_POLICY }); + const telemetry = useQuery({ + queryKey: ['monitor', 'vehicle-card', 'telemetry', vin], + queryFn: ({ signal }) => api.latestTelemetry(vin, signal), + enabled: enabled && vehicle?.primaryProtocol === 'GB32960', + staleTime: 10_000, + refetchInterval: enabled && activelyTracked && vehicle?.primaryProtocol === 'GB32960' ? 20_000 : false, + gcTime: QUERY_MEMORY.highVolumeGcTime, + ...LIVE_QUERY_POLICY + }); const address = useQuery({ queryKey: ['monitor', 'vehicle-card', 'address', addressPoint?.vin, addressPoint?.key], queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ @@ -214,5 +225,5 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, gcTime: QUERY_MEMORY.highVolumeGcTime }); - return { detail, activeAlerts, address }; + return { detail, activeAlerts, telemetry, address }; } diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useSideSheetA11y.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useSideSheetA11y.ts new file mode 100644 index 00000000..6e49a795 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useSideSheetA11y.ts @@ -0,0 +1,16 @@ +import { useLayoutEffect } from 'react'; + +export function useSideSheetA11y(open: boolean, selector: string, id: string, label: string, closeLabel: string) { + useLayoutEffect(() => { + if (!open) return; + const apply = () => { + const dialog = document.querySelector(`${selector} .semi-sidesheet-inner`); + dialog?.setAttribute('id', id); + dialog?.setAttribute('aria-label', label); + dialog?.querySelector('.semi-sidesheet-close')?.setAttribute('aria-label', closeLabel); + }; + apply(); + const frame = window.requestAnimationFrame(apply); + return () => window.cancelAnimationFrame(frame); + }, [closeLabel, id, label, open, selector]); +} diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx index 3a6aed12..3c3e1804 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx @@ -8,19 +8,23 @@ const routing = vi.hoisted(() => ({ scheduleIdleRoutePreloads: vi.fn(() => vi.fn()), shouldPreloadRouteOnIntent: vi.fn(() => true) })); -const auth = vi.hoisted(() => ({ session: { name: 'test-user', role: 'viewer' as const } as any })); +const auth = vi.hoisted(() => ({ + session: { name: 'test-user', username: 'test-user', role: 'viewer' as const } as any, + logout: vi.fn() +})); vi.mock('../routing/routeModules', () => routing); vi.mock('../auth/AuthGate', () => ({ - usePlatformSession: () => ({ session: auth.session, logout: vi.fn() }) + usePlatformSession: () => ({ session: auth.session, logout: auth.logout }) })); import { AppShell } from './AppShell'; afterEach(() => { cleanup(); + vi.restoreAllMocks(); vi.clearAllMocks(); - auth.session = { name: 'test-user', role: 'viewer' }; + auth.session = { name: 'test-user', username: 'test-user', role: 'viewer' }; }); test('renders only explicitly assigned customer menus', () => { @@ -61,6 +65,22 @@ test('reschedules likely route preloads when the active module changes', async ( expect(secondCleanup).toHaveBeenCalledTimes(1); }); +test('marks the content as a full-height track workspace after sidebar navigation', async () => { + const scrollTo = vi.fn(); + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: scrollTo }); + render( + }>页面内容} /> + ); + + expect(document.querySelector('.v2-content')).not.toHaveClass('is-track-workspace'); + scrollTo.mockClear(); + fireEvent.click(screen.getByRole('link', { name: '轨迹回放' })); + + await waitFor(() => expect(document.querySelector('.v2-content')).toHaveClass('is-track-workspace')); + expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' }); + expect(screen.getByRole('heading', { name: '轨迹回放' })).toBeInTheDocument(); +}); + test('opens contextual help for the active module and closes it without navigating', () => { render( }>页面内容} /> @@ -72,7 +92,87 @@ test('opens contextual help for the active module and closes it without navigati expect(screen.getByRole('dialog', { name: '全局监控' })).toHaveTextContent('筛选会自动生效'); expect(help).toHaveAttribute('aria-expanded', 'true'); - fireEvent.click(screen.getByRole('button', { name: '关闭帮助' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByLabelText('关闭帮助')); + expect(help).toHaveAttribute('aria-expanded', 'false'); + expect(document.body.style.overflow).not.toBe('hidden'); expect(screen.getByText('页面内容')).toBeInTheDocument(); }); + +test('uses one Semi account menu for identity, password and logout actions', async () => { + render( + }>页面内容} /> + ); + + const trigger = screen.getByRole('button', { name: '账号菜单,test-user' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + + expect(await screen.findByText('@test-user')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: '账号菜单,test-user' })).toHaveAttribute('aria-expanded', 'true')); + fireEvent.click(screen.getByText('修改登录密码')); + + expect(screen.getByRole('dialog', { name: '修改登录密码' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + fireEvent.click(trigger); + fireEvent.click(await screen.findByText('退出登录')); + expect(auth.logout).toHaveBeenCalledTimes(1); +}); + +test('uses the compact mobile navigation and opens secondary modules in a Semi SideSheet', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + media: '(max-width: 680px)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + }); + + render( + }>页面内容} /> + ); + + expect(screen.getByRole('link', { name: '全局监控' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '车辆查询' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '轨迹回放' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '里程查询' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '历史数据' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '更多功能' })); + + const moreSheet = screen.getByRole('dialog', { name: '更多功能' }); + expect(moreSheet).toHaveTextContent('数据分析与系统管理'); + expect(screen.getByRole('link', { name: '历史数据' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '告警中心' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '接入管理' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '账号管理' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '运维质量' })).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('关闭更多功能')); + expect(screen.getByRole('button', { name: '更多功能' })).toHaveAttribute('aria-expanded', 'false'); + expect(document.body.style.overflow).not.toBe('hidden'); +}); + +test('automatically collapses the Semi sidebar at tablet width', () => { + vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ + matches: query === '(max-width: 900px)', + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + render( + }>页面内容} /> + ); + + expect(screen.getByRole('link', { name: '全局监控' })).toHaveAttribute('title', '全局监控'); + expect(screen.queryByRole('button', { name: '收起侧栏' })).not.toBeInTheDocument(); + expect(document.querySelector('.v2-sidebar')).toHaveClass('is-collapsed'); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index fe47a246..fb4aa1e8 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -2,6 +2,7 @@ import { IconAlarm, IconBarChartHStroked, IconBox, + IconChevronDown, IconChevronLeft, IconHelpCircle, IconHome, @@ -12,13 +13,19 @@ import { IconUser, IconExit } from '@douyinfe/semi-icons'; -import { FormEvent, useEffect, useState } from 'react'; -import { NavLink, Outlet, useLocation } from 'react-router-dom'; +import { Avatar, Button, Dropdown, Input, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui'; +import { FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react'; +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { api } from '../../api/client'; import { usePlatformSession } from '../auth/AuthGate'; import { hasMenu } from '../auth/session'; +import { useMobileLayout } from '../hooks/useMobileLayout'; +import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules'; +const { Header, Sider, Content } = Layout; +const { Text, Title } = Typography; + const navigation = [ { to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome }, { to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch }, @@ -54,21 +61,17 @@ const pageHelp: Record = { users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] } }; -function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) { +function ContextHelp({ section, visible, onClose }: { section: string; visible: boolean; onClose: () => void }) { const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] }; - useEffect(() => { - const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; - window.addEventListener('keydown', closeOnEscape); - return () => window.removeEventListener('keydown', closeOnEscape); - }, [onClose]); - return
{ if (event.target === event.currentTarget) onClose(); }}> -
} onCancel={onClose} footer={}> +
+ {content.summary}
    {content.tips.map((tip, index) =>
  1. {index + 1}{tip}
  2. )}
Esc关闭帮助
- -
; +
+ ; } export function AppShell() { @@ -77,36 +80,78 @@ export function AppShell() { const activeRoutePath = `/${section}`; const { session, logout } = usePlatformSession(); const [helpOpen, setHelpOpen] = useState(false); + const [accountOpen, setAccountOpen] = useState(false); const [passwordOpen, setPasswordOpen] = useState(false); - const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches); + const mobileLayout = useMobileLayout(); const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role]; useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]); - useEffect(() => { - const media = window.matchMedia('(max-width: 680px)'); - const update = () => setMobileLayout(media.matches); - media.addEventListener('change', update); - update(); - return () => media.removeEventListener('change', update); - }, []); + useLayoutEffect(() => { + const content = document.querySelector('.v2-content'); + if (!content) return; + const reset = () => { + content.scrollTop = 0; + content.scrollLeft = 0; + content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' }); + }; + reset(); + let trailingFrame = 0; + const frame = window.requestAnimationFrame(() => { + reset(); + trailingFrame = window.requestAnimationFrame(reset); + }); + const timer = window.setTimeout(reset, 120); + return () => { + window.cancelAnimationFrame(frame); + window.cancelAnimationFrame(trailingFrame); + window.clearTimeout(timer); + }; + }, [location.pathname]); return ( -
- {mobileLayout ? : } -
-
-

{pageNames[section] ?? '车辆数据中台'}

+ + {mobileLayout ? : } + +
+
羚牛车辆数据中台{pageNames[section] ?? '车辆数据中台'}
- - - - + +
-
-
-
- {helpOpen ?
setHelpOpen(false)} />
: null} + + + + setHelpOpen(false)} /> {passwordOpen ? setPasswordOpen(false)} onChanged={logout} /> : null} -
+ ); } @@ -127,19 +172,16 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged setError(reason instanceof Error ? reason.message : '密码修改失败'); } finally { setPending(false); } }; - useEffect(() => { - const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !pending) onClose(); }; - window.addEventListener('keydown', closeOnEscape); - return () => window.removeEventListener('keydown', closeOnEscape); - }, [onClose, pending]); - return
{ if (event.target === event.currentTarget && !pending) onClose(); }}>
-

修改登录密码

修改成功后,所有设备需要重新登录。

- - - - {error ? {error} : null} -
-
; + return +
+ 修改成功后,所有设备需要重新登录。 + + + + {error ? {error} : null} +
+
+
; } const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]]; @@ -154,51 +196,55 @@ function MobileNavigation() { const moreActive = more.some((item) => location.pathname.startsWith(item.to)); const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; useEffect(() => setMoreOpen(false), [location.pathname]); - useEffect(() => { - if (!moreOpen) return; - const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setMoreOpen(false); }; - window.addEventListener('keydown', closeOnEscape); - return () => window.removeEventListener('keydown', closeOnEscape); - }, [moreOpen]); + useSideSheetA11y(moreOpen, '.v2-mobile-more-sidesheet', 'v2-mobile-more', '更多功能', '关闭更多功能'); const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}>{label}; return <> - {moreOpen && more.length ?
{ if (event.target === event.currentTarget) setMoreOpen(false); }}>
更多功能数据分析与系统管理
: null} + {more.length ? 更多功能数据分析与系统管理} aria-label="更多功能" footer={null} onCancel={() => setMoreOpen(false)}> : null} ; } -function Sidebar() { +function Sidebar({ activePath }: { activePath: string }) { const { session } = usePlatformSession(); + const navigate = useNavigate(); const [collapsed, setCollapsed] = useState(false); + const compactLayout = useMobileLayout(900); + const effectiveCollapsed = collapsed || compactLayout; const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; - const visibleNavigation = navigation.filter((item) => hasMenu(session, item.menu)); + const visibleNavigation = [ + ...navigation.filter((item) => hasMenu(session, item.menu)), + ...(hasMenu(session, 'operations') ? [{ to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }] : []) + ]; + const items = visibleNavigation.map(({ to, label, icon: Icon }) => ({ + itemKey: to, + text: label, + icon: , + link: to, + linkOptions: { + 'aria-label': label, + title: effectiveCollapsed ? label : undefined, + onClick: (event: MouseEvent) => { event.preventDefault(); navigate(to); }, + onPointerEnter: () => warmRoute(to), + onFocus: () => warmRoute(to), + onPointerDown: () => warmRoute(to) + } + })); return ( - + : null} + ); } diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index 06c5c741..25a4f4f1 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -344,7 +344,9 @@ test('recovers in place after a transient AMap failure and renders data that arr ); expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ })); + const retry = screen.getByRole('button', { name: /重新加载地图/ }); + expect(retry).toHaveClass('semi-button', 'v2-map-retry-action'); + fireEvent.click(retry); expect(setData).not.toHaveBeenCalled(); await act(async () => { @@ -520,6 +522,23 @@ test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data' expect(Math.abs(bounds[3] - 24)).toBeLessThan(0.0005); }); +test('drops a wrapped world viewport instead of emitting invalid WGS-84 bounds', async () => { + getZoom.mockReturnValue(3); + getBounds.mockReturnValue({ + getSouthWest: () => ({ getLng: () => -80, getLat: () => -20 }), + getNorthEast: () => ({ getLng: () => 260, getLat: () => 70 }) + }); + window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; + window.AMapLoader = { load: vi.fn(async () => amapMock()) }; + const onViewportChange = vi.fn(); + + render( undefined} onViewportChange={onViewportChange} />); + + await waitFor(() => expect(onViewportChange).toHaveBeenCalled()); + const viewport = onViewportChange.mock.calls[onViewportChange.mock.calls.length - 1]?.[0] as { zoom: number; bounds: string }; + expect(viewport).toEqual({ zoom: 3, bounds: '' }); +}); + test('renders one selected plate and smoothly follows it until the map is dragged', async () => { useFastAnimationFrames(); window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; @@ -702,7 +721,8 @@ test('renders crisp province totals and drills into the existing cluster layer', expect(setData).toHaveBeenLastCalledWith([]); const provinceIndex = markerOptions.findIndex((options) => String(options.content).includes('广东省')); expect(String(markerOptions[provinceIndex]?.content)).toContain('3'); - expect(String(markerOptions[provinceIndex]?.content)).toContain('在线 2'); + expect(String(markerOptions[provinceIndex]?.content)).toContain('广东'); + expect(String(markerOptions[provinceIndex]?.content)).not.toContain('在线'); markerInstances[provinceIndex]?.handlers.get('click')?.(); expect(setZoomAndCenter).toHaveBeenLastCalledWith(7, [113.266, 23.132]); diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index 6f337e41..fecdc156 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -1,4 +1,4 @@ -import { IconEyeClosed, IconEyeOpened, IconMapPin, IconRefresh } from '@douyinfe/semi-icons'; +import { IconEyeClosed, IconEyeOpened, IconMapPin } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { getAMapConfig, isAMapConfigured } from '../../config/appConfig'; import { @@ -16,8 +16,9 @@ import { type AMapOverlay } from '../../integrations/amap'; import type { MonitorMapProvincePoint, MonitorMapResponse, VehicleRealtimeRow } from '../../api/types'; -import { vehicleStatus } from '../domain/monitor'; +import { normalizeMonitorBounds, vehicleStatus } from '../domain/monitor'; import type { MonitorViewport } from '../hooks/useMonitorData'; +import { MapRetryAction } from '../shared/RecoveryActions'; const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444']; const CLUSTER_VISUAL_CACHE_LIMIT = 256; @@ -78,11 +79,9 @@ function viewportFromMap(map: AMapMap): MonitorViewport | null { ]; const longitudes = wgsCorners.map(([longitude]) => longitude); const latitudes = wgsCorners.map(([, latitude]) => latitude); - return { - zoom, - bounds: [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)] - .map((value) => value.toFixed(6)).join(',') - }; + const viewportBounds = [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)] + .map((value) => value.toFixed(6)).join(','); + return { zoom, bounds: normalizeMonitorBounds(viewportBounds) }; } function viewportCenter(viewport?: MonitorViewport) { @@ -757,13 +756,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect existing.marker.off?.('click', existing.click); existing.marker.setMap?.(null); } - const onlinePercent = province.count ? Math.round(province.online / province.count * 100) : 0; - const displayName = compactMarkers ? compactProvinceName(province.name) : province.name; + const displayName = compactProvinceName(province.name); const marker = new AMap.Marker({ position: [province.longitude, province.latitude], offset: new AMap.Pixel(0, 0), zIndex: 180, - content: `
${connectorLength ? '' : ''}
` + content: `
${connectorLength ? '' : ''}
` }); const click = () => map.setZoomAndCenter?.(PROVINCE_DRILL_ZOOM, [province.longitude, province.latitude]); marker.on?.('click', click); @@ -1053,7 +1051,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
{state === 'loading' ? <>高德地图加载中 : null} {state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null} - {state === 'error' ? <>地图加载失败,请检查高德 Key、域名白名单和网络 : null} + {state === 'error' ? <>地图加载失败,请检查高德 Key、域名白名单和网络 setLoadAttempt((value) => value + 1)} /> : null}
) : null}
diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx index 5b8e8ad7..3e5be9f7 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx @@ -128,7 +128,9 @@ test('defers the map runtime until valid data exists, retries a transient failur onFollowChange={() => undefined} />); expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ })); + const retry = screen.getByRole('button', { name: /重新加载地图/ }); + expect(retry).toHaveClass('semi-button', 'v2-map-retry-action'); + fireEvent.click(retry); await waitFor(() => expect(mapInstances).toHaveLength(1)); expect(load).toHaveBeenCalledTimes(2); expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument(); diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx index f2d4544a..ac320b28 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx @@ -1,8 +1,8 @@ -import { IconRefresh } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { HistoryLocationRow, TrackStop } from '../../api/types'; import { getAMapConfig, isAMapConfigured } from '../../config/appConfig'; import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap'; +import { MapRetryAction } from '../shared/RecoveryActions'; function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: string) { if (kind === 'current') return ''; @@ -153,7 +153,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow {state === 'loading' ? <>轨迹地图加载中 : null} {state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null} {state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null} - {state === 'error' ? <>地图加载失败,请检查高德地图配置或网络 : null} + {state === 'error' ? <>地图加载失败,请检查高德地图配置或网络 setLoadAttempt((value) => value + 1)} /> : null}
: null} ; } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx index 2ea42640..544302df 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -11,12 +11,15 @@ const mocks = vi.hoisted(() => ({ accessThresholds: vi.fn(), updateAccessThresholds: vi.fn() })); const auth = vi.hoisted(() => ({ role: 'admin' })); +const layout = vi.hoisted(() => ({ mobile: false })); vi.mock('../../api/client', () => ({ api: mocks })); vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) })); +vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile })); afterEach(() => { cleanup(); auth.role = 'admin'; + layout.mobile = false; Object.values(mocks).forEach((mock) => mock.mockReset()); }); @@ -44,9 +47,23 @@ test('removes old access rows immediately when the vehicle filter scope changes' ? Promise.resolve({ items: [accessRow('OLDVIN', '旧接入车牌')], total: 1, limit: 50, offset: 0 }) : new Promise>((resolve) => { resolveNew = resolve; })); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + const view = render(); expect(await screen.findByText('旧接入车牌')).toBeInTheDocument(); + for (const className of ['v2-access-filter-card-v3', 'v2-access-kpis-card-v3', 'v2-access-table-v3']) { + expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument(); + } + expect(view.container.querySelector('.v2-access-semi-table.semi-table-wrapper')).toBeInTheDocument(); + expect(view.container.querySelector('.v2-access-table-scroll-v3 > table')).not.toBeInTheDocument(); + const desktopRow = screen.getByTestId('access-row-OLDVIN'); + expect(desktopRow).toHaveAttribute('role', 'button'); + expect(desktopRow).toHaveAttribute('aria-expanded', 'false'); + fireEvent.keyDown(desktopRow, { key: 'Enter' }); + expect(await screen.findByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument(); + expect(desktopRow).toHaveAttribute('aria-expanded', 'true'); + const inspectorHeader = screen.getByRole('heading', { level: 5, name: '旧接入车牌' }).closest('.v2-workspace-panel-header'); + expect(inspectorHeader).toBeInTheDocument(); + expect(within(inspectorHeader!).getByText('OLDVIN')).toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox', { name: '车辆' }), { target: { value: 'NEWVIN' } }); fireEvent.click(screen.getByRole('button', { name: '查询' })); @@ -66,13 +83,23 @@ test('reports and independently retries unresolved identity and threshold failur const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); + fireEvent.click(await screen.findByRole('button', { name: /接入治理/ })); + expect(await screen.findByRole('dialog', { name: '接入治理配置' })).toBeInTheDocument(); const identityError = await screen.findByText('待绑定身份服务超时'); const thresholdError = await screen.findByText('阈值配置读取失败'); fireEvent.click(within(identityError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ })); fireEvent.click(within(thresholdError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ })); - expect(await screen.findByText('另有 1 条来源身份待绑定')).toBeInTheDocument(); - expect(await screen.findByText('在线判定阈值 · v1')).toBeInTheDocument(); + const identityTitle = await screen.findByText('来源身份待绑定'); + const identityCollapse = identityTitle.closest('.semi-collapse'); + expect(identityCollapse).toHaveClass('v2-access-identity-queue-v3'); + expect(within(identityCollapse!).getByText('1 条').closest('.semi-tag')).toBeTruthy(); + expect(within(identityCollapse!).getByText('138****0001').closest('.semi-card')).toHaveClass('v2-access-identity-card'); + + const thresholdTitle = await screen.findByText('在线判定阈值'); + const thresholdCollapse = thresholdTitle.closest('.semi-collapse'); + expect(thresholdCollapse).toHaveClass('v2-access-settings'); + expect(within(thresholdCollapse!).getByText('v1').closest('.semi-tag')).toBeTruthy(); expect(screen.queryByText('待绑定身份服务超时')).not.toBeInTheDocument(); expect(screen.queryByText('阈值配置读取失败')).not.toBeInTheDocument(); expect(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2); @@ -97,3 +124,26 @@ test('does not request or render admin-only thresholds for a read-only session', expect(mocks.accessThresholds).not.toHaveBeenCalled(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); + +test('renders mobile access vehicles as selectable Semi cards', async () => { + layout.mobile = true; + prepareBaseData(); + mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + const action = await screen.findByRole('button', { name: '查看 粤A00001 接入详情' }); + expect(action).toHaveClass('semi-button', 'v2-access-mobile-action'); + expect(action.closest('.semi-card')).toHaveClass('v2-access-mobile-card'); + expect(action).toHaveAttribute('aria-pressed', 'false'); + expect(action).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(action); + expect(action).toHaveAttribute('aria-pressed', 'true'); + expect(action).toHaveAttribute('aria-expanded', 'true'); + const dialog = await screen.findByRole('dialog', { name: '车辆接入详情' }); + expect(within(dialog).getByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument(); + expect(dialog.querySelector('.v2-access-inspector-v3.semi-card')).toBeInTheDocument(); + expect(dialog.querySelector('.v2-access-inspector-summary.semi-descriptions')).toBeInTheDocument(); + expect(dialog.querySelector('.v2-access-protocol-details.semi-card-group')).toBeInTheDocument(); + expect(dialog.querySelectorAll('.v2-access-protocol-detail.semi-card')).toHaveLength(3); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index 73fc51da..b8f994d2 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -1,4 +1,5 @@ -import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } from '@douyinfe/semi-icons'; +import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons'; +import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; @@ -6,11 +7,18 @@ import { api } from '../../api/client'; import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types'; import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; import { InlineError } from '../shared/AsyncState'; +import { MobileFilterToggle } from '../shared/MobileFilterToggle'; +import { MetricActionButton } from '../shared/MetricActionButton'; +import { PageHeader } from '../shared/PageHeader'; +import { TablePagination } from '../shared/TablePagination'; +import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; +import { detailTriggerRow } from '../shared/detailTriggerRow'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; import { downloadBlob } from '../domain/download'; import { useMobileLayout } from '../hooks/useMobileLayout'; +import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); @@ -35,19 +43,27 @@ function compactTime(value: string) { function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) { const state = !status?.connected ? 'missing' : status.onlineState; const label = state === 'missing' ? '无来源' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报'; + const color = state === 'online' ? 'green' : state === 'offline' || state === 'unknown' ? 'orange' : 'grey'; if (detailed) { - return
-
{status?.protocol}{label}
-
-
接入厂家
{status?.provider || '—'}
-
首次接入
{formatAccessTime(status?.firstSeenAt || '')}
-
最新上报
{formatAccessTime(status?.latestReceivedAt || '')}
-
当前离线
{formatSeconds(status?.freshnessSec)}
-
上报间隔
{formatSeconds(status?.reportIntervalSec)}
-
数据延迟
{formatSeconds(status?.dataDelaySec)}
-
-

{status?.firstSeenEvidence || '当前未发现该协议来源'}

-
; + return {status?.protocol}} + headerExtraContent={{label}} + headerLine + bodyStyle={{ padding: 0 }} + > + {formatSeconds(status?.dataDelaySec)} } + ]} /> +

+ {status?.firstSeenEvidence || '当前未发现该协议来源'} +

+
; } return
{label} @@ -57,7 +73,8 @@ function ProtocolState({ status, detailed = false }: { status?: AccessProtocolSt } function ConnectionState({ row }: { row: AccessVehicleRow }) { - return
{connectionLabels[row.connectionState]}{row.actualProtocols.length} 个真实来源
; + const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange'; + return
{connectionLabels[row.connectionState]}{row.actualProtocols.length} 个真实来源
; } function ProtocolCoverage({ summary }: { summary?: AccessSummary }) { @@ -69,23 +86,81 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
; } -function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) { - return