提交 794730a0 authored 作者: 陈泽健's avatar 陈泽健

feat(device-sim): 设备模拟多主题配置 + 执行引擎死锁修复

新增设备模拟多主题配置(前后端),支持按设备类型独立配置主题路径;
修复执行引擎三项死锁:专用 threading.Thread 替代 run_in_executor、--loop asyncio 禁用 uvloop、headless 默认值取自环境变量、paho-mqtt 可选导入
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 60b0212e
...@@ -129,6 +129,8 @@ async def _ensure_columns(conn) -> None: ...@@ -129,6 +129,8 @@ async def _ensure_columns(conn) -> None:
("modules", "parent_id", "VARCHAR(32) DEFAULT NULL"), ("modules", "parent_id", "VARCHAR(32) DEFAULT NULL"),
("test_cases", "case_type", "VARCHAR(20) DEFAULT 'ui'"), ("test_cases", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
("executions", "case_type", "VARCHAR(20) DEFAULT 'ui'"), ("executions", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 设备模拟:环境配置多主题字段(旧库升级)
("device_env_configs", "topics", "JSON"),
] ]
def _do_ensure(sync_conn) -> None: def _do_ensure(sync_conn) -> None:
......
...@@ -145,7 +145,9 @@ class PlaywrightExecutor: ...@@ -145,7 +145,9 @@ class PlaywrightExecutor:
self.config = config or {} self.config = config or {}
self.timeout: int = self.config.get("timeout", 30000) self.timeout: int = self.config.get("timeout", 30000)
self.screenshot_enabled: bool = self.config.get("screenshot", True) self.screenshot_enabled: bool = self.config.get("screenshot", True)
self.headless: bool = self.config.get("headless", False) # headless 默认值取自全局配置 settings.PLAYWRIGHT_HEADLESS(由环境变量 PLAYWRIGHT_HEADLESS 控制)。
# 容器内无 X Server,必须 headless=True;原默认 False(headed)会导致容器内启动崩溃。
self.headless: bool = self.config.get("headless", settings.PLAYWRIGHT_HEADLESS)
self.screenshot_dir: str = self.config.get( self.screenshot_dir: str = self.config.get(
"screenshot_dir", settings.SCREENSHOT_DIR "screenshot_dir", settings.SCREENSHOT_DIR
) )
......
...@@ -48,7 +48,8 @@ class EnvConfig(Base): ...@@ -48,7 +48,8 @@ class EnvConfig(Base):
broker_port: Mapped[int] = mapped_column(Integer, default=1883, comment="端口号") broker_port: Mapped[int] = mapped_column(Integer, default=1883, comment="端口号")
username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="MQTT 用户名") username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="MQTT 用户名")
password_encrypted: Mapped[Optional[str]] = mapped_column(String(500), nullable=True, comment="密码(加密存储)") password_encrypted: Mapped[Optional[str]] = mapped_column(String(500), nullable=True, comment="密码(加密存储)")
topic_prefix: Mapped[str] = mapped_column(String(200), default="device", comment="主题前缀") topic_prefix: Mapped[str] = mapped_column(String(200), default="device", comment="主题前缀(已弃用,建议使用 topics)")
topics: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="多主题前缀映射,如 {'door': 'device/door', 'paperless': 'device/paperless'}")
use_tls: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否启用 TLS") use_tls: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否启用 TLS")
client_id_prefix: Mapped[str] = mapped_column(String(100), default="sim", comment="客户端 ID 前缀") client_id_prefix: Mapped[str] = mapped_column(String(100), default="sim", comment="客户端 ID 前缀")
status: Mapped[str] = mapped_column(String(20), default="disconnected", comment="连接状态") status: Mapped[str] = mapped_column(String(20), default="disconnected", comment="连接状态")
......
...@@ -327,6 +327,7 @@ async def manual_report( ...@@ -327,6 +327,7 @@ async def manual_report(
@router.get("/report-logs", response_model=ReportLogListResponse) @router.get("/report-logs", response_model=ReportLogListResponse)
async def list_report_logs( async def list_report_logs(
device_id: Optional[str] = Query(None, description="设备 ID 筛选"), device_id: Optional[str] = Query(None, description="设备 ID 筛选"),
direction: Optional[str] = Query(None, description="方向筛选:publish(上报)/subscribe(接收)"),
skip: int = Query(0, ge=0, description="跳过的记录数"), skip: int = Query(0, ge=0, description="跳过的记录数"),
limit: int = Query(100, ge=1, le=500, description="返回记录数上限"), limit: int = Query(100, ge=1, le=500, description="返回记录数上限"),
start_time: Optional[datetime] = Query(None, description="开始时间"), start_time: Optional[datetime] = Query(None, description="开始时间"),
...@@ -336,11 +337,12 @@ async def list_report_logs( ...@@ -336,11 +337,12 @@ async def list_report_logs(
""" """
获取上报记录列表 获取上报记录列表
支持按设备、时间范围筛选和分页。 支持按设备、方向、时间范围筛选和分页。
""" """
try: try:
items, total = await service.list_report_logs( items, total = await service.list_report_logs(
device_id=device_id, device_id=device_id,
direction=direction,
skip=skip, skip=skip,
limit=limit, limit=limit,
start_time=start_time, start_time=start_time,
......
...@@ -10,10 +10,16 @@ ...@@ -10,10 +10,16 @@
""" """
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List, Dict
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
def to_camel(string: str) -> str:
"""Convert snake_case to camelCase"""
components = string.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
# ==================== 环境配置 Schema ==================== # ==================== 环境配置 Schema ====================
class EnvConfigBase(BaseModel): class EnvConfigBase(BaseModel):
...@@ -24,10 +30,16 @@ class EnvConfigBase(BaseModel): ...@@ -24,10 +30,16 @@ class EnvConfigBase(BaseModel):
broker_port: int = Field(default=1883, ge=1, le=65535, description="端口号") broker_port: int = Field(default=1883, ge=1, le=65535, description="端口号")
username: Optional[str] = Field(default=None, max_length=100, description="MQTT 用户名") username: Optional[str] = Field(default=None, max_length=100, description="MQTT 用户名")
password: Optional[str] = Field(default=None, max_length=500, description="MQTT 密码") password: Optional[str] = Field(default=None, max_length=500, description="MQTT 密码")
topic_prefix: str = Field(default="device", max_length=200, description="主题前缀") topic_prefix: str = Field(default="device", max_length=200, description="主题前缀(已弃用,建议使用 topics)")
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射,key为设备类型,value为主题前缀")
use_tls: bool = Field(default=False, description="是否启用 TLS") use_tls: bool = Field(default=False, description="是否启用 TLS")
client_id_prefix: str = Field(default="sim", max_length=100, description="客户端 ID 前缀") client_id_prefix: str = Field(default="sim", max_length=100, description="客户端 ID 前缀")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class EnvConfigCreate(EnvConfigBase): class EnvConfigCreate(EnvConfigBase):
"""创建环境配置请求""" """创建环境配置请求"""
...@@ -42,10 +54,16 @@ class EnvConfigUpdate(BaseModel): ...@@ -42,10 +54,16 @@ class EnvConfigUpdate(BaseModel):
broker_port: Optional[int] = Field(default=None, ge=1, le=65535, description="端口号") broker_port: Optional[int] = Field(default=None, ge=1, le=65535, description="端口号")
username: Optional[str] = Field(default=None, max_length=100, description="MQTT 用户名") username: Optional[str] = Field(default=None, max_length=100, description="MQTT 用户名")
password: Optional[str] = Field(default=None, max_length=500, description="MQTT 密码") password: Optional[str] = Field(default=None, max_length=500, description="MQTT 密码")
topic_prefix: Optional[str] = Field(default=None, max_length=200, description="主题前缀") topic_prefix: Optional[str] = Field(default=None, max_length=200, description="主题前缀(已弃用,建议使用 topics)")
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射")
use_tls: Optional[bool] = Field(default=None, description="是否启用 TLS") use_tls: Optional[bool] = Field(default=None, description="是否启用 TLS")
client_id_prefix: Optional[str] = Field(default=None, max_length=100, description="客户端 ID 前缀") client_id_prefix: Optional[str] = Field(default=None, max_length=100, description="客户端 ID 前缀")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class EnvConfigResponse(EnvConfigBase): class EnvConfigResponse(EnvConfigBase):
"""环境配置响应""" """环境配置响应"""
...@@ -56,7 +74,11 @@ class EnvConfigResponse(EnvConfigBase): ...@@ -56,7 +74,11 @@ class EnvConfigResponse(EnvConfigBase):
created_at: datetime = Field(..., description="创建时间") created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间") updated_at: datetime = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)
class EnvConfigListResponse(BaseModel): class EnvConfigListResponse(BaseModel):
...@@ -78,6 +100,11 @@ class SimulatorBase(BaseModel): ...@@ -78,6 +100,11 @@ class SimulatorBase(BaseModel):
auto_reconnect: bool = Field(default=True, description="是否自动重连") auto_reconnect: bool = Field(default=True, description="是否自动重连")
metadata: Optional[dict] = Field(default=None, description="设备自定义属性") metadata: Optional[dict] = Field(default=None, description="设备自定义属性")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class SimulatorCreate(SimulatorBase): class SimulatorCreate(SimulatorBase):
"""创建模拟设备请求""" """创建模拟设备请求"""
...@@ -91,6 +118,11 @@ class SimulatorUpdate(BaseModel): ...@@ -91,6 +118,11 @@ class SimulatorUpdate(BaseModel):
auto_reconnect: Optional[bool] = Field(default=None, description="是否自动重连") auto_reconnect: Optional[bool] = Field(default=None, description="是否自动重连")
metadata: Optional[dict] = Field(default=None, description="设备自定义属性") metadata: Optional[dict] = Field(default=None, description="设备自定义属性")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class SimulatorResponse(SimulatorBase): class SimulatorResponse(SimulatorBase):
"""模拟设备响应""" """模拟设备响应"""
...@@ -102,7 +134,11 @@ class SimulatorResponse(SimulatorBase): ...@@ -102,7 +134,11 @@ class SimulatorResponse(SimulatorBase):
created_at: datetime = Field(..., description="创建时间") created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间") updated_at: datetime = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)
class SimulatorListResponse(BaseModel): class SimulatorListResponse(BaseModel):
...@@ -126,7 +162,11 @@ class ReportLogResponse(BaseModel): ...@@ -126,7 +162,11 @@ class ReportLogResponse(BaseModel):
error_message: Optional[str] = Field(default=None, description="错误信息") error_message: Optional[str] = Field(default=None, description="错误信息")
created_at: datetime = Field(..., description="创建时间") created_at: datetime = Field(..., description="创建时间")
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)
class ReportLogListResponse(BaseModel): class ReportLogListResponse(BaseModel):
......
...@@ -136,6 +136,16 @@ class DeviceSimService: ...@@ -136,6 +136,16 @@ class DeviceSimService:
Returns: Returns:
EnvConfig: 创建的配置对象 EnvConfig: 创建的配置对象
""" """
# 自动生成默认多主题映射(如果未传入 topics)
topics = data.topics
if not topics:
topics = {
"door": f"{data.topic_prefix}/door",
"paperless": f"{data.topic_prefix}/paperless",
"central": f"{data.topic_prefix}/central",
"client": f"{data.topic_prefix}/client",
}
config = EnvConfig( config = EnvConfig(
id=generate_id("env"), id=generate_id("env"),
name=data.name, name=data.name,
...@@ -143,6 +153,7 @@ class DeviceSimService: ...@@ -143,6 +153,7 @@ class DeviceSimService:
broker_port=data.broker_port, broker_port=data.broker_port,
username=data.username, username=data.username,
topic_prefix=data.topic_prefix, topic_prefix=data.topic_prefix,
topics=topics,
use_tls=data.use_tls, use_tls=data.use_tls,
client_id_prefix=data.client_id_prefix, client_id_prefix=data.client_id_prefix,
status="disconnected", status="disconnected",
...@@ -175,6 +186,15 @@ class DeviceSimService: ...@@ -175,6 +186,15 @@ class DeviceSimService:
if update_data["password"]: if update_data["password"]:
config.password_encrypted = _encrypt_password(update_data["password"]) config.password_encrypted = _encrypt_password(update_data["password"])
del update_data["password"] del update_data["password"]
# 如果 topics 有更新,同步更新 topic_prefix(向后兼容)
if "topics" in update_data and update_data["topics"]:
# 取第一个设备类型的前缀作为 topic_prefix 的兼容值
first_type = next(iter(update_data["topics"].values()), "")
if first_type:
# 提取公共前缀部分(如 "device/door" -> "device")
parts = first_type.split("/")
if len(parts) >= 1:
config.topic_prefix = parts[0]
for key, value in update_data.items(): for key, value in update_data.items():
setattr(config, key, value) setattr(config, key, value)
...@@ -458,12 +478,24 @@ class DeviceSimService: ...@@ -458,12 +478,24 @@ class DeviceSimService:
config.status = "connected" config.status = "connected"
config.last_connected_at = datetime.now() config.last_connected_at = datetime.now()
# 创建并启动模拟器 # 创建并启动模拟器,传递多主题配置
# 兼容旧数据:topics 为空时,从 topic_prefix 自动生成默认映射
topics = config.topics
if not topics:
prefix = config.topic_prefix or "device"
topics = {
"door": f"{prefix}/door",
"paperless": f"{prefix}/paperless",
"central": f"{prefix}/central",
"client": f"{prefix}/client",
}
sim = create_simulator( sim = create_simulator(
device_type=simulator.device_type, device_type=simulator.device_type,
device_id=simulator.device_id, device_id=simulator.device_id,
env_config_id=config.id, env_config_id=config.id,
mqtt_manager=mqtt_manager, mqtt_manager=mqtt_manager,
topics=topics,
) )
if not sim: if not sim:
raise ValueError(f"不支持的设备类型: {simulator.device_type}") raise ValueError(f"不支持的设备类型: {simulator.device_type}")
...@@ -582,6 +614,7 @@ class DeviceSimService: ...@@ -582,6 +614,7 @@ class DeviceSimService:
async def list_report_logs( async def list_report_logs(
self, self,
device_id: Optional[str] = None, device_id: Optional[str] = None,
direction: Optional[str] = None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
start_time: Optional[datetime] = None, start_time: Optional[datetime] = None,
...@@ -592,6 +625,7 @@ class DeviceSimService: ...@@ -592,6 +625,7 @@ class DeviceSimService:
Args: Args:
device_id: 设备 ID 筛选 device_id: 设备 ID 筛选
direction: 方向筛选 (publish/subscribe)
skip: 跳过的记录数 skip: 跳过的记录数
limit: 返回记录数上限 limit: 返回记录数上限
start_time: 开始时间 start_time: 开始时间
...@@ -603,6 +637,8 @@ class DeviceSimService: ...@@ -603,6 +637,8 @@ class DeviceSimService:
conditions = [] conditions = []
if device_id: if device_id:
conditions.append(ReportLog.device_id == device_id) conditions.append(ReportLog.device_id == device_id)
if direction:
conditions.append(ReportLog.direction == direction)
if start_time: if start_time:
conditions.append(ReportLog.created_at >= start_time) conditions.append(ReportLog.created_at >= start_time)
if end_time: if end_time:
......
...@@ -15,6 +15,7 @@ import subprocess ...@@ -15,6 +15,7 @@ import subprocess
import sys import sys
import os import os
import json import json
import threading
from pathlib import Path from pathlib import Path
from typing import Optional, List, Tuple, Dict, Any, Callable from typing import Optional, List, Tuple, Dict, Any, Callable
from datetime import datetime from datetime import datetime
...@@ -329,15 +330,36 @@ class ExecutionService: ...@@ -329,15 +330,36 @@ class ExecutionService:
"case_dict": case.to_dict(), "case_dict": case.to_dict(),
}) })
# 在线程池中批量执行(传入事件循环和广播函数以接通 WebSocket 步骤回调) # 用专用线程执行 Playwright,避免 asyncio 线程池复用线程导致 greenlet 死锁。
all_results = await loop.run_in_executor( # 根因:sync_playwright().start() 在 ThreadPoolExecutor 复用的 worker 线程上
None, # 会永久卡死(容器内已多次复现:仅打印「开始执行」,永不打印「执行器启动成功」)。
run_all_cases_sync, # 专用线程不关联任何 asyncio 事件循环,sync API 可正常启动;
# 步骤回调已通过 asyncio.run_coroutine_threadsafe(..., ev_loop) 跨线程回桥。
exec_holder = {} # {"data": [...]} 或 {"err": Exception}
def _playwright_worker():
try:
exec_holder["data"] = run_all_cases_sync(
[c["case_dict"] for c in cases_to_run], [c["case_dict"] for c in cases_to_run],
config, config,
loop, loop,
manager.broadcast, manager.broadcast,
) )
except Exception as e: # noqa: BLE001 - 线程内异常需透传到主协程
exec_holder["err"] = e
worker_thread = threading.Thread(
target=_playwright_worker,
name=f"playwright-exec-{execution_id}",
daemon=True,
)
worker_thread.start()
# 主协程挂起等待专用线程结束(run_in_executor 仅用于 join,不执行 Playwright)
await loop.run_in_executor(None, worker_thread.join)
if "err" in exec_holder:
raise exec_holder["err"]
all_results = exec_holder.get("data", [])
# 更新结果 # 更新结果
for i, case_info in enumerate(cases_to_run): for i, case_info in enumerate(cases_to_run):
......
...@@ -9,14 +9,24 @@ ...@@ -9,14 +9,24 @@
最后修改:2026-07-29 最后修改:2026-07-29
""" """
from __future__ import annotations
import json import json
import logging import logging
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Callable, Optional, Dict from typing import Callable, Optional, Dict, Any
import paho.mqtt.client as mqtt # paho-mqtt 为可选依赖:设备模拟(device_sim)模块未启用 / 未联调时,
# Broker 不配置,paho 可能未安装。此处降级为可选导入,避免阻塞整个应用启动。
# 仅在真正调用 MQTT 功能时(连接/发布)才会因 mqtt 为 None 抛错。
try:
import paho.mqtt.client as mqtt
PAHO_AVAILABLE: bool = True
except ImportError: # pragma: no cover - 依赖未安装时的优雅降级
mqtt = None # type: ignore[assignment]
PAHO_AVAILABLE = False
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
......
...@@ -37,7 +37,8 @@ DEFAULT_INTERVAL_MAP = { ...@@ -37,7 +37,8 @@ DEFAULT_INTERVAL_MAP = {
def create_simulator(device_type: str, device_id: str, def create_simulator(device_type: str, device_id: str,
env_config_id: str, mqtt_manager: MqttManager, env_config_id: str, mqtt_manager: MqttManager,
report_interval: Optional[int] = None) -> Optional[BaseSimulator]: report_interval: Optional[int] = None,
topics: Optional[dict] = None) -> Optional[BaseSimulator]:
""" """
模拟器工厂方法 模拟器工厂方法
...@@ -49,6 +50,7 @@ def create_simulator(device_type: str, device_id: str, ...@@ -49,6 +50,7 @@ def create_simulator(device_type: str, device_id: str,
env_config_id: 环境配置 ID env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器实例 mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒),None 使用默认值 report_interval: 上报间隔(秒),None 使用默认值
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
Returns: Returns:
BaseSimulator: 模拟器实例,如果设备类型不支持则返回 None BaseSimulator: 模拟器实例,如果设备类型不支持则返回 None
...@@ -64,7 +66,8 @@ def create_simulator(device_type: str, device_id: str, ...@@ -64,7 +66,8 @@ def create_simulator(device_type: str, device_id: str,
device_id=device_id, device_id=device_id,
env_config_id=env_config_id, env_config_id=env_config_id,
mqtt_manager=mqtt_manager, mqtt_manager=mqtt_manager,
report_interval=report_interval report_interval=report_interval,
topics=topics or {}
) )
......
...@@ -42,7 +42,8 @@ class BaseSimulator(ABC): ...@@ -42,7 +42,8 @@ class BaseSimulator(ABC):
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
device_type: str, mqtt_manager: MqttManager, device_type: str, mqtt_manager: MqttManager,
report_interval: int = 30): report_interval: int = 30,
topics: Optional[dict] = None):
""" """
初始化模拟设备 初始化模拟设备
...@@ -52,6 +53,7 @@ class BaseSimulator(ABC): ...@@ -52,6 +53,7 @@ class BaseSimulator(ABC):
device_type: 设备类型 device_type: 设备类型
mqtt_manager: MQTT 管理器实例 mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒) report_interval: 上报间隔(秒)
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
""" """
self.device_id = device_id self.device_id = device_id
self.env_config_id = env_config_id self.env_config_id = env_config_id
...@@ -62,16 +64,20 @@ class BaseSimulator(ABC): ...@@ -62,16 +64,20 @@ class BaseSimulator(ABC):
self._report_interval = report_interval self._report_interval = report_interval
self._on_report_callback = None self._on_report_callback = None
self._lock = threading.Lock() self._lock = threading.Lock()
self.topics = topics or {}
@abstractmethod
def get_topic_prefix(self) -> str: def get_topic_prefix(self) -> str:
""" """
获取设备主题前缀 获取设备主题前缀
优先使用环境配置中的多主题映射(topics),如果没有配置则使用默认值。
Returns: Returns:
str: 主题前缀,如 "device/door" str: 主题前缀,如 "device/door"
""" """
pass if self.topics and self.device_type in self.topics:
return self.topics[self.device_type]
return f"device/{self.device_type}"
@abstractmethod @abstractmethod
def build_register_payload(self) -> dict: def build_register_payload(self) -> dict:
......
...@@ -97,8 +97,10 @@ class CentralSimulator(BaseSimulator): ...@@ -97,8 +97,10 @@ class CentralSimulator(BaseSimulator):
} }
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30): mqtt_manager: MqttManager, report_interval: int = 30,
super().__init__(device_id, env_config_id, "central", mqtt_manager, report_interval) topics: Optional[dict] = None):
super().__init__(device_id, env_config_id, "central", mqtt_manager,
report_interval, topics)
self._sub_devices = {} self._sub_devices = {}
self._current_scene = "power_off" self._current_scene = "power_off"
self._init_sub_devices() self._init_sub_devices()
...@@ -108,10 +110,6 @@ class CentralSimulator(BaseSimulator): ...@@ -108,10 +110,6 @@ class CentralSimulator(BaseSimulator):
for dev_id, config in self._DEFAULT_SUB_DEVICES.items(): for dev_id, config in self._DEFAULT_SUB_DEVICES.items():
self._sub_devices[dev_id] = dict(config) self._sub_devices[dev_id] = dict(config)
def get_topic_prefix(self) -> str:
"""获取中控设备主题前缀"""
return "device/central"
def build_register_payload(self) -> dict: def build_register_payload(self) -> dict:
"""构建设备注册消息""" """构建设备注册消息"""
return { return {
...@@ -162,6 +160,15 @@ class CentralSimulator(BaseSimulator): ...@@ -162,6 +160,15 @@ class CentralSimulator(BaseSimulator):
command = payload.get("command", "") command = payload.get("command", "")
logger.info(f"中控设备收到指令: device_id={self.device_id}, command={command}") logger.info(f"中控设备收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response" response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "control_sub_device": if command == "control_sub_device":
......
...@@ -42,7 +42,8 @@ class ClientSimulator(BaseSimulator): ...@@ -42,7 +42,8 @@ class ClientSimulator(BaseSimulator):
_VERSIONS = ["V1.2.0", "V1.3.0", "V1.4.0", "V2.0.0", "V2.1.0"] _VERSIONS = ["V1.2.0", "V1.3.0", "V1.4.0", "V2.0.0", "V2.1.0"]
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 5): mqtt_manager: MqttManager, report_interval: int = 5,
topics: Optional[dict] = None):
""" """
初始化集控客户端模拟器 初始化集控客户端模拟器
...@@ -51,17 +52,15 @@ class ClientSimulator(BaseSimulator): ...@@ -51,17 +52,15 @@ class ClientSimulator(BaseSimulator):
env_config_id: 环境配置 ID env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器 mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒),集控客户端心跳频率更高 report_interval: 上报间隔(秒),集控客户端心跳频率更高
topics: 多主题前缀映射
""" """
super().__init__(device_id, env_config_id, "client", mqtt_manager, report_interval) super().__init__(device_id, env_config_id, "client", mqtt_manager,
report_interval, topics)
self._version = random.choice(self._VERSIONS) self._version = random.choice(self._VERSIONS)
self._heartbeat_count = 0 self._heartbeat_count = 0
self._log_level = "info" self._log_level = "info"
self._firmware_updating = False self._firmware_updating = False
def get_topic_prefix(self) -> str:
"""获取集控客户端主题前缀"""
return "device/client"
def build_register_payload(self) -> dict: def build_register_payload(self) -> dict:
"""构建设备注册消息""" """构建设备注册消息"""
return { return {
...@@ -112,6 +111,15 @@ class ClientSimulator(BaseSimulator): ...@@ -112,6 +111,15 @@ class ClientSimulator(BaseSimulator):
command = payload.get("command", "") command = payload.get("command", "")
logger.info(f"集控客户端收到指令: device_id={self.device_id}, command={command}") logger.info(f"集控客户端收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response" response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "remote_restart": if command == "remote_restart":
......
...@@ -38,7 +38,8 @@ class DoorSimulator(BaseSimulator): ...@@ -38,7 +38,8 @@ class DoorSimulator(BaseSimulator):
""" """
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30): mqtt_manager: MqttManager, report_interval: int = 30,
topics: Optional[dict] = None):
""" """
初始化门口屏模拟器 初始化门口屏模拟器
...@@ -47,16 +48,14 @@ class DoorSimulator(BaseSimulator): ...@@ -47,16 +48,14 @@ class DoorSimulator(BaseSimulator):
env_config_id: 环境配置 ID env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器 mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒) report_interval: 上报间隔(秒)
topics: 多主题前缀映射
""" """
super().__init__(device_id, env_config_id, "door", mqtt_manager, report_interval) super().__init__(device_id, env_config_id, "door", mqtt_manager,
report_interval, topics)
self._door_status = "closed" self._door_status = "closed"
self._battery_level = random.randint(60, 100) self._battery_level = random.randint(60, 100)
self._call_active = False self._call_active = False
def get_topic_prefix(self) -> str:
"""获取门口屏主题前缀"""
return "device/door"
def build_register_payload(self) -> dict: def build_register_payload(self) -> dict:
"""构建设备注册消息""" """构建设备注册消息"""
return { return {
...@@ -101,7 +100,15 @@ class DoorSimulator(BaseSimulator): ...@@ -101,7 +100,15 @@ class DoorSimulator(BaseSimulator):
command = payload.get("command", "") command = payload.get("command", "")
logger.info(f"门口屏收到指令: device_id={self.device_id}, command={command}") logger.info(f"门口屏收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command" command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response" response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "unlock": if command == "unlock":
......
...@@ -52,18 +52,16 @@ class PaperlessSimulator(BaseSimulator): ...@@ -52,18 +52,16 @@ class PaperlessSimulator(BaseSimulator):
] ]
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30): mqtt_manager: MqttManager, report_interval: int = 30,
super().__init__(device_id, env_config_id, "paperless", mqtt_manager, report_interval) topics: Optional[dict] = None):
super().__init__(device_id, env_config_id, "paperless", mqtt_manager,
report_interval, topics)
self._meeting_status = "idle" self._meeting_status = "idle"
self._signed_in = False self._signed_in = False
self._file_sync_progress = 0 self._file_sync_progress = 0
self._screen_sharing = False self._screen_sharing = False
self._current_agenda = "" self._current_agenda = ""
def get_topic_prefix(self) -> str:
"""获取无纸化设备主题前缀"""
return "device/paperless"
def build_register_payload(self) -> dict: def build_register_payload(self) -> dict:
"""构建设备注册消息""" """构建设备注册消息"""
return { return {
...@@ -106,6 +104,15 @@ class PaperlessSimulator(BaseSimulator): ...@@ -106,6 +104,15 @@ class PaperlessSimulator(BaseSimulator):
command = payload.get("command", "") command = payload.get("command", "")
logger.info(f"无纸化设备收到指令: device_id={self.device_id}, command={command}") logger.info(f"无纸化设备收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response" response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "start_meeting": if command == "start_meeting":
......
...@@ -34,4 +34,7 @@ RUN mkdir -p /app/data/screenshots /app/data/reports \ ...@@ -34,4 +34,7 @@ RUN mkdir -p /app/data/screenshots /app/data/reports \
EXPOSE 80 EXPOSE 80
# 启动 uvicorn,监听 80 端口(同时提供 API + 前端静态文件) # 启动 uvicorn,监听 80 端口(同时提供 API + 前端静态文件)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"] # --loop asyncio:禁用 uvloop。根因——uvloop(libuv) 与 Playwright sync API 的
\ No newline at end of file # greenlet 线程桥接不兼容,在工作线程调用 sync_playwright().start() 会永久死锁
# (容器内多次复现)。强制默认 asyncio 事件循环即可正常执行用例。
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--loop", "asyncio"]
\ No newline at end of file
...@@ -40,6 +40,9 @@ services: ...@@ -40,6 +40,9 @@ services:
dockerfile: ../deploy/Dockerfile dockerfile: ../deploy/Dockerfile
container_name: plat-auto-test-app container_name: plat-auto-test-app
restart: unless-stopped restart: unless-stopped
# 强制 asyncio 事件循环(禁用 uvloop),修复 Playwright sync API 死锁。
# 覆盖镜像 CMD,无需重建镜像即可生效。详见 Dockerfile 注释。
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--loop", "asyncio"]
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
......
...@@ -107,6 +107,7 @@ export function manualReport(id: string, data: ManualReportRequest): Promise<{ m ...@@ -107,6 +107,7 @@ export function manualReport(id: string, data: ManualReportRequest): Promise<{ m
/** 获取上报记录列表 */ /** 获取上报记录列表 */
export function listReportLogs(params?: { export function listReportLogs(params?: {
deviceId?: string deviceId?: string
direction?: string
skip?: number skip?: number
limit?: number limit?: number
startTime?: string startTime?: string
......
...@@ -11,6 +11,17 @@ ...@@ -11,6 +11,17 @@
<div class="log-header"> <div class="log-header">
<h4>上报记录</h4> <h4>上报记录</h4>
<div class="log-actions"> <div class="log-actions">
<el-select
v-model="directionFilter"
placeholder="方向"
size="small"
style="width: 100px"
@change="onFilterChange"
>
<el-option label="全部" value="" />
<el-option label="上报" value="publish" />
<el-option label="接收" value="subscribe" />
</el-select>
<el-date-picker <el-date-picker
v-model="timeRange" v-model="timeRange"
type="datetimerange" type="datetimerange"
...@@ -95,6 +106,7 @@ const total = ref(0) ...@@ -95,6 +106,7 @@ const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = 20 const pageSize = 20
const timeRange = ref<any>(null) const timeRange = ref<any>(null)
const directionFilter = ref('')
const cleaning = ref(false) const cleaning = ref(false)
/** 加载上报记录 */ /** 加载上报记录 */
...@@ -106,6 +118,7 @@ async function loadLogs() { ...@@ -106,6 +118,7 @@ async function loadLogs() {
limit: pageSize, limit: pageSize,
} }
if (props.deviceId) params.deviceId = props.deviceId if (props.deviceId) params.deviceId = props.deviceId
if (directionFilter.value) params.direction = directionFilter.value
if (timeRange.value) { if (timeRange.value) {
params.startTime = timeRange.value[0] params.startTime = timeRange.value[0]
params.endTime = timeRange.value[1] params.endTime = timeRange.value[1]
...@@ -121,6 +134,12 @@ async function loadLogs() { ...@@ -121,6 +134,12 @@ async function loadLogs() {
} }
} }
/** 方向筛选变更:回到第 1 页并重新加载 */
function onFilterChange() {
currentPage.value = 1
loadLogs()
}
/** 清理旧记录 */ /** 清理旧记录 */
async function handleCleanup() { async function handleCleanup() {
cleaning.value = true cleaning.value = true
......
...@@ -33,6 +33,7 @@ export interface EnvConfig { ...@@ -33,6 +33,7 @@ export interface EnvConfig {
username: string | null username: string | null
password: string | null password: string | null
topicPrefix: string topicPrefix: string
topics: Record<string, string> | null
useTls: boolean useTls: boolean
clientIdPrefix: string clientIdPrefix: string
status: string status: string
...@@ -49,6 +50,7 @@ export interface EnvConfigCreate { ...@@ -49,6 +50,7 @@ export interface EnvConfigCreate {
username?: string | null username?: string | null
password?: string | null password?: string | null
topicPrefix?: string topicPrefix?: string
topics?: Record<string, string> | null
useTls?: boolean useTls?: boolean
clientIdPrefix?: string clientIdPrefix?: string
} }
...@@ -61,6 +63,7 @@ export interface EnvConfigUpdate { ...@@ -61,6 +63,7 @@ export interface EnvConfigUpdate {
username?: string | null username?: string | null
password?: string | null password?: string | null
topicPrefix?: string topicPrefix?: string
topics?: Record<string, string> | null
useTls?: boolean useTls?: boolean
clientIdPrefix?: string clientIdPrefix?: string
} }
......
<!-- <!--
组件名称:EnvConfig.vue 组件名称:EnvConfig.vue
组件描述:环境配置管理页面 - MQTT Broker 连接配置 CRUD 组件描述:环境配置管理页面 - MQTT Broker 连接配置 CRUD,支持多主题配置
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -28,7 +28,22 @@ ...@@ -28,7 +28,22 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="topicPrefix" label="主题前缀" min-width="140" /> <el-table-column label="主题配置" min-width="220">
<template #default="{ row }">
<div class="topic-tags">
<el-tag
v-for="(prefix, type) in normalizeTopics(row)"
:key="type"
size="small"
type="info"
class="topic-tag"
>
{{ deviceTypeLabel(type as string) }}: {{ prefix }}
</el-tag>
<span v-if="!hasTopics(row)" class="topic-empty">{{ row.topicPrefix || 'device' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="最后连接" width="170"> <el-table-column label="最后连接" width="170">
<template #default="{ row }"> <template #default="{ row }">
{{ row.lastConnectedAt ? formatTime(row.lastConnectedAt) : '-' }} {{ row.lastConnectedAt ? formatTime(row.lastConnectedAt) : '-' }}
...@@ -49,7 +64,7 @@ ...@@ -49,7 +64,7 @@
</el-card> </el-card>
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="600px"> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="640px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="top"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="top">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
...@@ -58,8 +73,8 @@ ...@@ -58,8 +73,8 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="主题前缀" prop="topicPrefix"> <el-form-item label="主题基础前缀" prop="topicPrefix">
<el-input v-model="form.topicPrefix" placeholder="如:device" /> <el-input v-model="form.topicPrefix" placeholder="如:device" @input="onBasePrefixChange" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
...@@ -87,7 +102,27 @@ ...@@ -87,7 +102,27 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="20">
<!-- 多主题配置 -->
<el-divider content-position="left">主题配置(按设备类型)</el-divider>
<div class="topics-editor">
<div class="topic-item" v-for="dt in deviceTypes" :key="dt">
<span class="topic-label">{{ deviceTypeLabel(dt) }}</span>
<el-input
v-model="topicValues[dt]"
:placeholder="`${form.topicPrefix || 'device'}/${dt}`"
size="default"
class="topic-input"
>
<template #append>/{{ dt }}</template>
</el-input>
</div>
<el-tag type="info" size="small" effect="plain" class="topic-tip">
修改基础前缀可批量重置各设备主题;也可单独自定义每个设备类型的主题路径
</el-tag>
</div>
<el-row :gutter="20" style="margin-top: 12px">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="客户端 ID 前缀"> <el-form-item label="客户端 ID 前缀">
<el-input v-model="form.clientIdPrefix" placeholder="如:sim" /> <el-input v-model="form.clientIdPrefix" placeholder="如:sim" />
...@@ -121,6 +156,20 @@ import { ...@@ -121,6 +156,20 @@ import {
} from '@/api/deviceSim' } from '@/api/deviceSim'
import type { EnvConfig, EnvConfigCreate } from '@/types/device' import type { EnvConfig, EnvConfigCreate } from '@/types/device'
// 支持的设备类型
const deviceTypes = ['door', 'paperless', 'central', 'client'] as const
const deviceTypeLabels: Record<string, string> = {
door: '门口屏',
paperless: '无纸化',
central: '中控设备',
client: '集控客户端',
}
/** 设备类型中文名 */
function deviceTypeLabel(type: string): string {
return deviceTypeLabels[type] || type
}
const loading = ref(false) const loading = ref(false)
const configs = ref<EnvConfig[]>([]) const configs = ref<EnvConfig[]>([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
...@@ -129,6 +178,16 @@ const saving = ref(false) ...@@ -129,6 +178,16 @@ const saving = ref(false)
const editId = ref('') const editId = ref('')
const formRef = ref() const formRef = ref()
/** 根据基础前缀生成默认多主题映射 */
function buildDefaultTopics(prefix?: string | null): Record<string, string> {
const p = prefix || 'device'
const result: Record<string, string> = {}
for (const dt of deviceTypes) {
result[dt] = `${p}/${dt}`
}
return result
}
const defaultForm = (): EnvConfigCreate => ({ const defaultForm = (): EnvConfigCreate => ({
name: '', name: '',
brokerHost: '', brokerHost: '',
...@@ -136,17 +195,41 @@ const defaultForm = (): EnvConfigCreate => ({ ...@@ -136,17 +195,41 @@ const defaultForm = (): EnvConfigCreate => ({
username: '', username: '',
password: '', password: '',
topicPrefix: 'device', topicPrefix: 'device',
topics: buildDefaultTopics('device'),
useTls: false, useTls: false,
clientIdPrefix: 'sim', clientIdPrefix: 'sim',
}) })
const form = reactive<EnvConfigCreate>(defaultForm()) const form = reactive<EnvConfigCreate>(defaultForm())
// 主题编辑对象(保证非空,用于模板双向绑定)
const topicValues = reactive<Record<string, string>>(buildDefaultTopics('device'))
const rules = { const rules = {
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }], name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
brokerHost: [{ required: true, message: '请输入 Broker 地址', trigger: 'blur' }], brokerHost: [{ required: true, message: '请输入 Broker 地址', trigger: 'blur' }],
brokerPort: [{ required: true, message: '请输入端口号', trigger: 'blur' }], brokerPort: [{ required: true, message: '请输入端口号', trigger: 'blur' }],
topicPrefix: [{ required: true, message: '请输入主题前缀', trigger: 'blur' }], topicPrefix: [{ required: true, message: '请输入主题基础前缀', trigger: 'blur' }],
}
/** 基础前缀变更时,批量重置各设备主题(仅当用户未自定义时) */
function onBasePrefixChange() {
const fresh = buildDefaultTopics(form.topicPrefix)
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, fresh)
}
/** 规范化主题配置:兼容旧数据(topics 为空时从 topicPrefix 生成) */
function normalizeTopics(row: EnvConfig): Record<string, string> {
if (row.topics && Object.keys(row.topics).length > 0) {
return row.topics
}
return buildDefaultTopics(row.topicPrefix || 'device')
}
/** 是否已有多主题配置 */
function hasTopics(row: EnvConfig): boolean {
return !!(row.topics && Object.keys(row.topics).length > 0)
} }
/** 加载配置列表 */ /** 加载配置列表 */
...@@ -167,6 +250,8 @@ function openCreateDialog() { ...@@ -167,6 +250,8 @@ function openCreateDialog() {
isEdit.value = false isEdit.value = false
editId.value = '' editId.value = ''
Object.assign(form, defaultForm()) Object.assign(form, defaultForm())
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, buildDefaultTopics('device'))
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -174,6 +259,7 @@ function openCreateDialog() { ...@@ -174,6 +259,7 @@ function openCreateDialog() {
function openEditDialog(row: EnvConfig) { function openEditDialog(row: EnvConfig) {
isEdit.value = true isEdit.value = true
editId.value = row.id editId.value = row.id
const topics = normalizeTopics(row)
Object.assign(form, { Object.assign(form, {
name: row.name, name: row.name,
brokerHost: row.brokerHost, brokerHost: row.brokerHost,
...@@ -181,9 +267,12 @@ function openEditDialog(row: EnvConfig) { ...@@ -181,9 +267,12 @@ function openEditDialog(row: EnvConfig) {
username: row.username, username: row.username,
password: '', password: '',
topicPrefix: row.topicPrefix, topicPrefix: row.topicPrefix,
topics,
useTls: row.useTls, useTls: row.useTls,
clientIdPrefix: row.clientIdPrefix, clientIdPrefix: row.clientIdPrefix,
}) })
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, topics)
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -192,6 +281,9 @@ async function handleSave() { ...@@ -192,6 +281,9 @@ async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false) const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return if (!valid) return
// 将主题编辑对象回写到 form
form.topics = { ...topicValues }
saving.value = true saving.value = true
try { try {
if (isEdit.value) { if (isEdit.value) {
...@@ -275,4 +367,51 @@ onMounted(() => { ...@@ -275,4 +367,51 @@ onMounted(() => {
color: #303133; color: #303133;
margin: 0; margin: 0;
} }
.topic-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.topic-tag {
margin: 0;
}
.topic-empty {
color: #909399;
font-size: 13px;
}
.topics-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #ebeef5;
}
.topic-item {
display: flex;
align-items: center;
gap: 10px;
}
.topic-label {
width: 80px;
flex-shrink: 0;
font-size: 13px;
color: #606266;
}
.topic-input {
flex: 1;
}
.topic-tip {
margin-top: 4px;
align-self: flex-start;
}
</style> </style>
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论