新建了数据库,及相关维护操作功能
This commit is contained in:
parent
aa4b14181c
commit
fb51407990
84
.trae/skills/py-zh-commenter/SKILL.md
Normal file
84
.trae/skills/py-zh-commenter/SKILL.md
Normal 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。
|
||||||
|
|
||||||
@ -1 +1,2 @@
|
|||||||
|
"""SmartEDT 后端包。"""
|
||||||
|
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
|
"""API 子包:HTTP 路由、WebSocket 处理与 Pydantic schema。"""
|
||||||
|
|
||||||
|
|||||||
81
backend/api/auth_routes.py
Normal file
81
backend/api/auth_routes.py
Normal 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
155
backend/api/rbac_routes.py
Normal 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
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
35
backend/api/unity_routes.py
Normal file
35
backend/api/unity_routes.py
Normal 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
111
backend/api/user_routes.py
Normal 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
|
||||||
@ -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
3
backend/auth/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""认证子包:密码哈希、token 签发与 FastAPI 依赖。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
56
backend/auth/deps.py
Normal file
56
backend/auth/deps.py
Normal 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
52
backend/auth/passwords.py
Normal 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
99
backend/auth/tokens.py
Normal 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"))
|
||||||
@ -1 +1,2 @@
|
|||||||
|
"""后端配置子包。"""
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
|
"""数据库子包:schema 定义与 DB 工具脚本。"""
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
68
backend/database/init_db.py
Normal file
68
backend/database/init_db.py
Normal 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())
|
||||||
@ -1,47 +1,79 @@
|
|||||||
|
"""数据库 Schema 定义。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- ORM 模型:用于结构相对稳定、需要 ORM 能力的表(例如 Simulation)
|
||||||
|
- Core Table:用于时序/大数据量写入或更灵活的 SQL 操作(例如 vehicle_signals、server_metrics、RBAC 表等)
|
||||||
|
"""
|
||||||
|
|
||||||
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 原始内容(主控→Unity,JSONB)")
|
||||||
|
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="仿真任务 ID(sim_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="仿真任务 ID(sim_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="仿真任务 ID(sim_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)"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
|
"""设备子包:设备抽象与具体实现。"""
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
|
"""后端业务服务子包。"""
|
||||||
|
|||||||
45
backend/services/auth_service.py
Normal file
45
backend/services/auth_service.py
Normal 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
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
186
backend/services/rbac_service.py
Normal file
186
backend/services/rbac_service.py
Normal 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)
|
||||||
@ -1,3 +1,10 @@
|
|||||||
|
"""服务器监控采集服务。
|
||||||
|
|
||||||
|
以较高频率采样系统指标(CPU/内存),并以较低频率进行下采样后:
|
||||||
|
- 通过 WebSocket 广播给前端
|
||||||
|
- 写入 TimescaleDB(server_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
|
||||||
|
|
||||||
|
|||||||
@ -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_signals(TimescaleDB)。"""
|
||||||
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(
|
||||||
|
|||||||
48
backend/services/unity_socket_client.py
Normal file
48
backend/services/unity_socket_client.py
Normal 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
|
||||||
169
backend/services/user_service.py
Normal file
169
backend/services/user_service.py
Normal 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()
|
||||||
@ -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
78
tools/docx_to_md.py
Normal 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())
|
||||||
BIN
汽车数字孪生系统_数据交互协议.docx
Normal file
BIN
汽车数字孪生系统_数据交互协议.docx
Normal file
Binary file not shown.
571
汽车数字孪生系统_数据交互协议.md
Normal file
571
汽车数字孪生系统_数据交互协议.md
Normal 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 | 夜间 |
|
||||||
Loading…
Reference in New Issue
Block a user