# 执行计划 — 环境配置多主题与设备接收记录优化

> **文档类型**: 执行计划文档
> **创建日期**: 2026-07-29
> **关联 PRD**: `_PRD_需求优化_环境配置多主题与设备接收记录.md` v1.0
> **负责人**: czj
> **优先级**: P0
> **状态**: 待评审

---

## 一、执行概述

### 1.1 需求概要

在设备模拟模块现有基础上进行两项优化：

1. **环境配置多主题支持**：将 `topic_prefix`（单字符串）升级为 `topics`（JSON 对象映射），支持为每个设备类型独立配置主题前缀，各模拟器从环境配置中读取对应类型的主题，取代硬编码。
2. **设备接收记录补齐**：在 4 个模拟器（门口屏/无纸化/中控/集控客户端）的指令处理方法中，将收到的平台下发的原始指令记录为 `direction=subscribe` 的接收记录，并新增 API 方向筛选参数。

### 1.2 技术变更范围

| 层级 | 影响文件 | 变更类型 |
|------|---------|---------|
| 后端模型 | `backend/app/models/device_sim.py` | 修改：`EnvConfig` 新增 `topics` JSON 字段 |
| 后端 Schema | `backend/app/schemas/device_sim.py` | 修改：新增 `topics` 字段，向后兼容 |
| 后端路由 | `backend/app/routers/device_sim.py` | 修改：`GET /report-logs` 新增 `direction` 参数 |
| 后端服务 | `backend/app/services/device_sim_service.py` | 修改：创建时自动生成 topics；启动时传递 topics |
| 后端模拟器工厂 | `backend/app/simulators/__init__.py` | 修改：`create_simulator()` 新增 `topics` 参数 |
| 后端模拟器基类 | `backend/app/simulators/base_simulator.py` | 修改：`topics` 属性；`get_topic_prefix()` 从 topics 读取 |
| 后端门口屏模拟器 | `backend/app/simulators/door_simulator.py` | 修改：移除硬编码；`_on_command()` 加接收记录 |
| 后端无纸化模拟器 | `backend/app/simulators/paperless_simulator.py` | 修改：同上 |
| 后端中控模拟器 | `backend/app/simulators/central_simulator.py` | 修改：同上 |
| 后端集控客户端模拟器 | `backend/app/simulators/client_simulator.py` | 修改：同上 |
| 前端类型定义 | `frontend/src/types/device.ts` | 修改：`EnvConfig.topics` 字段类型 |
| 前端 API 封装 | `frontend/src/api/deviceSim.ts` | 修改：`listReportLogs` 新增 `direction` 参数 |
| 前端环境配置页 | `frontend/src/views/device-sim/EnvConfig.vue` | 修改：多主题列表编辑 UI |
| 前端上报记录组件 | `frontend/src/components/device/ReportLog.vue` | 修改：新增方向筛选下拉框 |

### 1.3 预计工期

| 阶段 | 内容 | 预计工时 |
|------|------|---------|
| 阶段一 | 后端数据模型 + Schema 变更（多主题） | 1 天 |
| 阶段二 | 模拟器主题读取改造 + 向后兼容 | 1 天 |
| 阶段三 | 接收记录补齐（4 个模拟器） | 0.5 天 |
| 阶段四 | 接收记录 API + 方向筛选 | 0.5 天 |
| 阶段五 | 前端多主题编辑 UI | 1 天 |
| 阶段六 | 前端方向筛选 + 联调验证 | 0.5 天 |
| **总计** | | **4.5 天** |

---

## 二、任务分解与实施计划

### 2.1 阶段一：后端数据模型与 Schema 变更（1 天）

#### 任务清单

| 任务ID | 任务名称 | 文件 | 说明 | 优先级 |
|--------|---------|------|------|--------|
| OPT-001 | 模型新增 `topics` 字段 | `backend/app/models/device_sim.py` | `EnvConfig` 新增 `topics: Mapped[Optional[dict]]` JSON 列 | P0 |
| OPT-002 | Schema 新增 `topics` 字段 | `backend/app/schemas/device_sim.py` | `EnvConfigBase` 新增 `topics: Optional[Dict[str, str]]`；`EnvConfigResponse` 包含 `topics` | P0 |
| OPT-003 | Schema 向后兼容 | `backend/app/schemas/device_sim.py` | `topic_prefix` 字段保留（标记为 deprecated），服务端自动生成 topics | P0 |
| OPT-004 | 服务层创建逻辑适配 | `backend/app/services/device_sim_service.py` | `create_env_config()` 中当 `topics` 未传入时，从 `topic_prefix` 自动生成默认 topics 映射 | P0 |
| OPT-005 | 服务层启动逻辑适配 | `backend/app/services/device_sim_service.py` | `start_simulator()` 中读取环境配置的 `topics` 传递给模拟器 | P0 |

#### 详细实施

**OPT-001：模型修改**

```python
# backend/app/models/device_sim.py
# 在 EnvConfig 类中新增字段
topics: Mapped[Optional[dict]] = mapped_column(
    JSON, nullable=True,
    comment="多主题前缀映射，如 {'door': 'device/door', 'paperless': 'device/paperless'}"
)
```

**OPT-002：Schema 修改**

```python
# backend/app/schemas/device_sim.py
from typing import Dict, Optional

class EnvConfigBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    broker_host: str = Field(..., min_length=1, max_length=255)
    broker_port: int = Field(default=1883, ge=1, le=65535)
    username: Optional[str] = Field(default=None, max_length=100)
    password: Optional[str] = Field(default=None, max_length=500)
    # topic_prefix 保留用于向后兼容
    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)
    client_id_prefix: str = Field(default="sim", max_length=100)
```

**OPT-004：服务层创建逻辑**

```python
# backend/app/services/device_sim_service.py
async def create_env_config(self, data: EnvConfigCreate) -> 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(
        id=generate_id("env"),
        name=data.name,
        broker_host=data.broker_host,
        broker_port=data.broker_port,
        username=data.username,
        topic_prefix=data.topic_prefix,  # 保留旧字段
        topics=topics,  # 新增多主题字段
        use_tls=data.use_tls,
        client_id_prefix=data.client_id_prefix,
        status="disconnected",
    )
    # ...
```

**OPT-005：启动逻辑适配**

```python
# backend/app/services/device_sim_service.py
async def start_simulator(self, device_id: str) -> bool:
    # ... 获取 simulator 和 config ...
    
    # 创建并启动模拟器，传递 topics
    sim = create_simulator(
        device_type=simulator.device_type,
        device_id=simulator.device_id,
        env_config_id=config.id,
        mqtt_manager=mqtt_manager,
        topics=config.topics or {},  # 传递多主题配置
    )
    # ...
```

---

### 2.2 阶段二：模拟器主题读取改造（1 天）

#### 任务清单

| 任务ID | 任务名称 | 文件 | 说明 | 优先级 |
|--------|---------|------|------|--------|
| OPT-006 | 模拟器工厂新增 topics 参数 | `backend/app/simulators/__init__.py` | `create_simulator()` 新增 `topics` 参数，传递给模拟器构造函数 | P0 |
| OPT-007 | 基类新增 topics 属性 | `backend/app/simulators/base_simulator.py` | `__init__` 接收 `topics` 参数；`get_topic_prefix()` 改为从 topics 读取 | P0 |
| OPT-008 | 门口屏模拟器移除硬编码 | `backend/app/simulators/door_simulator.py` | 移除 `get_topic_prefix()` 的硬编码，由基类处理 | P0 |
| OPT-009 | 无纸化模拟器移除硬编码 | `backend/app/simulators/paperless_simulator.py` | 同上 | P0 |
| OPT-010 | 中控模拟器移除硬编码 | `backend/app/simulators/central_simulator.py` | 同上 | P0 |
| OPT-011 | 集控客户端模拟器移除硬编码 | `backend/app/simulators/client_simulator.py` | 同上 | P0 |

#### 详细实施

**OPT-007：基类修改**

```python
# backend/app/simulators/base_simulator.py
class BaseSimulator(ABC):
    def __init__(self, device_id: str, env_config_id: str,
                 device_type: str, mqtt_manager: MqttManager,
                 report_interval: int = 30,
                 topics: Optional[dict] = None):  # 新增参数
        self.device_id = device_id
        self.env_config_id = env_config_id
        self.device_type = device_type
        self.mqtt = mqtt_manager
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._report_interval = report_interval
        self._on_report_callback = None
        self._lock = threading.Lock()
        self.topics = topics or {}  # 新增属性

    def get_topic_prefix(self) -> str:
        """
        获取设备主题前缀
        
        优先使用环境配置中的多主题映射，如果没有配置则使用默认值。
        """
        if self.topics and self.device_type in self.topics:
            return self.topics[self.device_type]
        return f"device/{self.device_type}"
```

**OPT-008~011：各子类修改**

各子类中移除 `get_topic_prefix()` 方法的重写（由基类统一处理），或保留但改为调用 `super().get_topic_prefix()`。

**OPT-006：工厂修改**

```python
# backend/app/simulators/__init__.py
def create_simulator(device_type: str, device_id: str,
                     env_config_id: str, mqtt_manager: MqttManager,
                     report_interval: Optional[int] = None,
                     topics: Optional[dict] = None) -> Optional[BaseSimulator]:
    simulator_class = SIMULATOR_MAP.get(device_type)
    if not simulator_class:
        return None
    if report_interval is None:
        report_interval = DEFAULT_INTERVAL_MAP.get(device_type, 30)
    return simulator_class(
        device_id=device_id,
        env_config_id=env_config_id,
        mqtt_manager=mqtt_manager,
        report_interval=report_interval,
        topics=topics or {}
    )
```

---

### 2.3 阶段三：接收记录补齐（0.5 天）

#### 任务清单

| 任务ID | 任务名称 | 文件 | 说明 | 优先级 |
|--------|---------|------|------|--------|
| OPT-012 | 门口屏接收记录 | `backend/app/simulators/door_simulator.py` | `_on_command()` 开头记录接收指令 | P0 |
| OPT-013 | 无纸化接收记录 | `backend/app/simulators/paperless_simulator.py` | 同上 | P0 |
| OPT-014 | 中控接收记录 | `backend/app/simulators/central_simulator.py` | 同上 | P0 |
| OPT-015 | 集控客户端接收记录 | `backend/app/simulators/client_simulator.py` | 同上 | P0 |

#### 详细实施

每个模拟器修改模式一致，以门口屏为例：

```python
# backend/app/simulators/door_simulator.py
def _on_command(self, topic: str, payload: dict) -> None:
    try:
        command = payload.get("command", "")
        logger.info(f"门口屏收到指令: device_id={self.device_id}, command={command}")

        # ==== 新增：记录接收到的平台下发指令 ====
        self._notify_report(
            topic,           # 原始指令主题（如 device/door/{id}/command）
            payload,         # 原始指令内容（如 {"command": "unlock"}）
            direction="subscribe",
            status="success"
        )
        # ====================================

        # 原有处理逻辑不变...
        # ...
    except Exception as e:
        logger.error(f"门口屏指令处理异常: {e}")
```

其他三个模拟器（`paperless_simulator.py`、`central_simulator.py`、`client_simulator.py`）做相同的修改，在 `_on_command()` 方法中 `command = payload.get("command", "")` 和 `logger.info(...)` 之后、`response_topic = ...` 之前插入接收记录代码。

---

### 2.4 阶段四：接收记录 API + 方向筛选（0.5 天）

#### 任务清单

| 任务ID | 任务名称 | 文件 | 说明 | 优先级 |
|--------|---------|------|------|--------|
| OPT-016 | 路由新增 direction 参数 | `backend/app/routers/device_sim.py` | `GET /report-logs` 新增 `direction` 查询参数 | P0 |
| OPT-017 | 服务层 direction 筛选 | `backend/app/services/device_sim_service.py` | `list_report_logs()` 新增 `direction` 筛选条件 | P0 |

#### 详细实施

**OPT-016：路由修改**

```python
# backend/app/routers/device_sim.py
@router.get("/report-logs", response_model=ReportLogListResponse)
async def list_report_logs(
    device_id: Optional[str] = Query(None, description="设备 ID 筛选"),
    direction: Optional[str] = Query(None, description="方向筛选：publish/subscribe"),
    skip: int = Query(0, ge=0, description="跳过的记录数"),
    limit: int = Query(100, ge=1, le=500, description="返回记录数上限"),
    start_time: Optional[datetime] = Query(None, description="开始时间"),
    end_time: Optional[datetime] = Query(None, description="结束时间"),
    service: DeviceSimService = Depends(get_device_sim_service)
):
    items, total = await service.list_report_logs(
        device_id=device_id,
        direction=direction,  # 新增参数
        skip=skip,
        limit=limit,
        start_time=start_time,
        end_time=end_time,
    )
    # ...
```

**OPT-017：服务层修改**

```python
# backend/app/services/device_sim_service.py
async def list_report_logs(
    self,
    device_id: Optional[str] = None,
    direction: Optional[str] = None,  # 新增参数
    skip: int = 0,
    limit: int = 100,
    start_time: Optional[datetime] = None,
    end_time: Optional[datetime] = None,
) -> Tuple[List[ReportLog], int]:
    conditions = []
    if device_id:
        conditions.append(ReportLog.device_id == device_id)
    if direction:  # 新增方向筛选
        conditions.append(ReportLog.direction == direction)
    if start_time:
        conditions.append(ReportLog.created_at >= start_time)
    if end_time:
        conditions.append(ReportLog.created_at <= end_time)
    # ... 后续逻辑不变
```

---

### 2.5 阶段五：前端多主题编辑 UI（1 天）

#### 任务清单

| 任务ID | 任务名称 | 文件 | 说明 | 优先级 |
|--------|---------|------|------|--------|
| OPT-018 | 前端类型定义更新 | `frontend/src/types/device.ts` | `EnvConfig` 接口新增 `topics: Record<string, string>` | P0 |
| OPT-019 | 前端 API 封装更新 | `frontend/src/api/deviceSim.ts` | `listReportLogs` 参数新增 `direction` | P0 |
| OPT-020 | 环境配置页多主题编辑 | `frontend/src/views/device-sim/EnvConfig.vue` | 表单从单行输入改为多主题列表编辑 | P0 |

#### 详细实施

**OPT-018：类型定义更新**

```typescript
// frontend/src/types/device.ts
export interface EnvConfig {
  id: string
  name: string
  brokerHost: string
  brokerPort: number
  username: string | null
  password: string | null
  topicPrefix: string  // 保留（向后兼容）
  topics: Record<string, string> | null  // 新增：多主题映射
  useTls: boolean
  clientIdPrefix: string
  status: string
  lastConnectedAt: string | null
  createdAt: string
  updatedAt: string
}

export interface EnvConfigCreate {
  name: string
  brokerHost: string
  brokerPort?: number
  username?: string | null
  password?: string | null
  topicPrefix?: string
  topics?: Record<string, string> | null  // 新增
  useTls?: boolean
  clientIdPrefix?: string
}
```

**OPT-019：API 封装更新**

```typescript
// frontend/src/api/deviceSim.ts
export function listReportLogs(params?: {
  deviceId?: string
  direction?: string  // 新增
  skip?: number
  limit?: number
  startTime?: string
  endTime?: string
}): Promise<ReportLogListResponse> {
  return request.get(`${BASE}/report-logs`, { params })
}
```

**OPT-020：环境配置页多主题编辑**

```vue
<!-- frontend/src/views/device-sim/EnvConfig.vue -->
<!-- 将原来的单行主题输入框替换为多主题列表 -->

<el-form-item label="主题配置">
  <div class="topics-editor">
    <div class="topic-item" v-for="(deviceType, index) in topicDeviceTypes" :key="deviceType">
      <span class="topic-label">{{ DEVICE_TYPE_LABELS[deviceType] }}:</span>
      <el-input
        v-model="form.topics[deviceType]"
        :placeholder="`device/${deviceType}`"
        size="small"
        style="width: 200px"
      />
    </div>
    <el-tag type="info" size="small" style="margin-top: 8px">
      默认使用 device/{设备类型}，可自定义每个设备类型的前缀
    </el-tag>
  </div>
</el-form-item>
```

---

### 2.6 阶段六：前端方向筛选 + 联调验证（0.5 天）

#### 任务清单

| 任务ID | 任务名称 | 文件 | 说明 | 优先级 |
|--------|---------|------|------|--------|
| OPT-021 | 上报记录组件方向筛选 | `frontend/src/components/device/ReportLog.vue` | 新增方向筛选下拉框（全部/上报/接收） | P0 |
| OPT-022 | 全流程联调验证 | 各文件 | 启动模拟设备，验证多主题读取和接收记录 | P0 |

#### 详细实施

**OPT-021：ReportLog 方向筛选**

```vue
<!-- frontend/src/components/device/ReportLog.vue -->
<!-- 在时间筛选器旁增加方向筛选下拉框 -->

<div class="log-actions">
  <el-select v-model="directionFilter" placeholder="方向" size="small" style="width: 100px" @change="loadLogs">
    <el-option label="全部" value="" />
    <el-option label="上报" value="publish" />
    <el-option label="接收" value="subscribe" />
  </el-select>
  <el-date-picker
    v-model="timeRange"
    type="datetimerange"
    range-separator="至"
    start-placeholder="开始时间"
    end-placeholder="结束时间"
    value-format="YYYY-MM-DD HH:mm:ss"
    style="width: 320px"
    @change="loadLogs"
  />
  <!-- ... 原有按钮 -->
</div>
```

对应 script 中新增：

```typescript
const directionFilter = ref('')

// 在 loadLogs 的 params 中增加
if (directionFilter.value) params.direction = directionFilter.value
```

---

## 三、验收标准

| 验收项 | 预期结果 | 测试方法 |
|--------|---------|---------|
| 多主题创建 | 创建环境配置时填写 4 个设备类型的主题前缀，保存后回显正确 | 前端操作 |
| 旧数据兼容 | 已有环境配置编辑时，`topic_prefix` 自动转为 4 个默认主题前缀 | 前端操作 |
| 门口屏主题读取 | 启动门口屏模拟器，查看其发布消息的主题是否为配置的前缀 | 检查上报记录 |
| 无纸化主题读取 | 启动无纸化模拟器，主题使用配置的前缀 | 检查上报记录 |
| 中控主题读取 | 启动中控模拟器，主题使用配置的前缀 | 检查上报记录 |
| 集控客户端主题读取 | 启动集控客户端模拟器，主题使用配置的前缀 | 检查上报记录 |
| 门口屏接收记录 | 平台下发 unlock 指令，上报记录中出现方向为「接收」的记录 | 检查上报记录列表 |
| 无纸化接收记录 | 平台下发 start_meeting 指令，出现接收记录 | 检查上报记录列表 |
| 中控接收记录 | 平台下发 switch_scene 指令，出现接收记录 | 检查上报记录列表 |
| 集控客户端接收记录 | 平台下发 remote_restart 指令，出现接收记录 | 检查上报记录列表 |
| 方向筛选 | 筛选「接收」只显示接收记录，筛选「上报」只显示上报记录 | 前端操作 |

---

## 四、测试计划

### 4.1 单元测试

| 测试项 | 测试内容 | 预期 |
|--------|---------|------|
| 多主题生成 | 传入 `topic_prefix="device"`，验证自动生成 topics 包含 4 种设备类型 | 生成正确 |
| 多主题自定义 | 传入自定义 topics，验证存储和读取一致 | 存储正确 |
| 接收记录写入 | 调用 `_notify_report(topic, payload, direction="subscribe")`，验证数据库写入 direction=subscribe | 写入正确 |
| 方向筛选 | 按 direction 筛选，验证返回结果正确 | 筛选正确 |

### 4.2 集成测试

1. 启动后端服务，创建环境配置（填写多主题）
2. 创建 4 种类型的模拟设备各一个
3. 逐个启动设备，检查 MQTT 主题是否正确使用配置的前缀
4. 通过 MQTT 客户端手动向设备 command 主题下发指令
5. 检查上报记录列表，验证：
   - 方向为「接收」的记录已出现
   - 记录内容包含正确的指令主题和 payload
6. 前端筛选方向为「接收」，验证只显示接收记录

---

## 五、风险评估

| 风险 | 影响 | 概率 | 应对措施 |
|------|------|------|---------|
| 旧数据 `topic_prefix` 字段迁移遗漏 | 已有环境配置可能无法正常使用 | 中 | 服务层做兼容，读取时检测 topics 为空则自动生成 |
| 模拟器 `get_topic_prefix()` 被多处调用修改不全 | 部分主题仍使用硬编码 | 低 | 全局搜索 `get_topic_prefix` 和 `"device/"` 确保修改完整 |
| 接收记录写入影响原有指令处理性能 | 平台下发指令响应变慢 | 低 | 接收记录使用异步写入，不阻塞主流程 |
| 前端多主题编辑表单复杂度过高 | 用户体验下降 | 低 | 提供默认值，简化操作 |

---

## 六、实施记录

| 日期 | 任务ID | 完成情况 | 备注 |
|------|--------|---------|------|
| - | - | - | - |

---

## 七、后续工作

| 项目 | 说明 | 优先级 |
|------|------|--------|
| 删除 `topic_prefix` 字段 | 待 `topics` 字段稳定运行后，可移除 `topic_prefix` 字段 | P2 |
| 接收记录 WebSocket 推送 | 实时推送平台下发指令通知到前端 | P2 |
| 平台下发指令 HTTP API | 提供 HTTP 接口让用户手动向设备下发指令，不依赖外部 MQTT 客户端 | P2 |

---

*本文档由 Claude Code 生成，用于指导设备模拟模块的优化开发。*