新建了数据库,及相关维护操作功能

This commit is contained in:
root 2026-01-25 22:34:33 +08:00
parent aa4b14181c
commit fb51407990
41 changed files with 2420 additions and 47 deletions

View File

@ -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。

View File

@ -1 +1,2 @@
"""SmartEDT 后端包。"""

View File

@ -1 +1,2 @@
"""API 子包HTTP 路由、WebSocket 处理与 Pydantic schema。"""

View File

@ -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

155
backend/api/rbac_routes.py Normal file
View File

@ -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

View File

@ -1,3 +1,12 @@
"""基础业务路由(仿真/设备/文件下载等)。
该文件保留项目早期的示例接口与基础能力
- 健康检查
- 设备连接状态示例
- 启停仿真
- 文件下载带目录穿越保护
"""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@ -11,14 +20,20 @@ from backend.utils import safe_join
def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRouter: def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRouter:
"""构造基础业务路由。
说明此项目采用router 工厂函数风格通过参数注入 service/配置而不是全局依赖容器
"""
router = APIRouter() router = APIRouter()
@router.get("/health", response_model=HealthResponse) @router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse: async def health() -> HealthResponse:
"""健康检查(用于容器编排/负载均衡探活)。"""
return HealthResponse() return HealthResponse()
@router.get("/api/devices") @router.get("/api/devices")
async def devices(): async def devices():
"""返回设备列表(当前为示例数据,反映仿真运行时状态)。"""
runtime = simulation_manager.current() runtime = simulation_manager.current()
return { return {
"data": [ "data": [
@ -32,16 +47,19 @@ def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRou
@router.post("/api/simulation/start", response_model=SimulationStartResponse) @router.post("/api/simulation/start", response_model=SimulationStartResponse)
async def start_simulation(body: SimulationStartRequest) -> SimulationStartResponse: async def start_simulation(body: SimulationStartRequest) -> SimulationStartResponse:
"""启动仿真。"""
simulation_id = await simulation_manager.start(body.model_dump()) simulation_id = await simulation_manager.start(body.model_dump())
return SimulationStartResponse(simulation_id=simulation_id) return SimulationStartResponse(simulation_id=simulation_id)
@router.post("/api/simulation/{simulation_id}/stop", response_model=SimulationStopResponse) @router.post("/api/simulation/{simulation_id}/stop", response_model=SimulationStopResponse)
async def stop_simulation(simulation_id: str) -> SimulationStopResponse: async def stop_simulation(simulation_id: str) -> SimulationStopResponse:
"""停止仿真。"""
await simulation_manager.stop(simulation_id) await simulation_manager.stop(simulation_id)
return SimulationStopResponse(simulation_id=simulation_id, status="stopped") return SimulationStopResponse(simulation_id=simulation_id, status="stopped")
@router.get("/files/{file_path:path}") @router.get("/files/{file_path:path}")
async def files(file_path: str): async def files(file_path: str):
"""下载文件(相对 file_root并校验路径合法性。"""
try: try:
resolved = safe_join(file_root, file_path) resolved = safe_join(file_root, file_path)
except ValueError: except ValueError:
@ -51,4 +69,3 @@ def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRou
return FileResponse(str(resolved)) return FileResponse(str(resolved))
return router return router

View File

@ -1,15 +1,28 @@
"""API 层的请求/响应数据模型Pydantic
该文件集中定义后端 HTTP 接口的入参与返回结构便于
- 校验请求字段长度范围等
- 生成 OpenAPI 文档
- 在路由层与前端之间形成稳定契约
"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class HealthResponse(BaseModel): class HealthResponse(BaseModel):
"""健康检查返回。"""
status: str = "ok" status: str = "ok"
class SimulationStartRequest(BaseModel): class SimulationStartRequest(BaseModel):
"""启动仿真的请求体。"""
scenario: str | None = None scenario: str | None = None
weather: str | None = None weather: str | None = None
time_period: str | None = None time_period: str | None = None
@ -27,3 +40,146 @@ class SimulationStopResponse(BaseModel):
simulation_id: str simulation_id: str
status: 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

View File

@ -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

111
backend/api/user_routes.py Normal file
View File

@ -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

View File

@ -1,3 +1,8 @@
"""WebSocket 路由处理器。
当前实现仅用于维持连接并将连接注册到 Broadcaster便于服务端主动推送消息
"""
from __future__ import annotations from __future__ import annotations
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
@ -6,8 +11,10 @@ from backend.services.broadcaster import Broadcaster
async def websocket_handler(ws: WebSocket, broadcaster: Broadcaster) -> None: async def websocket_handler(ws: WebSocket, broadcaster: Broadcaster) -> None:
"""WebSocket 连接处理:接入、注册、保持心跳、断开清理。"""
await ws.accept() await ws.accept()
await broadcaster.add(ws) await broadcaster.add(ws)
logger.info("WebSocket 连接接入:%s", ws.client)
try: try:
while True: while True:
await ws.receive_text() await ws.receive_text()
@ -16,4 +23,4 @@ async def websocket_handler(ws: WebSocket, broadcaster: Broadcaster) -> None:
pass pass
finally: finally:
await broadcaster.remove(ws) await broadcaster.remove(ws)
logger.info("WebSocket 连接断开:%s", ws.client)

3
backend/auth/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""认证子包密码哈希、token 签发与 FastAPI 依赖。"""
from __future__ import annotations

56
backend/auth/deps.py Normal file
View File

@ -0,0 +1,56 @@
"""FastAPI 鉴权相关依赖Depends
当前提供 get_current_user(session_factory) Authorization: Bearer <token> 解析并加载用户信息
"""
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

52
backend/auth/passwords.py Normal file
View File

@ -0,0 +1,52 @@
"""密码哈希与校验。
当前实现使用 PBKDF2-HMAC-SHA256内置 hashlib以避免引入额外依赖
存储格式
pbkdf2_sha256$<iterations>$<salt_b64url>$<dk_b64url>
"""
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"))

99
backend/auth/tokens.py Normal file
View File

@ -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"))

View File

@ -1 +1,2 @@
"""后端配置子包。"""

View File

@ -10,3 +10,6 @@ path = data
url = postgresql+psycopg://smartedt:postgres@127.0.0.1:5432/smartedt url = postgresql+psycopg://smartedt:postgres@127.0.0.1:5432/smartedt
timescaledb = True timescaledb = True
[UNITY]
host = 127.0.0.1
port = 6000

View File

@ -1,3 +1,12 @@
"""配置加载与设置模型。
优先级 ->
1. 环境变量 SMARTEDT_CONFIG 指定的配置文件
2. 在若干候选位置寻找 config.ini兼容 PyInstaller 打包运行态
3. 环境变量的 fallback
4. 内置默认值
"""
from __future__ import annotations from __future__ import annotations
import configparser import configparser
@ -8,6 +17,8 @@ from pathlib import Path
@dataclass(frozen=True) @dataclass(frozen=True)
class ServerSettings: class ServerSettings:
"""服务监听配置。"""
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 5000 port: int = 5000
debug: bool = False debug: bool = False
@ -15,25 +26,39 @@ class ServerSettings:
@dataclass(frozen=True) @dataclass(frozen=True)
class FileSettings: class FileSettings:
"""文件存储相关配置。"""
root_path: Path root_path: Path
@dataclass(frozen=True) @dataclass(frozen=True)
class DatabaseSettings: class DatabaseSettings:
"""数据库连接相关配置。"""
url: str url: str
timescaledb: bool = True timescaledb: bool = True
@dataclass(frozen=True)
class UnitySettings:
host: str = "127.0.0.1"
port: int = 6000
@dataclass(frozen=True) @dataclass(frozen=True)
class AppSettings: class AppSettings:
"""应用聚合配置。"""
server: ServerSettings server: ServerSettings
files: FileSettings files: FileSettings
database: DatabaseSettings database: DatabaseSettings
unity: UnitySettings
import sys import sys
def _find_config_file() -> Path | None: def _find_config_file() -> Path | None:
"""尝试从若干候选位置定位 config.ini包含 PyInstaller 运行态)。"""
# Handle PyInstaller frozen state # Handle PyInstaller frozen state
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# If onefile, _MEIPASS. If onedir, executable dir or _internal # If onefile, _MEIPASS. If onedir, executable dir or _internal
@ -63,6 +88,7 @@ def _find_config_file() -> Path | None:
def load_settings() -> AppSettings: def load_settings() -> AppSettings:
"""加载并返回应用配置。"""
config = configparser.ConfigParser() config = configparser.ConfigParser()
config_path = os.getenv("SMARTEDT_CONFIG") config_path = os.getenv("SMARTEDT_CONFIG")
if config_path: if config_path:
@ -93,9 +119,14 @@ def load_settings() -> AppSettings:
fallback=os.getenv("SMARTEDT_TIMESCALEDB", "True").lower() == "true", 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( return AppSettings(
server=server, server=server,
files=FileSettings(root_path=root_path), files=FileSettings(root_path=root_path),
database=DatabaseSettings(url=database_url, timescaledb=timescaledb), database=DatabaseSettings(url=database_url, timescaledb=timescaledb),
unity=unity,
) )

View File

@ -1 +1 @@
"""数据库子包schema 定义与 DB 工具脚本。"""

View File

@ -1,7 +1,17 @@
"""数据库连通性检查脚本。
用途
- 快速验证 PostgreSQL 是否可连接
- 尝试加载 TimescaleDB 扩展并输出版本信息若可用
注意脚本只做连通性验证不会打印密码
"""
import os import os
import sys import sys
def check_database(): def check_database():
"""检查数据库连接与 TimescaleDB 扩展可用性。"""
print("正在检查数据库连接...") print("正在检查数据库连接...")
# 连接参数:通过环境变量覆盖(不在输出中打印密码) # 连接参数:通过环境变量覆盖(不在输出中打印密码)
@ -57,6 +67,7 @@ def check_database():
return False return False
if __name__ == "__main__": if __name__ == "__main__":
"""作为脚本运行时的入口。"""
try: try:
import psycopg # noqa: F401 import psycopg # noqa: F401
except ImportError: except ImportError:

View File

@ -1,3 +1,8 @@
"""数据库引擎与 Session 工厂。
集中封装 SQLAlchemy async engine async_sessionmaker 的创建逻辑便于在主程序中统一注入
"""
from __future__ import annotations from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine 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: def create_engine(settings: DatabaseSettings) -> AsyncEngine:
"""根据配置创建 AsyncEngine。"""
return create_async_engine( return create_async_engine(
settings.url, settings.url,
pool_pre_ping=True, pool_pre_ping=True,
@ -14,5 +20,5 @@ def create_engine(settings: DatabaseSettings) -> AsyncEngine:
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""创建异步 Session 工厂expire_on_commit=False 便于返回已提交对象)。"""
return async_sessionmaker(engine, expire_on_commit=False) return async_sessionmaker(engine, expire_on_commit=False)

View File

@ -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())

View File

@ -1,47 +1,79 @@
"""数据库 Schema 定义。
说明
- ORM 模型用于结构相对稳定需要 ORM 能力的表例如 Simulation
- Core Table用于时序/大数据量写入或更灵活的 SQL 操作例如 vehicle_signalsserver_metricsRBAC 表等
"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime 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.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""SQLAlchemy Declarative Base。"""
pass pass
class Simulation(Base): class SimulationScene(Base):
__tablename__ = "simulations" """仿真场景配置(非时序数据)。"""
__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") 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") 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="仿真场景名称") status: Mapped[str] = mapped_column(String(32), index=True, comment="仿真任务状态wait/running/stopped/archived 等)")
scenario_config: Mapped[dict] = mapped_column(JSON, default=dict, comment="仿真场景配置JSON") operator: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True, comment="仿真操作员")
config_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="配置创建时间UTC") """以下为开始时发送给Unity程序的连接及初始化配置"""
operator: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True, comment="仿真操作员") unity_host: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="Unity Socket 主机")
archived: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否归档") 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 原始内容主控→UnityJSONB")
init_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="InitConfig 发送时间UTC")
vehicle_signals = Table( vehicle_signals = Table(
"sim_vehicle_signals", "sim_vehicle_signals",
Base.metadata, Base.metadata,
Column("ts", DateTime(timezone=True), nullable=False, index=True, comment="信号采样时间UTC"), 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="仿真任务 IDsim_tasks.task_id"),
Column("device_id", String(64), nullable=False, index=True, comment="设备 ID"), Column("vehicle_id", String(64), nullable=False, index=True, comment="实物车辆ID默认值为'0'"),
Column("seq", BigInteger, nullable=False, comment="信号序列号(单仿真内递增)"), Column("seq", BigInteger, nullable=False, comment="信号序列号(单仿真内递增)"),
Column("signals", JSONB, nullable=False, comment="车辆信号载荷JSONB"), Column("signals", JSONB, nullable=False, comment="车辆信号载荷JSONB"),
Index("idx_vehicle_signals_sim_ts", "simulation_id", "ts"), Index("idx_vehicle_signals_sim_ts", "simulation_id", "ts"),
comment="车辆信号时序数据TimescaleDB hypertable", comment="车辆信号时序数据TimescaleDB hypertable",
) )
unity_vehicle_frames = Table( unity_frames = Table(
"sim_unity_vehicle_frames", "sim_unity_frames",
Base.metadata, Base.metadata,
Column("ts", DateTime(timezone=True), nullable=False, index=True, comment="帧时间UTC"), 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="仿真任务 IDsim_tasks.task_id"),
Column("vehicle_id", String(64), nullable=False, index=True, comment="虚拟车辆 ID"), Column("vehicle_id", String(64), nullable=False, index=True, comment="虚拟车辆 ID"),
Column("seq", BigInteger, nullable=False, comment="帧序号(单仿真单车内递增)"), Column("seq", BigInteger, nullable=False, comment="帧序号(单仿真单车内递增)"),
Column("pos_x", Float, nullable=False, comment="位置 X世界坐标"), Column("pos_x", Float, nullable=False, comment="位置 X世界坐标"),
@ -67,7 +99,7 @@ screen_recordings = Table(
"sim_screen_videos", "sim_screen_videos",
Base.metadata, Base.metadata,
Column("video_id", String(64), primary_key=True, comment="录制文件记录 ID"), 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="仿真任务 IDsim_tasks.task_id"),
Column("screen_type", String(32), nullable=False, index=True, comment="屏幕类型big_screen/vehicle_screen 等)"), 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("source_name", String(64), nullable=True, index=True, comment="录制源名称(可选,如设备号/通道号)"),
Column("status", String(32), nullable=False, index=True, comment="状态recording/ready/failed 等)"), 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: async def init_schema(engine) -> None:
"""初始化数据库表结构与必要的兼容性变更。
该函数会
- create_all创建 Base.metadata 里声明的表
- 插入默认角色若不存在
- 对历史表做列/索引补齐兼容升级
"""
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
if not isinstance(engine, AsyncEngine): if not isinstance(engine, AsyncEngine):
raise TypeError("engine must be AsyncEngine") raise TypeError("engine must be AsyncEngine")
async with engine.begin() as conn: 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.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(
await conn.execute(text("ALTER TABLE simulations ADD COLUMN IF NOT EXISTS config_created_at TIMESTAMPTZ")) text(
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)")) INSERT INTO sys_role (role_id, role_name, role_desc, is_active)
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_config_created_at ON simulations (config_created_at)")) VALUES
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_operator ON simulations (operator)")) ('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: async def init_timescaledb(engine) -> None:
"""初始化 TimescaleDB 扩展与 hypertable/索引(若启用)。"""
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb")) await conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb"))
await conn.execute( await conn.execute(
@ -214,11 +284,11 @@ async def init_timescaledb(engine) -> None:
) )
await conn.execute( await conn.execute(
text( 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( await conn.execute(
text( 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)"
) )
) )

View File

@ -1,3 +1,13 @@
"""数据库性能与功能测试脚本(开发/压测用途)。
该脚本会
- 初始化 schema TimescaleDB若可用
- 批量写入模拟车辆信号JSONB
- 运行几类常见查询并输出耗时
注意该脚本会写入大量数据请不要在生产库中执行
"""
import asyncio import asyncio
import os import os
import time import time
@ -7,11 +17,12 @@ from datetime import datetime, timezone
from sqlalchemy import insert, select, text from sqlalchemy import insert, select, text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.engine.url import make_url 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 from backend.config.settings import load_settings
# 模拟数据生成 # 模拟数据生成
def generate_payload(): def generate_payload():
"""生成一条模拟车辆信号负载(用于写入 JSONB"""
return { return {
"steering_wheel_angle_deg": round(random.uniform(-450, 450), 1), "steering_wheel_angle_deg": round(random.uniform(-450, 450), 1),
"brake_pedal_travel_mm": round(random.uniform(0, 100), 1), "brake_pedal_travel_mm": round(random.uniform(0, 100), 1),
@ -38,6 +49,7 @@ def generate_payload():
} }
def _redact_url(url: str) -> str: def _redact_url(url: str) -> str:
"""隐藏数据库 URL 中的密码,避免误打印敏感信息。"""
try: try:
parsed = make_url(url) parsed = make_url(url)
if parsed.password: if parsed.password:
@ -47,6 +59,7 @@ def _redact_url(url: str) -> str:
return url return url
async def run_test(): async def run_test():
"""执行写入/查询性能测试。"""
settings = load_settings() settings = load_settings()
db_url = os.getenv("SMARTEDT_TEST_DATABASE_URL", settings.database.url).strip() db_url = os.getenv("SMARTEDT_TEST_DATABASE_URL", settings.database.url).strip()

View File

@ -1 +1 @@
"""设备子包:设备抽象与具体实现。"""

View File

@ -1,3 +1,8 @@
"""设备抽象层。
用于统一不同设备真实硬件仿真设备Mock的连接与状态查询接口
"""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@ -5,21 +10,27 @@ from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class DeviceInfo: class DeviceInfo:
"""设备信息快照(可用于 API 输出)。"""
device_id: str device_id: str
device_type: str device_type: str
connected: bool connected: bool
class DeviceAdapter: class DeviceAdapter:
"""设备适配器接口(异步)。"""
device_id: str device_id: str
device_type: str device_type: str
async def connect(self) -> None: async def connect(self) -> None:
"""建立与设备的连接。"""
raise NotImplementedError raise NotImplementedError
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""断开与设备的连接。"""
raise NotImplementedError raise NotImplementedError
async def is_connected(self) -> bool: async def is_connected(self) -> bool:
"""返回当前连接状态。"""
raise NotImplementedError raise NotImplementedError

View File

@ -1,3 +1,8 @@
"""Mock 车辆设备实现。
用于在没有真实硬件接入时生成可用于联调的车辆信号数据
"""
from __future__ import annotations from __future__ import annotations
import random import random
@ -8,6 +13,8 @@ from backend.device.base import DeviceAdapter
@dataclass(frozen=True) @dataclass(frozen=True)
class VehicleSignalPayload: class VehicleSignalPayload:
"""一帧车辆信号载荷(用于广播/落库)。"""
steering_wheel_angle_deg: float steering_wheel_angle_deg: float
brake_pedal_travel_mm: float brake_pedal_travel_mm: float
throttle_pedal_travel_mm: float throttle_pedal_travel_mm: float
@ -22,6 +29,7 @@ class VehicleSignalPayload:
temperature_c: float temperature_c: float
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""转为可 JSON 序列化的 dict。"""
return { return {
"steering_wheel_angle_deg": self.steering_wheel_angle_deg, "steering_wheel_angle_deg": self.steering_wheel_angle_deg,
"brake_pedal_travel_mm": self.brake_pedal_travel_mm, "brake_pedal_travel_mm": self.brake_pedal_travel_mm,
@ -39,21 +47,27 @@ class VehicleSignalPayload:
class MockVehicleDevice(DeviceAdapter): class MockVehicleDevice(DeviceAdapter):
"""模拟车辆设备:提供 connect/disconnect/is_connected 与 sample()。"""
def __init__(self, device_id: str = "controlbox_01") -> None: def __init__(self, device_id: str = "controlbox_01") -> None:
self.device_id = device_id self.device_id = device_id
self.device_type = "mock_vehicle" self.device_type = "mock_vehicle"
self._connected = False self._connected = False
async def connect(self) -> None: async def connect(self) -> None:
"""模拟建立连接。"""
self._connected = True self._connected = True
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""模拟断开连接。"""
self._connected = False self._connected = False
async def is_connected(self) -> bool: async def is_connected(self) -> bool:
"""返回当前连接状态。"""
return self._connected return self._connected
def sample(self) -> VehicleSignalPayload: def sample(self) -> VehicleSignalPayload:
"""采样生成一帧模拟信号。"""
steering = random.uniform(-180.0, 180.0) steering = random.uniform(-180.0, 180.0)
brake = max(0.0, random.gauss(2.0, 1.0)) brake = max(0.0, random.gauss(2.0, 1.0))
throttle = max(0.0, random.gauss(15.0, 5.0)) throttle = max(0.0, random.gauss(15.0, 5.0))
@ -86,4 +100,3 @@ class MockVehicleDevice(DeviceAdapter):
current_a=current, current_a=current,
temperature_c=temp, temperature_c=temp,
) )

View File

@ -1,3 +1,12 @@
"""SmartEDT 后端服务入口。
主要职责
- 加载配置与初始化日志
- 初始化数据库 schema/TimescaleDB
- 构造核心服务仿真监控鉴权/RBAC
- 挂载 HTTP/WebSocket 路由并启动 uvicorn
"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@ -35,19 +44,22 @@ from backend.database.schema import init_schema, init_timescaledb
from backend.services.broadcaster import Broadcaster from backend.services.broadcaster import Broadcaster
from backend.services.simulation_manager import SimulationManager from backend.services.simulation_manager import SimulationManager
from backend.services.server_monitor import ServerMonitorService from backend.services.server_monitor import ServerMonitorService
from backend.services.unity_socket_client import UnitySocketClient
from backend.device.mock_vehicle import MockVehicleDevice 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 from backend.utils import configure_logging
logger = logging.getLogger("backend") logger = logging.getLogger("backend")
def _default_backend_log_file() -> Path | None: def _default_backend_log_file() -> Path | None:
"""在打包运行态下返回默认日志文件路径;开发态返回 None。"""
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
exe_dir = Path(sys.executable).resolve().parent exe_dir = Path(sys.executable).resolve().parent
return exe_dir / "logs" / "backend.log" return exe_dir / "logs" / "backend.log"
return None return None
def _force_windows_selector_event_loop_for_uvicorn() -> None: def _force_windows_selector_event_loop_for_uvicorn() -> None:
"""避免 uvicorn 在 Windows 上切换到 ProactorEventLoop与 psycopg async 不兼容)。"""
if platform.system() != "Windows": if platform.system() != "Windows":
return return
try: try:
@ -62,6 +74,8 @@ def _force_windows_selector_event_loop_for_uvicorn() -> None:
# 全局单例容器(简单实现) # 全局单例容器(简单实现)
class Container: class Container:
"""简易全局容器集中创建与持有配置、DB 引擎、session 工厂与各服务单例。"""
def __init__(self): def __init__(self):
load_dotenv() load_dotenv()
self.settings = load_settings() self.settings = load_settings()
@ -76,11 +90,13 @@ class Container:
self.engine = create_engine(self.settings.database) self.engine = create_engine(self.settings.database)
self.session_factory = create_session_factory(self.engine) self.session_factory = create_session_factory(self.engine)
self.broadcaster = Broadcaster() self.broadcaster = Broadcaster()
self.unity_client = UnitySocketClient(self.settings.unity.host, self.settings.unity.port)
# 实例化服务 # 实例化服务
self.simulation_manager = SimulationManager( self.simulation_manager = SimulationManager(
self.session_factory, self.session_factory,
self.broadcaster self.broadcaster,
unity_client=self.unity_client,
) )
# 实例化监控服务 # 实例化监控服务
@ -93,6 +109,7 @@ container = Container()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""FastAPI 生命周期:启动初始化与停机清理。"""
# 启动前初始化 # 启动前初始化
await init_schema(container.engine) await init_schema(container.engine)
if container.settings.database.timescaledb: 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 = 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(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") @app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket): async def ws_endpoint(websocket: WebSocket):
@ -125,6 +146,7 @@ async def ws_endpoint(websocket: WebSocket):
def main() -> None: def main() -> None:
"""命令行入口:解析参数并启动 uvicorn。"""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--host", default=None) parser.add_argument("--host", default=None)
parser.add_argument("--port", type=int, default=None) parser.add_argument("--port", type=int, default=None)

View File

@ -1 +1 @@
"""后端业务服务子包。"""

View File

@ -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

View File

@ -1,3 +1,8 @@
"""WebSocket 广播器。
维护当前在线的 WebSocket 连接集合并支持向所有连接广播 JSON 消息
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -7,19 +12,27 @@ from starlette.websockets import WebSocket
class Broadcaster: class Broadcaster:
"""简单的 WebSocket 广播器(线程安全:使用 asyncio.Lock"""
def __init__(self) -> None: def __init__(self) -> None:
self._clients: set[WebSocket] = set() self._clients: set[WebSocket] = set()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def add(self, ws: WebSocket) -> None: async def add(self, ws: WebSocket) -> None:
"""注册连接。"""
async with self._lock: async with self._lock:
self._clients.add(ws) self._clients.add(ws)
async def remove(self, ws: WebSocket) -> None: async def remove(self, ws: WebSocket) -> None:
"""移除连接(若不存在则忽略)。"""
async with self._lock: async with self._lock:
self._clients.discard(ws) self._clients.discard(ws)
async def broadcast_json(self, message: dict[str, Any]) -> None: async def broadcast_json(self, message: dict[str, Any]) -> None:
"""向所有连接广播 JSON。
若某个连接发送失败会被自动移除避免集合泄漏
"""
async with self._lock: async with self._lock:
clients = list(self._clients) clients = list(self._clients)
for ws in clients: for ws in clients:
@ -27,4 +40,3 @@ class Broadcaster:
await ws.send_json(message) await ws.send_json(message)
except Exception: except Exception:
await self.remove(ws) await self.remove(ws)

View File

@ -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)

View File

@ -1,3 +1,10 @@
"""服务器监控采集服务。
以较高频率采样系统指标CPU/内存并以较低频率进行下采样后
- 通过 WebSocket 广播给前端
- 写入 TimescaleDBserver_metrics hypertable
"""
import asyncio import asyncio
import time import time
import logging import logging
@ -14,6 +21,8 @@ from backend.services.broadcaster import Broadcaster
logger = logging.getLogger("backend.monitor") logger = logging.getLogger("backend.monitor")
class ServerMonitorService: class ServerMonitorService:
"""服务器资源监控服务(采样 + 下采样 + 广播 + 落库)。"""
def __init__(self, session_factory: async_sessionmaker, broadcaster: Broadcaster): def __init__(self, session_factory: async_sessionmaker, broadcaster: Broadcaster):
self._session_factory = session_factory self._session_factory = session_factory
self._broadcaster = broadcaster self._broadcaster = broadcaster
@ -29,6 +38,7 @@ class ServerMonitorService:
self._buffer_mem = [] self._buffer_mem = []
async def start(self): async def start(self):
"""启动监控循环(幂等)。"""
if self._running: if self._running:
return return
self._running = True self._running = True
@ -36,6 +46,7 @@ class ServerMonitorService:
logger.info("ServerMonitorService started") logger.info("ServerMonitorService started")
async def stop(self): async def stop(self):
"""停止监控循环并等待任务结束。"""
self._running = False self._running = False
if self._task: if self._task:
try: try:
@ -45,6 +56,7 @@ class ServerMonitorService:
logger.info("ServerMonitorService stopped") logger.info("ServerMonitorService stopped")
async def _run_loop(self): async def _run_loop(self):
"""采样循环50Hz 采样10Hz 报告。"""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
next_time = loop.time() next_time = loop.time()
@ -76,6 +88,7 @@ class ServerMonitorService:
await asyncio.sleep(0) await asyncio.sleep(0)
async def _process_and_report(self): async def _process_and_report(self):
"""对采样缓冲区做下采样,并完成广播与落库。"""
if not self._buffer_cpu: if not self._buffer_cpu:
return return

View File

@ -1,3 +1,12 @@
"""仿真管理服务。
负责
- 仿真生命周期start/stop
- 设备接入目前为 MockVehicleDevice
- 信号采样与广播WebSocket
- 信号落库TimescaleDB hypertable
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -9,9 +18,10 @@ from typing import Any
from sqlalchemy import insert from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 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.device.mock_vehicle import MockVehicleDevice
from backend.services.broadcaster import Broadcaster from backend.services.broadcaster import Broadcaster
from backend.services.unity_socket_client import UnitySocketClient
from backend.utils import utc_now from backend.utils import utc_now
@ -20,51 +30,158 @@ logger = logging.getLogger("backend.simulation")
@dataclass @dataclass
class SimulationRuntime: class SimulationRuntime:
"""运行中的仿真信息(内存态)。"""
simulation_id: str simulation_id: str
status: str status: str
task: asyncio.Task | None = None task: asyncio.Task | None = None
class SimulationManager: class SimulationManager:
"""仿真生命周期管理器。"""
def __init__( def __init__(
self, self,
session_factory: async_sessionmaker[AsyncSession], session_factory: async_sessionmaker[AsyncSession],
broadcaster: Broadcaster, broadcaster: Broadcaster,
unity_client: UnitySocketClient | None = None,
) -> None: ) -> None:
self._session_factory = session_factory self._session_factory = session_factory
self._broadcaster = broadcaster self._broadcaster = broadcaster
self._unity_client = unity_client
self._runtime: SimulationRuntime | None = None self._runtime: SimulationRuntime | None = None
self._device = MockVehicleDevice() self._device = MockVehicleDevice()
self._seq = 0 self._seq = 0
self._command_seq = 0
def current(self) -> SimulationRuntime | None: def current(self) -> SimulationRuntime | None:
"""返回当前运行中的仿真(若无则为 None"""
return self._runtime return self._runtime
async def register_device(self, device: MockVehicleDevice) -> None: async def register_device(self, device: MockVehicleDevice) -> None:
"""注册仿真设备实现(用于采样)。"""
self._device = device 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: async def start(self, scenario_config: dict[str, Any]) -> str:
"""启动仿真并返回 simulation_id。
说明如果已有仿真在运行会直接返回当前 simulation_id幂等
"""
if self._runtime and self._runtime.status == "running": if self._runtime and self._runtime.status == "running":
return self._runtime.simulation_id return self._runtime.simulation_id
simulation_id = "SIM" + utc_now().strftime("%Y%m%d%H%M%S") + secrets.token_hex(2).upper() simulation_id = "SIM" + utc_now().strftime("%Y%m%d%H%M%S") + secrets.token_hex(2).upper()
started_at = utc_now() 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") operator = scenario_config.get("driver") or scenario_config.get("operator")
config_created_at = started_at config_created_at = started_at
async with self._session_factory() as session: async with self._session_factory() as session:
session.add( session.add(
Simulation( SimulationTask(
simulation_id=simulation_id, task_id=simulation_id,
status="running", task_name=task_name,
scene_id=None,
scene_name=None,
scene_config=scenario_config,
config_created_at=config_created_at,
started_at=started_at, started_at=started_at,
ended_at=None, ended_at=None,
scenario_name=scenario_name, status="running",
scenario_config=scenario_config,
config_created_at=config_created_at,
operator=operator, 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() await session.commit()
@ -75,9 +192,12 @@ class SimulationManager:
await self._broadcaster.broadcast_json( await self._broadcaster.broadcast_json(
{"type": "simulation.status", "ts": started_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "running"}} {"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 return simulation_id
async def stop(self, simulation_id: str) -> None: async def stop(self, simulation_id: str) -> None:
"""停止仿真(若 simulation_id 不匹配当前运行实例则忽略)。"""
runtime = self._runtime runtime = self._runtime
if not runtime or runtime.simulation_id != simulation_id: if not runtime or runtime.simulation_id != simulation_id:
return return
@ -94,7 +214,7 @@ class SimulationManager:
ended_at = utc_now() ended_at = utc_now()
async with self._session_factory() as session: async with self._session_factory() as session:
sim = await session.get(Simulation, simulation_id) sim = await session.get(SimulationTask, simulation_id)
if sim: if sim:
sim.status = "stopped" sim.status = "stopped"
sim.ended_at = ended_at sim.ended_at = ended_at
@ -103,9 +223,12 @@ class SimulationManager:
await self._broadcaster.broadcast_json( await self._broadcaster.broadcast_json(
{"type": "simulation.status", "ts": ended_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "stopped"}} {"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 self._runtime = None
async def _run_loop(self, simulation_id: str) -> None: async def _run_loop(self, simulation_id: str) -> None:
"""仿真运行循环:采样设备信号、广播并写入数据库。"""
try: try:
while True: while True:
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
@ -131,6 +254,7 @@ class SimulationManager:
logger.exception("simulation loop crashed") logger.exception("simulation loop crashed")
async def _persist_signal(self, ts, simulation_id: str, device_id: str, seq: int, signals: dict[str, Any]) -> None: async def _persist_signal(self, ts, simulation_id: str, device_id: str, seq: int, signals: dict[str, Any]) -> None:
"""将单条信号写入 sim_vehicle_signalsTimescaleDB"""
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( await session.execute(
insert(vehicle_signals).values( insert(vehicle_signals).values(

View File

@ -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

View File

@ -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()

View File

@ -1,3 +1,11 @@
"""后端通用工具函数。
该模块放置与业务无关的通用能力
- UTC 时间获取
- 日志初始化控制台 + 可选文件滚动
- 受限路径拼接防目录穿越
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -8,10 +16,17 @@ from pathlib import Path
def utc_now() -> datetime: def utc_now() -> datetime:
"""返回当前 UTC 时间timezone-aware"""
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def configure_logging(level: str, log_file: Path | None = None) -> None: 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) level_value = getattr(logging, level.upper(), logging.INFO)
logging_handlers: list[logging.Handler] = [logging.StreamHandler()] logging_handlers: list[logging.Handler] = [logging.StreamHandler()]
if log_file is not None: 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: def project_root() -> Path:
"""返回项目根目录backend 的上一级)。"""
return Path(__file__).resolve().parents[1] return Path(__file__).resolve().parents[1]
def safe_join(root: Path, untrusted_path: str) -> Path: def safe_join(root: Path, untrusted_path: str) -> Path:
"""将不可信路径拼接到 root 下,并阻止目录穿越/绝对路径/UNC 路径。
主要用于下载/文件访问等接口避免访问到文件根目录之外
"""
if untrusted_path.startswith(("\\\\", "//")): if untrusted_path.startswith(("\\\\", "//")):
raise ValueError("UNC path is not allowed") raise ValueError("UNC path is not allowed")
if os.path.isabs(untrusted_path): if os.path.isabs(untrusted_path):

78
tools/docx_to_md.py Normal file
View File

@ -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())

Binary file not shown.

View File

@ -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 | 夜间 |