diff --git a/vehicle-data-platform/deploy/install-web-release.sh b/vehicle-data-platform/deploy/install-web-release.sh index 7822ad7c..1777c870 100755 --- a/vehicle-data-platform/deploy/install-web-release.sh +++ b/vehicle-data-platform/deploy/install-web-release.sh @@ -23,6 +23,7 @@ if test -n "$API_BINARY"; then fi test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; } test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; } +test -f "$SCRIPT_DIR/verify-customer-demo.py" || { printf 'customer demo gate is missing\n' >&2; exit 1; } old=$(readlink -f "$ROOT/current") next="$ROOT/releases/$RELEASE_ID" @@ -86,8 +87,9 @@ fi cp -a "$old/deploy" "$next/deploy" cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh" cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py" +cp "$SCRIPT_DIR/verify-customer-demo.py" "$next/deploy/verify-customer-demo.py" cp "$0" "$next/deploy/install-web-release.sh" -chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py" +chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py" "$next/deploy/verify-customer-demo.py" for runtime_file in alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync alert-benchmark; do test ! -f "$next/$runtime_file" || chmod +x "$next/$runtime_file" done diff --git a/vehicle-data-platform/deploy/install-web-release.test.sh b/vehicle-data-platform/deploy/install-web-release.test.sh index b90ed7d1..019206b6 100755 --- a/vehicle-data-platform/deploy/install-web-release.test.sh +++ b/vehicle-data-platform/deploy/install-web-release.test.sh @@ -41,6 +41,7 @@ test "$(cat "$root/current/alert-evaluator")" = 'old evaluator' test -f "$root/current/web/assets/new.js" test -f "$root/current/web/assets/old.js" test -f "$root/current/web/.compatibility-manifests/1.assets" +test -x "$root/current/deploy/verify-customer-demo.py" test ! -e "$root/current/lingniu-vehicle-platform.service" test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3 grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out" diff --git a/vehicle-data-platform/deploy/verify-customer-demo.py b/vehicle-data-platform/deploy/verify-customer-demo.py new file mode 100755 index 00000000..41c6c270 --- /dev/null +++ b/vehicle-data-platform/deploy/verify-customer-demo.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 + +from __future__ import print_function + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + + +class GateError(RuntimeError): + pass + + +class Client: + def __init__(self, base_url, timeout): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def request(self, method, path, token="", payload=None, expected_statuses=(200,)): + body = None + headers = {} + if token: + headers["Authorization"] = "Bearer " + token + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(self.base_url + path, data=body, headers=headers, method=method) + try: + response = urllib.request.urlopen(request, timeout=self.timeout) + status = response.getcode() + raw = response.read() + except urllib.error.HTTPError as error: + status = error.code + raw = error.read() + if status not in expected_statuses: + message = "HTTP {0}".format(status) + try: + envelope = json.loads(raw.decode("utf-8")) + message = envelope.get("message") or envelope.get("error") or message + except (ValueError, UnicodeDecodeError): + pass + raise GateError("{0} {1} failed: {2}".format(method, path, message)) + if not raw: + return status, None + try: + return status, json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return status, raw.decode("utf-8", "replace") + + def data(self, method, path, token="", payload=None): + _, envelope = self.request(method, path, token=token, payload=payload) + if not isinstance(envelope, dict) or "data" not in envelope: + raise GateError("{0} {1} returned no data envelope".format(method, path)) + return envelope["data"] + + +def query(path, values): + return path + "?" + urllib.parse.urlencode(values) + + +def normalized_vins(value): + vins = [] + for item in value.replace("\n", ",").split(","): + vin = item.strip().upper() + if vin and vin not in vins: + vins.append(vin) + if not vins: + raise GateError("at least one expected VIN is required") + return vins + + +def require(condition, message): + if not condition: + raise GateError(message) + + +def static_route(client, path): + status, body = client.request("GET", path) + require(status == 200 and isinstance(body, str) and "羚牛车辆数据中台" in body, "SPA route failed: " + path) + + +def run_gate(args, password): + client = Client(args.base_url, args.timeout) + expected_vins = normalized_vins(args.expected_vins) + expected_menus = set(item.strip() for item in args.expected_menus.split(",") if item.strip()) + login = client.data("POST", "/api/v2/auth/login", payload={"username": args.username, "password": password}) + token = login.get("accessToken") if isinstance(login, dict) else "" + require(bool(token), "login did not return an access token") + try: + session = client.data("GET", "/api/v2/session", token=token) + require(session.get("role") == "customer", "account is not a customer") + require(set(session.get("menuKeys") or []) == expected_menus, "customer menu grants do not match the four-menu baseline") + require(session.get("vehicleCount") == len(expected_vins), "session vehicle count does not match the expected demo cohort") + + fleet = client.data("GET", query("/api/realtime/vehicles", {"limit": 100, "offset": 0}), token=token) + fleet_by_vin = dict((item.get("vin", "").upper(), item) for item in fleet.get("items") or []) + visible_vins = set(fleet_by_vin) + require(fleet.get("total") == len(expected_vins), "realtime vehicle total is outside the assigned Scope") + require(visible_vins == set(expected_vins), "realtime vehicle set does not match the assigned Scope") + + sample_checks = [ + (args.multi_source_vin, lambda vehicle: (vehicle.get("sourceCount") or 0) > 1 and vehicle.get("locationAvailable") is True, "multi-source"), + (args.single_source_vin, lambda vehicle: vehicle.get("sourceCount") == 1 and vehicle.get("locationAvailable") is True, "single-source"), + (args.no_location_vin, lambda vehicle: vehicle.get("locationAvailable") is False, "no-location"), + (args.offline_vin, lambda vehicle: vehicle.get("online") is False and vehicle.get("locationAvailable") is True, "offline-with-location") + ] + checked_samples = 0 + for sample_vin, predicate, label in sample_checks: + if not sample_vin: + continue + normalized = sample_vin.strip().upper() + require(normalized in expected_vins, label + " sample is not in the expected cohort") + require(predicate(fleet_by_vin.get(normalized) or {}), label + " sample no longer matches its demo category") + checked_samples += 1 + + for vin in expected_vins: + detail = client.data("GET", query("/api/vehicle-service", {"keyword": vin, "limit": 20}), token=token) + require(detail.get("lookupResolved") is True and detail.get("vin") == vin, "vehicle detail is unavailable for " + vin) + + if args.denied_vin: + denied_path = query("/api/vehicle-service", {"keyword": args.denied_vin.strip().upper(), "limit": 20}) + status, envelope = client.request("GET", denied_path, token=token, expected_statuses=(200, 403, 404)) + if status == 200: + denied = envelope.get("data") if isinstance(envelope, dict) else None + require(not isinstance(denied, dict) or denied.get("lookupResolved") is not True, "out-of-Scope VIN was resolved") + + if args.track_vin: + require(args.track_vin.strip().upper() in expected_vins, "track VIN is not in the expected cohort") + require(args.track_from and args.track_to, "track date range is required with --track-vin") + track = client.data("GET", query("/api/v2/tracks", { + "keyword": args.track_vin.strip().upper(), + "dateFrom": args.track_from, + "dateTo": args.track_to, + "maxPoints": 1600 + }), token=token) + require(len(track.get("points") or []) > 1, "track gate returned fewer than two points") + + if args.mileage_from or args.mileage_to: + require(args.mileage_from and args.mileage_to, "both mileage dates are required") + mileage = client.data("GET", query("/api/v2/statistics/mileage", { + "vins": ",".join(expected_vins), + "dateFrom": args.mileage_from, + "dateTo": args.mileage_to, + "protocols": "GB32960,JT808,YUTONG_MQTT" + }), token=token) + require((mileage.get("vehicleCount") or 0) > 0, "mileage gate returned no vehicles") + require((mileage.get("recordCount") or 0) > 0, "mileage gate returned no vehicle-day records") + + for route in ("/monitor", "/vehicles/" + expected_vins[0], "/tracks", "/statistics"): + static_route(client, route) + + return { + "username": args.username, + "vehicleCount": len(expected_vins), + "menus": sorted(expected_menus), + "trackChecked": bool(args.track_vin), + "mileageChecked": bool(args.mileage_from), + "deniedChecked": bool(args.denied_vin), + "sampleChecks": checked_samples + } + finally: + try: + client.request("POST", "/api/v2/auth/logout", token=token) + except GateError: + pass + + +def parser(): + value = argparse.ArgumentParser(description="Verify a customer demo account without persisting its password.") + value.add_argument("--base-url", default="http://127.0.0.1:20300") + value.add_argument("--username", required=True) + value.add_argument("--password-env", default="DEMO_PASSWORD") + value.add_argument("--expected-vins", required=True, help="Comma-separated VINs assigned to the account") + value.add_argument("--expected-menus", default="monitor,vehicles,tracks,statistics") + value.add_argument("--denied-vin", default="") + value.add_argument("--multi-source-vin", default="") + value.add_argument("--single-source-vin", default="") + value.add_argument("--no-location-vin", default="") + value.add_argument("--offline-vin", default="") + value.add_argument("--track-vin", default="") + value.add_argument("--track-from", default="") + value.add_argument("--track-to", default="") + value.add_argument("--mileage-from", default="") + value.add_argument("--mileage-to", default="") + value.add_argument("--timeout", type=float, default=20.0) + return value + + +def main(argv=None): + args = parser().parse_args(argv) + password = os.environ.get(args.password_env, "") + if not password: + print("customer_demo_gate=failed reason=password_environment_missing", file=sys.stderr) + return 2 + try: + result = run_gate(args, password) + except GateError as error: + print("customer_demo_gate=failed reason={0}".format(str(error).replace("\n", " ")), file=sys.stderr) + return 1 + print("customer_demo_gate=ok username={0} vehicles={1} menus={2} samples={3} track={4} mileage={5} denied={6}".format( + result["username"], + result["vehicleCount"], + ",".join(result["menus"]), + result["sampleChecks"], + int(result["trackChecked"]), + int(result["mileageChecked"]), + int(result["deniedChecked"]) + )) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vehicle-data-platform/deploy/verify-customer-demo.test.py b/vehicle-data-platform/deploy/verify-customer-demo.test.py new file mode 100755 index 00000000..ada0ea3a --- /dev/null +++ b/vehicle-data-platform/deploy/verify-customer-demo.test.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +from __future__ import print_function + +import json +import os +import subprocess +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + + +VIN = "LB9A32A21R0LS1464" +DENIED_VIN = "LA9935J21F0YWT578" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, _format, *_args): + pass + + def envelope(self, data, status=200): + body = json.dumps({"data": data, "traceId": "test", "timestamp": 1}).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + if self.path == "/api/v2/auth/login": + self.envelope({"accessToken": "customer-token", "expiresAt": "2026-07-17T00:00:00+08:00", "session": {}}) + return + if self.path == "/api/v2/auth/logout": + self.envelope({"loggedOut": True}) + return + self.envelope({}, 404) + + def do_GET(self): + parsed = urlparse(self.path) + values = parse_qs(parsed.query) + if parsed.path == "/api/v2/session": + self.envelope({"name": "客户验收", "role": "customer", "authMode": "enforce", "menuKeys": ["monitor", "vehicles", "tracks", "statistics"], "vehicleCount": 1}) + elif parsed.path == "/api/realtime/vehicles": + self.envelope({"items": [{"vin": VIN, "plate": "粤AGR6863", "sourceCount": 2, "online": True, "locationAvailable": True}], "total": 1, "limit": 100, "offset": 0}) + elif parsed.path == "/api/vehicle-service" and values.get("keyword") == [VIN]: + self.envelope({"lookupResolved": True, "vin": VIN}) + elif parsed.path == "/api/vehicle-service" and values.get("keyword") == [DENIED_VIN]: + self.envelope({"lookupResolved": False, "vin": ""}) + elif parsed.path == "/api/v2/tracks": + self.envelope({"vin": VIN, "points": [{"longitude": 113.1, "latitude": 23.1}, {"longitude": 113.2, "latitude": 23.2}]}) + elif parsed.path == "/api/v2/statistics/mileage": + self.envelope({"vehicleCount": 1, "recordCount": 1}) + elif parsed.path in ("/monitor", "/vehicles/" + VIN, "/tracks", "/statistics"): + body = "羚牛车辆数据中台".encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.envelope({}, 404) + + +class CustomerDemoGateTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = HTTPServer(("127.0.0.1", 0), Handler) + cls.thread = threading.Thread(target=cls.server.serve_forever) + cls.thread.daemon = True + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.thread.join() + + def test_complete_customer_demo_gate(self): + script = str(Path(__file__).with_name("verify-customer-demo.py")) + environment = dict(os.environ) + environment["DEMO_PASSWORD"] = "not-persisted" + result = subprocess.run([ + sys.executable, script, + "--base-url", "http://127.0.0.1:{0}".format(self.server.server_port), + "--username", "customer-demo", + "--expected-vins", VIN, + "--denied-vin", DENIED_VIN, + "--multi-source-vin", VIN, + "--track-vin", VIN, + "--track-from", "2026-07-16T17:00:00+08:00", + "--track-to", "2026-07-16T18:00:00+08:00", + "--mileage-from", "2026-07-17", + "--mileage-to", "2026-07-17" + ], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("customer_demo_gate=ok", result.stdout) + self.assertIn("vehicles=1", result.stdout) + self.assertIn("samples=1 track=1 mileage=1 denied=1", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/vehicle-data-platform/docs/customer-demo-acceptance.md b/vehicle-data-platform/docs/customer-demo-acceptance.md new file mode 100644 index 00000000..76860023 --- /dev/null +++ b/vehicle-data-platform/docs/customer-demo-acceptance.md @@ -0,0 +1,120 @@ +# 客户演示账号与验收手册 + +更新时间:2026-07-16 + +本手册用于 0716 会议 P0-08:向秦总、蒲总演示客户侧四个菜单,并在不扩大车辆 Scope、不保存明文密码的前提下形成可重复验收证据。 + +## 当前生产基线 + +截至 2026-07-16 17:16(Asia/Shanghai): + +- ECS 共 1,024 辆权威绑定车辆。 +- 平台有 1 个管理员账号和 1 个既有客户账号。 +- 当前没有能从用户名或显示名明确对应“秦总、蒲总”的两个账号。 +- 会议纪要没有提供两人的登录名、初始密码、客户/租户标识和允许查看的车辆清单,因此不得猜测身份或直接扩大既有客户权限。 + +账号创建时必须只授予: + +- `monitor`:全局监控 +- `vehicles`:车辆查询 +- `tracks`:轨迹回放 +- `statistics`:里程查询 + +不得授予历史数据、告警、接入管理、运维质量或账号管理菜单。 + +## 建议演示车辆 + +以下是生产只读筛选得到的首选样本。状态会随实时上报变化,正式演示前必须重新运行门禁;若分类不再成立,应换成同类候选,而不是篡改状态。 + +| 场景 | 车牌 | VIN | 当前证据 | +| --- | --- | --- | --- | +| 在线多来源 | 粤AGR6863 | `LB9A32A21R0LS1464` | GB32960 + JT808,2 个来源均在线且有位置 | +| 在线单来源 | 浙F31088F | `LA9GG68L5PBAF4797` | JT808 单来源、在线且有位置 | +| 无实时位置 | 沪BH391挂 | `LA9935J21F0YWT578` | 权威车辆存在,无来源、无位置 | +| 离线但有最后位置 | 沪A09100F | `LKLG7C4E0NA774774` | JT808 来源离线,保留最后有效位置 | + +历史能力预检: + +| 车辆 | 2026-07-16 轨迹 | 2026-07-10 至 07-16 里程 | +| --- | --- | --- | +| 粤AGR6863 | 返回 1,600 个采样点,约 294.3 km,推荐 GB32960 | 7 个车辆日,约 542.8 km | +| 浙F31088F | 返回 1,600 个采样点,约 111.8 km,推荐 JT808 | 7 个车辆日,约 360.0 km | +| 沪A09100F | 返回 955 个点,约 31.9 km,推荐 JT808 | 7 个车辆日,约 280.82 km | + +这些数字只用于证明样本具备演示数据,不是业务结算口径。 + +## 授权时间边界 + +新账号分配车辆时会以实际创建/授权时间作为历史起点: + +- 轨迹只能查询授权开始后的数据。 +- 里程是自然日聚合;如果授权发生在当天零点之后,第一个可完整展示的里程日是下一自然日。 +- 不得为了演示直接回写或倒签 `valid_from`,否则会让客户看到真实授权前的数据并破坏审计。 + +因此若计划 2026-07-17 演示,应在 2026-07-16 完成账号和车辆授权;轨迹范围从实际授权时间之后开始,里程使用 2026-07-17 当日或之后数据。 + +## 自动验收门禁 + +密码只通过临时环境变量传入,不写命令行、脚本、日志或 Git: + +```bash +read -s DEMO_PASSWORD +export DEMO_PASSWORD + +deploy/verify-customer-demo.py \ + --base-url http://115.29.187.205:20300 \ + --username '<确认后的登录名>' \ + --expected-vins 'LB9A32A21R0LS1464,LA9GG68L5PBAF4797,LA9935J21F0YWT578,LKLG7C4E0NA774774' \ + --multi-source-vin LB9A32A21R0LS1464 \ + --single-source-vin LA9GG68L5PBAF4797 \ + --no-location-vin LA9935J21F0YWT578 \ + --offline-vin LKLG7C4E0NA774774 \ + --denied-vin LNXNEGRR7SR318212 \ + --track-vin LB9A32A21R0LS1464 \ + --track-from '2026-07-16T17:00:00+08:00' \ + --track-to '2026-07-17T17:00:00+08:00' \ + --mileage-from 2026-07-17 \ + --mileage-to 2026-07-17 + +unset DEMO_PASSWORD +``` + +门禁验证: + +1. 登录成功且角色为 `customer`。 +2. 菜单严格等于四个客户菜单。 +3. 会话车辆数、实时列表和预期 VIN 集合完全一致。 +4. 四类样本仍符合在线/离线、来源数和位置可用性分类。 +5. 每台授权车辆可读取单车详情。 +6. 未授权 VIN 被 403/404 或按“未解析”隐藏。 +7. 指定车辆存在可回放轨迹和车辆日里程。 +8. 全局监控、车辆详情、轨迹和里程 SPA 路由可加载。 +9. 验收结束后主动注销临时会话。 + +成功输出示例: + +```text +customer_demo_gate=ok username= vehicles=4 menus=monitor,statistics,tracks,vehicles samples=4 track=1 mileage=1 denied=1 +``` + +## 演示顺序 + +1. 使用客户账号登录,确认侧边栏只有四个菜单。 +2. 全局监控搜索四个样本,解释在线多源、在线单源、无位置和离线有位置。 +3. 选择粤AGR6863,进入单车详情,查看最新上报、推荐来源和全部来源差异。 +4. 返回原监控上下文,再进入轨迹回放。 +5. 打开里程查询,使用今天/昨天/近 7 天和协议优先级。 +6. 输入未授权 VIN,确认无法读取。 +7. 记录现场问题、责任人、优先级和是否阻塞交付。 + +## 尚需业务确认 + +正式创建两个账号前仍需确认: + +- 两个登录名或手机号; +- 显示名称; +- 各自初始密码的交付方式; +- 两人是否共享上述 4 辆演示车,或分别使用不同车辆 Scope; +- 可选的 OneOS/RuoYi `customerRef`、`tenantRef` 映射值。 + +这些信息会直接改变真实客户身份和车辆数据可见范围,不能由开发侧猜测。 diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index 90282643..35d0a6aa 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -144,6 +144,8 @@ EXPORT_MILLION_TEST=1 go test ./internal/platform -run '^TestHistoryExportMillio 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 | diff --git a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md index 4c25c931..06279350 100644 --- a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md +++ b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md @@ -273,7 +273,19 @@ ### P0-08 客户账号与演示验收 -状态:`待验收` +状态:`进行中` + +已完成准备: + +- 生产只读核对确认当前只有管理员和 1 个既有客户账号,尚无能明确对应秦总、蒲总的两个账号;未修改既有账号和车辆 Scope。 +- 已筛选在线多来源、在线单来源、无位置、离线有位置四类真实演示样本,并验证三台有历史能力的车辆具备当天轨迹和近 7 天里程。 +- 新增无明文密码落盘的客户演示门禁,可验证客户角色、严格四菜单、车辆集合、四类样本状态、越权 VIN 阻断、轨迹、里程和四个 SPA 路由;脚本及测试兼容 ECS Python 3.6.8。 +- 已形成 `docs/customer-demo-acceptance.md`,记录授权时间边界、建议样本、演示顺序、成功证据和现场问题模板。 + +当前依赖: + +- 会议纪要未给出两人的登录名/手机号、显示名称、初始密码交付方式、各自车辆 Scope,以及可选 OneOS/RuoYi 客户/租户映射。上述信息会直接改变真实客户数据可见范围,不能由开发侧猜测。 +- 新授权的轨迹只能从实际授权时间开始;自然日里程从下一个完整授权日开始。不得为演示倒签授权起点。 目标: