From fb51407990286d300e1107fdd4655edd372a98b6 Mon Sep 17 00:00:00 2001 From: root <13910913995@163.com> Date: Sun, 25 Jan 2026 22:34:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=BB=BA=E4=BA=86=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=EF=BC=8C=E5=8F=8A=E7=9B=B8=E5=85=B3=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/skills/py-zh-commenter/SKILL.md | 84 +++ ...DB.md => PostgreSQL及实时数据库安装指南.md | 0 ...LYSIS.md => TimescaleDB性能测试分析报告.md | 0 backend/__init__.py | 1 + backend/api/__init__.py | 1 + backend/api/auth_routes.py | 81 +++ backend/api/rbac_routes.py | 155 +++++ backend/api/routes.py | 19 +- backend/api/schemas.py | 156 +++++ backend/api/unity_routes.py | 35 ++ backend/api/user_routes.py | 111 ++++ backend/api/ws.py | 9 +- backend/auth/__init__.py | 3 + backend/auth/deps.py | 56 ++ backend/auth/passwords.py | 52 ++ backend/auth/tokens.py | 99 +++ backend/config/__init__.py | 1 + backend/config/config.ini | 3 + backend/config/settings.py | 33 +- backend/database/__init__.py | 2 +- backend/database/check_db.py | 11 + backend/database/engine.py | 8 +- backend/database/init_db.py | 68 +++ backend/database/schema.py | 118 +++- backend/database/test_db.py | 15 +- backend/device/__init__.py | 2 +- backend/device/base.py | 13 +- backend/device/mock_vehicle.py | 15 +- backend/main.py | 26 +- backend/services/__init__.py | 2 +- backend/services/auth_service.py | 45 ++ backend/services/broadcaster.py | 14 +- backend/services/rbac_service.py | 186 ++++++ backend/services/server_monitor.py | 13 + backend/services/simulation_manager.py | 144 ++++- backend/services/unity_socket_client.py | 48 ++ backend/services/user_service.py | 169 ++++++ backend/utils.py | 20 + tools/docx_to_md.py | 78 +++ 汽车数字孪生系统_数据交互协议.docx | Bin 0 -> 24118 bytes 汽车数字孪生系统_数据交互协议.md | 571 ++++++++++++++++++ 41 files changed, 2420 insertions(+), 47 deletions(-) create mode 100644 .trae/skills/py-zh-commenter/SKILL.md rename INSTALL_DB.md => PostgreSQL及实时数据库安装指南.md (100%) rename DB_PERFORMANCE_ANALYSIS.md => TimescaleDB性能测试分析报告.md (100%) create mode 100644 backend/api/auth_routes.py create mode 100644 backend/api/rbac_routes.py create mode 100644 backend/api/unity_routes.py create mode 100644 backend/api/user_routes.py create mode 100644 backend/auth/__init__.py create mode 100644 backend/auth/deps.py create mode 100644 backend/auth/passwords.py create mode 100644 backend/auth/tokens.py create mode 100644 backend/database/init_db.py create mode 100644 backend/services/auth_service.py create mode 100644 backend/services/rbac_service.py create mode 100644 backend/services/unity_socket_client.py create mode 100644 backend/services/user_service.py create mode 100644 tools/docx_to_md.py create mode 100644 汽车数字孪生系统_数据交互协议.docx create mode 100644 汽车数字孪生系统_数据交互协议.md diff --git a/.trae/skills/py-zh-commenter/SKILL.md b/.trae/skills/py-zh-commenter/SKILL.md new file mode 100644 index 0000000..74c68fa --- /dev/null +++ b/.trae/skills/py-zh-commenter/SKILL.md @@ -0,0 +1,84 @@ +--- +name: "py-zh-commenter" +description: "为 Python 代码补充清晰的中文注释与必要 docstring。用户要求“加中文注释/解释这段代码/补齐注释/可读性提升”时调用。" +--- + +# Python 中文注释助手(py-zh-commenter) + +## 目标 + +在**不改变代码行为**的前提下,为 Python 代码补充高质量的中文注释与必要的 docstring,提升可读性与可维护性。 + +## 何时调用 + +当用户提出以下需求时调用: +- “给这段 Python 代码加中文注释/补齐注释” +- “解释代码在做什么,希望在源码里体现” +- “帮我把关键逻辑注释清楚/写 docstring” + +不适用于: +- 纯代码审查(请用 code-reviewer / pr-reviewer) +- 重构/改逻辑(除非用户明确要求) + +## 输出原则(必须遵守) + +1. **不改逻辑**:仅添加注释/docstring 与必要的类型提示(仅当非常明确且不影响行为)。 +2. **少而精**:不写“显而易见”的注释;只解释“为什么/边界/约束/不变量/副作用/复杂算法/业务含义”。 +3. **就近放置**: + - 模块:文件头 docstring(可选,描述模块职责) + - 类/函数:docstring(参数/返回/异常/副作用/线程安全/性能) + - 复杂分支/循环:行内注释说明意图与约束 +4. **风格一致**:遵循项目现有格式(注释语气、标点、缩进、是否偏好 docstring)。 +5. **安全合规**:不在注释中写入密钥、口令、内网地址等敏感信息;不把用户数据或隐私写进注释。 +6. **避免噪音**:不要对每行都加注释;避免把代码翻译成中文。 + +## 注释内容清单(优先级从高到低) + +- 业务含义:字段/表/状态机/权限点的语义 +- 不变量:必须满足的条件(例如“role_id 必须存在且启用”) +- 边界情况:空值/异常路径/并发/重试/超时 +- 副作用:数据库写入、网络请求、文件读写、全局状态变更 +- 性能:复杂度、批处理、索引依赖、潜在慢点 +- 安全:鉴权、权限校验点、数据校验原因 + +## 注释模板(可直接复用) + +### 函数 docstring(推荐) + +```python +def foo(x: int) -> int: + \"\"\"一句话说明做什么。 + + Args: + x: 参数含义(业务语义/单位/范围)。 + + Returns: + 返回值含义(单位/范围/是否可能为空)。 + + Raises: + ValueError: 触发条件说明。 + \"\"\" +``` + +### 复杂分支注释(示例) + +```python +# 这里做 X 的原因:…… +# 约束:……(例如必须在事务内/必须先校验权限) +if cond: + ... +``` + +## 执行步骤(给模型的工作流) + +1. 先快速阅读:识别模块职责、关键数据结构、外部依赖(DB/HTTP/文件/线程/协程)。 +2. 找“复杂点”:多层嵌套、隐藏副作用、异常处理、权限判断、数据转换。 +3. 优先写 docstring:为对外接口(路由函数/服务方法/工具函数)补齐输入输出语义。 +4. 补关键行注释:只在需要解释“意图/原因/约束”的地方写。 +5. 自检:确保不引入代码变更、不新增导入导致 lint/test 变化。 + +## 交付格式 + +- 如果是仓库内改动:直接在对应 `.py` 文件中增加注释/docstring。 +- 如果用户只要解释:给出“建议应写在源码中的注释内容”,并可选附带 patch。 + diff --git a/INSTALL_DB.md b/PostgreSQL及实时数据库安装指南.md similarity index 100% rename from INSTALL_DB.md rename to PostgreSQL及实时数据库安装指南.md diff --git a/DB_PERFORMANCE_ANALYSIS.md b/TimescaleDB性能测试分析报告.md similarity index 100% rename from DB_PERFORMANCE_ANALYSIS.md rename to TimescaleDB性能测试分析报告.md diff --git a/backend/__init__.py b/backend/__init__.py index 8b13789..b54784d 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -1 +1,2 @@ +"""SmartEDT 后端包。""" diff --git a/backend/api/__init__.py b/backend/api/__init__.py index 8b13789..f021d7c 100644 --- a/backend/api/__init__.py +++ b/backend/api/__init__.py @@ -1 +1,2 @@ +"""API 子包:HTTP 路由、WebSocket 处理与 Pydantic schema。""" diff --git a/backend/api/auth_routes.py b/backend/api/auth_routes.py new file mode 100644 index 0000000..fc67d1a --- /dev/null +++ b/backend/api/auth_routes.py @@ -0,0 +1,81 @@ +"""认证相关路由。 + +提供: +- 登录获取 Bearer Token +- 系统首次初始化(bootstrap:创建首个管理员) +- 获取当前登录用户信息(/me) +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.api.schemas import BootstrapRequest, BootstrapResponse, LoginRequest, LoginResponse, MeResponse, TokenResponse, UserResponse +from backend.auth.deps import get_current_user +from backend.database.schema import sys_user +from backend.services.auth_service import AuthService +from backend.services.user_service import UserService + + +def get_router(session_factory: async_sessionmaker[AsyncSession]) -> APIRouter: + """构造认证路由。""" + router = APIRouter(prefix="/api/auth", tags=["auth"]) + auth = AuthService(session_factory) + users = UserService(session_factory) + + @router.post("/login", response_model=LoginResponse) + async def login(body: LoginRequest) -> LoginResponse: + """用户名密码登录,返回 access_token。""" + try: + token, user_row = await auth.login(username=body.username, password=body.password, expires_in_seconds=3600) + except PermissionError: + raise HTTPException(status_code=403, detail="inactive user") + except ValueError: + raise HTTPException(status_code=401, detail="invalid credentials") + user = await users.get_user(str(user_row["user_id"])) + if not user: + raise HTTPException(status_code=401, detail="invalid credentials") + return LoginResponse(token=TokenResponse(access_token=token, expires_in=3600), user=UserResponse(**user)) + + @router.post("/bootstrap", response_model=BootstrapResponse) + async def bootstrap(body: BootstrapRequest) -> BootstrapResponse: + """初始化系统:当系统还没有任何用户时,创建首个管理员并返回 token。""" + async with session_factory() as session: + exists = (await session.execute(select(sys_user.c.user_id).limit(1))).first() + if exists: + raise HTTPException(status_code=409, detail="already initialized") + try: + created = await users.create_user( + user_id=None, + username=body.username, + display_name=body.display_name, + password=body.password, + role_id="admin", + is_active=True, + extra=None, + ) + except Exception: + raise HTTPException(status_code=400, detail="bootstrap failed") + token, _ = await auth.login(username=body.username, password=body.password, expires_in_seconds=3600) + return BootstrapResponse(token=TokenResponse(access_token=token, expires_in=3600), user=UserResponse(**created)) + + @router.get("/me", response_model=MeResponse) + async def me(current_user: dict = Depends(get_current_user(session_factory))) -> MeResponse: + """返回当前登录用户(由 Authorization: Bearer token 解析)。""" + user = UserResponse( + user_id=str(current_user["user_id"]), + username=str(current_user["username"]), + display_name=current_user.get("display_name"), + role_id=str(current_user["role_id"]), + role_name=current_user.get("role_name"), + is_active=bool(current_user.get("is_active")), + last_login_at=current_user.get("last_login_at"), + created_at=current_user.get("created_at"), + updated_at=current_user.get("updated_at"), + extra=current_user.get("extra"), + ) + return MeResponse(user=user) + + return router diff --git a/backend/api/rbac_routes.py b/backend/api/rbac_routes.py new file mode 100644 index 0000000..ac65258 --- /dev/null +++ b/backend/api/rbac_routes.py @@ -0,0 +1,155 @@ +"""角色/权限(RBAC)管理路由。 + +说明: +- 该模块提供角色与权限的增删改查,以及“给角色配置权限点”的接口。 +- 当前实现采用最小化策略:仅允许系统管理员(role_id=admin)访问。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.api.schemas import ( + PermissionCreateRequest, + PermissionResponse, + RoleCreateRequest, + RolePermissionsResponse, + RolePermissionsUpdateRequest, + RoleResponse, + RoleUpdateRequest, +) +from backend.auth.deps import get_current_user +from backend.services.rbac_service import RbacService + + +def get_router(session_factory: async_sessionmaker[AsyncSession]) -> APIRouter: + """构造 RBAC 路由。""" + router = APIRouter(prefix="/api", tags=["rbac"]) + rbac = RbacService(session_factory) + + def _require_admin(user: dict) -> None: + """管理员校验(当前仅按 role_id 判断)。""" + if user.get("role_id") != "admin": + raise HTTPException(status_code=403, detail="forbidden") + + @router.get("/roles", response_model=list[RoleResponse]) + async def list_roles(current_user: dict = Depends(get_current_user(session_factory))) -> list[RoleResponse]: + """查询角色列表。""" + _require_admin(current_user) + rows = await rbac.list_roles() + return [RoleResponse(**r) for r in rows] + + @router.get("/roles/{role_id}", response_model=RoleResponse) + async def get_role(role_id: str, current_user: dict = Depends(get_current_user(session_factory))) -> RoleResponse: + """查询角色详情。""" + _require_admin(current_user) + role = await rbac.get_role(role_id) + if not role: + raise HTTPException(status_code=404, detail="not found") + return RoleResponse(**role) + + @router.post("/roles", response_model=RoleResponse) + async def create_role(body: RoleCreateRequest, current_user: dict = Depends(get_current_user(session_factory))) -> RoleResponse: + """创建角色。""" + _require_admin(current_user) + try: + role = await rbac.create_role( + role_id=body.role_id, + role_name=body.role_name, + role_desc=body.role_desc, + is_active=body.is_active, + extra=body.extra, + ) + except IntegrityError: + raise HTTPException(status_code=409, detail="conflict") + return RoleResponse(**role) + + @router.patch("/roles/{role_id}", response_model=RoleResponse) + async def update_role( + role_id: str, body: RoleUpdateRequest, current_user: dict = Depends(get_current_user(session_factory)) + ) -> RoleResponse: + """更新角色。""" + _require_admin(current_user) + try: + role = await rbac.update_role( + role_id, + role_name=body.role_name, + role_desc=body.role_desc, + is_active=body.is_active, + extra=body.extra, + ) + except IntegrityError: + raise HTTPException(status_code=409, detail="conflict") + if not role: + raise HTTPException(status_code=404, detail="not found") + return RoleResponse(**role) + + @router.delete("/roles/{role_id}") + async def delete_role(role_id: str, current_user: dict = Depends(get_current_user(session_factory))) -> dict: + """禁用角色(软删除)。""" + _require_admin(current_user) + ok = await rbac.disable_role(role_id) + if not ok: + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + @router.get("/permissions", response_model=list[PermissionResponse]) + async def list_permissions(current_user: dict = Depends(get_current_user(session_factory))) -> list[PermissionResponse]: + """查询权限点列表。""" + _require_admin(current_user) + rows = await rbac.list_permissions() + return [PermissionResponse(**r) for r in rows] + + @router.post("/permissions", response_model=PermissionResponse) + async def create_permission( + body: PermissionCreateRequest, current_user: dict = Depends(get_current_user(session_factory)) + ) -> PermissionResponse: + """创建权限点(perm_code 支持自定义命名)。""" + _require_admin(current_user) + try: + perm = await rbac.create_permission( + perm_code=body.perm_code, perm_name=body.perm_name, perm_group=body.perm_group, perm_desc=body.perm_desc + ) + except IntegrityError: + raise HTTPException(status_code=409, detail="conflict") + return PermissionResponse(**perm) + + @router.delete("/permissions/{perm_code}") + async def delete_permission(perm_code: str, current_user: dict = Depends(get_current_user(session_factory))) -> dict: + """删除权限点。""" + _require_admin(current_user) + ok = await rbac.delete_permission(perm_code) + if not ok: + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + @router.get("/roles/{role_id}/permissions", response_model=RolePermissionsResponse) + async def get_role_permissions(role_id: str, current_user: dict = Depends(get_current_user(session_factory))) -> RolePermissionsResponse: + """查询角色拥有的权限点集合。""" + _require_admin(current_user) + role = await rbac.get_role(role_id) + if not role: + raise HTTPException(status_code=404, detail="not found") + codes = await rbac.get_role_permissions(role_id) + return RolePermissionsResponse(role_id=role_id, perm_codes=codes) + + @router.put("/roles/{role_id}/permissions", response_model=RolePermissionsResponse) + async def set_role_permissions( + role_id: str, + body: RolePermissionsUpdateRequest, + current_user: dict = Depends(get_current_user(session_factory)), + ) -> RolePermissionsResponse: + """覆盖设置角色权限点集合。""" + _require_admin(current_user) + role = await rbac.get_role(role_id) + if not role: + raise HTTPException(status_code=404, detail="not found") + try: + codes = await rbac.set_role_permissions(role_id=role_id, perm_codes=body.perm_codes) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return RolePermissionsResponse(role_id=role_id, perm_codes=codes) + + return router diff --git a/backend/api/routes.py b/backend/api/routes.py index 20976d8..84da575 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -1,3 +1,12 @@ +"""基础业务路由(仿真/设备/文件下载等)。 + +该文件保留项目早期的示例接口与基础能力: +- 健康检查 +- 设备连接状态(示例) +- 启停仿真 +- 文件下载(带目录穿越保护) +""" + from __future__ import annotations from pathlib import Path @@ -11,14 +20,20 @@ from backend.utils import safe_join def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRouter: + """构造基础业务路由。 + + 说明:此项目采用“router 工厂函数”风格,通过参数注入 service/配置,而不是全局依赖容器。 + """ router = APIRouter() @router.get("/health", response_model=HealthResponse) async def health() -> HealthResponse: + """健康检查(用于容器编排/负载均衡探活)。""" return HealthResponse() @router.get("/api/devices") async def devices(): + """返回设备列表(当前为示例数据,反映仿真运行时状态)。""" runtime = simulation_manager.current() return { "data": [ @@ -32,16 +47,19 @@ def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRou @router.post("/api/simulation/start", response_model=SimulationStartResponse) async def start_simulation(body: SimulationStartRequest) -> SimulationStartResponse: + """启动仿真。""" simulation_id = await simulation_manager.start(body.model_dump()) return SimulationStartResponse(simulation_id=simulation_id) @router.post("/api/simulation/{simulation_id}/stop", response_model=SimulationStopResponse) async def stop_simulation(simulation_id: str) -> SimulationStopResponse: + """停止仿真。""" await simulation_manager.stop(simulation_id) return SimulationStopResponse(simulation_id=simulation_id, status="stopped") @router.get("/files/{file_path:path}") async def files(file_path: str): + """下载文件(相对 file_root),并校验路径合法性。""" try: resolved = safe_join(file_root, file_path) except ValueError: @@ -51,4 +69,3 @@ def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRou return FileResponse(str(resolved)) return router - diff --git a/backend/api/schemas.py b/backend/api/schemas.py index 19791c4..a665e12 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -1,15 +1,28 @@ +"""API 层的请求/响应数据模型(Pydantic)。 + +该文件集中定义后端 HTTP 接口的入参与返回结构,便于: +- 校验请求字段(长度、范围等) +- 生成 OpenAPI 文档 +- 在路由层与前端之间形成稳定契约 +""" + from __future__ import annotations +from datetime import datetime from typing import Any from pydantic import BaseModel, Field class HealthResponse(BaseModel): + """健康检查返回。""" + status: str = "ok" class SimulationStartRequest(BaseModel): + """启动仿真的请求体。""" + scenario: str | None = None weather: str | None = None time_period: str | None = None @@ -27,3 +40,146 @@ class SimulationStopResponse(BaseModel): simulation_id: str status: str + +class TokenResponse(BaseModel): + """登录成功后返回的 token 信息。""" + + access_token: str + token_type: str = "bearer" + expires_in: int + + +class LoginRequest(BaseModel): + """用户名密码登录请求。""" + + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=128) + + +class RoleCreateRequest(BaseModel): + """创建角色请求。""" + + role_id: str | None = Field(default=None, max_length=64) + role_name: str = Field(min_length=1, max_length=64) + role_desc: str | None = Field(default=None, max_length=255) + is_active: bool = True + extra: dict[str, Any] | None = None + + +class RoleUpdateRequest(BaseModel): + role_name: str | None = Field(default=None, max_length=64) + role_desc: str | None = Field(default=None, max_length=255) + is_active: bool | None = None + extra: dict[str, Any] | None = None + + +class RoleResponse(BaseModel): + role_id: str + role_name: str + role_desc: str | None = None + is_active: bool + created_at: datetime | None = None + updated_at: datetime | None = None + extra: dict[str, Any] | None = None + + +class PermissionCreateRequest(BaseModel): + """创建权限点请求(perm_code 支持自定义命名规则)。""" + + perm_code: str = Field(min_length=1, max_length=128) + perm_name: str = Field(min_length=1, max_length=128) + perm_group: str | None = Field(default=None, max_length=64) + perm_desc: str | None = Field(default=None, max_length=255) + + +class PermissionResponse(BaseModel): + perm_code: str + perm_name: str + perm_group: str | None = None + perm_desc: str | None = None + created_at: datetime | None = None + + +class RolePermissionsUpdateRequest(BaseModel): + perm_codes: list[str] = Field(default_factory=list) + + +class RolePermissionsResponse(BaseModel): + role_id: str + perm_codes: list[str] + + +class UserCreateRequest(BaseModel): + """创建用户请求(包含明文密码,服务端将保存为哈希)。""" + + user_id: str | None = Field(default=None, max_length=64) + username: str = Field(min_length=1, max_length=64) + display_name: str | None = Field(default=None, max_length=64) + password: str = Field(min_length=1, max_length=128) + role_id: str = Field(min_length=1, max_length=64) + is_active: bool = True + extra: dict[str, Any] | None = None + + +class UserUpdateRequest(BaseModel): + display_name: str | None = Field(default=None, max_length=64) + role_id: str | None = Field(default=None, max_length=64) + is_active: bool | None = None + extra: dict[str, Any] | None = None + + +class UserPasswordUpdateRequest(BaseModel): + new_password: str = Field(min_length=1, max_length=128) + + +class UserResponse(BaseModel): + user_id: str + username: str + display_name: str | None = None + role_id: str + role_name: str | None = None + is_active: bool + last_login_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + extra: dict[str, Any] | None = None + + +class MeResponse(BaseModel): + """当前登录用户信息返回。""" + + user: UserResponse + + +class LoginResponse(BaseModel): + token: TokenResponse + user: UserResponse + + +class BootstrapRequest(BaseModel): + """系统首次初始化请求(仅允许在系统尚无任何用户时调用)。""" + + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=128) + display_name: str | None = Field(default=None, max_length=64) + + +class BootstrapResponse(BaseModel): + token: TokenResponse + user: UserResponse + + +class UnityInitConfigRequest(BaseModel): + payload: dict[str, Any] + + +class UnityInitConfigResponse(BaseModel): + simulation_id: str + + +class UnityCommandRequest(BaseModel): + payload: dict[str, Any] + + +class UnityCommandResponse(BaseModel): + ok: bool = True diff --git a/backend/api/unity_routes.py b/backend/api/unity_routes.py new file mode 100644 index 0000000..bfc4d9d --- /dev/null +++ b/backend/api/unity_routes.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.api.schemas import UnityCommandRequest, UnityCommandResponse, UnityInitConfigRequest, UnityInitConfigResponse +from backend.auth.deps import get_current_user +from backend.services.simulation_manager import SimulationManager + + +def get_router(simulation_manager: SimulationManager, session_factory: async_sessionmaker[AsyncSession]) -> APIRouter: + router = APIRouter(prefix="/api/unity", tags=["unity"]) + + def _require_admin(user: dict) -> None: + if user.get("role_id") != "admin": + raise HTTPException(status_code=403, detail="forbidden") + + @router.post("/initconfig", response_model=UnityInitConfigResponse) + async def initconfig( + body: UnityInitConfigRequest, current_user: dict = Depends(get_current_user(session_factory)) + ) -> UnityInitConfigResponse: + _require_admin(current_user) + simulation_id = await simulation_manager.init_config(body.payload) + return UnityInitConfigResponse(simulation_id=simulation_id) + + @router.post("/command", response_model=UnityCommandResponse) + async def command( + body: UnityCommandRequest, current_user: dict = Depends(get_current_user(session_factory)) + ) -> UnityCommandResponse: + _require_admin(current_user) + await simulation_manager.send_command(body.payload) + return UnityCommandResponse() + + return router + diff --git a/backend/api/user_routes.py b/backend/api/user_routes.py new file mode 100644 index 0000000..3a31e52 --- /dev/null +++ b/backend/api/user_routes.py @@ -0,0 +1,111 @@ +"""系统用户管理路由。 + +提供用户的增删改查与密码重置,并支持为用户分配角色。 +当前版本仅允许系统管理员(role_id=admin)访问。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.api.schemas import UserCreateRequest, UserPasswordUpdateRequest, UserResponse, UserUpdateRequest +from backend.auth.deps import get_current_user +from backend.services.rbac_service import RbacService +from backend.services.user_service import UserService + + +def get_router(session_factory: async_sessionmaker[AsyncSession]) -> APIRouter: + """构造用户管理路由。""" + router = APIRouter(prefix="/api", tags=["users"]) + users = UserService(session_factory) + rbac = RbacService(session_factory) + + def _require_admin(user: dict) -> None: + """管理员校验(当前仅按 role_id 判断)。""" + if user.get("role_id") != "admin": + raise HTTPException(status_code=403, detail="forbidden") + + @router.get("/users", response_model=list[UserResponse]) + async def list_users(current_user: dict = Depends(get_current_user(session_factory))) -> list[UserResponse]: + """查询用户列表。""" + _require_admin(current_user) + rows = await users.list_users() + return [UserResponse(**r) for r in rows] + + @router.get("/users/{user_id}", response_model=UserResponse) + async def get_user(user_id: str, current_user: dict = Depends(get_current_user(session_factory))) -> UserResponse: + """查询用户详情。""" + _require_admin(current_user) + row = await users.get_user(user_id) + if not row: + raise HTTPException(status_code=404, detail="not found") + return UserResponse(**row) + + @router.post("/users", response_model=UserResponse) + async def create_user(body: UserCreateRequest, current_user: dict = Depends(get_current_user(session_factory))) -> UserResponse: + """创建用户并写入密码哈希。""" + _require_admin(current_user) + role = await rbac.get_role(body.role_id) + if not role: + raise HTTPException(status_code=400, detail="invalid role_id") + try: + user = await users.create_user( + user_id=body.user_id, + username=body.username, + display_name=body.display_name, + password=body.password, + role_id=body.role_id, + is_active=body.is_active, + extra=body.extra, + ) + except IntegrityError: + raise HTTPException(status_code=409, detail="conflict") + return UserResponse(**user) + + @router.patch("/users/{user_id}", response_model=UserResponse) + async def update_user( + user_id: str, body: UserUpdateRequest, current_user: dict = Depends(get_current_user(session_factory)) + ) -> UserResponse: + """更新用户信息(可更新角色、启用状态与扩展字段)。""" + _require_admin(current_user) + if body.role_id is not None: + role = await rbac.get_role(body.role_id) + if not role: + raise HTTPException(status_code=400, detail="invalid role_id") + try: + updated = await users.update_user( + user_id, + display_name=body.display_name, + role_id=body.role_id, + is_active=body.is_active, + extra=body.extra, + ) + except IntegrityError: + raise HTTPException(status_code=409, detail="conflict") + if not updated: + raise HTTPException(status_code=404, detail="not found") + return UserResponse(**updated) + + @router.delete("/users/{user_id}") + async def delete_user(user_id: str, current_user: dict = Depends(get_current_user(session_factory))) -> dict: + """禁用用户(软删除)。""" + _require_admin(current_user) + ok = await users.disable_user(user_id) + if not ok: + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + @router.put("/users/{user_id}/password") + async def set_password( + user_id: str, body: UserPasswordUpdateRequest, current_user: dict = Depends(get_current_user(session_factory)) + ) -> dict: + """管理员重置指定用户密码。""" + _require_admin(current_user) + ok = await users.set_password(user_id, body.new_password) + if not ok: + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + return router diff --git a/backend/api/ws.py b/backend/api/ws.py index 4f81703..00e65ca 100644 --- a/backend/api/ws.py +++ b/backend/api/ws.py @@ -1,3 +1,8 @@ +"""WebSocket 路由处理器。 + +当前实现仅用于维持连接并将连接注册到 Broadcaster,便于服务端主动推送消息。 +""" + from __future__ import annotations from fastapi import WebSocket, WebSocketDisconnect @@ -6,8 +11,10 @@ from backend.services.broadcaster import Broadcaster async def websocket_handler(ws: WebSocket, broadcaster: Broadcaster) -> None: + """WebSocket 连接处理:接入、注册、保持心跳、断开清理。""" await ws.accept() await broadcaster.add(ws) + logger.info("WebSocket 连接接入:%s", ws.client) try: while True: await ws.receive_text() @@ -16,4 +23,4 @@ async def websocket_handler(ws: WebSocket, broadcaster: Broadcaster) -> None: pass finally: await broadcaster.remove(ws) - + logger.info("WebSocket 连接断开:%s", ws.client) diff --git a/backend/auth/__init__.py b/backend/auth/__init__.py new file mode 100644 index 0000000..4320405 --- /dev/null +++ b/backend/auth/__init__.py @@ -0,0 +1,3 @@ +"""认证子包:密码哈希、token 签发与 FastAPI 依赖。""" + +from __future__ import annotations diff --git a/backend/auth/deps.py b/backend/auth/deps.py new file mode 100644 index 0000000..b0fc711 --- /dev/null +++ b/backend/auth/deps.py @@ -0,0 +1,56 @@ +"""FastAPI 鉴权相关依赖(Depends)。 + +当前提供 get_current_user(session_factory),从 Authorization: Bearer 解析并加载用户信息。 +""" + +from __future__ import annotations + +from typing import Callable + +from fastapi import HTTPException, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.auth.tokens import verify_access_token +from backend.database.schema import sys_role, sys_user + + +def get_current_user(session_factory: async_sessionmaker[AsyncSession]) -> Callable[..., dict]: + """返回一个依赖函数:用于解析当前用户并从 DB 校验用户/角色启用状态。""" + async def _dep(request: Request) -> dict: + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(status_code=401, detail="missing token") + token = auth[len("Bearer ") :].strip() + try: + payload = verify_access_token(token) + except ValueError: + raise HTTPException(status_code=401, detail="invalid token") + + async with session_factory() as session: + q = ( + select( + sys_user.c.user_id, + sys_user.c.username, + sys_user.c.display_name, + sys_user.c.role_id, + sys_user.c.is_active, + sys_user.c.last_login_at, + sys_user.c.created_at, + sys_user.c.updated_at, + sys_user.c.extra, + sys_role.c.role_name, + sys_role.c.is_active.label("role_is_active"), + ) + .select_from(sys_user.join(sys_role, sys_user.c.role_id == sys_role.c.role_id)) + .where(sys_user.c.user_id == payload.user_id) + .limit(1) + ) + row = (await session.execute(q)).mappings().first() + if not row: + raise HTTPException(status_code=401, detail="user not found") + if not row["is_active"] or not row["role_is_active"]: + raise HTTPException(status_code=403, detail="inactive user") + return dict(row) + + return _dep diff --git a/backend/auth/passwords.py b/backend/auth/passwords.py new file mode 100644 index 0000000..793715f --- /dev/null +++ b/backend/auth/passwords.py @@ -0,0 +1,52 @@ +"""密码哈希与校验。 + +当前实现使用 PBKDF2-HMAC-SHA256(内置 hashlib),以避免引入额外依赖。 +存储格式: + pbkdf2_sha256$$$ +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets + + +_ALGO = "pbkdf2_sha256" +_ITERATIONS = 210_000 +_SALT_BYTES = 16 +_DKLEN = 32 + + +def hash_password(password: str) -> str: + """对明文密码进行哈希并返回可存储字符串。""" + if not isinstance(password, str) or not password: + raise ValueError("password required") + salt = secrets.token_bytes(_SALT_BYTES) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _ITERATIONS, dklen=_DKLEN) + salt_b64 = base64.urlsafe_b64encode(salt).decode("ascii").rstrip("=") + dk_b64 = base64.urlsafe_b64encode(dk).decode("ascii").rstrip("=") + return f"{_ALGO}${_ITERATIONS}${salt_b64}${dk_b64}" + + +def verify_password(password: str, stored_hash: str) -> bool: + """校验明文密码是否匹配已存储的哈希。""" + try: + algo, iters_s, salt_b64, dk_b64 = stored_hash.split("$", 3) + if algo != _ALGO: + return False + iterations = int(iters_s) + salt = _b64url_decode(salt_b64) + expected = _b64url_decode(dk_b64) + except Exception: + return False + + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, dklen=len(expected)) + return hmac.compare_digest(dk, expected) + + +def _b64url_decode(value: str) -> bytes: + """解码不带 padding 的 base64url 字符串。""" + padded = value + "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(padded.encode("ascii")) diff --git a/backend/auth/tokens.py b/backend/auth/tokens.py new file mode 100644 index 0000000..237a0b7 --- /dev/null +++ b/backend/auth/tokens.py @@ -0,0 +1,99 @@ +"""轻量 access token 签发与校验。 + +说明: +- 当前实现不是标准 JWT(避免引入额外依赖),而是“base64url(payload) + HMAC 签名”的轻量令牌。 +- 适用于内部系统的最小化认证需求;如需与第三方兼容,可替换为 JWT。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TokenPayload: + """解析后的 token 载荷。""" + + user_id: str + username: str + role_id: str + exp: int + iat: int + + +def issue_access_token(*, user_id: str, username: str, role_id: str, expires_in_seconds: int = 3600) -> str: + """签发 access token。""" + now = int(time.time()) + payload = { + "sub": user_id, + "username": username, + "role_id": role_id, + "iat": now, + "exp": now + int(expires_in_seconds), + "v": 1, + } + payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + payload_b64 = _b64url_encode(payload_bytes) + sig = _sign(payload_b64.encode("ascii")) + sig_b64 = _b64url_encode(sig) + return f"v1.{payload_b64}.{sig_b64}" + + +def verify_access_token(token: str) -> TokenPayload: + """校验 access token 并返回载荷。 + + Raises: + ValueError: token 非法或已过期。 + """ + if not token or not isinstance(token, str): + raise ValueError("invalid token") + parts = token.split(".") + if len(parts) != 3 or parts[0] != "v1": + raise ValueError("invalid token") + payload_b64, sig_b64 = parts[1], parts[2] + expected_sig = _sign(payload_b64.encode("ascii")) + actual_sig = _b64url_decode(sig_b64) + if not hmac.compare_digest(expected_sig, actual_sig): + raise ValueError("invalid token") + payload_raw = _b64url_decode(payload_b64) + payload = json.loads(payload_raw.decode("utf-8")) + exp = int(payload.get("exp")) + if int(time.time()) >= exp: + raise ValueError("token expired") + return TokenPayload( + user_id=str(payload.get("sub")), + username=str(payload.get("username")), + role_id=str(payload.get("role_id")), + exp=exp, + iat=int(payload.get("iat")), + ) + + +def access_token_secret() -> bytes: + """获取 token 签名密钥(来自环境变量 SMARTEDT_AUTH_SECRET)。""" + secret = os.getenv("SMARTEDT_AUTH_SECRET", "").strip() + if not secret: + secret = "smartedt-dev-secret-change-me" + return secret.encode("utf-8") + + +def _sign(message: bytes) -> bytes: + """对 message 做 HMAC-SHA256 签名。""" + return hmac.new(access_token_secret(), message, hashlib.sha256).digest() + + +def _b64url_encode(data: bytes) -> str: + """编码为不带 padding 的 base64url 字符串。""" + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64url_decode(value: str) -> bytes: + """解码不带 padding 的 base64url 字符串。""" + padded = value + "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(padded.encode("ascii")) diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 8b13789..523b120 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -1 +1,2 @@ +"""后端配置子包。""" diff --git a/backend/config/config.ini b/backend/config/config.ini index e6635c6..6f17784 100644 --- a/backend/config/config.ini +++ b/backend/config/config.ini @@ -10,3 +10,6 @@ path = data url = postgresql+psycopg://smartedt:postgres@127.0.0.1:5432/smartedt timescaledb = True +[UNITY] +host = 127.0.0.1 +port = 6000 diff --git a/backend/config/settings.py b/backend/config/settings.py index 1d6f473..41ec9a9 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,3 +1,12 @@ +"""配置加载与设置模型。 + +优先级(高 -> 低): +1. 环境变量 SMARTEDT_CONFIG 指定的配置文件 +2. 在若干候选位置寻找 config.ini(兼容 PyInstaller 打包运行态) +3. 环境变量的 fallback +4. 内置默认值 +""" + from __future__ import annotations import configparser @@ -8,6 +17,8 @@ from pathlib import Path @dataclass(frozen=True) class ServerSettings: + """服务监听配置。""" + host: str = "0.0.0.0" port: int = 5000 debug: bool = False @@ -15,25 +26,39 @@ class ServerSettings: @dataclass(frozen=True) class FileSettings: + """文件存储相关配置。""" + root_path: Path @dataclass(frozen=True) class DatabaseSettings: + """数据库连接相关配置。""" + url: str timescaledb: bool = True +@dataclass(frozen=True) +class UnitySettings: + host: str = "127.0.0.1" + port: int = 6000 + + @dataclass(frozen=True) class AppSettings: + """应用聚合配置。""" + server: ServerSettings files: FileSettings database: DatabaseSettings + unity: UnitySettings import sys def _find_config_file() -> Path | None: + """尝试从若干候选位置定位 config.ini(包含 PyInstaller 运行态)。""" # Handle PyInstaller frozen state if getattr(sys, 'frozen', False): # If onefile, _MEIPASS. If onedir, executable dir or _internal @@ -63,6 +88,7 @@ def _find_config_file() -> Path | None: def load_settings() -> AppSettings: + """加载并返回应用配置。""" config = configparser.ConfigParser() config_path = os.getenv("SMARTEDT_CONFIG") if config_path: @@ -93,9 +119,14 @@ def load_settings() -> AppSettings: fallback=os.getenv("SMARTEDT_TIMESCALEDB", "True").lower() == "true", ) + unity = UnitySettings( + host=config.get("UNITY", "host", fallback=os.getenv("SMARTEDT_UNITY_HOST", "127.0.0.1")), + port=config.getint("UNITY", "port", fallback=int(os.getenv("SMARTEDT_UNITY_PORT", "6000"))), + ) + return AppSettings( server=server, files=FileSettings(root_path=root_path), database=DatabaseSettings(url=database_url, timescaledb=timescaledb), + unity=unity, ) - diff --git a/backend/database/__init__.py b/backend/database/__init__.py index 8b13789..a7de1ec 100644 --- a/backend/database/__init__.py +++ b/backend/database/__init__.py @@ -1 +1 @@ - +"""数据库子包:schema 定义与 DB 工具脚本。""" diff --git a/backend/database/check_db.py b/backend/database/check_db.py index 0ca5625..e666032 100644 --- a/backend/database/check_db.py +++ b/backend/database/check_db.py @@ -1,7 +1,17 @@ +"""数据库连通性检查脚本。 + +用途: +- 快速验证 PostgreSQL 是否可连接 +- 尝试加载 TimescaleDB 扩展,并输出版本信息(若可用) + +注意:脚本只做连通性验证,不会打印密码。 +""" + import os import sys def check_database(): + """检查数据库连接与 TimescaleDB 扩展可用性。""" print("正在检查数据库连接...") # 连接参数:通过环境变量覆盖(不在输出中打印密码) @@ -57,6 +67,7 @@ def check_database(): return False if __name__ == "__main__": + """作为脚本运行时的入口。""" try: import psycopg # noqa: F401 except ImportError: diff --git a/backend/database/engine.py b/backend/database/engine.py index 145ec5b..b19d435 100644 --- a/backend/database/engine.py +++ b/backend/database/engine.py @@ -1,3 +1,8 @@ +"""数据库引擎与 Session 工厂。 + +集中封装 SQLAlchemy async engine 与 async_sessionmaker 的创建逻辑,便于在主程序中统一注入。 +""" + from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine @@ -6,6 +11,7 @@ from backend.config.settings import DatabaseSettings def create_engine(settings: DatabaseSettings) -> AsyncEngine: + """根据配置创建 AsyncEngine。""" return create_async_engine( settings.url, pool_pre_ping=True, @@ -14,5 +20,5 @@ def create_engine(settings: DatabaseSettings) -> AsyncEngine: def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + """创建异步 Session 工厂(expire_on_commit=False 便于返回已提交对象)。""" return async_sessionmaker(engine, expire_on_commit=False) - diff --git a/backend/database/init_db.py b/backend/database/init_db.py new file mode 100644 index 0000000..09bef74 --- /dev/null +++ b/backend/database/init_db.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import argparse +import os +import platform +import sys +from pathlib import Path + +from sqlalchemy.engine.url import make_url +from sqlalchemy.ext.asyncio import create_async_engine + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from backend.config.settings import load_settings +from backend.database.schema import init_schema, init_timescaledb + + +def _redact_url(url: str) -> str: + try: + parsed = make_url(url) + if parsed.password: + parsed = parsed.set(password="***") + return str(parsed) + except Exception: + return url + + +async def _run(url: str, enable_timescaledb: bool) -> None: + engine = create_async_engine(url, echo=False, pool_pre_ping=True) + try: + await init_schema(engine) + if enable_timescaledb: + await init_timescaledb(engine) + finally: + await engine.dispose() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default=None) + parser.add_argument("--timescaledb", action="store_true") + parser.add_argument("--no-timescaledb", action="store_true") + args = parser.parse_args() + + settings = load_settings() + url = (args.url or os.getenv("SMARTEDT_DATABASE_URL") or settings.database.url).strip() + enable_timescaledb = settings.database.timescaledb + if args.timescaledb: + enable_timescaledb = True + if args.no_timescaledb: + enable_timescaledb = False + + print(f"Connecting to DB: {_redact_url(url)}") + print(f"Init schema: yes; init timescaledb: {'yes' if enable_timescaledb else 'no'}") + + import asyncio + if platform.system() == "Windows" and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(_run(url, enable_timescaledb)) + print("✅ 初始化完成") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/database/schema.py b/backend/database/schema.py index b9b7ffa..6ce790a 100644 --- a/backend/database/schema.py +++ b/backend/database/schema.py @@ -1,47 +1,79 @@ +"""数据库 Schema 定义。 + +说明: +- ORM 模型:用于结构相对稳定、需要 ORM 能力的表(例如 Simulation) +- Core Table:用于时序/大数据量写入或更灵活的 SQL 操作(例如 vehicle_signals、server_metrics、RBAC 表等) +""" + from __future__ import annotations from datetime import datetime -from sqlalchemy import JSON, BigInteger, Boolean, Column, DateTime, Float, ForeignKey, Index, String, Table, text +from sqlalchemy import JSON, BigInteger, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String, Table, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): + """SQLAlchemy Declarative Base。""" + pass -class Simulation(Base): - __tablename__ = "simulations" +class SimulationScene(Base): + """仿真场景配置(非时序数据)。""" + __tablename__ = "sim_scenes" + scene_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="场景 ID") + scene_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True, comment="场景名称") + scene_desc: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="场景描述(可选)") + scene_config: Mapped[dict] = mapped_column(JSON, default=dict, comment="场景配置信息(JSON)") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True, comment="是否启用") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=text("now()"), index=True, comment="创建时间(UTC)") + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, comment="更新时间(UTC)") - simulation_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="仿真 ID") - status: Mapped[str] = mapped_column(String(32), index=True, comment="仿真状态(running/stopped 等)") + +class SimulationTask(Base): + """仿真任务记录(非时序数据)。""" + __tablename__ = "sim_tasks" + """以下为仿真任务相关配置""" + task_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="任务 ID") + task_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="任务名称") + scene_id: Mapped[str | None] = mapped_column( + String(64), ForeignKey("sim_scenes.scene_id"), nullable=True, index=True, comment="仿真场景 ID(场景表中选择)" + ) + scene_name: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True, comment="仿真场景名称") + scene_config: Mapped[dict] = mapped_column(JSON, default=dict, comment="仿真场景配置信息") + config_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="配置创建时间(UTC)") + """以下为仿真启停操作状态相关记录信息""" started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True, comment="开始时间(UTC)") ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="结束时间(UTC)") - scenario_name: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True, comment="仿真场景名称") - scenario_config: Mapped[dict] = mapped_column(JSON, default=dict, comment="仿真场景配置(JSON)") - config_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="配置创建时间(UTC)") - operator: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True, comment="仿真操作员") - archived: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否归档") + status: Mapped[str] = mapped_column(String(32), index=True, comment="仿真任务状态(wait/running/stopped/archived 等)") + operator: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True, comment="仿真操作员") + """以下为开始时发送给Unity程序的连接及初始化配置""" + unity_host: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="Unity Socket 主机") + unity_port: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="Unity Socket 端口") + sync_timestamp: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="同步基准时间戳(毫秒)") + init_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="InitConfig 原始内容(主控→Unity,JSONB)") + init_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="InitConfig 发送时间(UTC)") vehicle_signals = Table( "sim_vehicle_signals", Base.metadata, Column("ts", DateTime(timezone=True), nullable=False, index=True, comment="信号采样时间(UTC)"), - Column("simulation_id", String(64), nullable=False, index=True, comment="仿真 ID"), - Column("device_id", String(64), nullable=False, index=True, comment="设备 ID"), + Column("simulation_id", String(64), nullable=False, index=True, comment="仿真任务 ID(sim_tasks.task_id)"), + Column("vehicle_id", String(64), nullable=False, index=True, comment="实物车辆ID,默认值为'0'"), Column("seq", BigInteger, nullable=False, comment="信号序列号(单仿真内递增)"), Column("signals", JSONB, nullable=False, comment="车辆信号载荷(JSONB)"), Index("idx_vehicle_signals_sim_ts", "simulation_id", "ts"), comment="车辆信号时序数据(TimescaleDB hypertable)", ) -unity_vehicle_frames = Table( - "sim_unity_vehicle_frames", +unity_frames = Table( + "sim_unity_frames", Base.metadata, Column("ts", DateTime(timezone=True), nullable=False, index=True, comment="帧时间(UTC)"), - Column("simulation_id", String(64), nullable=False, index=True, comment="仿真 ID"), + Column("simulation_id", String(64), nullable=False, index=True, comment="仿真任务 ID(sim_tasks.task_id)"), Column("vehicle_id", String(64), nullable=False, index=True, comment="虚拟车辆 ID"), Column("seq", BigInteger, nullable=False, comment="帧序号(单仿真单车内递增)"), Column("pos_x", Float, nullable=False, comment="位置 X(世界坐标)"), @@ -67,7 +99,7 @@ screen_recordings = Table( "sim_screen_videos", Base.metadata, Column("video_id", String(64), primary_key=True, comment="录制文件记录 ID"), - Column("simulation_id", String(64), nullable=False, index=True, comment="仿真 ID"), + Column("simulation_id", String(64), nullable=False, index=True, comment="仿真任务 ID(sim_tasks.task_id)"), Column("screen_type", String(32), nullable=False, index=True, comment="屏幕类型(big_screen/vehicle_screen 等)"), Column("source_name", String(64), nullable=True, index=True, comment="录制源名称(可选,如设备号/通道号)"), Column("status", String(32), nullable=False, index=True, comment="状态(recording/ready/failed 等)"), @@ -174,22 +206,60 @@ server_metrics = Table( async def init_schema(engine) -> None: + """初始化数据库表结构与必要的兼容性变更。 + + 该函数会: + - create_all:创建 Base.metadata 里声明的表 + - 插入默认角色(若不存在) + - 对历史表做列/索引补齐(兼容升级) + """ from sqlalchemy.ext.asyncio import AsyncEngine if not isinstance(engine, AsyncEngine): raise TypeError("engine must be AsyncEngine") async with engine.begin() as conn: + await conn.execute(text("DROP INDEX IF EXISTS idx_vehicle_signals_sim_ts")) + await conn.execute(text("DROP INDEX IF EXISTS idx_unity_frames_sim_vehicle_ts")) + await conn.execute(text("DROP INDEX IF EXISTS idx_screen_recordings_sim_screen_created")) + await conn.execute(text("DROP INDEX IF EXISTS idx_screen_recordings_sim_screen_time")) + await conn.execute(text("DROP INDEX IF EXISTS idx_sys_role_permission_role")) + await conn.execute(text("DROP INDEX IF EXISTS idx_sys_role_permission_perm")) + await conn.execute(text("DROP INDEX IF EXISTS idx_sys_logs_user_ts")) + await conn.execute(text("DROP INDEX IF EXISTS idx_sys_logs_action_ts")) + await conn.execute(text("DROP INDEX IF EXISTS idx_server_metrics_host_ts")) await conn.run_sync(Base.metadata.create_all) - await conn.execute(text("ALTER TABLE simulations ADD COLUMN IF NOT EXISTS scenario_name VARCHAR(255)")) - await conn.execute(text("ALTER TABLE simulations ADD COLUMN IF NOT EXISTS config_created_at TIMESTAMPTZ")) - await conn.execute(text("ALTER TABLE simulations ADD COLUMN IF NOT EXISTS operator VARCHAR(64)")) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_scenario_name ON simulations (scenario_name)")) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_config_created_at ON simulations (config_created_at)")) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_operator ON simulations (operator)")) + await conn.execute( + text( + """ + INSERT INTO sys_role (role_id, role_name, role_desc, is_active) + VALUES + ('admin', '系统管理员', '系统管理员', TRUE), + ('auditor', '审计员', '审计员', TRUE), + ('teacher', '老师', '老师', TRUE), + ('student', '学生', '学生', TRUE) + ON CONFLICT (role_id) DO NOTHING + """ + ) + ) + await conn.execute( + text( + """ + INSERT INTO sim_scenes (scene_id, scene_name, scene_desc, scene_config, is_active) + VALUES + ('scene_01', '城市道路', '默认场景 1', '{}'::json, TRUE), + ('scene_02', '高速公路', '默认场景 2', '{}'::json, TRUE), + ('scene_03', '学校道路', '默认场景 3', '{}'::json, TRUE), + ('scene_04', '场地训练', '默认场景 4', '{}'::json, TRUE), + ('scene_05', '综合测试', '默认场景 5', '{}'::json, TRUE) + ON CONFLICT (scene_id) DO NOTHING + """ + ) + ) async def init_timescaledb(engine) -> None: + """初始化 TimescaleDB 扩展与 hypertable/索引(若启用)。""" async with engine.begin() as conn: await conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb")) await conn.execute( @@ -214,11 +284,11 @@ async def init_timescaledb(engine) -> None: ) await conn.execute( text( - "SELECT create_hypertable('sim_unity_vehicle_frames', 'ts', if_not_exists => TRUE)" + "SELECT create_hypertable('sim_unity_frames', 'ts', if_not_exists => TRUE)" ) ) await conn.execute( text( - "CREATE INDEX IF NOT EXISTS idx_unity_frames_sim_vehicle_ts_desc ON sim_unity_vehicle_frames (simulation_id, vehicle_id, ts DESC)" + "CREATE INDEX IF NOT EXISTS idx_unity_frames_sim_vehicle_ts_desc ON sim_unity_frames (simulation_id, vehicle_id, ts DESC)" ) ) diff --git a/backend/database/test_db.py b/backend/database/test_db.py index c8674ae..37a1bab 100644 --- a/backend/database/test_db.py +++ b/backend/database/test_db.py @@ -1,3 +1,13 @@ +"""数据库性能与功能测试脚本(开发/压测用途)。 + +该脚本会: +- 初始化 schema 与 TimescaleDB(若可用) +- 批量写入模拟车辆信号(JSONB) +- 运行几类常见查询并输出耗时 + +注意:该脚本会写入大量数据,请不要在生产库中执行。 +""" + import asyncio import os import time @@ -7,11 +17,12 @@ from datetime import datetime, timezone from sqlalchemy import insert, select, text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.engine.url import make_url -from backend.database.schema import vehicle_signals, Simulation, init_schema, init_timescaledb +from backend.database.schema import SimulationTask, unity_frames, vehicle_signals, init_schema, init_timescaledb from backend.config.settings import load_settings # 模拟数据生成 def generate_payload(): + """生成一条模拟车辆信号负载(用于写入 JSONB)。""" return { "steering_wheel_angle_deg": round(random.uniform(-450, 450), 1), "brake_pedal_travel_mm": round(random.uniform(0, 100), 1), @@ -38,6 +49,7 @@ def generate_payload(): } def _redact_url(url: str) -> str: + """隐藏数据库 URL 中的密码,避免误打印敏感信息。""" try: parsed = make_url(url) if parsed.password: @@ -47,6 +59,7 @@ def _redact_url(url: str) -> str: return url async def run_test(): + """执行写入/查询性能测试。""" settings = load_settings() db_url = os.getenv("SMARTEDT_TEST_DATABASE_URL", settings.database.url).strip() diff --git a/backend/device/__init__.py b/backend/device/__init__.py index 8b13789..979c335 100644 --- a/backend/device/__init__.py +++ b/backend/device/__init__.py @@ -1 +1 @@ - +"""设备子包:设备抽象与具体实现。""" diff --git a/backend/device/base.py b/backend/device/base.py index 244b1e0..7152d3d 100644 --- a/backend/device/base.py +++ b/backend/device/base.py @@ -1,3 +1,8 @@ +"""设备抽象层。 + +用于统一不同设备(真实硬件、仿真设备、Mock)的连接与状态查询接口。 +""" + from __future__ import annotations from dataclasses import dataclass @@ -5,21 +10,27 @@ from dataclasses import dataclass @dataclass(frozen=True) class DeviceInfo: + """设备信息快照(可用于 API 输出)。""" + device_id: str device_type: str connected: bool class DeviceAdapter: + """设备适配器接口(异步)。""" + device_id: str device_type: str async def connect(self) -> None: + """建立与设备的连接。""" raise NotImplementedError async def disconnect(self) -> None: + """断开与设备的连接。""" raise NotImplementedError async def is_connected(self) -> bool: + """返回当前连接状态。""" raise NotImplementedError - diff --git a/backend/device/mock_vehicle.py b/backend/device/mock_vehicle.py index f5242b3..94f7289 100644 --- a/backend/device/mock_vehicle.py +++ b/backend/device/mock_vehicle.py @@ -1,3 +1,8 @@ +"""Mock 车辆设备实现。 + +用于在没有真实硬件接入时,生成可用于联调的车辆信号数据。 +""" + from __future__ import annotations import random @@ -8,6 +13,8 @@ from backend.device.base import DeviceAdapter @dataclass(frozen=True) class VehicleSignalPayload: + """一帧车辆信号载荷(用于广播/落库)。""" + steering_wheel_angle_deg: float brake_pedal_travel_mm: float throttle_pedal_travel_mm: float @@ -22,6 +29,7 @@ class VehicleSignalPayload: temperature_c: float def to_dict(self) -> dict: + """转为可 JSON 序列化的 dict。""" return { "steering_wheel_angle_deg": self.steering_wheel_angle_deg, "brake_pedal_travel_mm": self.brake_pedal_travel_mm, @@ -39,21 +47,27 @@ class VehicleSignalPayload: class MockVehicleDevice(DeviceAdapter): + """模拟车辆设备:提供 connect/disconnect/is_connected 与 sample()。""" + def __init__(self, device_id: str = "controlbox_01") -> None: self.device_id = device_id self.device_type = "mock_vehicle" self._connected = False async def connect(self) -> None: + """模拟建立连接。""" self._connected = True async def disconnect(self) -> None: + """模拟断开连接。""" self._connected = False async def is_connected(self) -> bool: + """返回当前连接状态。""" return self._connected def sample(self) -> VehicleSignalPayload: + """采样生成一帧模拟信号。""" steering = random.uniform(-180.0, 180.0) brake = max(0.0, random.gauss(2.0, 1.0)) throttle = max(0.0, random.gauss(15.0, 5.0)) @@ -86,4 +100,3 @@ class MockVehicleDevice(DeviceAdapter): current_a=current, temperature_c=temp, ) - diff --git a/backend/main.py b/backend/main.py index c49f64d..cf12aa9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,12 @@ +"""SmartEDT 后端服务入口。 + +主要职责: +- 加载配置与初始化日志 +- 初始化数据库 schema/TimescaleDB +- 构造核心服务(仿真、监控、鉴权/RBAC) +- 挂载 HTTP/WebSocket 路由并启动 uvicorn +""" + from __future__ import annotations import argparse @@ -35,19 +44,22 @@ from backend.database.schema import init_schema, init_timescaledb from backend.services.broadcaster import Broadcaster from backend.services.simulation_manager import SimulationManager from backend.services.server_monitor import ServerMonitorService +from backend.services.unity_socket_client import UnitySocketClient from backend.device.mock_vehicle import MockVehicleDevice -from backend.api import routes, ws +from backend.api import auth_routes, rbac_routes, routes, unity_routes, user_routes, ws from backend.utils import configure_logging logger = logging.getLogger("backend") def _default_backend_log_file() -> Path | None: + """在打包运行态下返回默认日志文件路径;开发态返回 None。""" if getattr(sys, "frozen", False): exe_dir = Path(sys.executable).resolve().parent return exe_dir / "logs" / "backend.log" return None def _force_windows_selector_event_loop_for_uvicorn() -> None: + """避免 uvicorn 在 Windows 上切换到 ProactorEventLoop(与 psycopg async 不兼容)。""" if platform.system() != "Windows": return try: @@ -62,6 +74,8 @@ def _force_windows_selector_event_loop_for_uvicorn() -> None: # 全局单例容器(简单实现) class Container: + """简易全局容器:集中创建与持有配置、DB 引擎、session 工厂与各服务单例。""" + def __init__(self): load_dotenv() self.settings = load_settings() @@ -76,11 +90,13 @@ class Container: self.engine = create_engine(self.settings.database) self.session_factory = create_session_factory(self.engine) self.broadcaster = Broadcaster() + self.unity_client = UnitySocketClient(self.settings.unity.host, self.settings.unity.port) # 实例化服务 self.simulation_manager = SimulationManager( self.session_factory, - self.broadcaster + self.broadcaster, + unity_client=self.unity_client, ) # 实例化监控服务 @@ -93,6 +109,7 @@ container = Container() @asynccontextmanager async def lifespan(app: FastAPI): + """FastAPI 生命周期:启动初始化与停机清理。""" # 启动前初始化 await init_schema(container.engine) if container.settings.database.timescaledb: @@ -118,6 +135,10 @@ async def lifespan(app: FastAPI): app = FastAPI(title="SmartEDT Backend", version="0.1.0", lifespan=lifespan) app.include_router(routes.get_router(simulation_manager=container.simulation_manager, file_root=container.file_root)) +app.include_router(auth_routes.get_router(session_factory=container.session_factory)) +app.include_router(rbac_routes.get_router(session_factory=container.session_factory)) +app.include_router(user_routes.get_router(session_factory=container.session_factory)) +app.include_router(unity_routes.get_router(simulation_manager=container.simulation_manager, session_factory=container.session_factory)) @app.websocket("/ws") async def ws_endpoint(websocket: WebSocket): @@ -125,6 +146,7 @@ async def ws_endpoint(websocket: WebSocket): def main() -> None: + """命令行入口:解析参数并启动 uvicorn。""" parser = argparse.ArgumentParser() parser.add_argument("--host", default=None) parser.add_argument("--port", type=int, default=None) diff --git a/backend/services/__init__.py b/backend/services/__init__.py index 8b13789..69b9e80 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -1 +1 @@ - +"""后端业务服务子包。""" diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py new file mode 100644 index 0000000..8dec263 --- /dev/null +++ b/backend/services/auth_service.py @@ -0,0 +1,45 @@ +"""认证服务。 + +该模块实现“用户名 + 密码”的登录校验,并签发 access token。 +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.auth.passwords import verify_password +from backend.auth.tokens import issue_access_token +from backend.services.user_service import UserService + + +class AuthService: + """认证相关业务逻辑(不直接绑定 HTTP 框架)。""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + self._users = UserService(session_factory) + + async def login(self, *, username: str, password: str, expires_in_seconds: int = 3600) -> tuple[str, dict]: + """登录并返回 (token, 用户行数据)。 + + Raises: + ValueError: 用户名或密码错误。 + PermissionError: 用户已被禁用。 + """ + user_row = await self._users.get_user_by_username(username) + if not user_row: + raise ValueError("用户名或密码错误!") + if not user_row.get("is_active", True): + raise PermissionError("用户已被禁用!") + stored = user_row.get("password_hash") or "" + if not verify_password(password, stored): + raise ValueError("用户名或密码错误!") + + await self._users.touch_last_login(str(user_row["user_id"])) + token = issue_access_token( + user_id=str(user_row["user_id"]), + username=str(user_row["username"]), + role_id=str(user_row["role_id"]), + expires_in_seconds=expires_in_seconds, + ) + return token, user_row diff --git a/backend/services/broadcaster.py b/backend/services/broadcaster.py index c1a0a7f..e2a3cbb 100644 --- a/backend/services/broadcaster.py +++ b/backend/services/broadcaster.py @@ -1,3 +1,8 @@ +"""WebSocket 广播器。 + +维护当前在线的 WebSocket 连接集合,并支持向所有连接广播 JSON 消息。 +""" + from __future__ import annotations import asyncio @@ -7,19 +12,27 @@ from starlette.websockets import WebSocket class Broadcaster: + """简单的 WebSocket 广播器(线程安全:使用 asyncio.Lock)。""" + def __init__(self) -> None: self._clients: set[WebSocket] = set() self._lock = asyncio.Lock() async def add(self, ws: WebSocket) -> None: + """注册连接。""" async with self._lock: self._clients.add(ws) async def remove(self, ws: WebSocket) -> None: + """移除连接(若不存在则忽略)。""" async with self._lock: self._clients.discard(ws) async def broadcast_json(self, message: dict[str, Any]) -> None: + """向所有连接广播 JSON。 + + 若某个连接发送失败,会被自动移除,避免集合泄漏。 + """ async with self._lock: clients = list(self._clients) for ws in clients: @@ -27,4 +40,3 @@ class Broadcaster: await ws.send_json(message) except Exception: await self.remove(ws) - diff --git a/backend/services/rbac_service.py b/backend/services/rbac_service.py new file mode 100644 index 0000000..6ebb81b --- /dev/null +++ b/backend/services/rbac_service.py @@ -0,0 +1,186 @@ +"""RBAC(角色/权限)服务。 + +该模块围绕 sys_role / sys_permission / sys_role_permission 三张表提供基本的增删改查与绑定关系维护。 +""" + +from __future__ import annotations + +import secrets +from typing import Any + +from sqlalchemy import delete, insert, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.database.schema import sys_permission, sys_role, sys_role_permission +from backend.utils import utc_now + + +class RbacService: + """角色与权限点管理服务(SQLAlchemy Core)。""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def list_roles(self) -> list[dict[str, Any]]: + """查询角色列表。""" + async with self._session_factory() as session: + q = ( + select( + sys_role.c.role_id, + sys_role.c.role_name, + sys_role.c.role_desc, + sys_role.c.is_active, + sys_role.c.created_at, + sys_role.c.updated_at, + sys_role.c.extra, + ) + .order_by(sys_role.c.role_name.asc()) + ) + return [dict(r) for r in (await session.execute(q)).mappings().all()] + + async def get_role(self, role_id: str) -> dict[str, Any] | None: + """按 role_id 查询角色。""" + async with self._session_factory() as session: + q = select(sys_role).where(sys_role.c.role_id == role_id).limit(1) + row = (await session.execute(q)).mappings().first() + return dict(row) if row else None + + async def create_role( + self, + *, + role_id: str | None, + role_name: str, + role_desc: str | None = None, + is_active: bool = True, + extra: dict | None = None, + ) -> dict[str, Any]: + """创建角色。 + + 说明:role_id 不传时会自动生成。 + """ + rid = role_id or ("role_" + secrets.token_hex(8)) + values: dict[str, Any] = { + "role_id": rid, + "role_name": role_name, + "role_desc": role_desc, + "is_active": is_active, + "updated_at": utc_now(), + "extra": extra, + } + async with self._session_factory() as session: + try: + await session.execute(insert(sys_role).values(**values)) + await session.commit() + except IntegrityError: + await session.rollback() + raise + created = await self.get_role(rid) + if not created: + raise RuntimeError("failed to create role") + return created + + async def update_role( + self, + role_id: str, + *, + role_name: str | None = None, + role_desc: str | None = None, + is_active: bool | None = None, + extra: dict | None = None, + ) -> dict[str, Any] | None: + """更新角色字段(仅更新传入的字段)。""" + patch: dict[str, Any] = {"updated_at": utc_now()} + if role_name is not None: + patch["role_name"] = role_name + if role_desc is not None: + patch["role_desc"] = role_desc + if is_active is not None: + patch["is_active"] = is_active + if extra is not None: + patch["extra"] = extra + async with self._session_factory() as session: + try: + res = await session.execute(update(sys_role).where(sys_role.c.role_id == role_id).values(**patch)) + await session.commit() + except IntegrityError: + await session.rollback() + raise + if res.rowcount == 0: + return None + return await self.get_role(role_id) + + async def disable_role(self, role_id: str) -> bool: + """禁用角色(软删除)。""" + async with self._session_factory() as session: + res = await session.execute( + update(sys_role).where(sys_role.c.role_id == role_id).values(is_active=False, updated_at=utc_now()) + ) + await session.commit() + return bool(res.rowcount) + + async def list_permissions(self) -> list[dict[str, Any]]: + """查询权限点列表。""" + async with self._session_factory() as session: + q = select(sys_permission).order_by(sys_permission.c.perm_group.asc().nulls_last(), sys_permission.c.perm_code.asc()) + return [dict(r) for r in (await session.execute(q)).mappings().all()] + + async def create_permission( + self, *, perm_code: str, perm_name: str, perm_group: str | None = None, perm_desc: str | None = None + ) -> dict[str, Any]: + """创建权限点。""" + async with self._session_factory() as session: + try: + await session.execute( + insert(sys_permission).values( + perm_code=perm_code, perm_name=perm_name, perm_group=perm_group, perm_desc=perm_desc + ) + ) + await session.commit() + except IntegrityError: + await session.rollback() + raise + q = select(sys_permission).where(sys_permission.c.perm_code == perm_code).limit(1) + row = (await session.execute(q)).mappings().first() + if not row: + raise RuntimeError("failed to create permission") + return dict(row) + + async def delete_permission(self, perm_code: str) -> bool: + """删除权限点,并清理与角色的关联。""" + async with self._session_factory() as session: + await session.execute(delete(sys_role_permission).where(sys_role_permission.c.perm_code == perm_code)) + res = await session.execute(delete(sys_permission).where(sys_permission.c.perm_code == perm_code)) + await session.commit() + return bool(res.rowcount) + + async def get_role_permissions(self, role_id: str) -> list[str]: + """查询指定角色拥有的权限点编码列表。""" + async with self._session_factory() as session: + q = select(sys_role_permission.c.perm_code).where(sys_role_permission.c.role_id == role_id) + rows = (await session.execute(q)).scalars().all() + return list(rows) + + async def set_role_permissions(self, *, role_id: str, perm_codes: list[str]) -> list[str]: + """覆盖设置角色权限点集合(先删后插)。""" + unique = list(dict.fromkeys(perm_codes)) + async with self._session_factory() as session: + if unique: + q = select(sys_permission.c.perm_code).where(sys_permission.c.perm_code.in_(unique)) + existing = set((await session.execute(q)).scalars().all()) + missing = [c for c in unique if c not in existing] + if missing: + raise ValueError(f"missing permissions: {', '.join(missing)}") + + try: + await session.execute(delete(sys_role_permission).where(sys_role_permission.c.role_id == role_id)) + if unique: + await session.execute( + insert(sys_role_permission), + [{"role_id": role_id, "perm_code": code} for code in unique], + ) + await session.commit() + except IntegrityError: + await session.rollback() + raise + return await self.get_role_permissions(role_id) diff --git a/backend/services/server_monitor.py b/backend/services/server_monitor.py index 46e175f..a23f1fb 100644 --- a/backend/services/server_monitor.py +++ b/backend/services/server_monitor.py @@ -1,3 +1,10 @@ +"""服务器监控采集服务。 + +以较高频率采样系统指标(CPU/内存),并以较低频率进行下采样后: +- 通过 WebSocket 广播给前端 +- 写入 TimescaleDB(server_metrics hypertable) +""" + import asyncio import time import logging @@ -14,6 +21,8 @@ from backend.services.broadcaster import Broadcaster logger = logging.getLogger("backend.monitor") class ServerMonitorService: + """服务器资源监控服务(采样 + 下采样 + 广播 + 落库)。""" + def __init__(self, session_factory: async_sessionmaker, broadcaster: Broadcaster): self._session_factory = session_factory self._broadcaster = broadcaster @@ -29,6 +38,7 @@ class ServerMonitorService: self._buffer_mem = [] async def start(self): + """启动监控循环(幂等)。""" if self._running: return self._running = True @@ -36,6 +46,7 @@ class ServerMonitorService: logger.info("ServerMonitorService started") async def stop(self): + """停止监控循环并等待任务结束。""" self._running = False if self._task: try: @@ -45,6 +56,7 @@ class ServerMonitorService: logger.info("ServerMonitorService stopped") async def _run_loop(self): + """采样循环:50Hz 采样,10Hz 报告。""" loop = asyncio.get_running_loop() next_time = loop.time() @@ -76,6 +88,7 @@ class ServerMonitorService: await asyncio.sleep(0) async def _process_and_report(self): + """对采样缓冲区做下采样,并完成广播与落库。""" if not self._buffer_cpu: return diff --git a/backend/services/simulation_manager.py b/backend/services/simulation_manager.py index 6508054..f67c087 100644 --- a/backend/services/simulation_manager.py +++ b/backend/services/simulation_manager.py @@ -1,3 +1,12 @@ +"""仿真管理服务。 + +负责: +- 仿真生命周期(start/stop) +- 设备接入(目前为 MockVehicleDevice) +- 信号采样与广播(WebSocket) +- 信号落库(TimescaleDB hypertable) +""" + from __future__ import annotations import asyncio @@ -9,9 +18,10 @@ from typing import Any from sqlalchemy import insert from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from backend.database.schema import Simulation, vehicle_signals +from backend.database.schema import SimulationTask, vehicle_signals from backend.device.mock_vehicle import MockVehicleDevice from backend.services.broadcaster import Broadcaster +from backend.services.unity_socket_client import UnitySocketClient from backend.utils import utc_now @@ -20,51 +30,158 @@ logger = logging.getLogger("backend.simulation") @dataclass class SimulationRuntime: + """运行中的仿真信息(内存态)。""" + simulation_id: str status: str task: asyncio.Task | None = None class SimulationManager: + """仿真生命周期管理器。""" + def __init__( self, session_factory: async_sessionmaker[AsyncSession], broadcaster: Broadcaster, + unity_client: UnitySocketClient | None = None, ) -> None: self._session_factory = session_factory self._broadcaster = broadcaster + self._unity_client = unity_client self._runtime: SimulationRuntime | None = None self._device = MockVehicleDevice() self._seq = 0 + self._command_seq = 0 def current(self) -> SimulationRuntime | None: + """返回当前运行中的仿真(若无则为 None)。""" return self._runtime async def register_device(self, device: MockVehicleDevice) -> None: + """注册仿真设备实现(用于采样)。""" self._device = device + async def init_config(self, init_config: dict[str, Any]) -> str: + session_info = init_config.get("session") or {} + driver_info = init_config.get("driver") or {} + vehicle_info = init_config.get("vehicle") or {} + scene_info = init_config.get("scene") or {} + + task_id = str(session_info.get("taskId") or "").strip() or None + simulation_id = None + if task_id and len(task_id) <= 64: + simulation_id = task_id + if not simulation_id: + simulation_id = "SIM" + utc_now().strftime("%Y%m%d%H%M%S") + secrets.token_hex(2).upper() + + now = utc_now() + task_name = (session_info.get("taskName") or None) + sync_timestamp = session_info.get("syncTimestamp") + driver_id = (driver_info.get("driverId") or None) + vehicle_id = (vehicle_info.get("vehicleId") or None) + scene_id = (scene_info.get("sceneId") or None) + scene_name = (scene_info.get("sceneName") or None) + scene_config = scene_info if isinstance(scene_info, dict) else {} + operator = driver_info.get("name") or None + + async with self._session_factory() as session: + sim = await session.get(SimulationTask, simulation_id) + if sim is None: + sim = SimulationTask( + task_id=simulation_id, + task_name=task_name, + scene_id=scene_id, + scene_name=scene_name, + scene_config=scene_config, + config_created_at=now, + started_at=now, + ended_at=None, + status="wait", + operator=operator, + unity_host=self._unity_client.host if self._unity_client is not None else None, + unity_port=self._unity_client.port if self._unity_client is not None else None, + sync_timestamp=int(sync_timestamp) if sync_timestamp is not None else None, + init_config=init_config, + init_sent_at=None, + ) + session.add(sim) + else: + sim.task_name = task_name + sim.scene_id = scene_id + sim.scene_name = scene_name + sim.scene_config = scene_config + sim.config_created_at = now + sim.operator = operator + sim.sync_timestamp = int(sync_timestamp) if sync_timestamp is not None else None + sim.init_config = init_config + if self._unity_client is not None: + sim.unity_host = self._unity_client.host + sim.unity_port = self._unity_client.port + + await session.commit() + + if self._unity_client is not None: + payload = dict(init_config) + payload.setdefault("msgType", "init") + await self._unity_client.send_json(payload) + async with self._session_factory() as session: + sim = await session.get(SimulationTask, simulation_id) + if sim is not None: + sim.init_sent_at = utc_now() + await session.commit() + + await self._broadcaster.broadcast_json( + {"type": "simulation.init_config", "ts": now.timestamp(), "simulation_id": simulation_id, "payload": init_config} + ) + return simulation_id + + async def send_command(self, command: dict[str, Any]) -> None: + if self._unity_client is None: + raise RuntimeError("unity client not configured") + payload = dict(command) + payload.setdefault("msgType", "command") + payload.setdefault("timestamp", int(utc_now().timestamp() * 1000)) + if "seqId" not in payload: + self._command_seq += 1 + payload["seqId"] = self._command_seq + await self._unity_client.send_json(payload) + await self._broadcaster.broadcast_json( + {"type": "simulation.command", "ts": utc_now().timestamp(), "payload": payload} + ) + async def start(self, scenario_config: dict[str, Any]) -> str: + """启动仿真并返回 simulation_id。 + + 说明:如果已有仿真在运行,会直接返回当前 simulation_id(幂等)。 + """ if self._runtime and self._runtime.status == "running": return self._runtime.simulation_id simulation_id = "SIM" + utc_now().strftime("%Y%m%d%H%M%S") + secrets.token_hex(2).upper() started_at = utc_now() - scenario_name = scenario_config.get("scenario") + task_name = scenario_config.get("scenario") operator = scenario_config.get("driver") or scenario_config.get("operator") config_created_at = started_at async with self._session_factory() as session: session.add( - Simulation( - simulation_id=simulation_id, - status="running", + SimulationTask( + task_id=simulation_id, + task_name=task_name, + scene_id=None, + scene_name=None, + scene_config=scenario_config, + config_created_at=config_created_at, started_at=started_at, ended_at=None, - scenario_name=scenario_name, - scenario_config=scenario_config, - config_created_at=config_created_at, + status="running", operator=operator, - archived=False, + unity_host=self._unity_client.host if self._unity_client is not None else None, + unity_port=self._unity_client.port if self._unity_client is not None else None, + sync_timestamp=None, + init_config=None, + init_sent_at=None, ) ) await session.commit() @@ -75,9 +192,12 @@ class SimulationManager: await self._broadcaster.broadcast_json( {"type": "simulation.status", "ts": started_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "running"}} ) + if self._unity_client is not None: + await self.send_command({"action": "start"}) return simulation_id async def stop(self, simulation_id: str) -> None: + """停止仿真(若 simulation_id 不匹配当前运行实例则忽略)。""" runtime = self._runtime if not runtime or runtime.simulation_id != simulation_id: return @@ -94,7 +214,7 @@ class SimulationManager: ended_at = utc_now() async with self._session_factory() as session: - sim = await session.get(Simulation, simulation_id) + sim = await session.get(SimulationTask, simulation_id) if sim: sim.status = "stopped" sim.ended_at = ended_at @@ -103,9 +223,12 @@ class SimulationManager: await self._broadcaster.broadcast_json( {"type": "simulation.status", "ts": ended_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "stopped"}} ) + if self._unity_client is not None: + await self.send_command({"action": "stop"}) self._runtime = None async def _run_loop(self, simulation_id: str) -> None: + """仿真运行循环:采样设备信号、广播并写入数据库。""" try: while True: await asyncio.sleep(0.05) @@ -131,6 +254,7 @@ class SimulationManager: logger.exception("simulation loop crashed") async def _persist_signal(self, ts, simulation_id: str, device_id: str, seq: int, signals: dict[str, Any]) -> None: + """将单条信号写入 sim_vehicle_signals(TimescaleDB)。""" async with self._session_factory() as session: await session.execute( insert(vehicle_signals).values( diff --git a/backend/services/unity_socket_client.py b/backend/services/unity_socket_client.py new file mode 100644 index 0000000..8ac39cc --- /dev/null +++ b/backend/services/unity_socket_client.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Any + + +class UnitySocketClient: + def __init__(self, host: str, port: int) -> None: + self._host = host + self._port = port + self._lock = asyncio.Lock() + self._writer: asyncio.StreamWriter | None = None + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + return self._port + + async def send_json(self, payload: dict[str, Any]) -> None: + data = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") + async with self._lock: + writer = await self._ensure_connected() + try: + writer.write(data) + await writer.drain() + except Exception: + await self._close_writer() + raise + + async def _ensure_connected(self) -> asyncio.StreamWriter: + if self._writer is not None and not self._writer.is_closing(): + return self._writer + reader, writer = await asyncio.open_connection(self._host, self._port) + self._writer = writer + return writer + + async def _close_writer(self) -> None: + if self._writer is None: + return + try: + self._writer.close() + await self._writer.wait_closed() + finally: + self._writer = None diff --git a/backend/services/user_service.py b/backend/services/user_service.py new file mode 100644 index 0000000..0c1c30a --- /dev/null +++ b/backend/services/user_service.py @@ -0,0 +1,169 @@ +"""系统用户服务。 + +围绕 sys_user 表提供用户的增删改查、密码设置(写入哈希)、登录时间维护等能力。 +""" + +from __future__ import annotations + +import secrets +from typing import Any + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.auth.passwords import hash_password +from backend.database.schema import sys_role, sys_user +from backend.utils import utc_now + + +class UserService: + """系统用户管理服务(SQLAlchemy Core)。""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def list_users(self) -> list[dict[str, Any]]: + """查询用户列表(包含角色名称)。""" + async with self._session_factory() as session: + q = ( + select( + sys_user.c.user_id, + sys_user.c.username, + sys_user.c.display_name, + sys_user.c.role_id, + sys_role.c.role_name, + sys_user.c.is_active, + sys_user.c.last_login_at, + sys_user.c.created_at, + sys_user.c.updated_at, + sys_user.c.extra, + ) + .select_from(sys_user.join(sys_role, sys_user.c.role_id == sys_role.c.role_id)) + .order_by(sys_user.c.created_at.desc()) + ) + return [dict(r) for r in (await session.execute(q)).mappings().all()] + + async def get_user(self, user_id: str) -> dict[str, Any] | None: + """按 user_id 查询用户(包含角色名称)。""" + async with self._session_factory() as session: + q = ( + select( + sys_user.c.user_id, + sys_user.c.username, + sys_user.c.display_name, + sys_user.c.role_id, + sys_role.c.role_name, + sys_user.c.is_active, + sys_user.c.last_login_at, + sys_user.c.created_at, + sys_user.c.updated_at, + sys_user.c.extra, + ) + .select_from(sys_user.join(sys_role, sys_user.c.role_id == sys_role.c.role_id)) + .where(sys_user.c.user_id == user_id) + .limit(1) + ) + row = (await session.execute(q)).mappings().first() + return dict(row) if row else None + + async def get_user_by_username(self, username: str) -> dict[str, Any] | None: + """按 username 查询用户(用于登录)。""" + async with self._session_factory() as session: + q = select(sys_user).where(sys_user.c.username == username).limit(1) + row = (await session.execute(q)).mappings().first() + return dict(row) if row else None + + async def create_user( + self, + *, + user_id: str | None, + username: str, + password: str, + role_id: str, + display_name: str | None = None, + is_active: bool = True, + extra: dict | None = None, + ) -> dict[str, Any]: + """创建用户并写入密码哈希。""" + uid = user_id or ("user_" + secrets.token_hex(8)) + password_hash = hash_password(password) + async with self._session_factory() as session: + try: + await session.execute( + sys_user.insert().values( + user_id=uid, + username=username, + display_name=display_name, + password_hash=password_hash, + role_id=role_id, + is_active=is_active, + updated_at=utc_now(), + extra=extra, + ) + ) + await session.commit() + except IntegrityError: + await session.rollback() + raise + created = await self.get_user(uid) + if not created: + raise RuntimeError("failed to create user") + return created + + async def update_user( + self, + user_id: str, + *, + display_name: str | None = None, + role_id: str | None = None, + is_active: bool | None = None, + extra: dict | None = None, + ) -> dict[str, Any] | None: + """更新用户字段(仅更新传入的字段)。""" + patch: dict[str, Any] = {"updated_at": utc_now()} + if display_name is not None: + patch["display_name"] = display_name + if role_id is not None: + patch["role_id"] = role_id + if is_active is not None: + patch["is_active"] = is_active + if extra is not None: + patch["extra"] = extra + async with self._session_factory() as session: + try: + res = await session.execute(update(sys_user).where(sys_user.c.user_id == user_id).values(**patch)) + await session.commit() + except IntegrityError: + await session.rollback() + raise + if res.rowcount == 0: + return None + return await self.get_user(user_id) + + async def disable_user(self, user_id: str) -> bool: + """禁用用户(软删除)。""" + async with self._session_factory() as session: + res = await session.execute( + update(sys_user).where(sys_user.c.user_id == user_id).values(is_active=False, updated_at=utc_now()) + ) + await session.commit() + return bool(res.rowcount) + + async def set_password(self, user_id: str, new_password: str) -> bool: + """设置用户密码(保存为哈希,不存明文)。""" + password_hash = hash_password(new_password) + async with self._session_factory() as session: + res = await session.execute( + update(sys_user).where(sys_user.c.user_id == user_id).values(password_hash=password_hash, updated_at=utc_now()) + ) + await session.commit() + return bool(res.rowcount) + + async def touch_last_login(self, user_id: str) -> None: + """更新用户最近登录时间。""" + async with self._session_factory() as session: + await session.execute( + update(sys_user).where(sys_user.c.user_id == user_id).values(last_login_at=utc_now(), updated_at=utc_now()) + ) + await session.commit() diff --git a/backend/utils.py b/backend/utils.py index 382163a..c199c44 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -1,3 +1,11 @@ +"""后端通用工具函数。 + +该模块放置与业务无关的通用能力: +- UTC 时间获取 +- 日志初始化(控制台 + 可选文件滚动) +- 受限路径拼接(防目录穿越) +""" + from __future__ import annotations import logging @@ -8,10 +16,17 @@ from pathlib import Path def utc_now() -> datetime: + """返回当前 UTC 时间(timezone-aware)。""" return datetime.now(timezone.utc) def configure_logging(level: str, log_file: Path | None = None) -> None: + """配置全局日志。 + + Args: + level: 日志级别字符串(例如 "INFO" / "DEBUG")。 + log_file: 可选的日志文件路径;提供时启用按天滚动。 + """ level_value = getattr(logging, level.upper(), logging.INFO) logging_handlers: list[logging.Handler] = [logging.StreamHandler()] if log_file is not None: @@ -39,10 +54,15 @@ def configure_logging(level: str, log_file: Path | None = None) -> None: def project_root() -> Path: + """返回项目根目录(backend 的上一级)。""" return Path(__file__).resolve().parents[1] def safe_join(root: Path, untrusted_path: str) -> Path: + """将不可信路径拼接到 root 下,并阻止目录穿越/绝对路径/UNC 路径。 + + 主要用于下载/文件访问等接口,避免访问到文件根目录之外。 + """ if untrusted_path.startswith(("\\\\", "//")): raise ValueError("UNC path is not allowed") if os.path.isabs(untrusted_path): diff --git a/tools/docx_to_md.py b/tools/docx_to_md.py new file mode 100644 index 0000000..043b205 --- /dev/null +++ b/tools/docx_to_md.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +"""将 .docx 文档转换为 Markdown。 + +实现思路: +- 使用 mammoth 将 docx 转为 HTML(对 Word 样式有较好兼容) +- 再使用 markdownify 将 HTML 转为 Markdown +- 可选导出文档内图片到 assets 目录,并在 Markdown 中引用相对路径 +""" + +import argparse +import os +from pathlib import Path + + +def main() -> int: + """命令行入口:执行 docx -> md 转换。""" + parser = argparse.ArgumentParser() + parser.add_argument("docx_path") + parser.add_argument("md_path") + parser.add_argument("--assets-dir", default=None) + args = parser.parse_args() + + docx_path = Path(args.docx_path).expanduser().resolve() + md_path = Path(args.md_path).expanduser().resolve() + assets_dir = Path(args.assets_dir).expanduser().resolve() if args.assets_dir else None + + if not docx_path.exists() or not docx_path.is_file(): + raise FileNotFoundError(str(docx_path)) + + import mammoth + from markdownify import markdownify as md + + image_index = 0 + + def _convert_image(image): + """将 docx 内嵌图片写入 assets 目录,并返回 Markdown 可用的相对路径。""" + nonlocal image_index + if assets_dir is None: + # 不导出图片时,返回空 src,避免把图片内容直接内联到 Markdown + return {"src": ""} + assets_dir.mkdir(parents=True, exist_ok=True) + ext = _guess_image_extension(image.content_type) + image_index += 1 + name = f"image_{image_index:03d}{ext}" + target = assets_dir / name + with target.open("wb") as f: + f.write(image.read()) + rel = os.path.relpath(target, md_path.parent) + rel = rel.replace("\\", "/") + return {"src": rel} + + result = mammoth.convert_to_html(docx_path, convert_image=mammoth.images.img_element(_convert_image)) + html = result.value + markdown = md(html, heading_style="ATX", bullets="-") + + md_path.parent.mkdir(parents=True, exist_ok=True) + md_path.write_text(markdown, encoding="utf-8") + return 0 + + +def _guess_image_extension(content_type: str) -> str: + """根据图片的 MIME 类型推断文件扩展名。""" + mapping = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/bmp": ".bmp", + "image/tiff": ".tiff", + "image/webp": ".webp", + "image/svg+xml": ".svg", + } + return mapping.get(content_type, "") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/汽车数字孪生系统_数据交互协议.docx b/汽车数字孪生系统_数据交互协议.docx new file mode 100644 index 0000000000000000000000000000000000000000..0e83379dbf2b585a0e136d4cf4c9ceeaafcb768b GIT binary patch literal 24118 zcmbq)bC4*_vgg>gZQHhO+qP}nwr$%yW83!3nKLtczWesYzI$W$udRrV>dwl_%E(_; zb!BF^f;2D)6u>_vT<%cdpZdQGW@Fwu;PY1GZ^}tu~6l$&^dcRO? zT_-z*iIE?vhX2vGs2;w zG+z{~{Ce7a7F=iHxzmlj%Ru#3sr915N0g#5Un0Ut_M2 zWi3f6fU77eNtD1fjrXS3h@46GHw`B+wA*%(x{w2O5(}(og8^qA`(Dq343pLsk4BJd z6$51vq97oKqJ-%wWmj(8Fd$UUnb>0wLqZWO2g_7kJdz@lqDDuXDC8=&gPzk^@S;z5V;ymDZ$a5puJn0+G+Cal~M1`o-BH=W7Hah?% z!*Ri&N4TZPL;Gs*%I$m*nRCJNl>L-d+3GE!c9d?Da((V)Nc9Sp)(aMPs(;iSxjOr7 zMIqCRKBRyqk?2w!B=mML3>Cmu>z4Vg7kc^<0G9AC_si8N(Y z*v-qyzy*H73L7o0im&pz#&R=M_W8-K=|9%WQPubZ^&?u zIJng~=MWj^{UWx$=u&B}Qc+vo$~OsD-MxDy*5v(PL_rTQtjUd10}s&PbUh!7->4f^ zr#9wpTTACI(bvd+k6ebIY~c2$BU4O6h$L4jg8<;v8DW+%jzNr?h-#(NFWmQI&dh*$ zHKE#H-w%U^Ta}H~mH3;uYjD0TPli5>YUm*1AYdcz?9Y(7qK;vG!!WwI-`PyNL?k6!;&*oDZfY|hfAa^K5#<%Aq$^c>H2B-0REZn|M%?*^sjB*?VU{i z!~9E1OydYJ006?K008j+&Eo9hX=CdAkLg!*rJc7X5c|rhegH?6of1f;noO!?>^D+S zx{10|igI}20}CT)0u1jFX!nw&sLphfsz}aiao8pa%T=g8*sor`qn~*If?YEj z8)+lhAcl^;SUj9v`<^WJO*y53&>}

%EhD3uHnq4Lc$-Am$F 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 aZ-@~@0p<4neuH_(MHsxfpCCH7SN{j8*0-ww literal 0 HcmV?d00001 diff --git a/汽车数字孪生系统_数据交互协议.md b/汽车数字孪生系统_数据交互协议.md new file mode 100644 index 0000000..2f470e7 --- /dev/null +++ b/汽车数字孪生系统_数据交互协议.md @@ -0,0 +1,571 @@ +# 汽车数字孪生系统 + +主控程序和Unity数据交互协议文档 + +# 一、系统交互 + +系统由两台独立主机组成: + +• 主控程序(负责传感器数据采集、系统控制、数据记录存储等) + +• Unity数字孪生(负责三维场景渲染、运动计算、视频录制) + +数据流向: + +| | | | | +| --- | --- | --- | --- | +| **数据类型** | **方向** | **频率** | **说明** | +| InitConfig | 主控 → Unity | 1次/任务 | 初始化车辆、驾驶员、场景配置 | +| Command | 主控 → Unity | 低频 | 开始/停止/暂停等控制指令 | +| DriveData | 主控 → Unity | 50Hz | 传感器数据驱动虚拟车运动 | +| FrameRecord | Unity → 主控 | 50Hz | 车辆位置记录用于回放 | +| Status | Unity → 主控 | 按需 | Unity运行状态反馈 | + +# 二、InitConfig - 初始化配置 + +方向:主控 → Unity + +频率:任务开始时发送 1 次 + +用途:配置车辆信息、驾驶员信息、场景参数、录制设置 + +{ + +"msgType": "init", // 消息类型:初始化配置 + +"timestamp": 1737388800000, // 发送时间戳 + +"session": { // ===== 会话信息 ===== + +"sessionId": "sess\_20250120\_143000\_001", // 会话唯一ID + +"taskId": "task\_brake\_001", // 任务ID + +"taskName": "紧急制动测试", // 任务名称(显示用) + +"createTime": "2025-01-20 14:30:00", // 创建时间(可读格式) + +"syncTimestamp": 1737388800000 // 同步基准时间戳(数据/视频对齐用) + +}, + +"driver": { // ===== 驾驶员信息 ===== + +"driverId": "D20250001", // 驾驶员编号 + +"name": "张三", // 驾驶员姓名 + +"department": "培训一部", // 所属部门 + +"level": "初级学员", // 学员等级/身份 + +"avatar": "" // 头像URL(或预制) + +}, + +"vehicle": { // ===== 车辆配置 ===== + +"vehicleId": "V003", // 车辆编号 + +"model": "改装教学车A型", // 车型名称 + +"plateNo": "沪A·12345", // 车牌号 + +"color": "#FFFFFF", // 车身颜色(十六进制) + +"colorName": "珍珠白" // 颜色名称(显示用) + +}, + +"scene": { // ===== 场景配置 ===== + +"sceneId": "scene\_03", // 场景ID + +"sceneName": "城市道路", // 场景名称 + +"sceneFile": "CityRoad", // Unity场景文件名 + +"weather": "sunny", // 天气:sunny/rain/fog/night + +"spawnPoint": "SpawnPoint\_A", // 出生点预制体名称 + +"hasGpsMapping": true // 该场景是否有GPS坐标映射 + +}, + +"recording": { // ===== 录制设置 ===== + +"enabled": true, // 是否启用录制 + +"recordId": "rec\_20250120\_143000\_001", // 录制ID(与视频文件关联) + +"frameRate": 50, // 数据录制帧率 + +"videoFrameRate": 30, // 视频录制帧率 + +"videoResolution": "1280x720" // 视频分辨率 + +}, + +"vehicleParams": { // ===== 车辆物理参数 ===== + +"wheelRadius": 0.32, // 轮胎半径(米) + +"steeringRatio": 15.5, // 方向盘转向比 + +"wheelbase": 2.68 // 轴距(米) + +} + +} + +# 三、Command - 控制指令 + +方向:主控 → Unity + +频率:低频,人工触发时发送 + +用途:系统控制、模式切换、回放控制、相机和UI控制 + +{ + +"msgType": "command", // 消息类型:控制指令 + +"timestamp": 1737388800000, // 发送时间戳 + +"seqId": 1, // 指令序列号(递增) + +"action": "start", // ===== 主动作 ===== + +// start - 开始任务 + +// stop - 停止任务 + +// pause - 暂停 + +// resume - 恢复 + +// reset - 重置到起点 + +// emergency\_stop - 紧急制动 + +"mode": { // ===== 模式设置(可选)===== + +"type": "realtime", // realtime - 实时驱动模式 + +// playback - 回放模式 + +// standby - 待机模式 + +"playbackId": null // 回放模式时指定录制ID + +}, + +"playback": { // ===== 回放控制(回放模式有效)===== + +"action": "play", // play/pause/seek/setSpeed + +"seekFrame": 0, // 跳转到指定帧号 + +"seekTime": 0.0, // 跳转到指定时间(秒) + +"playSpeed": 1.0 // 播放速度倍率 + +}, + +"camera": { // ===== 相机控制(可选)===== + +"viewMode": "chase", // driver - 驾驶员视角 + +// chase - 追尾视角 + +"fov": 60 // 视野角度 + +}, + +"ui": { // ===== UI控制(暂定,根据UI调整)===== + +"showDashboard": true, // 显示仪表盘 + +"showTelemetry": true, // 显示遥测数据面板 + +"showTrajectory": false, // 显示行驶轨迹线 + +"showMinimap": true // 显示小地图 + +} + +} + +# 四、DriveData - 实时驱动数据 + +方向:主控 → Unity + +频率:50Hz(每 20ms 一帧) + +用途:传感器数据驱动Unity虚拟车辆运动 + +{ + +"msgType": "drive", // 消息类型:驱动数据 + +"ts": 1737388800123, // 时间戳(毫秒) + +"seq": 50001, // 帧序列号(递增,丢帧检测用) + +// ==================== 运动控制核心 ==================== + +"speed": 45.6, // 车速(km/h) + +"steer": 12.5, // 方向盘角度(度)左负右正 + +"wheelRpm": { // 各轮转速(rpm) + +"fl": 650.0, // 左前轮 Front-Left + +"fr": 652.0, // 右前轮 Front-Right + +"rl": 648.0, // 左后轮 Rear-Left + +"rr": 649.0 // 右后轮 Rear-Right + +}, + +// ==================== 踏板状态 ==================== + +"throttle": 0.35, // 油门/电门开度 0.0~1.0 + +"brake": 0.0, // 刹车开度 0.0~1.0 + +"handbrake": 0.0, // 手刹开度 0.0~1.0 + +// ==================== 传动系统 ==================== + +"gear": 3, // 挡位:-1倒挡/0空挡/1~N前进挡 + +"engine": { // 发动机/电机数据(可选) + +"rpm": 2800, // 转速(rpm)(可选) + +"torque": 180.5 // 输出扭矩(N·m)(可选) + +}, + +// ==================== IMU数据(可选)==================== + +"imu": { // 惯性测量单元 + +"ax": 0.15, // X轴加速度(m/s²)纵向 + +"ay": -0.02, // Y轴加速度(m/s²)垂直 + +"az": 0.05, // Z轴加速度(m/s²)横向 + +"gx": 0.01, // X轴角速度(rad/s)俯仰 + +"gy": 0.005, // Y轴角速度(rad/s)横滚 + +"gz": 0.12 // Z轴角速度(rad/s)偏航 + +}, + +// ==================== 车灯状态 ==================== + +"lights": { // 车灯 + +"head": 1, // 大灯:0关/1近光/2远光 + +"turn": 0, // 转向灯:0关/1左/2右/3双闪 + +"brake": false, // 刹车灯 + +"reverse": false, // 倒车灯 + +"fog": false // 雾灯 + +}, + +// ==================== 其他状态 ==================== + +"misc": { // 其他 + +"horn": false, // 喇叭 + +"wiper": 0, // 雨刷:0关/1低速/2高速 + +"seatbelt": true // 安全带是否系好 + +} + +} + +# 五、FrameRecord - 帧记录数据 + +方向:Unity → 主控 + +频率:50Hz(每 20ms 一帧) + +用途:记录Unity计算的车辆绝对位置,用于精确回放 + +{ + +"msgType": "frame", // 消息类型:帧记录 + +"ts": 1737388800123, // 时间戳(毫秒) + +"seq": 50001, // 帧序列号(与DriveData对应) + +"elapsed": 100.02, // 相对任务开始的时间(秒) + +// ==================== 车辆位置(Unity世界坐标)==================== + +"pos": { // 位置 + +"x": 1250.35, // X坐标(米) + +"y": 0.42, // Y坐标/高度(米) + +"z": 3560.78 // Z坐标(米) + +}, + +// ==================== 车辆旋转 ==================== + +"rot": { // 旋转(四元数)程序用 + +"x": 0.0, + +"y": 0.383, + +"z": 0.0, + +"w": 0.924 + +}, + +"euler": { // 旋转(欧拉角,度)便于查看 + +"pitch": 0.5, // 俯仰角(抬头为正) + +"yaw": 45.2, // 偏航角/航向(顺时针为正) + +"roll": -0.1 // 横滚角(右倾为正) + +}, + +// ==================== 速度向量 ==================== + +"vel": { // 速度 + +"x": 32.1, // X方向速度(m/s) + +"y": 0.0, // Y方向速度(m/s) + +"z": 31.8, // Z方向速度(m/s) + +"speed": 45.2 // 合速度(km/h) + +}, + +// ==================== 车轮状态(视觉还原用)==================== + +"wheels": { // 车轮 + +"steerAngle": 12.5, // 前轮实际转向角(度) + +"fl": { // 左前轮 + +"rot": 1250.6, // 累计旋转角度(度) + +"susp": 0.05 // 悬挂压缩量(米) + +}, + +"fr": { // 右前轮 + +"rot": 1252.1, + +"susp": 0.04 + +}, + +"rl": { // 左后轮 + +"rot": 1248.3, + +"susp": 0.03 + +}, + +"rr": { // 右后轮 + +"rot": 1249.0, + +"susp": 0.03 + +} + +}, + +// ==================== 视觉状态 ==================== + +"visual": { // 视觉状态 + +"gear": 3, // 挡位显示 + +"steerWheel": 45.0, // 方向盘角度(内饰视角用) + +"speedometer": 45.6, // 速度表读数 + +"tachometer": 2800, // 转速表读数 + +"lights": { // 车灯状态 + +"head": 1, + +"turn": 0, + +"brake": false, + +"reverse": false + +} + +}, + +// ==================== GPS显示数据(学校场景用) ==================== + +"gps": { // GPS坐标(从Unity坐标换算) + +"valid": true, // 该场景是否有GPS映射 + +"lat": 31.230416, // 纬度 + +"lng": 121.473701, // 经度 + +"alt": 4.5, // 海拔(米) + +"heading": 45.2 // GPS航向(度) + +} + +} + +# 六、Status - Unity状态反馈 + +方向:Unity → 主控 + +频率:状态变化时发送,或主控查询时响应 + +用途:反馈Unity运行状态、性能指标、错误信息 + +{ + +"msgType": "status", // 消息类型:状态反馈 + +"ts": 1737388800000, // 时间戳 + +"state": "running", // ===== Unity当前状态 ===== + +// loading - 加载场景中 + +// ready - 就绪,等待开始 + +// running - 任务运行中 + +// paused - 已暂停 + +// stopped - 已停止 + +// playback - 回放中 + +// error - 出错 + +"scene": { // ===== 场景状态 ===== + +"loaded": true, // 场景是否加载完成 + +"sceneId": "scene\_03", // 当前场景ID + +"spawnPointFound": true // 出生点预制体是否找到 + +}, + +"recording": { // ===== 录制状态 ===== + +"isRecording": true, // 是否正在录制 + +"recordId": "rec\_20250120\_143000\_001", // 当前录制ID + +"frameCount": 15000, // 已录制帧数 + +"duration": 300.0 // 已录制时长(秒) + +}, + +"performance": { // ===== 性能指标 ===== + +"fps": 58, // 当前帧率 + +"renderTime": 12.5, // 渲染耗时(ms) + +"physicsTime": 2.3, // 物理计算耗时(ms) + +"encodeTime": 8.2 // 视频编码耗时(ms) + +}, + +"connection": { // ===== 连接状态 ===== + +"lastDriveDataSeq": 50001, // 最后收到的DriveData序列号 + +"lastDriveDataTime": 1737388800123, // 最后收到DriveData的时间 + +"dataLossCount": 0 // 累计丢帧数 + +}, + +"error": { // ===== 错误信息 ===== + +"code": 0, // 错误码:0表示无错误 + +"message": "" // 错误描述 + +} + +} + +# 状态汇总: + +## 车灯状态 + +| | | | +| --- | --- | --- | +| **字段** | **值** | **说明** | +| head | 0 / 1 / 2 | 大灯:关 / 近光 / 远光 | +| turn | 0 / 1 / 2 / 3 | 转向灯:关 / 左 / 右 / 双闪 | +| wiper | 0 / 1 / 2 | 雨刷:关 / 低速 / 高速 | +| gear | -1 / 0 / 1~N | 挡位:倒挡 / 空挡(N/P) / 前进挡 | + +## Unity状态 + +| | | +| --- | --- | +| **state值** | **说明** | +| loading | 加载场景中 | +| ready | 就绪,等待开始指令 | +| running | 任务运行中(实时模式) | +| paused | 已暂停 | +| stopped | 已停止 | +| playback | 回放模式运行中 | +| error | 发生错误 | + +## 天气 + +| | | +| --- | --- | +| **weather值** | **说明** | +| sunny | 晴天 | +| rain | 雨天 | +| fog | 雾天 | +| night | 夜间 | \ No newline at end of file