32 KiB
Deployment
Build
Run from repository root:
cd vehicle-data-platform
pnpm --dir apps/web install
pnpm run web:build
cd apps/api
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-api ./cmd/platform-api
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-evaluator ./cmd/alert-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-stream-evaluator ./cmd/alert-stream-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-migrate ./cmd/platform-migrate
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync
# Optional release/performance gate; this binary is run on demand, not installed as a service.
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-benchmark ./cmd/alert-benchmark
ECS Paths
/opt/lingniu-vehicle-platform/current
/opt/lingniu-vehicle-platform/releases
/opt/lingniu-vehicle-platform/env/platform.env
Browser-safe release switch
The web application splits every major route into a hashed lazy-loaded asset. An already-open browser tab can therefore request the previous release's route asset after current has switched. Keep exactly one previous generation of original build assets in the new release before the atomic symlink switch:
CURRENT_WEB=/opt/lingniu-vehicle-platform/current/web
NEW_WEB=/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/web
# Archives created on macOS must disable AppleDouble metadata. As a server-side
# guard, remove any metadata files before recording the release manifest.
COPYFILE_DISABLE=1 tar -C apps/web/dist -czf "/tmp/$PLATFORM_RELEASE-web.tar.gz" .
find "$NEW_WEB" -type f -name '._*' -delete
# Record only this release's own build assets before adding compatibility files.
find "$NEW_WEB/assets" -type f -printf '%P\n' | sort > "$NEW_WEB/.release-assets"
if [ -f "$CURRENT_WEB/.release-assets" ]; then
while IFS= read -r asset; do
test -n "$asset" || continue
mkdir -p "$NEW_WEB/assets/$(dirname "$asset")"
cp -n "$CURRENT_WEB/assets/$asset" "$NEW_WEB/assets/$asset"
done < "$CURRENT_WEB/.release-assets"
else
# One-time compatibility for releases created before the manifest existed.
cp -an "$CURRENT_WEB/assets/." "$NEW_WEB/assets/"
fi
Never overwrite a new hashed file. The .release-assets manifest prevents compatibility files from accumulating recursively: the next release copies only the immediately previous build's original assets. The React route boundary also detects a failed dynamic import, reloads once with a short cooldown and keeps the application shell usable if recovery still fails.
After the switch, verify the root document, runtime configuration, every current asset and every compatibility asset from the immediately previous release. The smoke downloads each asset and compares it byte-for-byte with the release file, so a SPA fallback returning index.html with HTTP 200 cannot hide a missing chunk:
PREVIOUS_WEB=/opt/lingniu-vehicle-platform/releases/$PREVIOUS_RELEASE/web
/opt/lingniu-vehicle-platform/current/deploy/verify-web-release.sh \
/opt/lingniu-vehicle-platform/current/web \
http://127.0.0.1:20300 \
"$PREVIOUS_WEB/.release-assets"
deploy/install-web-release.sh also accepts an optional third argument containing a newly built platform-api. When provided, it atomically publishes the new API and Web together. The installer inherits every runtime binary that exists in the previous release (alert-evaluator, alert-stream-evaluator, platform-migrate, oneos-scope-sync and the optional benchmark) before switching the symlink, so a later systemd restart cannot fail because a Web-oriented release omitted an unchanged binary:
deploy/install-web-release.sh \
"$PLATFORM_RELEASE" \
"/tmp/$PLATFORM_RELEASE-web.tar.gz" \
"/tmp/$PLATFORM_RELEASE-platform-api"
Environment
HTTP_ADDR=:20300
STATIC_DIR=/opt/lingniu-vehicle-platform/current/web
MYSQL_DSN=lingniu_vehicle:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/lingniu_vehicle_data?parseTime=true&loc=Local
REDIS_ADDR=r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379
REDIS_USERNAME=lingniu_vehicle
REDIS_PASSWORD=***
REDIS_DB=50
TDENGINE_DRIVER=taosWS
TDENGINE_DSN=root:***@ws(172.17.111.57:6041)/
TDENGINE_DATABASE=lingniu_vehicle_ts
CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check
DATA_MODE=production
EXPORT_DIR=/opt/lingniu-vehicle-platform/data/exports
AUTH_MODE=enforce
# JSON array with viewer/operator/admin principals. Keep this file mode 0600.
AUTH_TOKENS_JSON=[{"token":"<at-least-16-random-characters>","name":"ecs-admin","role":"admin"}]
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=<strong-first-admin-password>
AUTH_SESSION_TTL_HOURS=12
ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
ONEOS_SCOPE_MAX_REJECTED=100
ONEOS_SCOPE_MAX_REJECT_RATIO=0.10
REQUEST_TIMEOUT_MS=5000
AMAP_WEB_JS_KEY=***
AMAP_SECURITY_JS_CODE=***
AMAP_SECURITY_SERVICE_HOST=/_AMapService
# Optional reserved server-side key for AMap REST/track services.
AMAP_API_KEY=***
PLATFORM_RELEASE=platform-YYYYMMDDHHMMSS
ALERT_EVALUATION_INTERVAL_SEC=10
ALERT_STREAM_MODE=active
# Defaults to KAFKA_BROKERS from the shared ingest environment when omitted.
ALERT_STREAM_KAFKA_BROKERS=<kafka-host>:9092
ALERT_STREAM_KAFKA_TOPICS=vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1
ALERT_STREAM_KAFKA_GROUP=vehicle-alert-stream-shadow-v1
ALERT_STREAM_BATCH_SIZE=200
ALERT_STREAM_BATCH_WAIT_MS=100
ALERT_STREAM_LATENESS_SEC=120
ALERT_STREAM_MODE=shadow only validates envelopes and advances checkpoints; active additionally evaluates every non-freshness_sec rule in the same MySQL transaction. Keep the existing consumer group when switching so active resumes the shadow-proven offsets instead of replaying retained history. In active mode the snapshot evaluator retains exclusive ownership of freshness_sec and skips all dynamic telemetry rules. Roll back the mode to shadow and restart the API plus both evaluators if active batches fail; transaction rollback prevents a failed rule effect from advancing the authoritative checkpoint.
AMAP_WEB_JS_KEY is served to the browser through /app-config.js so the same static build can be reused across environments. For production, set both AMAP_SECURITY_JS_CODE and AMAP_SECURITY_SERVICE_HOST=/_AMapService; the API service keeps the security code on the server and proxies AMap requests with jscode appended. Only omit AMAP_SECURITY_SERVICE_HOST for controlled debugging where exposing AMAP_SECURITY_JS_CODE to the browser is acceptable. AMAP_API_KEY is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration.
apps/web/public/app-config.js is ignored and may contain developer-local values. pnpm build always replaces its copied output with app-config.example.js before packaging, then verifies an exact byte match. This prevents a local security code from entering a release archive even though production requests are dynamically intercepted by the API. The release smoke gate separately rejects any /app-config.js response containing amapSecurityJsCode and requires amapSecurityServiceHost=/_AMapService.
PLATFORM_RELEASE is surfaced by /api/ops/health at data.runtime.platformRelease so operators can confirm which ECS release is currently active after a deployment.
The global monitor persists its navigation workspace in the /monitor query string and passes a validated monitorReturn only to vehicle detail, track, history and mileage routes. Reverse proxies must preserve query strings on SPA fallback responses. Do not rewrite or decode the nested return value separately: the client accepts only a relative same-application /monitor path, canonicalizes filters, pagination and viewport values, and rejects external origins or malformed bounds. Production smoke should request all affected SPA routes and verify that the current MonitorReturnBar-* and monitorContext-* assets are served from the active release.
All three platform systemd units first load /opt/lingniu-go-native/env/base.env for the existing MySQL, Redis, TDengine and Kafka connection settings, then load platform.env for platform-specific overrides. DATA_MODE=production is mandatory on ECS: a missing or unreachable MySQL connection returns DATA_STORE_UNAVAILABLE instead of silently serving demonstration data. Use DATA_MODE=mock only for local development.
EXPORT_DIR must point outside the release symlink. The API writes mode-0640 CSV files and an atomic jobs.json index there; completed tasks survive API restarts, while interrupted queued/running jobs are marked failed and can be recreated. Every job persists its owner account, role/user type, customer/tenant references, resolved VINs and per-vehicle time scope. Non-admin principals can list and download only their own jobs; customers additionally pass a live account-status and vehicle-grant check before execution, every 5,000-row batch and every download. Old ownerless jobs remain visible only to administrators. The browser downloads through an authenticated Blob request rather than a direct anchor, so the Bearer credential is always present.
Exports run one at a time, use 5,000-row forward-only TDengine cursors instead of OFFSET, stream into a .part file, and atomically publish the final CSV only after flush, fsync, and close succeed. A task is limited to five vehicles, 31 days, 32 metrics, 1,000,000 rows, and 30 minutes. Both the initial count and observed rows enforce the row cap so late-arriving data cannot bypass it. Keep the directory mode 0750, preserve jobs.json and completed CSVs across releases, and include the directory in retention/backup policy. Export routes require an authenticated viewer-capable principal; ownership and current Scope are enforced inside the export service instead of relying only on role middleware.
Release verification for this path includes the opt-in synthetic million-row gate:
cd vehicle-data-platform/apps/api
EXPORT_MILLION_TEST=1 go test ./internal/platform -run '^TestHistoryExportMillionRows$' -count=1 -v
Production smoke must create an export for an active VIN, wait for completed, verify rowCount == processedRows == totalRows, and assert that owner, role, resolved VINs and time scope are present. Download with the same Bearer principal, confirm the CSV contains export audit, vehicle Scope, query-range, metric/unit and quality metadata, and verify a guessed task ID returns 404. Compare the downloaded SHA-256 and byte count before and after one API restart, and confirm that no .part file remains. The automated gate must separately cover two mutually isolated customer principals plus account/vehicle revocation during execution.
Before a customer demonstration, run deploy/verify-customer-demo.py with the password supplied only through DEMO_PASSWORD. The gate logs in as the actual customer, requires the exact four customer menus, compares the complete realtime VIN set with the approved cohort, verifies representative source/location states, confirms an out-of-Scope VIN cannot resolve, checks optional track and mileage evidence, loads the four customer SPA routes and logs out. The script never creates an account, changes grants or persists the password; account identity and VIN Scope must be approved separately. See docs/customer-demo-acceptance.md.
AUTH_MODE=enforce is mandatory on ECS. Tokens are stored as SHA-256 comparisons in memory and sent as Bearer credentials; the browser stores the entered token only in sessionStorage. Roles are cumulative:
| Role | Permissions |
|---|---|
viewer |
Read pages, query data and inspect evidence; may create/download only its own history exports when the menu is available |
operator |
Viewer permissions plus alert actions and notification reads; export access remains owner-bound |
admin |
Operator permissions plus rule and access-threshold configuration |
After editing the environment file, run chmod 600 /opt/lingniu-vehicle-platform/env/platform.env. Never put a real token in Git, static JavaScript, shell history or deployment logs.
ONEOS_MYSQL_DSN must use a dedicated account with direct table-level SELECT grants only. The sync binary runs SHOW GRANTS FOR CURRENT_USER before every read and accepts only global USAGE plus SELECT on the seven tables used by its query. It refuses database/global reads, ALL PRIVILEGES, DML, DDL, PROCESS, replication, roles, SHOW VIEW, or any other privilege. It then opens a repeatable-read, read-only transaction, applies a 10-second statement timeout, classifies invalid customer/contract relationships, and atomically publishes a content-addressed local snapshot. Never point it at the existing ln-bi account: production audit showed that account still has broad write and replication privileges.
Have an RDS administrator review and run docs/oneos-scope-reader-provision.sql separately. It restricts the login source to the verified ECS private address and grants SELECT on only the seven source tables used by the query. Do not include that DDL in application deployment or migration automation. Store platform.env as root-owned mode 0600, and verify SHOW GRANTS before enabling the timer.
The active lifecycle comes from OneOS delivery and return task facts: the newest active delivery must have delivery_status IN (2,3), and it must not have an active return task with status IN (2,3,5). The sync intentionally does not use vehicle_lease_order_record.last_return_time, because OneOS currently writes that aggregate field while a return form is still a draft. The aggregate record remains a separate customer-ownership cross-check; missing, duplicate, or conflicting ownership fails closed.
The default two-minute timer reads one bounded current-scope result set (639 rows at the 2026-07-14 transactional-fact baseline), so it avoids per-request access to OneOS and adds negligible RDS load. A run is rejected before publication if it has no accepted rows, more than 100 rejected rows, or a rejected ratio above 10%. Current baseline is 613 accepted and 26 quarantined. An unchanged content hash records a successful run without duplicating the snapshot.
Forward Migration
Before switching the API symlink, apply the idempotent access-threshold migration with a MySQL account allowed to create/alter platform-owned tables:
export MYSQL_DSN="$(sed -n 's/^MYSQL_DSN=//p' /opt/lingniu-go-native/env/base.env | tail -1)"
test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/platform-migrate \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/001_access_thresholds.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/002_alert_center.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/003_alert_rule_advanced.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/004_alert_repeat_index.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/005_metric_catalog.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/006_vehicle_profile.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/007_alert_master_data_scope.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/008_access_projection.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/009_alert_stream_checkpoint.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/010_alert_stream_metric_mapping.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql
The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward ADD COLUMN and CREATE INDEX statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration 008 adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its (protocol, vin) primary key. Migration 009 creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration 014 backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history.
Install and start the evaluator as a separate unit only after API smoke checks and a small enabled-rule review:
sudo cp deploy/systemd/lingniu-vehicle-alert-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-alert-stream-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator lingniu-vehicle-oneos-scope-sync.timer
sudo systemctl status --no-pager lingniu-vehicle-alert-evaluator
sudo systemctl status --no-pager lingniu-vehicle-alert-stream-evaluator
sudo journalctl -u lingniu-vehicle-alert-evaluator -n 100 --no-pager
sudo journalctl -u lingniu-vehicle-alert-stream-evaluator -n 100 --no-pager
sudo systemctl status --no-pager lingniu-vehicle-oneos-scope-sync.timer
sudo journalctl -u lingniu-vehicle-oneos-scope-sync.service -n 100 --no-pager
Health
read -rsp 'Platform admin token: ' PLATFORM_TOKEN; echo
AUTH_HEADER="Authorization: Bearer $PLATFORM_TOKEN"
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/session
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/ops/health
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/metrics
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/monitor/summary?limit=200'
curl -fsS -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
-d '{"keyword":"LNXNEGRR7SR318212","status":"active","limit":20,"offset":0}' \
http://127.0.0.1:20300/api/v2/alerts/events
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/vehicles/LNXNEGRR7SR318212/telemetry/latest
curl -fsS -H "$AUTH_HEADER" \
'http://127.0.0.1:20300/api/v2/vehicles/LNXNEGRR7SR318212/source-evidence?date=2026-07-16'
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/vehicles?limit=5'
curl -fsS -H "$AUTH_HEADER" \
'http://127.0.0.1:20300/api/v2/history/series?keyword=LNXNEGRR7SR318212&dateFrom=2026-07-14T00%3A00&dateTo=2026-07-14T06%3A00&targetPoints=240'
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":5,"offset":0}' \
http://127.0.0.1:20300/api/v2/access/vehicles
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":20,"offset":0}' \
http://127.0.0.1:20300/api/v2/access/unresolved-identities
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/access/thresholds
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
-d '{"sourceSystem":"release-smoke","sourceVersion":"dry-run-1","conflictPolicy":"preserve","dryRun":true,"items":[{"vin":"RELEASE_SMOKE_MISSING","operationStatus":"unknown"}]}' \
http://127.0.0.1:20300/api/v2/vehicle-profiles/sync
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":5,"offset":0}' \
http://127.0.0.1:20300/api/v2/alerts/events
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/alerts/rules
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/alerts/notifications?unreadOnly=true&limit=5'
curl -fsS http://127.0.0.1:20300/app-config.js
curl -fsS http://127.0.0.1:20300/ | grep -q '<div id="root"></div>'
MAIN_ASSET="$(sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' /opt/lingniu-vehicle-platform/current/web/index.html | head -1)"
curl -fsS "http://127.0.0.1:20300$MAIN_ASSET" >/dev/null
unset PLATFORM_TOKEN AUTH_HEADER
The release gate must include the root document, runtime configuration, every asset in the current .release-assets manifest and every asset in the immediately previous release manifest. API-only smoke checks are insufficient because an incomplete archive can leave the service healthy while the browser returns 404 or an HTML fallback for a missing chunk. Before upload, verify the archive contains web/index.html, platform-api, both evaluator binaries, platform-migrate, oneos-scope-sync, every numbered migration, the web-release verifier and all platform systemd units.
When alertStream.lastInvalidCode reports a recent missing_vin_jt808, query /api/v2/access/unresolved-identities as a viewer and hand the masked evidence to the identity owner. The response must not contain raw phone or unmasked identifier fields. Confirm the evidence against the GPS provider or vehicle owner, then maintain the authoritative vehicle_identity_binding; never derive VIN from the terminal number. The queue excludes a terminal after a valid binding exists. Retain the invalid counter as audit evidence and verify the recent warning clears after five minutes without another invalid frame.
The history-series gate must use an active production VIN and a current local-time window. Verify that rawPointCount > 0, every returned series remains within the requested point budget, dateFrom/dateTo and point timestamps describe the same absolute window, and the browser renders Asia/Shanghai labels. TDengine timestamp literals for this endpoint include an explicit RFC3339 offset; bare UTC-looking strings are unsafe because the server session may interpret them again in its local timezone.
The history-query gate must also request both location and raw through /api/v2/history/query. Every returned row must contain a non-empty qualityReason; RAW columns must include fields discovered from the bounded fetched scope rather than only the visible page. In the browser, verify field-name/key search, multi-select, the single visual time-range control, selected-field consistency across table/trend/export, and absence of a RAW-evidence placeholder when evidenceId is unavailable. Keep the existing maximum of five vehicles, 200 rows per page and 1,000 fetched rows for interactive field discovery.
The history-export gate must verify owner-bound listing and authenticated Blob download. A customer A task must be absent from customer B's list and return 404 when B guesses its ID; an administrator may audit it. Persisted job JSON and CSV metadata must retain the owner and exact per-VIN time scope after an API restart. Disabling the customer or revoking any required vehicle grant must stop an incomplete task and deny download of a previously completed file.
Track smoke must also select the final playback event and verify synchronized SOC/direction/alarm availability plus a resolved current address. For high-frequency sources, confirm positive subsecond samples do not become alternating zero-second 数据间隔 segments; only non-increasing timestamps or intervals over ten minutes are gaps. Access smoke should exercise model/provider and one paired first/latest receive-time range, then confirm the filter survives a shareable URL and every returned row remains in scope.
Global-monitor smoke must verify zoom=5 returns clusters, a city bounds at zoom=13 returns at most 2,000 lightweight points, and invalid/reversed bounds return HTTP 400. In the browser, the rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting driving or idle must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1 plus go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem.
Source-evidence smoke must use a vehicle with at least two protocols and a vehicle with more than one JT808 terminal. Verify that the response returns every actual location/mileage candidate, exactly one current recommendation, protocol-internal selection, availability/quality state and cross-source differences. Raw source_key, source IP, endpoint, phone and device identifiers must not appear; terminal labels must remain masked. The browser must not call this endpoint before the operator expands coordinates or mileage. Production baseline for 沪A35898F on 2026-07-16 returned two JT808 terminals with one recommendation; 20 sequential requests had a 26.6ms median and 29.1ms P95.
Latest-telemetry smoke must use an actively reporting VIN and verify that populated categories exactly sum to values.length, catalog-mapped values retain both the unified key and exact sourceField, every value has frame/time/protocol evidence, and the quality reason agrees with freshness and parser status. scannedFrames must remain at or below 15: the implementation resolves identity once, queries the three supported protocol tables concurrently with five rows each, and never runs a pagination count. The browser must display source, compact device time and quality without deriving labels, units or categories from RAW keys. The bounded synthetic gate is go test ./internal/platform -run 'TestBuildLatestTelemetryResponse|TestLatestTelemetryQuality|TestLatestTelemetryBoundsProtocolReads' -count=1 plus go test ./internal/platform -run '^$' -bench BenchmarkLatestTelemetryHundredFrames -benchmem.
Alert evaluation is intentionally not exposed as an HTTP route because it mutates candidate/event state. Production evaluation is owned by the evaluator unit. Confirm its rules/vehicles/candidates_advanced/duplicate_observations/late_observations/stale_evidence_skipped/opened/recovered log line and verify the resulting event through the read APIs. A release gate with an active scoped rule must survive at least one persisted candidate reload and one persisted last-trigger reload; MySQL DATETIME(3) values are scanned directly as times so millisecond values are never forced through an integer UNIX_TIMESTAMP conversion.
The Kafka stream evaluator accepts only explicit shadow and active modes. A new group starts at the latest retained offset, then resumes committed offsets. Each batch validates the canonical FIELDS envelope, protocol/topic pairing, field namespace, identity and event/receive time. In active mode it locks enabled dynamic rules, reads only state belonging to the current batch's rules/VINs/protocols, applies candidate/event/action/notification changes, and updates the MySQL checkpoint in one transaction; Kafka is committed only afterward. Replayed offsets below the database checkpoint are skipped. Evidence beyond the configured receive/event lateness window can still drive data_delay_sec, but cannot open or recover speed/SOC/alarm rules. /api/ops/health.alertStream and the Alert Kafka stream link expose mode, partitions, checkpoint lag, processed/valid/invalid/late/replay counts, last invalid code/time and last update. A recent missing_vin_<protocol> warning means a real terminal is still reporting without an authoritative VIN; resolve it through access/identity operations instead of fabricating a binding. Historical invalid totals remain audit evidence but stop holding the link in warning five minutes after the last invalid message. Release requires lag below the configured gate, no unexplained invalid messages and an active-rule canary with exactly-once event evidence.
Alert Candidate Write Benchmark
alert-benchmark measures the evaluator's seven-column candidate batch shape against the configured MySQL/RDS connection. It never writes vehicle_alert_candidate itself and only accepts two compiled-in benchmark table names.
- The default
temporarymode creates a connection-scoped temporary table. It is the lowest-risk network, SQL and index-path check. durablemode requires--confirm-durable-write, takes a zero-wait MySQL advisory lock, creates a dedicated physicalvehicle_alert_candidate_benchmark_durabletable withLIKE vehicle_alert_candidate, commits one transaction, verifies the row count, drops the table and verifies its removal throughinformation_schema. A stale table from an interrupted prior run is removed only after the lock is held.- Both modes are bounded to 100,000 rows and 1,000 rows per insert. The process uses an independent cleanup timeout so normal error returns still clean up the benchmark table.
export MYSQL_DSN="$(sed -n 's/^MYSQL_DSN=//p' /opt/lingniu-go-native/env/base.env | tail -1)"
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode temporary --rows 10000 --batch-size 500
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode durable --confirm-durable-write --rows 10000 --batch-size 500
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode durable --confirm-durable-write --rows 100000 --batch-size 500
unset MYSQL_DSN
ECS baseline on 2026-07-14 against production RDS MySQL 8.0.36. Every row count matched and every dedicated table cleanup was verified:
| Mode | Rows | Batch | Transactions | Duration | Throughput | Observed redo delta | Cleanup |
|---|---|---|---|---|---|---|---|
| temporary | 10,000 | 500 | 1 | 192ms | 51,883 rows/s | n/a | verified |
| temporary | 100,000 | 500 | 1 | 1,765ms | 56,646 rows/s | n/a | verified |
| durable | 10,000 | 500 | 1 | 195ms | 51,234 rows/s | 3,121,664 bytes | 3ms, verified |
| durable | 100,000 | 500 | 1 | 1,971ms | 50,713 rows/s | 31,037,440 bytes | 3ms, verified |
globalStatusDelta is sampled from MySQL instance-global counters, including Innodb_os_log_written, Binlog_cache_use, Binlog_cache_disk_use and Com_commit. These values prove redo/binlog machinery was active around the durable run, but they are shared with concurrent RDS traffic and are not an exact per-benchmark attribution. The durable result closes the committed physical-table throughput gap without polluting business data; it still does not replace long-running evaluator observation with active rules, lock-contention tests, RDS Performance Insights, or binlog/redo monitoring over a representative peak window.
Rollback
Keep the previous release directory until post-release monitoring is complete. Database changes are forward-compatible and are not rolled back. If API or browser checks fail, atomically restore the old symlink and restart both units:
PREVIOUS_RELEASE=/opt/lingniu-vehicle-platform/releases/<previous-release>
sudo ln -sfn "$PREVIOUS_RELEASE" /opt/lingniu-vehicle-platform/current
sudo systemctl restart lingniu-vehicle-platform
sudo systemctl stop lingniu-vehicle-alert-evaluator
sudo systemctl status --no-pager lingniu-vehicle-platform
Do not roll back the symlink if the old binary cannot tolerate the forward schema; validate this before every migration. The current 001–011 migrations are forward-only additions of tables, columns and indexes; the old realtime writer ignores the nullable/defaulted 008 columns and the prior API does not select them. Migration 003 once stopped after its first two MySQL ALTER statements because last_value was a reserved identifier; the final immutable migration uses observed_value, and the journaled runner safely resumed only the duplicate schema objects before creating the missing state table. Do not edit an applied migration—add the next numbered migration for future schema changes.