From 1477b997c9b7ea8c7a81f23f8b67ca6d10cbdb13 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 2 Jul 2026 01:34:28 +0800 Subject: [PATCH] test: add go native production smoke --- docs/operations/current-ecs-deployment.md | 9 +- tools/go_native_prod_smoke.py | 217 ++++++++++++++++++++++ tools/test_go_native_prod_smoke.py | 83 +++++++++ 3 files changed, 308 insertions(+), 1 deletion(-) create mode 100755 tools/go_native_prod_smoke.py create mode 100644 tools/test_go_native_prod_smoke.py diff --git a/docs/operations/current-ecs-deployment.md b/docs/operations/current-ecs-deployment.md index 833186bc..743d16b5 100644 --- a/docs/operations/current-ecs-deployment.md +++ b/docs/operations/current-ecs-deployment.md @@ -136,6 +136,14 @@ curl -sS 'http://115.29.187.205:20210/api/stats/daily-metrics?dateFrom=2026-07-0 分页参数统一使用 `limit` 和 `offset`。 +部署后推荐直接运行 Go 原生生产 smoke: + +```bash +python3 tools/go_native_prod_smoke.py --date 2026-07-02 --timeout 8 +``` + +该脚本通过 `20210` HTTP API 验证 GB32960、JT808、宇通 MQTT 的 RAW 查询,JT808 的位置/里程点查询,以及 GB32960/JT808 的 `daily_mileage_km`、`daily_total_mileage_km` 统计。任一检查不达标时退出码为非 0。 + ## 部署命令 在开发机或 CI 构建 Linux amd64: @@ -180,4 +188,3 @@ journalctl -u lingniu-go-realtime-api --since '5 minutes ago' --no-pager - `realtime-api` 正在监听 `20210`。 - GB32960、JT808、YUTONG_MQTT 的东八区 RAW 查询均可命中生产数据。 - `vehicle_daily_metric` 可查到 `daily_mileage_km` 和 `daily_total_mileage_km`。 - diff --git a/tools/go_native_prod_smoke.py b/tools/go_native_prod_smoke.py new file mode 100755 index 00000000..c8e3fba9 --- /dev/null +++ b/tools/go_native_prod_smoke.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""HTTP smoke checks for the Go native production vehicle ingest stack.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass +from typing import Any + + +SHANGHAI = dt.timezone(dt.timedelta(hours=8)) +DEFAULT_BASE_URL = "http://115.29.187.205:20210" + + +@dataclass(frozen=True) +class CheckSpec: + name: str + path: str + params: dict[str, Any] + minimum: int + + +@dataclass(frozen=True) +class Check: + name: str + status: str + count: int + minimum: int + message: str + + +def shanghai_day_window(now: dt.datetime | None = None) -> tuple[str, str]: + now = now or dt.datetime.now(tz=SHANGHAI) + local = now.astimezone(SHANGHAI) + start = dt.datetime(local.year, local.month, local.day, tzinfo=SHANGHAI) + end = start + dt.timedelta(days=1) + return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds") + + +def api_url(base_url: str, path: str, params: dict[str, Any]) -> str: + return base_url.rstrip("/") + path + "?" + urllib.parse.urlencode(params) + + +def query_json(base_url: str, path: str, params: dict[str, Any], timeout: float) -> dict[str, Any]: + request = urllib.request.Request( + api_url(base_url, path, params), + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def check_total(name: str, payload: dict[str, Any], minimum: int) -> Check: + count = int(payload.get("total") or 0) + sample = sample_summary(payload.get("items") or []) + if count >= minimum: + return Check(name, "pass", count, minimum, f"count={count}; sample={sample}") + return Check(name, "fail", count, minimum, f"count={count}; expected >= {minimum}; sample={sample}") + + +def sample_summary(items: list[Any]) -> str: + if not items: + return "{}" + first = items[0] + if not isinstance(first, dict): + return json.dumps(first, ensure_ascii=False, sort_keys=True) + keep = { + key: first.get(key) + for key in ( + "ts", + "stat_date", + "protocol", + "vehicle_key", + "vin", + "phone", + "message_id_hex", + "metric_key", + "metric_value", + "total_mileage_km", + "latest_total_mileage_km", + ) + if first.get(key) not in (None, "") + } + return json.dumps(keep, ensure_ascii=False, sort_keys=True) + + +def overall_status(checks: list[Check]) -> str: + return "fail" if any(check.status == "fail" for check in checks) else "pass" + + +def build_check_specs( + *, + date_from: str, + date_to: str, + stat_date: str, + min_raw: int, + min_history: int, + min_stat: int, +) -> list[CheckSpec]: + raw_params = {"dateFrom": date_from, "dateTo": date_to, "limit": 1} + history_params = {"protocol": "JT808", "dateFrom": date_from, "dateTo": date_to, "limit": 1} + return [ + CheckSpec("gb32960.raw", "/api/history/raw-frames", {**raw_params, "protocol": "GB32960"}, min_raw), + CheckSpec("jt808.raw", "/api/history/raw-frames", {**raw_params, "protocol": "JT808"}, min_raw), + CheckSpec("yutong_mqtt.raw", "/api/history/raw-frames", {**raw_params, "protocol": "YUTONG_MQTT"}, min_raw), + CheckSpec("jt808.locations", "/api/history/locations", history_params, min_history), + CheckSpec("jt808.mileage_points", "/api/history/mileage-points", history_params, min_history), + CheckSpec( + "gb32960.daily_mileage", + "/api/stats/daily-metrics", + { + "protocol": "GB32960", + "metricKey": "daily_mileage_km", + "dateFrom": stat_date, + "dateTo": stat_date, + "limit": 1, + }, + min_stat, + ), + CheckSpec( + "gb32960.daily_total_mileage", + "/api/stats/daily-metrics", + { + "protocol": "GB32960", + "metricKey": "daily_total_mileage_km", + "dateFrom": stat_date, + "dateTo": stat_date, + "limit": 1, + }, + min_stat, + ), + CheckSpec( + "jt808.daily_mileage", + "/api/stats/daily-metrics", + { + "protocol": "JT808", + "metricKey": "daily_mileage_km", + "dateFrom": stat_date, + "dateTo": stat_date, + "limit": 1, + }, + min_stat, + ), + CheckSpec( + "jt808.daily_total_mileage", + "/api/stats/daily-metrics", + { + "protocol": "JT808", + "metricKey": "daily_total_mileage_km", + "dateFrom": stat_date, + "dateTo": stat_date, + "limit": 1, + }, + min_stat, + ), + ] + + +def run_checks(base_url: str, specs: list[CheckSpec], timeout: float) -> list[Check]: + checks: list[Check] = [] + for spec in specs: + try: + payload = query_json(base_url, spec.path, spec.params, timeout) + checks.append(check_total(spec.name, payload, spec.minimum)) + except Exception as exc: # pragma: no cover - exercised by live failures. + checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}")) + return checks + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default=DEFAULT_BASE_URL) + parser.add_argument("--date", help="Shanghai local date, YYYY-MM-DD. Defaults to today.") + parser.add_argument("--timeout", type=float, default=5.0) + parser.add_argument("--min-raw", type=int, default=1) + parser.add_argument("--min-history", type=int, default=1) + parser.add_argument("--min-stat", type=int, default=1) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.date: + day = dt.date.fromisoformat(args.date) + start = dt.datetime(day.year, day.month, day.day, tzinfo=SHANGHAI) + date_from, date_to = shanghai_day_window(start) + stat_date = args.date + else: + date_from, date_to = shanghai_day_window() + stat_date = date_from[:10] + specs = build_check_specs( + date_from=date_from, + date_to=date_to, + stat_date=stat_date, + min_raw=args.min_raw, + min_history=args.min_history, + min_stat=args.min_stat, + ) + checks = run_checks(args.base_url, specs, args.timeout) + status = overall_status(checks) + print(json.dumps({ + "status": status, + "baseUrl": args.base_url, + "dateFrom": date_from, + "dateTo": date_to, + "checks": [asdict(check) for check in checks], + }, ensure_ascii=False, indent=2)) + return 0 if status == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/test_go_native_prod_smoke.py b/tools/test_go_native_prod_smoke.py new file mode 100644 index 00000000..78543fe7 --- /dev/null +++ b/tools/test_go_native_prod_smoke.py @@ -0,0 +1,83 @@ +import datetime as dt +import unittest + +from tools import go_native_prod_smoke as smoke + + +class GoNativeProdSmokeTest(unittest.TestCase): + def test_shanghai_day_window_uses_plus_eight_bounds(self): + now = dt.datetime(2026, 7, 2, 1, 35, tzinfo=smoke.SHANGHAI) + + date_from, date_to = smoke.shanghai_day_window(now) + + self.assertEqual(date_from, "2026-07-02T00:00:00+08:00") + self.assertEqual(date_to, "2026-07-03T00:00:00+08:00") + + def test_api_url_encodes_plus_eight_date_range(self): + url = smoke.api_url( + "http://example.test/base/", + "/api/history/raw-frames", + { + "protocol": "JT808", + "dateFrom": "2026-07-02T00:00:00+08:00", + "dateTo": "2026-07-03T00:00:00+08:00", + "limit": 1, + }, + ) + + self.assertEqual( + url, + "http://example.test/base/api/history/raw-frames?" + "protocol=JT808&dateFrom=2026-07-02T00%3A00%3A00%2B08%3A00" + "&dateTo=2026-07-03T00%3A00%3A00%2B08%3A00&limit=1", + ) + + def test_total_check_passes_when_total_reaches_minimum(self): + check = smoke.check_total( + "jt808.raw", + {"total": 2, "items": [{"vehicle_key": "JT808:013307811170"}]}, + minimum=1, + ) + + self.assertEqual(check.status, "pass") + self.assertEqual(check.count, 2) + self.assertIn("JT808:013307811170", check.message) + + def test_total_check_fails_when_total_is_below_minimum(self): + check = smoke.check_total("gb32960.raw", {"total": 0, "items": []}, minimum=1) + + self.assertEqual(check.status, "fail") + self.assertEqual(check.count, 0) + self.assertIn("expected >= 1", check.message) + + def test_overall_status_fails_if_any_check_fails(self): + checks = [ + smoke.Check("a", "pass", 1, 1, "ok"), + smoke.Check("b", "fail", 0, 1, "bad"), + ] + + self.assertEqual(smoke.overall_status(checks), "fail") + + def test_build_checks_cover_three_raw_protocols_and_two_daily_metric_protocols(self): + specs = smoke.build_check_specs( + date_from="2026-07-02T00:00:00+08:00", + date_to="2026-07-03T00:00:00+08:00", + stat_date="2026-07-02", + min_raw=1, + min_history=1, + min_stat=1, + ) + + names = [spec.name for spec in specs] + + self.assertIn("gb32960.raw", names) + self.assertIn("jt808.raw", names) + self.assertIn("yutong_mqtt.raw", names) + self.assertIn("jt808.locations", names) + self.assertIn("jt808.mileage_points", names) + self.assertIn("gb32960.daily_mileage", names) + self.assertIn("jt808.daily_total_mileage", names) + + +if __name__ == "__main__": + unittest.main()