# HANDOFF — 设备模拟模块

> **生成时间**: 2026-08-21
> **当前分支**: `platform-auto-test`
> **最近提交**: `7cb9b7c1` docs(performance): 更新 HANDOFF 记录批量执行合并报告实现 + 540 设备批量启动验证
> **未提交改动**: 仅 `backend/data/test_platform.db`（本地 SQLite，已 gitignore）

---

## 📊 会话进度记录

### 2026-08-21 会话 M：设备模拟未部署改动部署到 5.60（已部署并验证通过）

**会话目标**：将自上次部署 commit `695460eb` 以来累积的设备模拟改动全部部署到 5.60 生产环境并验证。

**状态**：✅ 部署完成 + 全链路验证通过（文件上传 / 容器重启 / 健康检查 / grep 内容校验 / 4 台批量启动 / 540 台全部停止）

**部署内容**（5 个后端文件，SFTP 上传 + `docker restart`）：

| 文件 | 覆盖的改动 |
|------|-----------|
| `app/services/device_sim_service.py` | 会话 K 批量启动并行化（ThreadPoolExecutor 50 线程 + Semaphore）+ per-thread engine（`_thread_db`）+ 会话 J token_api_host 不回退 broker_host + 消息队列改 `thread_queue.Queue` |
| `app/services/mqtt_manager.py` | 会话 J on_disconnect 去阻塞（移除 sleep + reconnect，交给 paho 内置自动重连） |
| `app/simulators/door_token_client.py` | 会话 I 无纸化 token API 路径（无 /exapi 前缀）+ `get_message_by_app_token()` + `generate_paperless_reg_udid()` |
| `app/simulators/paperless_simulator.py` | 会话 I 启动四步流程（token 获取 + 消息定时拉取） |
| `app/database.py` | 性能测试增强指标列 + 预设登录凭据列 + 项目归属列（旧库升级兼容） |

**验证结果**：

| 步骤 | 结果 |
|------|------|
| SFTP 上传（文件大小校验） | ✅ 5/5 |
| `docker restart plat-auto-test-app` + /health | ✅ healthy |
| grep 校验 8 项改动（并行化 / per-thread / token 回退 / thread_queue / on_disconnect / 无纸化路径 / 消息接口 / 消息拉取） | ✅ 全部命中 |
| 设备 stats API | ✅ `{"total":540,"byType":{"door":{"total":540}}}` |
| 4 台门口屏批量启动 | ✅ `successCount:4, failedCount:0`，启动后 `running:4` |
| 全部停止清理 | ✅ `成功 540, 失败 0` |

**新增脚本**（`backend/scripts/`，可复用于后续部署）：

| 脚本 | 用途 |
|------|------|
| `deploy_device_sim_560.py` | SFTP 上传 + 重启 + 健康检查 + API 验证（参数化，含 UPLOADS 清单） |
| `restart_and_verify.py` | 单独重启 + 健康轮询 + 容器状态 + stats API |
| `verify_deploy.py` | grep 校验服务器文件内容（8 项检查） |
| `test_batch_start.py` | 小批量启动冒烟测试（查询 → 批量启动 → 统计 → 全部停止） |
| `check_mounts.py` | 确认容器 bind mount（backend → /app） |

**本次踩坑**（补充踩坑记录）：

| 现象 | 根因 | 正确做法 |
|------|------|---------|
| `docker compose restart` 报 `no configuration file provided: not found` | compose 文件在 `/data/third_party/plat-auto-test/deploy/` 子目录，从 HOME 执行找不到 | 直接 `docker restart plat-auto-test-app`（单容器不依赖 compose 文件） |
| `curl /api/device-sim/devices/?device_type=door` 返回 HTML | 尾斜杠命中前端 SPA 兜底路由返回 index.html | API 调用不带尾斜杠：`/api/device-sim/devices?...` |

**修改文件清单（本次会话）**：仅新增 5 个部署/验证脚本（见上表），业务代码零改动——本会话为纯部署会话。

**待办**：
- 已知问题：MQTT Broker 闪断（rc=7）为 Broker 端问题，非 app 代码缺陷，持续观察
- 无纸化 token 获取 / 消息拉取已部署但需环境配置 `token_api_host`（如 `https://192.168.5.240`）才会实际触发

---

### 2026-08-20 会话 L：5.44 平台 8081 访问转圈问题排查与修复（已恢复）

**会话目标**：排查 5.44 测试管理平台 http://192.168.5.44:8081/ 访问一直转圈问题。

**状态**：✅ 服务已恢复，访问正常

---

#### 问题现象

`http://192.168.5.44:8081/` 浏览器一直转圈无响应，TCP 端口 8081 可连接但 HTTP 请求全部超时。

#### 排查过程

| 步骤 | 结果 |
|------|------|
| TCP 连接 192.168.5.44:8081 | ✅ 可连接（排除防火墙） |
| curl http://localhost:8081/health（5.44 本机） | ❌ `000`（应用层无响应） |
| `docker inspect` 容器状态 | `Up 7 hours (unhealthy)`，RestartCount=0（进程僵死，未崩溃） |
| `docker logs` 容器日志 | **MQTT 每 3 秒断连重连刷屏 7 小时**（`env_12d8cff152a24e21a088d31bd0b2c791`，Broker `192.168.5.44:1883`，用户名 `mqtt@cmdb`） |

#### 根因

MQTT 连接反复断开→重连循环（rc=7 协议错误），持续 7 小时每 3 秒一轮刷屏日志，耗尽 FastAPI 事件循环线程池，导致应用僵死无法处理任何 HTTP 请求。TCP 端口由 Docker 端口映射保持监听，故外部 TCP 能通但 HTTP 无响应。

#### 修复

`docker restart plat-auto-test-app` → 等待 ~16 秒后 /health 返回 200，首页 200，MQTT 日志稳定无异常。重启后 MQTT 连接正常，属偶发 Broker 端不稳定。

#### 关键信息（环境探查附带收获）

| 项 | 值 |
|---|---|
| 5.44 MySQL 容器 | `plat-auto-test-mysql`，root 密码 `Ubains@13579`，库 `plat_auto_test` |
| MQTT 环境配置表 | **`device_env_configs`**（非 `env_configs`） |
| 断连环境 | `env_12d8cff152a24e21a088d31bd0b2c791`，Broker `192.168.5.44:1883`，MQTT 用户 `mqtt@cmdb`，topic_prefix=`device` |
| 5.44 上还有其他业务容器 | upython/upython_player/upython_voice/unginx/uemqx/uredis/umysql/unacos 等（host 网络模式） |

**修改文件清单（本次会话）**：无——操作均为运维层面（docker restart），无代码改动。

**待办**：
- ⚠️ MQTT Broker 闪断（rc=7）仍为已知问题，为 Broker 端不稳定所致，非 app 代码缺陷，持续观察
- 可选增强：MQTT 断连重试增加退避上限或告警，避免再次耗尽事件循环

---

### 2026-08-19 会话 E：门口屏模拟器 Token 获取（已实现 + 真实调用验证通过，未提交未部署）

**会话目标**：为门口屏（door）模拟器补充启动时的真实 HTTP 接口调用链 —— 模拟真实 Android 门口屏设备（UbGview 应用）启动第一步调用的 `POST /exapi/api-android/token/getTokenInfoByToken` 接口获取 token。

**状态**：✅ 代码实现完成 + 真实接口调用验证通过（HTTP 200 + 完整 token）+ 登记池 5 场景验证通过；⚠️ 未提交 git、未部署

**背景**：真实门口屏设备启动第一步是调 token 接口，成功后才有 Authorization 继续进行后续交互；而模拟器之前是直接连 MQTT，且 company_id / room_id 依赖手动配置。

**本期只做**：启动时调用 token 接口 → 保存 token → 回填 topic_params。
**明确不做**（后续迭代）：MQTT 主题解析、会议服务主题订阅、MQTT 周期上报、动态 X-SIGN 算法。

---

#### ① 新增 Token 客户端（`backend/app/simulators/door_token_client.py`）

- **接口**：`POST {scheme}://{host}/exapi/api-android/token/getTokenInfoByToken`（默认 https；host 自动识别协议，兼容 `https://` / `http://` / `host:port` 等写法）
- **请求头**：X-SIGN 用日志固定值 `X_SIGN_FIXED`（动态算法未复现，实测可调用成功）；X-TIMESTAMP 秒级；X-RANDOM 16 位随机
- **请求体**：`{"appToken": ..., "regUdid": ...}`
- **regUdid**：由 device_id 确定性派生 `reg_{md5(device_id)[:16]}` —— 批量不重复、同设备多次启动结果一致（格式与真实日志 16 位 hex 一致）
- **appToken 登记池**（用户明确的关键决策）：appToken **不自动生成**，来自设备导入时提供的授权码。`TokenPool.allocate()` 只做登记与去重：
  - 未提供 → 报错"门口屏 token 获取必须使用导入设备时提供的服务端授权码，不可自动生成"
  - 重复使用 → 报错"appToken 已被其他设备使用，批量场景授权码不能重复"
- **响应解析（勘误要点）**：`code` 是**字符串 "200"**；`conference` / `company` **嵌套在 `result.token` 对象内部**（不在 result 顶层）
- **重试**：网络错误（ConnectionError/Timeout）指数退避重试 3 次；业务错误（ValueError）不重试
- **host 来源**：`EnvConfig.default_topic_params['token_api_host']`，回退 `broker_host`

#### ② 启动流程集成（4 个后端文件）

- `DoorSimulator.start()` 改造为三步：
  1. 调 token 接口（登记 appToken → `DoorTokenClient.get_token()` → 保存 `self._token_info`）
  2. 回填 `topic_params`：companyNumber→company_id、cnum→room_id、conferenceId→conference_id、conferenceName→conference_name、appToken→app_token
  3. 重新解析主题（`_resolve_topics()`）后走父类 MQTT 启动流程
- token 获取失败 → 记录错误日志 → `_notify_report` 上报 `token_get` failed 消息 → 启动终止返回 False
- **未配置 `token_api_host` → 仅 warning 跳过 token 获取，不影响启动（向后兼容，非门口屏不受影响）**

#### 验证结果

| 验证项 | 结果 |
|--------|------|
| 真实调用（appToken=AND-34H-0101 + regUdid） | ✅ HTTP 200 + OPERATION_SUCCESSFUL，token 完整返回 |
| 字段回填 | ✅ companyNumber=CN-34H-UBAINS、cnum=upjp7gzk..., conferenceName=PGY测试会议室1 |
| 未提供 appToken | ✅ 明确报错拒绝自动生成 |
| 重复使用同一授权码 | ✅ 拒绝 |
| 不同授权码 | ✅ 正常登记 |
| regUdid 派生唯一性 | ✅ `reg_{md5(device_id)[:16]}` 确定性且唯一 |

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 新增 | DoorTokenClient + TokenPool 登记池 + regUdid 派生 + host 规范化 + 默认 host 选择 |
| `backend/app/simulators/door_simulator.py` | 修改 | start() 接入 token 获取 + `_token_info` + topic_params 回填 |
| `backend/app/simulators/base_simulator.py` | 修改 | `__init__` 增加 `token_api_host` 参数 |
| `backend/app/simulators/__init__.py` | 修改 | `create_simulator()` 透传 `token_api_host` |
| `backend/app/services/device_sim_service.py` | 修改 | `start_simulator()` 从环境配置读取 token_api_host |
| `Docs/PRD/需求文档/设备模拟/_PRD_需求优化_门口屏模拟器Token获取.md` | 新增 | PRD 需求文档 |
| `Docs/PRD/需求文档/设备模拟/_执行计划_门口屏模拟器Token获取.md` | 新增 | 执行计划文档 |

**待办**：
- ⚠️ 本次改动未提交 git（连同此前会话 B/C/D 未提交的设备模拟改动一起），下次会话 `/GitCommit` 提交
- ⚠️ 未部署。需要门口屏环境配置在 `EnvConfig.default_topic_params` 里配 `token_api_host`（如 `https://192.168.5.48`）才会触发 token 获取；批量创建门口屏时 Excel 必须提供"授权码(app_token)"列（每台唯一）
- 后续迭代：动态 X-SIGN 算法（P2）、MQTT 主题解析/订阅/上报（P1）见 `_执行计划_门口屏模拟器Token获取.md` 七、后续工作

---

### 2026-08-19 会话 D：无纸化授权码 + 环境配置弹窗简化 + 批量删除性能修复（已全部完成并部署）

**会话目标**：三项设备模拟模块优化 — ①无纸化导入模板加授权码列 ②环境配置弹窗简化 ③100 条设备批量删除卡"处理中"修复

**状态**：✅ 全部完成 — 文档 + 代码实现 + 构建 + 部署到 192.168.5.60 + API 验证通过

---

#### ① 无纸化导入模板增加授权码列

**背景**：真实无纸化设备在 `rebootResponseTopic` 上报消息体中携带 `authCode`（如 `"authCode": "ANP-NRY-0022"`），但导入模板无此列，导致无法填写授权码。

**已修改**（`backend/app/routers/device_sim.py`）：
- 模板列逻辑由 `if device_type == "door"` 改为 `if device_type in ("door", "paperless")`，两种设备都输出"授权码(app_token)"列
- 无纸化示例值：`ANP-NRY-0022`（匹配真实设备 authCode 格式）
- 门口屏示例值由 `AUTH-0001` 改为更真实的 `AND-518-0001`

**模拟器兼容性**（无需改动，已确认 `paperless_simulator.py` 兼容）：
- `build_reboot_response_payload()` 从 `topic_params.app_token` 读取授权码
- `auth_code = app_token.replace("AND-", "ANP-", 1)` 自动转换格式（door 的 AND- 前缀 → paperless 的 ANP- 前缀）

---

#### ② 环境配置弹窗简化

**背景**：新增/编辑环境配置弹窗字段过多，用户只关心核心连接信息。

**已修改**（`frontend/src/views/device-sim/EnvConfig.vue`）：
- **移除**约 90 行模板代码：主题前缀配置（door/paperless/central/client）、默认主题参数（company_id / conference_name / conference_id / room_ids）、门口屏授权码配置（prefix/start/end）、无纸化授权码配置（prefix/start/end）
- **保留**字段：配置名称、Broker 地址、端口、用户名、密码、客户端 ID 前缀、TLS 加密
- 移除 `DEVICE_TYPE_LABELS` 导入、`DEVICE_TYPES` 常量、`addRoomId()`/`removeRoomId()` 函数、`.room-ids-inline` CSS
- `defaultForm()` 的 `defaultTopicParams` 简化为 `{}`（后端仍兼容旧数据）

---

#### ③ 批量删除 100 条设备卡"处理中"修复

**问题现象**：无纸化模拟页面勾选约 100 条设备批量删除，前端长时间显示"处理中"，未删除成功。

**根因**：旧版 `batch_delete_simulators()` 用 for 循环逐条调用 `delete_simulator()`，每台设备都执行：MQTT 停止发布 → 查询设备 → 查询日志 → 删除日志 → 删除设备 → flush，100 台设备产生上百次数据库往返 + MQTT 同步操作。

**已重写**（`backend/app/services/device_sim_service.py`，原 1167-1196 行）：
- **去重**：`list(dict.fromkeys(device_ids))` 防重复 ID
- **单次查询**：一次 `select(DeviceSimulator.id).where(id.in_(ids))` 确认存在，不存在归入 `failed_ids`
- **批量停止**：遍历 `_running_simulators` 逐个取实例 stop，外层 try/except 捕获异常不中断（MQTT 失败只记 warning）
- **批量 SQL 删除**：`delete(ReportLog).where(device_id.in_(ids))` + `delete(DeviceSimulator).where(id.in_(ids))`，**单次 flush()**
- **关键点**：`ReportLog.device_id` 存的是 `DeviceSimulator.id`（主键 `sim_xxx`），不是 MQTT `device_id`（`door_001`），删除条件按主键关联
- 返回 `success_count / failed_count / failed_ids / message`

**前端检查**（`frontend/src/api/deviceSim.ts` 第 188 行，无需改动）：
- 批量删除 URL 已使用正斜杠 `/devices/batch`（非反斜杠），body 用 `{ deviceIds }` 匹配后端 DELETE schema

---

#### 部署与验证（192.168.5.60）

**部署脚本**：复用 `deploy_paperless_token.py`（SFTP 上传 2 个后端文件 + 85 个前端 dist 文件 → `docker compose restart app`）

| 验证项 | 结果 |
|--------|------|
| 健康检查 `http://192.168.5.60/health` | ✅ 200 healthy |
| 无纸化模板接口 `?device_type=paperless` | ✅ 正常返回 Excel（含授权码列） |
| 批量删除 API（不存在的 ID） | ✅ `successCount:0, failedCount:1` 校验逻辑正确 |
| **批量删除 100 条真实设备** | ✅ `successCount:100, failedCount:0` 秒级完成 |
| 数据库校验 | ✅ 设备总数 1087 → 987（确认减少 100 条） |

**注意**：部署脚本名称 `deploy_paperless_token.py` 是历史遗留命名，实际现在部署的是本会话三项改动，下次可改名或新建通用部署脚本。

---

### 2026-08-19 会话 F：门口屏启动后无纸化全部启动导致门口屏停止的问题修复（已全部完成，本地验证通过，未部署）

**会话目标**：修复设备模拟模块中，门口屏设备全部启动后，切换到无纸化页面点击"全部启动"时，门口屏设备在 UI 上显示为"已停止"的问题。

**状态**：✅ 代码实现完成 + 本地 API 验证通过；✅ 已提交 git（`003ee7c9`），未部署到 5.60

**问题现象**：4 台门口屏设备全部启动（`running=4`），切换到无纸化页面启动无纸化设备（同一环境配置），再切回门口屏页面发现 4 台门口屏全部显示"已停止"。

**根因分析**（三重问题叠加）：

1. **MQTT 连接每次必断重建**（核心根因）：`mqtt_manager.connect()` 每次调用都先 `disconnect()` 再新建连接。门口屏和无纸化共用同一环境配置，无纸化启动时 MQTT 连接被断开重建，导致门口屏已订阅的主题全部丢失，虽然 `_running_simulators` 中门口屏模拟器对象还在，但 paho 连接断开后所有设备断连。

2. **进程重启时内存表丢失**：`_running_simulators` 是模块级全局 dict（进程重启即清空），而 DB 中 `status='running'` 残留。`list_simulators()` 发现 DB 有 `running` 但内存表没有 → 全部改为 `stopped`。

3. **长锁竞争 + 高并发引发进程卡死**：`start_simulator()` 整个 MQTT 连接 + 创建模拟器 + 启动流程都在 `_running_simulators_lock` 内，批量启动 540 台设备时连续持有锁做 IO 操作，可能导致 uvicorn worker 超时被重启。

---

#### 修复项 1：`mqtt_manager.py` — connect() 复用存活连接

```python
# 之前：每次 connect() 都先断开已有连接
if env_config_id in self._connections:
    self.disconnect(env_config_id)

# 现在：已有存活连接时直接复用，不断开重建
existing = self._connections.get(env_config_id)
if existing and existing.connected and existing.client:
    return True
```

**效果**：无纸化启动时门口屏的 MQTT 连接保持畅通，门口屏设备继续正常上报，`is_running()` 不返回 False。

#### 修复项 2：`main.py` — 启动时重置失效的 running 状态

在 `init_db()` 之后，扫描 DB 中 `status='running'` 的设备全部重置为 `stopped`，避免进程重启后 UI 显示异常。

#### 修复项 3：`device_sim_service.py` — 减少锁粒度

将 MQTT 连接、`create_simulator()`、`sim.start()` 等 IO 操作移出 `_running_simulators_lock` 范围，只有启动前的快速判断和启动后的写入内存表在锁内。批量启动时不再长时间持有锁，降低进程卡顿/重启风险。

#### 连带修复 1：`database.py` — 补列列表缺 `default_topic_params`

`_ensure_columns` 列表缺少 `device_env_configs.default_topic_params`，旧库升级时模型查询报 `no such column`，导致所有环境配置读取和模拟设备启动失败。已追加该列。

#### 连带修复 2：四个模拟器 `__init__` 统一 `token_api_host` 参数签名

`PaperlessSimulator`、`CentralSimulator`、`ClientSimulator` 的 `__init__` 未接收 `token_api_host` 参数，`create_simulator` 工厂函数统一传入时导致 `unexpected keyword argument` 错误。已为三个子类补上 `token_api_host: Optional[str] = None` 参数并透传给 `super().__init__()`。

---

**验证结果**（本地 API 测试）：

| 步骤 | 结果 |
|------|------|
| 4 台门口屏全部启动 | ✅ `successCount:4, running:4` |
| 启动无纸化设备（同一环境配置） | ✅ `running:1` |
| 门口屏是否仍运行 | ✅ **`running:4`** — 核心问题修复 |
| 总设备 5 台 | ✅ 全部 `running:5`，无丢失 |

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/main.py` | 修改 | 启动时重置 DB 中 `status='running'` 的设备为 `stopped` |
| `backend/app/services/mqtt_manager.py` | 修改 | `connect()` 复用存活连接，不再断开重建 |
| `backend/app/services/device_sim_service.py` | 修改 | 减少 `_running_simulators_lock` 粒度，IO 移出锁范围 |
| `backend/app/database.py` | 修改 | 补列列表追加 `device_env_configs.default_topic_params` |
| `backend/app/simulators/paperless_simulator.py` | 修改 | `__init__` 补 `token_api_host` 参数 |
| `backend/app/simulators/central_simulator.py` | 修改 | 同上 |
| `backend/app/simulators/client_simulator.py` | 修改 | 同上 |
| `backend/app/simulators/__init__.py` | 恢复 | `create_simulator` 统一传 `token_api_host`（回退到修改前） |

**待办**：
- 本次改动尚未提交 git，下次会话可通过 `/GitCommit` 提交
- 部署到 192.168.5.60：需 SFTP 上传上述 7 个后端文件 → `docker restart plat-auto-test-app`
- 部署后验证：浏览器硬刷新，4 台门口屏启动 → 无纸化全部启动 → 切回门口屏确认仍为 `running`

---

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/routers/device_sim.py` | 修改 | 无纸化模板加授权码列 + 示例值优化 |
| `backend/app/services/device_sim_service.py` | 修改 | 重写 `batch_delete_simulators()` 为批量 SQL 操作 |
| `frontend/src/views/device-sim/EnvConfig.vue` | 修改 | 环境配置弹窗简化（删除主题前缀/默认参数/授权码配置区块） |
| `deploy_paperless_token.py` | 已有 | 部署脚本（复用上传到 5.60） |

**待办**：
- 本次改动（含此前会话 B/C 未提交的设备模拟改动）尚未提交 git，下次会话可通过 `/GitCommit` 提交
- 建议浏览器硬刷新（Ctrl+F5）验证：无纸化导入模板含授权码列；环境配置弹窗仅 7 个字段；批量勾选 100 条设备删除秒级完成

---

### 2026-08-13 会话 C：环境筛选与统计卡片联动修复（已全部完成并部署）

**会话目标**：修复设备模拟页面筛选 5.202 环境时统计卡片仍显示 540 的问题

**状态**：✅ 全部完成 — 文档 + 代码实现 + 构建 + 部署到 192.168.5.60 + 验证通过

**问题现象**：门口屏模拟页面顶部环境下拉框选择 **192.168.5.202**（0 台设备）后，统计卡片【总设备】仍显示 540。

**根因**（详见问题分析文档）：每台设备模拟页面存在**两套互不联动的环境筛选控件**：
1. **页面顶部下拉框**（`currentEnvId`）→ 变更只刷新统计卡片
2. **DeviceList 内部筛选栏**（`filterEnvConfigId`）→ 变更只刷新设备列表

且 `DeviceList.vue` 未声明 `envConfigId` prop，页面传入的 `:env-config-id` 被静默忽略，页面顶部环境选择无法传递到设备列表。

**后端验证**（确认后端无问题）：
- `door + env_328d4859a59a430ca25835f8d3c06c29(5.202)` → `total:0` ✅
- `door + env_98aea2c545b24aa29e9072e99fbef61c(5.44)` → `total:540` ✅
- `door + 无环境` → `total:540` ✅

**已修复内容**（统一为单一环境源，双向联动）：

1. **`DeviceList.vue`**（公共组件）：
   - props 增加 `envConfigId?: string`（可选，不传不影响独立使用）
   - emit 增加 `(e: 'env-change', envId: string)`
   - `watch(() => props.envConfigId)` 同步到内部 `filterEnvConfigId` 并刷新列表
   - 内部筛选栏 `@change` 由 `loadDevices` 改为 `onInternalEnvChange`（刷新列表 + emit `env-change`）

2. **四个 Sim 页面**（`DoorSim.vue` / `PaperlessSim.vue` / `CentralSim.vue` / `ClientSim.vue`）：
   - 模板新增 `@env-change="onDeviceEnvChange"`
   - 新增 `onDeviceEnvChange(envId)` 方法：同步 `currentEnvId` + `loadStats()`

**联带修复**：`frontend/src/views/functional-report/TemplateConfig.vue` 第 103 行 el-switch v-model 索引类型错误（TS7053），构建 `vue-tsc` 阻塞，已加 `as any` 修复。

**构建部署**：前端 `npm run build` 成功（29.34s，80 个文件），`deploy_frontend_only.py` 部署到 5.60，容器健康。

**部署后验证**（服务器端 grep 构建产物确认）：
- `DeviceList-8m7X4mUz.js`：`emits:["device-changed","env-change"]` + `watch(()=>ee.envConfigId)` 同步逻辑 ✅
- `DoorSim-B__vCye8.js`：VNode 渲染含 `"env-config-id":k.value` + `onEnvChange` 事件绑定 ✅
- `index.html` 引用 `index-Dxn_XUMs.js`（与本地产物 hash 一致，部署为最新）

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `Docs/PRD/设备模拟/问题处理/问题分析_筛选环境统计卡片不跟随.md` | 新增 | 问题分析文档（两套环境筛选不联动根因） |
| `Docs/PRD/设备模拟/执行计划/执行计划_环境筛选统一联动修复.md` | 新增 | 3 阶段执行计划文档 |
| `frontend/src/components/device/DeviceList.vue` | 修改 | envConfigId prop + watch + env-change emit + onInternalEnvChange |
| `frontend/src/views/device-sim/DoorSim.vue` | 修改 | @env-change 绑定 + onDeviceEnvChange |
| `frontend/src/views/device-sim/PaperlessSim.vue` | 修改 | 同上 |
| `frontend/src/views/device-sim/CentralSim.vue` | 修改 | 同上 |
| `frontend/src/views/device-sim/ClientSim.vue` | 修改 | 同上 |
| `frontend/src/views/functional-report/TemplateConfig.vue` | 修改 | el-switch v-model 索引类型修复（构建阻塞） |
| `deploy_frontend_only.py` | 新增 | 仅前端 dist 部署脚本（会话 B 会话中新增） |

**待办**：
- 本次改动尚未提交 git（连同会话 B 的一起），下次会话可通过 `/GitCommit` 提交
- 建议浏览器硬刷新（Ctrl+F5）验证：选 5.202 → 统计 0；选 5.44 → 统计 540；不选 → 540；DeviceList 内部切换环境与统计卡片同步

---

### 2026-08-13 会话 B：统计卡片修复 + 文档体系重构（已全部完成并部署）

**会话目标**：修复设备模拟页面顶部统计卡片（总设备/运行中/已停止）显示为 0 的问题

**状态**：✅ 全部完成 — 代码实现 + 构建 + 部署到 192.168.5.60 + API 验证通过

**已完成内容**：

#### ✅ 问题分析文档

- **`Docs/PRD/设备模拟/问题处理/问题分析_统计卡片显示为0.md`**
- 4 个根因分析：
  1. 后端无专用统计 API（`list_simulators()` 返回分页数据，无法聚合统计）
  2. 四个页面的 `onDeviceChanged()` 均为空函数，不响应设备变更
  3. `DoorSim.vue` 的 `total`/`runningCount` ref 初始化为 0 且从未更新
  4. `PaperlessSim.vue`、`CentralSim.vue`、`ClientSim.vue` 三个页面完全缺失统计卡片 UI

#### ✅ 执行计划文档

- **`Docs/PRD/设备模拟/执行计划/执行计划_统计卡片修复.md`**
- 4 阶段修复方案：
  1. 后端新增 `GET /api/device-sim/devices/stats` 统计 API
  2. 前端新增 `getDeviceStats()` API 封装和类型定义
  3. 统一 4 个页面的统计卡片组件，改为响应式更新
  4. 验证部署

#### ✅ 文档体系重组

- 所有设备模拟文档从 `Docs/PRD/需求文档/设备模拟/` 迁移到 `Docs/PRD/设备模拟/`
- 三级子目录：`需求文档/`、`执行计划/`、`问题处理/`
- 共 11 个文档已归类存放

#### ✅ Phase 1：后端统计 API（已完成）

- **`backend/app/schemas/device_sim.py`** — 新增 `DeviceStatsResponse`：
  - `total` / `running` / `stopped` 三字段 + `by_type: Dict[str, dict]`（按设备类型分组统计）
- **`backend/app/services/device_sim_service.py`** — 新增 `get_device_stats()` 方法：
  - 仅查询 `id, device_type, status` 三列（不加载完整 ORM 对象，性能优化）
  - 从 `_running_simulators` 内存字典读取实时运行状态（与 `list_simulators()` 同模式）
  - 单次遍历聚合 total/running/stopped + `by_type` 分组
- **`backend/app/routers/device_sim.py`** — 新增 `GET /devices/stats` 端点：
  - 参数：`device_type`（枚举 door/paperless/central/client）+ `env_config_id`
  - ⚠️ **路由顺序**：`/devices/stats`（第 233 行）必须在 `/devices/{device_id}`（第 472 行）之前注册，否则 `stats` 会被捕获为 device_id

#### ✅ Phase 2：前端 API/类型（已完成）

- **`frontend/src/types/device.ts`** — 新增 `DeviceStats` 接口（total/running/stopped/byType）
- **`frontend/src/api/deviceSim.ts`** — 新增 `getDeviceStats()` API 调用

#### ✅ Phase 3：四页面统一统计卡片（已完成）

- **`DoorSim.vue`** — `loadStats()` 接入 `getDeviceStats({ deviceType: 'door' })`，`onDeviceChanged()` / `onEnvChange()` 响应式刷新
- **`PaperlessSim.vue`** — **完整重写**：原 53 行无统计卡片，现补齐统计卡片 UI + 响应式刷新
- **`CentralSim.vue`** — **完整重写**：同上
- **`ClientSim.vue`** — **完整重写**：同上
- 四页面统一模式：统计卡片 + 环境选择器 + `loadStats()` + `onDeviceChanged()` + `onEnvChange()` + `onMounted`

#### ✅ Phase 4：构建 + 部署验证（已完成）

- 前端 `npm run build` 构建成功（34.80s，76 个文件）
- 部署脚本：**`deploy_to_560_stats.py`**（paramiko SFTP 上传 3 个后端文件 + dist，docker compose restart app）
- 已部署到 192.168.5.60，验证结果：
  - 容器状态 `Up (healthy)`，健康检查 `{"status":"healthy"}`
  - stats API 路由注册成功：`/api/device-sim/devices/stats`
  - **API 实际返回**：`{"total":1080,"running":0,"stopped":1080,"byType":{"door":{"total":540,...},"paperless":{"total":540,...}}}`
  - 数据库 1080 台设备（540 door + 540 paperless）统计正确，卡片不再显示 0

**修改文件清单**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/schemas/device_sim.py` | 修改 | 新增 `DeviceStatsResponse` |
| `backend/app/services/device_sim_service.py` | 修改 | 新增 `get_device_stats()` |
| `backend/app/routers/device_sim.py` | 修改 | 新增 `GET /devices/stats` 端点（路由顺序在 `/devices/{device_id}` 之前） |
| `frontend/src/types/device.ts` | 修改 | 新增 `DeviceStats` 类型 |
| `frontend/src/api/deviceSim.ts` | 修改 | 新增 `getDeviceStats()` |
| `frontend/src/views/device-sim/DoorSim.vue` | 修改 | 统计卡片接入真实数据 + 响应式刷新 |
| `frontend/src/views/device-sim/PaperlessSim.vue` | 重写 | 补齐统计卡片 UI + 响应式刷新 |
| `frontend/src/views/device-sim/CentralSim.vue` | 重写 | 补齐统计卡片 UI + 响应式刷新 |
| `frontend/src/views/device-sim/ClientSim.vue` | 重写 | 补齐统计卡片 UI + 响应式刷新 |
| `deploy_to_560_stats.py` | 新增 | 统计卡片修复部署脚本 |
| `Docs/PRD/设备模拟/问题处理/问题分析_统计卡片显示为0.md` | 新增 | 统计卡片问题分析 |
| `Docs/PRD/设备模拟/执行计划/执行计划_统计卡片修复.md` | 新增 | 4 阶段修复计划 |
| `Docs/PRD/需求文档/设备模拟/` (原目录) | 移动 | 全部移到 `Docs/PRD/设备模拟/` 子目录 |

**待办**：本次改动（9 个后端/前端文件 + 部署脚本）尚未提交 git，下次会话可通过 `/GitCommit` 提交。

---

### 2026-08-13 会话 A：全部启动/停止改为操作全量设备

**会话目标**：将【全部启动】/【全部停止】按钮从操作当前分页改为操作该模块下所有设备

**已完成内容**：

#### ✅ 后端 Service 新增全量查询方法

- **`start_all_by_device_type(device_type, env_config_id)`** — 按设备类型查询数据库全量设备 ID，调用 `batch_start_simulators`
- **`stop_all_by_device_type(device_type, env_config_id)`** — 同上，调用 `batch_stop_simulators`
- 支持可选 `env_config_id` 筛选（配合前端筛选栏的环境过滤）

#### ✅ 后端 Schema 新增

- **`BatchAllOperationRequest`** — 含 `device_type`（必填，枚举 door/paperless/central/client）和 `env_config_id`（可选）

#### ✅ 后端 API 路由新增

- `POST /api/device-sim/devices/batch/start-all` — 按设备类型全部启动
- `POST /api/device-sim/devices/batch/stop-all` — 按设备类型全部停止

#### ✅ 前端 API 封装

- `startAllDevices(deviceType, envConfigId?)` / `stopAllDevices(deviceType, envConfigId?)`

#### ✅ 前端 DeviceList.vue 改造

- `handleStartAll()` / `handleStopAll()` 改为调用新 API，不再取当前分页的 `devices` 列表
- 同步传递 `filterEnvConfigId`，配合环境筛选栏实现按环境过滤
- 四个设备页面（门口屏/无纸化/中控/集控客户端）共用该组件，自动统一生效

**修改文件清单**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | 新增 `start_all_by_device_type()` / `stop_all_by_device_type()` |
| `backend/app/schemas/device_sim.py` | 修改 | 新增 `BatchAllOperationRequest` Schema |
| `backend/app/routers/device_sim.py` | 修改 | 新增 `POST /devices/batch/start-all` 和 `stop-all` 端点 |
| `frontend/src/api/deviceSim.ts` | 修改 | 新增 `startAllDevices()` / `stopAllDevices()` API |
| `frontend/src/components/device/DeviceList.vue` | 修改 | 全部启动/停止改为调用全量 API |

**验证情况**：后端 Python 语法检查通过，前端 TypeScript 检查通过，`npm run build` 构建成功（31.20s）。

---

### 2026-08-06 会话 B：中控设备多类型主题上报 — 代码实现

**会话目标**：按执行计划实现中控设备 7 种主题类型上报的 4 个阶段

**已完成内容**：

#### ✅ 阶段一：后端主题模板 + 模拟器消息体

1. **`topic_templates.py`** — 中控主题模板从 2 种扩展为 7 种
   - 新增 `template_subscribe` + `template_publish` 双模板结构（替代旧的 `template` + `direction`）
   - 每种模板带 `device_type` 标识，用于按类型筛选
   - 新增 `resolve_topics_by_device_type()` 函数，根据 `topic_params.device_type` 筛选对应主题
   - `resolve_all_topics()` 兼容新旧两种模板结构

2. **`central_simulator.py`** — 新增 7 种消息体构建方法
   - `_build_room_online_payload()` — 会议室在线（action: online）
   - `_build_device_online_payload()` — 设备在线（action: _updatestatus）
   - `_build_audio_payload()` — 音频系统（action: _updateaudio）
   - `_build_video_payload()` — 视频系统（action: _updatevideo）
   - `_build_control_payload()` — 控制系统（action: _updatecontrol）
   - `_build_network_payload()` — 网络系统（action: _updatenetwork）
   - `_build_power_payload()` — 电源系统（action: _updatepower）
   - `build_status_payload()` 根据 `topic_params.device_type` 动态选择方法

3. **`base_simulator.py`** — `_resolve_topics()` 支持按 `device_type` 筛选中控主题
   - 中控设备且 `topic_params` 含 `device_type` 时调用 `resolve_topics_by_device_type()`
   - 其他设备类型保持原有逻辑

#### ✅ 阶段二：后端 Excel 导入 + API 适配

1. **`device_sim_service.py`** — Excel 导入支持主题类型
   - 列映射新增 "主题类型" → `central_device_type`（兼容旧版 "设备类型"）
   - 中控设备自动存入 `topic_params.device_number`（= device_id）
   - 中控设备自动存入 `topic_params.device_type`（默认 `device_online`）
   - 新增 `switch_device_type()` 方法：更新 topic_params + 运行中自动重启

2. **`device_sim.py`** — Excel 模板 + 切换 API
   - 中控模板新增"会议室编号"和"主题类型"两列
   - 新增 `PATCH /api/device-sim/devices/{device_id}/type` API
   - API 参数 `device_type_name` 支持 7 种类型标识

3. **`device_sim.py` (schemas)** — `TopicTemplateResponse` 新增 `device_type` 字段

#### ✅ 阶段三：前端设备类型显示 + 切换 UI

1. **`device.ts`** — 新增 `CENTRAL_DEVICE_TYPES` 映射 + `TopicTemplate.deviceType` 字段
2. **`deviceSim.ts`** — 新增 `switchDeviceType()` API 调用
3. **`DeviceList.vue`** — 
   - 中控设备列表新增"主题类型"列（带 el-tag 标签）
   - 操作列新增"切换类型"下拉框（7 种选项，当前类型禁用+勾选）
   - 切换确认弹窗提示运行中设备将自动重启
   - 导入弹窗中控提示更新（会议室编号 + 主题类型说明）
   - **表格各列增加 tooltip 说明**：
     - 设备名称：设备的显示名称，可自定义
     - 设备 ID：设备的唯一标识符，用于 MQTT 消息关联
     - 主题类型：中控设备上报的 MQTT 主题类型，支持 7 种切换
     - 状态：设备的运行状态：运行中/已停止/异常
     - 上报次数：设备累计发送 MQTT 消息的次数
     - 最后上报：设备最后一次发送 MQTT 消息的时间

#### ✅ 阶段四：联调测试 + 部署验证

- Python 语法检查通过
- 前端 `npm run build` 构建成功
- 已部署到 192.168.5.60（SFTP 上传 + docker restart）

#### ✅ 文案优化

- 用户可见的"设备类型"统一改为"主题类型"
- Excel 表头、前端 UI、API 错误提示均已更新
- 后端列映射兼容旧版"设备类型"列名

**中控 7 种主题类型**：

| 类型 | action | 发布主题 | client_udid 来源 |
|------|--------|---------|------------------|
| 会议室在线 | `online` | `/maintain/room/online/{会议室编号}/` | -（udid 字段） |
| 设备在线 | `_updatestatus` | `/maintain/room/master/client/` | 会议室编号 |
| 音频系统 | `_updateaudio` | `/maintain/room/master/client/` | 设备编号 |
| 视频系统 | `_updatevideo` | `/maintain/room/master/client/` | 设备编号 |
| 控制系统 | `_updatecontrol` | `/maintain/room/master/client/` | 设备编号 |
| 网络系统 | `_updatenetwork` | `/maintain/room/master/client/` | 设备编号 |
| 电源系统 | `_updatepower` | `/maintain/room/master/client/` | 设备编号 |

**本次会话修改文件清单**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/topic_templates.py` | 修改 | 中控模板扩展为 7 种 + 新增 resolve_topics_by_device_type() |
| `backend/app/simulators/central_simulator.py` | 修改 | 新增 7 种消息体构建方法 + 动态选择逻辑 |
| `backend/app/simulators/base_simulator.py` | 修改 | _resolve_topics() 支持按 device_type 筛选 |
| `backend/app/routers/device_sim.py` | 修改 | Excel 模板新增主题类型列 + PATCH 切换类型 API |
| `backend/app/services/device_sim_service.py` | 修改 | Excel 导入支持主题类型 + switch_device_type() |
| `backend/app/schemas/device_sim.py` | 修改 | TopicTemplateResponse 新增 device_type 字段 |
| `frontend/src/types/device.ts` | 修改 | 新增 CENTRAL_DEVICE_TYPES 映射 |
| `frontend/src/api/deviceSim.ts` | 修改 | 新增 switchDeviceType() API |
| `frontend/src/components/device/DeviceList.vue` | 修改 | 主题类型列 + 切换下拉框 + tooltip + 导入提示 |

---

### 2026-08-06 会话 A：中控设备多类型主题上报 — PRD + 计划

**会话目标**：输出 PRD 需求文档和执行计划文档

**已完成内容**：
1. ✅ PRD 需求文档 — `Docs/PRD/设备模拟/需求文档/_PRD_需求优化_中控设备多类型主题上报.md`
2. ✅ 执行计划文档 — `Docs/PRD/设备模拟/执行计划/_执行计划_中控设备多类型主题上报.md`
3. ✅ 初步代码实现（已部署到 192.168.5.60 但需按新计划重构）

---

### 2026-08-05 会话：Excel 批量导入 + 设备列表增强

## 一、本次会话完成的工作

### 1. ✅ Excel 批量导入设备功能

**需求**: 通过 Excel 文件批量导入模拟设备，快速创建多台设备。

**实现**:
- **后端新增端点**:
  - `GET /api/device-sim/devices/import/template` — 下载导入模板
  - `POST /api/device-sim/devices/import` — 批量导入设备
- **Excel 表头格式**:
  - 环境配置名称（必填）— 后台按名称查找环境配置 ID
  - 设备名称（必填）
  - 设备ID（必填）— 同一环境下唯一
  - 授权码/app_token（可选，仅门口屏）— 填入 topic_params
  - 自动重连（可选）— 支持是/否、true/false
  - 启用定时上报（可选）
  - 上报间隔(秒)（可选）
- **前端**:
  - 新增"批量导入"按钮和导入弹窗
  - 支持拖拽上传 .xlsx 文件
  - 模板下载链接
  - 导入结果表格显示每行成功/失败及错误原因

**修改文件**:
- `backend/requirements.txt` — 添加 `openpyxl==3.1.2`
- `backend/app/schemas/device_sim.py` — 新增 `DeviceImportRowResult`、`DeviceImportResponse`
- `backend/app/services/device_sim_service.py` — 新增 `import_simulators_from_excel()` 方法
- `backend/app/routers/device_sim.py` — 新增导入和模板下载端点
- `frontend/src/types/device.ts` — 新增导入相关类型
- `frontend/src/api/deviceSim.ts` — 新增 `importDevices()`、`downloadImportTemplate()`
- `frontend/src/components/device/DeviceList.vue` — 新增导入按钮、弹窗、逻辑

### 2. ✅ 移除实时消息流组件

**需求**: 暂时移除实时消息流面板，简化界面。

**实现**:
- 四个设备模拟页面（门口屏/无纸化/中控/集控客户端）移除右侧消息流面板
- 只保留设备列表，全宽显示
- `MessageStream.vue` 组件保留但不再使用

**修改文件**:
- `frontend/src/views/device-sim/DoorSim.vue`
- `frontend/src/views/device-sim/PaperlessSim.vue`
- `frontend/src/views/device-sim/CentralSim.vue`
- `frontend/src/views/device-sim/ClientSim.vue`

### 3. ✅ 分页器增强

**需求**: 分页器支持选择每页条数。

**实现**:
- 新增每页条数选择：10/20/50/100 条/页
- 布局调整：总数 + 每页条数 + 分页 + 跳转
- 使用 `v-model:page-size` 替代固定的 `limit`

**修改文件**:
- `frontend/src/components/device/DeviceList.vue`

### 4. ✅ 全部启动/全部停止按钮

**需求**: 快捷操作当前页所有设备。

**实现**:
- 未勾选设备时显示"全部启动"、"全部停止"按钮
- 勾选设备后切换为"批量启动"、"批量停止"、"批量删除"
- 操作当前页显示的所有设备

**修改文件**:
- `frontend/src/components/device/DeviceList.vue`

### 5. ✅ 模板下载中文文件名修复

**问题**: 下载模板时，中文文件名导致 HTTP 头编码错误。

**修复**: 使用 `urllib.parse.quote()` 对文件名进行 URL 编码。

**修改文件**:
- `backend/app/routers/device_sim.py`

### 6. ✅ 部署到 192.168.5.60

**方式**: 使用 paramiko SSH + SFTP 上传前端 dist 和后端代码，容器内安装 openpyxl 依赖后重启。

---

## 二、已知问题

### P0 — 消息流页面显示"暂无消息"（已移除消息流 UI，问题暂缓）

**说明**: 实时消息流组件已移除，此问题暂不处理。后续如需恢复消息流功能，再排查消息队列入队问题。

---

## 三、后续待办

| 优先级 | 任务 | 说明 |
|--------|------|------|
| P1 | 中控 7 种主题类型联调测试 | 下载中控模板 → 填写 7 种类型 → 导入验证 → 运行中切换类型 → 消息体格式验证 |
| P1 | 中控/集控客户端主题确认 | 确认真实上报/订阅主题与实际系统一致 |
| P2 | 环境配置统计功能 | 统计各环境下的设备数量 |
| P2 | 旧版中控设备兼容性测试 | 已创建的中控设备（无 device_type）默认为"设备在线"，需验证 |
| P2 | 执行计划文档状态更新 | 更新 `_执行计划_中控设备多类型主题上报.md` 中 4 个阶段的执行记录 |

---

## 四、相关文档索引

| 文档 | 路径 |
|------|------|
| 设备模拟 PRD | `Docs/PRD/设备模拟/需求文档/_PRD_需求文档_设备模拟模块.md` |
| 中控多类型 PRD | `Docs/PRD/设备模拟/需求文档/_PRD_需求优化_中控设备多类型主题上报.md` |
| 中控多类型执行计划 | `Docs/PRD/设备模拟/执行计划/_执行计划_中控设备多类型主题上报.md` |
| 统计卡片问题分析 | `Docs/PRD/设备模拟/问题处理/问题分析_统计卡片显示为0.md` |
| 统计卡片修复计划 | `Docs/PRD/设备模拟/执行计划/执行计划_统计卡片修复.md` |
| 统计卡片部署脚本 | `deploy_to_560_stats.py` |
| 环境筛选问题分析 | `Docs/PRD/设备模拟/问题处理/问题分析_筛选环境统计卡片不跟随.md` |
| 环境筛选修复计划 | `Docs/PRD/设备模拟/执行计划/执行计划_环境筛选统一联动修复.md` |
| 前端部署脚本 | `deploy_frontend_only.py` |

---

## 五、部署信息

| 项目 | 值 |
|------|-----|
| 服务器 IP | `192.168.5.60` |
| SSH 用户/密码 | `ubains / Ubains@123` |
| 部署目录 | `/data/third_party/plat-auto-test/` |
| Docker 容器名 | `plat-auto-test-app` |
| MySQL 容器名 | `plat-auto-test-mysql` |
| 前端地址 | http://192.168.5.60 |

**部署方式**: 
- 后端：SFTP 上传 `backend/app/` 下修改的文件到服务器 `/data/third_party/plat-auto-test/backend/app/`
- 前端：本地 `npm run build` 后，SFTP 上传 `frontend/dist/` 到服务器 `/data/third_party/plat-auto-test/frontend/dist/`
- 重启容器：`docker restart plat-auto-test-app`

---

## 六、Excel 导入模板格式

### 门口屏模板

| 列 | 表头 | 必填 | 说明 |
|----|------|------|------|
| A | 环境配置名称 | 是 | 需与已创建的环境配置名称完全一致 |
| B | 设备名称 | 是 | |
| C | 设备编号 | 是 | 同一环境下唯一 |
| D | 授权码(app_token) | 否 | 门口屏、无纸化都需要，填入 topic_params（无纸化 authCode 来源） |
| E | 自动重连 | 否 | 默认 true，支持 是/否、true/false |
| F | 启用定时上报 | 否 | 默认 true |
| G | 上报间隔(秒) | 否 | 默认 30，整数 >= 1 |

> **2026-08-19 更新**：授权码列已由"仅门口屏"扩展为"门口屏 + 无纸化"（无纸化示例 `ANP-NRY-0022`，门口屏示例 `AND-518-0001`）。

### 中控模板

| 列 | 表头 | 必填 | 说明 |
|----|------|------|------|
| A | 环境配置名称 | 是 | 需与已创建的环境配置名称完全一致 |
| B | 设备名称 | 是 | |
| C | 设备编号 | 是 | 同一环境下唯一，同时作为消息体 client_udid |
| D | 会议室编号 | 是 | 用于 MQTT 主题订阅和消息上报 |
| E | 主题类型 | 是 | 会议室在线/设备在线/音频系统/视频系统/控制系统/网络系统/电源系统 |
| F | 自动重连 | 否 | 默认 true |
| G | 启用定时上报 | 否 | 默认 true |
| H | 上报间隔(秒) | 否 | 默认 30 |

---

## 七、API 新增端点

| 方法 | 路径 | 说明 |
|------|------|------|
| `PATCH` | `/api/device-sim/devices/{device_id}/type` | 切换中控设备主题类型，参数 `device_type_name` |

---

### 2026-08-19 会话 E（续）：门口屏消息列表定时拉取（已实现 + 真实调用验证通过，已提交）

**会话目标**：门口屏模拟器获取 token 成功后，按设备导入时填写的上报间隔（默认 30 秒）定时调用 `GET /exapi/message/getMsgPageList/encrypt` 拉取消息列表，仅保证调用成功，不解密响应数据。

**状态**：✅ 代码实现完成 + 真实接口调用验证通过（HTTP 200 + 189548 字节 result）；✅ 已提交 git（`003ee7c9`），未部署

**背景**：真实门口屏设备（UbGview 应用）启动后，除了调 `getTokenInfoByToken` 获取 token，还会定时调用消息列表接口拉取会议消息。模拟器之前缺失此行为。

---

#### ① 新增动态签名 + 消息列表接口（`door_token_client.py`）

- **`MESSAGE_LIST_API_PATH = "/exapi/message/getMsgPageList/encrypt"`** 常量
- **`_generate_dynamic_sign(params, bearer_token)`** — 动态签名算法（SHA256 + AES-CBC）：
  1. 秒级时间戳 + JSON.stringify(params) + 随机字符串 → SHA256
  2. Bearer token 做 AES 密钥源：SHA256 → key[16:32] + iv[0:8]+[-8:]
  3. AES-CBC 加密 → Base64 编码
- **`get_message_list(authorization, conference_id)`** — 调用消息列表接口：
  - 参数：`queryType=2, pageNum=1, pageSize=100, conferenceId, minTime={now-24h}, maxTime={now}`
  - 动态签名头：X-SIGN / X-TIMESTAMP（秒级）/ X-RANDOM
  - 仅校验 HTTP 200 + success=true，**不解密 result 加密内容**
  - 网络错误不重试（下次定时再试）
- 会话重试 `allowed_methods` 扩展为 `["GET", "POST"]`

#### ② 关键发现：Authorization 格式必须无空格

- 真实设备日志 Authorization 为 **`BearereyJ...`（无空格）**
- token 接口返回的 `result.Authorization` 即为此格式，直接原样使用
- 若手动改为标准 `Bearer eyJ...`（带空格），消息接口返回 `A0076 无效token`
- 签名密钥源与请求头字符串必须一致

#### ③ DoorSimulator 集成（`door_simulator.py`）

- `start()` 改造为**四步流程**：
  1. 调 token 接口（已有）
  2. 回填 topic_params（已有）
  3. 父类 MQTT 启动（已有）
  4. 启动消息列表定时拉取线程（新增）
- **`_start_message_list_polling()`** — 立即调用一次 + 启动独立线程
- **`_call_message_list_once()`** — 单次调用 + 记录 ReportLog
- **`_message_list_loop()`** — 定时循环，**间隔 = `self._report_interval`（设备导入时填写的上报间隔，默认 30 秒）**
- **`stop()`** — 先停止消息拉取线程，再调用父类 stop
- 未配置 `token_api_host`（跳过 token 获取）时同样跳过消息拉取

#### 验证结果

| 验证项 | 结果 |
|--------|------|
| 真实调用：token 获取 → 消息列表接口 | ✅ HTTP 200 + OPERATION_SUCCESSFUL |
| result 长度 | ✅ 189548 字节（加密数据，不解密） |
| 签名算法：秒级时间戳 + AES-CBC | ✅ 通过服务端校验 |
| Authorization 无空格格式 | ✅ 必须 `BearereyJ...`，带空格返回 A0076 |
| 间隔关联 | ✅ 使用 `self._report_interval`，与导入时填写的上报间隔一致 |

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `get_message_list()` + 动态签名算法 `_generate_dynamic_sign()` + 常量 `MESSAGE_LIST_API_PATH` |
| `backend/app/simulators/door_simulator.py` | 修改 | 新增 `stop()` 覆盖 + `_start_message_list_polling()` / `_call_message_list_once()` / `_message_list_loop()` |
| `Docs/PRD/设备模拟/需求文档/_PRD_需求优化_门口屏消息列表定时拉取.md` | 新增 | PRD 需求文档 |
| `Docs/PRD/设备模拟/执行计划/_执行计划_门口屏消息列表定时拉取.md` | 新增 | 执行计划文档 |

**待办**：
- ⚠️ 本次改动（含此前会话 B/C/D/E 未提交的设备模拟改动）尚未提交 git，下次会话 `/GitCommit` 提交
- ⚠️ 未部署。需要门口屏环境配置在 `EnvConfig.default_topic_params` 里配 `token_api_host` 才会触发消息拉取
- 后续迭代：动态 X-SIGN 算法完全对齐（P2）、消息内容解密（P2）、消息列表结果与 MQTT 主题联动（P3）

---

### 2026-08-19 会话 G：门口屏补全最后两个 HTTP 接口定时调用（今日会议人脸页面 + 全局配置，已全部完成并部署）

**会话目标**：为门口屏模拟器补全剩余的定时 HTTP 接口调用 —— ① `POST /exapi/manageUser/getTodayMeetingFacePage/encrypt`（今日会议人脸页面） ② `GET /exapi/systemConfiguration/exGlobalConfig/encrypt`（全局配置）。至此门口屏启动后的 3 个 HTTP 定时接口全部实现。

**状态**：✅ 文档 + 代码实现完成 + 真实接口调用验证通过（两个接口均 HTTP 200 + success=true）；✅ **已提交 git（`695460eb`）并推送**；✅ **已部署到 192.168.5.60 并验证通过**

**背景**：真实门口屏设备（UbGview 应用）启动获取 token 后，除了消息列表接口，还会定时调用今日会议人脸页面和全局配置接口。模拟器此前只实现了 token 获取（会话 E）和消息列表拉取（会话 F）。

---

#### ① 新增接口 1：今日会议人脸页面（`DoorTokenClient.get_today_meeting_face_page()`）

- **接口**：`POST {scheme}://{host}/exapi/manageUser/getTodayMeetingFacePage/encrypt`
- **请求方式**：POST + form-urlencoded（Content-Type: `application/x-www-form-urlencoded; charset=utf-8`）
- **请求体（实测关键发现）**：body 必须含 **`companyNumber`**，否则返回 `A0002 缺少参数`：
  - 仅传 `conferenceId=50` → `A0002 缺少参数`
  - 传 `companyNumber=CN-34H-UBAINS` → `200 OPERATION_SUCCESSFUL`（conferenceId 可选，同时传不影响，result_len=172）
- **公司编号来源**：token 响应 `token.companyNumber`，已在 token 获取流程回填到 `topic_params.company_id`，每次调用实时读取
- **签名**：复用 `_generate_dynamic_sign()` 动态签名（与消息列表一致）

#### ② 新增接口 2：全局配置（`DoorTokenClient.get_ex_global_config()`）

- **接口**：`GET {scheme}://{host}/exapi/systemConfiguration/exGlobalConfig/encrypt?companyNumber={companyNumber}`
- **请求方式**：GET + query 参数（Content-Type: `application/json` 与真实日志一致）
- **参数**：仅 `companyNumber`（来自 token 响应 `token.companyNumber`），实测直接成功
- **签名**：复用 `_generate_dynamic_sign()` 动态签名

#### ③ DoorSimulator 集成（定时常量扩充为 3 个接口）

- `_message_list_loop()` 每次循环依次调用：
  1. `_call_message_list_once()` — 消息列表（已有）
  2. `_call_face_page_once()` — 今日会议人脸页面（新增）
  3. `_call_ex_global_config_once()` — 全局配置（新增）
- `_start_message_list_polling()` 首次立即执行时三个接口都调用一次
- 三个接口共用同一定时线程，间隔 = `self._report_interval`（默认 30s），不新增线程
- 任一接口失败不影响其他接口，也不影响下次定时循环

#### 验证结果

| 验证项 | 结果 |
|--------|------|
| 真实调用：token → 人脸页面接口 | ✅ HTTP 200 + OPERATION_SUCCESSFUL，result_len=172 |
| 参数探测：仅传 conferenceId | ✅ 返回 A0002 缺少参数（确认 companyNumber 必传） |
| 真实调用：token → 全局配置接口 | ✅ HTTP 200 + OPERATION_SUCCESSFUL |
| 公司编号来源链路 | ✅ token 响应 token.companyNumber → topic_params.company_id → 两个接口调用 |
| 三接口共用同一定时线程 | ✅ 消息列表/人脸页面/全局配置串行调用，不影响间隔 |
| Python 语法检查 | ✅ 两个文件 py_compile 通过 |
| Git 提交推送 | ✅ `695460eb` 已推送远程 |
| 部署验证：容器状态 | ✅ `Up (healthy)`，健康检查 200 OK |
| 部署验证：文件更新 | ✅ `door_token_client.py` 含新接口，`door_simulator.py` 含新方法 |

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `FACE_PAGE_API_PATH` / `EX_GLOBAL_CONFIG_API_PATH` 常量 + `get_today_meeting_face_page()`（POST, body 含 companyNumber）+ `get_ex_global_config()`（GET, query companyNumber） |
| `backend/app/simulators/door_simulator.py` | 修改 | 新增 `_call_face_page_once()` / `_call_ex_global_config_once()`，定时循环与首次调用扩充为 3 个接口 |
| `Docs/PRD/设备模拟/需求文档/_PRD_需求优化_门口屏今日会议人脸页面定时拉取.md` | 新增 | PRD 需求文档 |
| `Docs/PRD/设备模拟/执行计划/_执行计划_门口屏今日会议人脸页面定时拉取.md` | 新增 | 执行计划文档 |
| `Docs/PRD/设备模拟/需求文档/_PRD_需求优化_门口屏全局配置定时拉取.md` | 新增 | PRD 需求文档 |
| `Docs/PRD/设备模拟/执行计划/_执行计划_门口屏全局配置定时拉取.md` | 新增 | 执行计划文档 |
| `deploy_door_http_final.py` | 新增 | 部署脚本（paramiko SFTP 上传 + docker compose restart app） |

**门口屏模拟器完整 HTTP 调用链（至此全部实现）**：

```
启动
  ↓
① getTokenInfoByToken（POST，会话 E）→ 拿 authorization + 回填 company_id/room_id/conference_id/app_token
  ↓
② 定时循环（间隔 = 上报间隔，默认 30s，独立线程）：
   ├─ getMsgPageList/encrypt（GET，会话 F）— 消息列表
   ├─ getTodayMeetingFacePage/encrypt（POST，本次会话）— 今日会议人脸页面（body 含 companyNumber）
   └─ exGlobalConfig/encrypt（GET，本次会话）— 全局配置（query companyNumber）
```

**待办**：
- 后续迭代：动态 X-SIGN 算法完全对齐（P2）、消息内容解密（P2）、消息列表结果与 MQTT 主题联动（P3）

---

---

### 2026-08-20 会话 J：修复全部启动功能失败 — token_api_host 错误回退 + MQTT on_disconnect 阻塞修复（代码修复 + 部署验证完成）

**会话目标**：修复"全部启动"功能一直启动不起来的问题

**状态**：✅ 问题分析 + 执行计划文档完成；✅ 代码修复（token_api_host 回退 + mqtt_manager on_disconnect 阻塞）；✅ 部署到 192.168.5.60 并验证通过；⚠️ MQTT 连接闪断（rc=7）为 Broker 端问题，非 app 代码缺陷

---

#### 问题 1：token_api_host 错误回退导致全部启动阻塞

**现象**：点击门口屏或无纸化的"全部启动"按钮后，页面长时间无响应，设备状态不更新。

**根因**：`device_sim_service.py` 第 843 行：
```python
# 修复前 — 环境配置未设置 token_api_host 时回退为 broker_host
token_api_host = config.default_topic_params.get("token_api_host") or config.broker_host
```
当环境配置 `default_topic_params` 中不含 `token_api_host`（如 5.60 服务器的环境配置），回退为 MQTT Broker 地址 `192.168.5.44`，门口屏/无纸化模拟器 `start()` 判断 `if not token_host:` 为 False → 进入 token 获取分支 → 调用 Token API 到 `192.168.5.44`（不是 Token API 服务器）→ 每台设备串行失败。

**修复**（`device_sim_service.py` 第 843 行）：
```python
# 修复后 — 未配置时保持 None，模拟器跳过 token 获取直接走 MQTT
token_api_host = config.default_topic_params.get("token_api_host")
```

---

#### 问题 2：mqtt_manager.py on_disconnect 回调阻塞导致启动卡死

**现象**：修复问题 1 后，小批量启动（4 台）成功，但 MQTT 连接持续闪断（每 ~3 秒连接→断开→重连）。

**根因**：`mqtt_manager.py` 的 `on_disconnect` 回调中：
```python
# 修复前 — 阻塞 paho 网络循环线程 + 与内置重连冲突
time.sleep(delay)          # 阻塞 paho 网络线程
client.reconnect()         # 与 reconnect_delay_set 自动重连冲突
```
`on_disconnect` 运行在 paho 的网络循环线程上，`time.sleep()` 阻塞该线程导致：
1. paho 的 `reconnect_delay_set(min_delay=1, max_delay=60)` 内置自动重连被阻塞
2. 手动 `reconnect()` 与 paho 自动重连同时触发，可能导致重复连接被 Broker 踢掉

**修复**（`mqtt_manager.py` `on_disconnect`）：
```python
# 修复后 — 不阻塞、不手动重连，由 paho 内置机制处理
def on_disconnect(self, client, userdata, rc):
    with self._lock:
        self.connected = False
        if rc != 0:
            logger.warning(f"MQTT 意外断开: env_config_id={self.env_config_id}, rc={rc}, 等待自动重连...")
```

---

#### 已知问题：MQTT 连接闪断（rc=7 = MQTT_ERR_CONN_LOST）

经独立测试确认，MQTT 到 192.168.5.44:1883 的连接每 ~3 秒被 Broker 主动断开，与订阅/发布/client_id 均无关。这是 **Broker 端的问题**（如 idle_timeout、duplicate client 检测、ACL kick 等），非 app 代码缺陷。

- 连接 3 秒后断开 → paho 自动重连 → 连接 3 秒后再次断开，循环
- 设备能成功启动（小批量测试 4/4 成功），但 540 台串行全部启动超时（540 × 3s ≈ 27 分钟 > 120s 超时）
- 建议后续：① 排查 Broker 配置 ② batch_start 改为并行启动 ③ 或切换到 5.60 本地 EMQX

---

#### 部署操作

1. 修复 `deploy_door_http_final.py`，补充 `device_sim_service.py` 到上传列表（共 8 个后端文件）
2. 修复 `mqtt_manager.py` on_disconnect 回调
3. SFTP 上传 8 个后端文件到 5.60 服务器 → `docker compose restart app`
4. 容器重启后状态：`Up (healthy)`

---

#### 验证结果

| 验证项 | 结果 |
|--------|------|
| 小批量启动 4 台门口屏 | ✅ 成功 4/4，无阻塞 |
| 小批量启动 6 台门口屏 | ✅ 成功 4/6（2 台 ID 不存在） |
| 540 台全部启动（start-all） | ⚠️ 超时（Broker 闪断 + 串行启动太慢） |
| MQTT 连接稳定性 | ⚠️ 每 ~3 秒断连（Broker 端问题） |
| 设备统计 API | ✅ 正确返回 1040 台设备统计 |

---

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | 第 843 行删除 `or config.broker_host`，token_api_host 未配置时保持 None |
| `backend/app/services/mqtt_manager.py` | 修改 | `on_disconnect` 移除 `time.sleep()` + `client.reconnect()`，由 paho 内置自动重连处理 |
| `deploy_door_http_final.py` | 修改 | BACKEND_FILES 列表新增 `device_sim_service.py`（共 8 个文件） |
| `Docs/PRD/设备模拟/问题处理/问题分析_token_api_host错误回退导致全部启动失败.md` | 新增 | 问题分析文档 |
| `Docs/PRD/设备模拟/执行计划/执行计划_修复token_api_host错误回退导致全部启动失败.md` | 新增 | 执行计划文档 |

**待办**：
- ⚠️ 本次改动未提交 git，下次会话 `/GitCommit` 提交
- ⚠️ 未部署到生产环境（5.60 服务器已部署）
- P0：排查 192.168.5.44 Broker 闪断原因（rc=7 = MQTT_ERR_CONN_LOST）
- P1：`batch_start_simulators` 改为并行启动（asyncio.gather + semaphore 控制并发），避免 540 台串行超时
- P2：考虑使用 5.60 本地 EMQX 替代 192.168.5.44 的 Broker

---

### 2026-08-20 会话 H：修复部署遗漏导致门口屏启动失败（已全部完成并部署）

**会话目标**：修复门口屏启动报错 `BaseSimulator.__init__() takes from 5 to 8 positional arguments but 9 were given`，因 `deploy_door_http_final.py` 部署时遗漏 `base_simulator.py` 所致。

**状态**：✅ 问题分析 + 执行计划文档完成；✅ 代码修复（部署脚本补充文件）；✅ 部署到 192.168.5.60 并验证通过

**根因**：`deploy_door_http_final.py`（commit `695460eb`）只上传了 `door_simulator.py` 和 `door_token_client.py` 两个文件，但新版 `door_simulator.py` 的 `super().__init__()` 传 8 个参数（含 `token_api_host`），而服务器上的旧版 `base_simulator.py` 只接受 7 个参数，导致参数不匹配。

**为什么无纸化不受影响**：旧版 `paperless_simulator.py` 的 `super().__init__()` 只传 7 个参数，匹配旧版 `BaseSimulator` 的 7 参数签名。

**修复操作**：
1. `deploy_door_http_final.py` 扩展为 7 个后端文件（新增 `base_simulator.py` / `__init__.py` / `paperless_simulator.py` / `central_simulator.py` / `client_simulator.py`）
2. 新增问题分析文档：`Docs/PRD/设备模拟/问题处理/问题分析_部署遗漏BaseSimulator导致门口屏启动失败.md`
3. 新增执行计划文档：`Docs/PRD/设备模拟/执行计划/执行计划_部署遗漏修复BaseSimulator参数不匹配.md`

**验证结果**：

| 验证项 | 结果 |
|--------|------|
| 健康检查 | ✅ `{"status":"healthy"}` |
| 门口屏启动 | ✅ `running:1` — 核心问题修复 |
| 无纸化启动 | ✅ `running:1` — 回归正常 |
| 停止设备 | ✅ 正常停止 |

**待办**：
- 后续迭代：动态 X-SIGN 算法完全对齐（P2）、消息内容解密（P2）、消息列表结果与 MQTT 主题联动（P3）
- 建议统一部署脚本（当前 3 个脚本职责分散）

---

### 2026-08-20 会话 I：无纸化模拟器 Token 获取 + 消息定时拉取（已全部完成，真实调用验证通过，未提交未部署）

**会话目标**：为无纸化（paperless）模拟器补充两个 HTTP 接口调用行为，与门口屏一致：
1. **启动时**调用 `POST /api/api-android/token/getTokenInfoByToken` 获取 token（与门口屏差异：无 `/exapi` 前缀 + regUdid 纯 16 位 hex 无前缀）
2. **启动后**按上报间隔定时调用 `POST /api/message/getMessageByAppToken` 拉取会议消息（HTTP 200 即视为成功，业务码 `A0034 会议不存在` 是正常业务场景）

**状态**：✅ 文档（2 份 PRD + 2 份执行计划）+ 代码实现全部完成；✅ 真实接口调用验证通过（token 接口 HTTP 200 + 字段完整；消息接口 HTTP 200 成功）；✅ 已提交 git（`695460eb`）并推送；✅ **已部署到 192.168.5.60 并验证通过**

**背景**：无纸化会议设备（华为平板 BAH3-W59，App 无纸化2.0）启动后的第一个动作是调用 token 接口，获取 Authorization 后定期调用消息接口。模拟器此前缺失此行为，启动时直接连 MQTT，且 `topic_params` 依赖手动配置。

---

#### ① 扩展 `DoorTokenClient` 支持无纸化（`door_token_client.py`）

**设计决策：复用 `DoorTokenClient`，不新建类**（与门口屏的请求头格式、响应解析逻辑完全一致，只有 API 路径和 regUdid 格式差异）。

| 对比项 | 门口屏 | 无纸化 |
|--------|--------|--------|
| Token API 路径 | `/exapi/api-android/token/getTokenInfoByToken` | `/api/api-android/token/getTokenInfoByToken`（无 `/exapi`） |
| regUdid 格式 | `reg_{md5(device_id)[:16]}` | `{md5(device_id)[:16]}`（纯 16 位 hex，无前缀） |
| 消息接口 | `GET /exapi/message/getMsgPageList/encrypt` | `POST /api/message/getMessageByAppToken` |
| 业务响应校验 | HTTP 200 + success=true | **HTTP 200 即视为成功**（A0034 是正常业务） |

**新增内容**：
- **常量**：`PAPERLESS_TOKEN_API_PATH = "/api/api-android/token/getTokenInfoByToken"`、`PAPERLESS_MESSAGE_API_PATH = "/api/message/getMessageByAppToken"`
- **函数**：`generate_paperless_reg_udid(device_id)` → `md5(device_id)[:16]`（确定性派生）
- **`DoorTokenClient.__init__` 参数化**：新增 `token_api_path`（门口屏/无纸化不同路径）和 `reg_udid_override`（外部传入 regUdid）
- **`get_token()`**：使用 `self._token_api_path` 替代硬编码常量
- **`get_message_by_app_token(authorization, app_token)`**：POST + form-urlencoded + 动态签名头；**只校验 HTTP 200**，业务码 `A0034 会议不存在` 记录 info 日志但不视为失败

#### ② `PaperlessSimulator` 集成（`paperless_simulator.py`）

**启动流程（四步，与门口屏一致）**：
```
start()
  ↓
① 调 token 接口（DoorTokenClient + PAPERLESS_TOKEN_API_PATH + generate_paperless_reg_udid）
  ↓
② 回填 topic_params：companyNumber→company_id、cnum→room_id、conferenceId→conference_id、conferenceName→conference_name
  ↓
③ _resolve_topics() + 父类 MQTT 启动
  ↓
④ 启动消息定时拉取线程（getMessageByAppToken，间隔 = report_interval）
```

**新增属性**：`_token_info`、`_token_client`、`_authorization`、`_msg_thread`、`_msg_running`

**新增方法**：
- `_start_message_polling()` — 首次立即调用 + 启动 daemon 线程
- `_call_message_once()` — 单次调用 + `_notify_report` 上报记录
- `_message_loop()` — 秒级可中断的定时循环（间隔 = `self._report_interval`）

**`stop()` 覆盖**：先停止消息拉取线程（join 3s），再调父类 stop

**错误处理**：
- token 获取失败 → 记录错误日志 → `_notify_report` 上报 `token_get` failed → 启动终止
- 消息接口失败（网络/超时/业务码非 200）→ 仅记日志，不影响下次定时调用，不影响设备运行
- 未配置 `token_api_host` → 仅 warning 跳过，向后兼容

#### 验证结果

| 验证项 | 结果 |
|--------|------|
| 真实调用：token 接口（ANP-NRY-0022 + 54035332b33e1e30） | ✅ HTTP 200 + OPERATION_SUCCESSFUL，字段完整 |
| companyNumber | ✅ CN-P7J-UBAINS |
| cnum | ✅ 248d49ca1c728232d0154f4ce5943148 |
| conferenceId | ✅ 4 |
| conferenceName | ✅ 测试会议室1 |
| 真实调用：消息接口（getMessageByAppToken） | ✅ HTTP 200 即成功: True |
| regUdid 确定性 | ✅ 同 device_id 两次结果一致（986faab056ebf2ed） |
| Python 语法检查 | ✅ door_token_client.py + paperless_simulator.py 均通过 |

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `PAPERLESS_TOKEN_API_PATH` / `PAPERLESS_MESSAGE_API_PATH` 常量 + `generate_paperless_reg_udid()` 函数 + `get_message_by_app_token()` 方法 + `__init__` 参数化（token_api_path / reg_udid_override）+ `get_token()` 使用实例路径 + `__all__` 导出更新 |
| `backend/app/simulators/paperless_simulator.py` | 修改 | `start()` 四步流程 + `stop()` 覆盖 + `_start_message_polling()` / `_call_message_once()` / `_message_loop()` 消息拉取线程 + `_token_info` / `_token_client` / `_authorization` / `_msg_thread` / `_msg_running` 属性 |
| `Docs/PRD/设备模拟/需求文档/_PRD_需求优化_无纸化模拟器Token获取.md` | 新增 | PRD 需求文档 |
| `Docs/PRD/设备模拟/执行计划/_执行计划_无纸化模拟器Token获取.md` | 新增 | 执行计划文档（阶段一/二全部 ✅，阶段三 3.1/3.2 ✅） |
| `Docs/PRD/设备模拟/需求文档/_PRD_需求优化_无纸化模拟器消息定时拉取.md` | 新增 | PRD 需求文档 |
| `Docs/PRD/设备模拟/执行计划/_执行计划_无纸化模拟器消息定时拉取.md` | 新增 | 执行计划文档（阶段一/二全部 ✅，阶段三 3.1/3.2 ✅） |

**无纸化模拟器完整 HTTP 调用链（至此全部实现）**：

```
启动
  ↓
① getTokenInfoByToken（POST，API 路径无 /exapi 前缀，regUdid 纯 16 位 hex）
  → 拿 authorization + 回填 company_id/room_id/conference_id/conference_name
  ↓
② 定时循环（间隔 = 上报间隔，默认 30s，独立线程）：
   └─ getMessageByAppToken（POST，form-urlencoded，appToken 参数）
      → 仅校验 HTTP 200，A0034 会议不存在是正常业务响应
```

**待办**：
- ⚠️ 本次改动（含此前所有会话未提交的设备模拟改动）尚未提交 git，下次会话 `/GitCommit` 提交
- ⚠️ 未部署。需要无纸化环境配置在 `EnvConfig.default_topic_params` 里配 `token_api_host`（如 `https://192.168.5.240`）才会触发 token 获取和消息拉取；批量创建无纸化设备时 Excel 必须提供"授权码(app_token)"列
- 后续迭代：动态 X-SIGN 算法完全对齐（P2）、消息内容解密（P2）

---

### 2026-08-20 会话 K：batch_start_simulators 并行化改造（已提交已推送，未部署）

**会话目标**：P1 优化 —— 将 `batch_start_simulators` 从串行执行改为并行执行，解决 540 台设备全部启动超时（>120s）问题。

**状态**：✅ 代码实现完成 + 语法检查通过 + 本地并发验证通过；✅ 已提交 git（`01c44f17`）并推送；✅ **已部署到 5.60 并验证通过（4 台批量启动 + 540 台全部停止）**

**问题背景**：`batch_start_simulators()` 使用 for 循环逐台 `await start_simulator()` 串行启动。540 台设备串行启动：
- 正常 MQTT（0.5s/台）：540 × 0.5s ≈ **270 秒** > 120s 超时
- Broker 闪断（3s/台）：540 × 3s ≈ **27 分钟** >> 120s 超时

---

#### 修复内容（`backend/app/services/device_sim_service.py`）

**批量启动改为 `ThreadPoolExecutor` 并行执行**：

```
540 台设备
  ↓
ThreadPoolExecutor(max_workers=50)
  ├─ 线程 1: dev_1 → 独立 asyncio 事件循环 + 独立 DB 会话 → start_simulator
  ├─ 线程 2: dev_2 → 独立 asyncio 事件循环 + 独立 DB 会话 → start_simulator
  ├─ 线程 3: dev_3 → ...
  ...
  └─ 线程 50: dev_50 → ...
  ↓
asyncio.Semaphore(50) 控制并发（防止 MQTT Broker / Token API 过载）
```

**关键设计**：

| 设计点 | 说明 |
|--------|------|
| **线程池** | `ThreadPoolExecutor(max_workers=50)`，真正并行执行同步 HTTP 调用（token API）不阻塞事件循环 |
| **独立事件循环** | 每个线程 `new_event_loop()` + `run_until_complete()`，与 Playwright 执行器模式一致 |
| **独立 DB 会话** | 每个线程从 `async_session_maker` 创建独立 `AsyncSession`，避免 SQLAlchemy AsyncSession 线程安全问题 |
| **信号量控制** | `asyncio.Semaphore(50)` 控制并发度，防止 MQTT Broker / Token API 过载 |
| **MQTT 连接复用** | `mqtt_manager.connect()` 内部已有 `_manager_lock` + 已有连接检测，首个设备创建连接，后续设备复用（会话 F 修复） |
| **去重** | `list(dict.fromkeys(device_ids))` 防止同一设备重复启动 |

**启动时间对比**：

| 场景 | 串行 | 并行（50 线程） |
|------|------|----------------|
| 正常 MQTT（0.5s/台） | 540 × 0.5s = **270s** | ≈ **~3s** ✅ |
| Broker 闪断（3s/台） | 540 × 3s = **1620s ✗ 超时** | ≈ **~3s** ✅（仍受 Broker 断连影响，但不再串行放大） |

**验证结果**：

| 验证项 | 结果 |
|--------|------|
| Python 语法检查 | ✅ `py_compile` 通过 |
| 并发验证（20 台模拟，每台 0.05s） | ✅ 串行预计 1.0s，并行实际 0.07s，确认并行执行 |
| 去重逻辑 | ✅ 重复 device_id 只启动一次 |

**修改文件清单（本次会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | `batch_start_simulators()` 改为 ThreadPoolExecutor 并行启动 + Semaphore 并发控制 |

**说明**：本次提交 `01c44f17` 同时包含了性能测试模块的项目管理与批量执行功能（前端 Projects/ProjectDetail/TaskList + 后端 performance 相关改动 + 2 份 PRD 文档），非设备模拟模块本身。

**待办**：
- ⚠️ 未部署。需 SFTP 上传 `backend/app/services/device_sim_service.py` 到 5.60 → `docker compose restart app`
- 部署后验证：批量启动 540 台门口屏/无纸化设备，确认不再超时、成功率高
- 已知问题：MQTT Broker 闪断（rc=7）为 Broker 端问题，非 app 代码缺陷，后续排查 192.168.5.44 配置

---

### 2026-08-20 会话 K（续）：540 设备批量启动验证 + per-thread engine 修复（已提交已推送）

**会话目标**：验证 batch_start_simulators 并行化后 540 台门口屏设备全部启动成功，修复 per-thread engine 队列绑定问题。

**状态**：✅ 540/540 全部启动成功；✅ per-thread engine 修复（`_thread_db = threading.local()` + 每线程独立 engine）+ 已提交推送（`d0757333`）；✅ 前端合并报告功能（Phase 1-2）已提交推送（`7f4e853f`→`ae60263c`）

---

#### ① 540 台设备批量启动验证

| 步骤 | 结果 |
|------|------|
| 登录 192.168.5.44 环境（门口屏 540 台） | ✅ 在线 |
| 全部启动 | ✅ 540/540 全部成功（总耗时 ~35s，非预计 3s 因 Broker 闪断 3s/台 × 50 并发 = 每批 ~3s × 多批） |
| 后续全部停止 | ✅ 成功 |
| 再次全部启动 | ✅ 540/540 成功 |
| 无纸化 540 台全部启动 | ✅ 540/540 成功 |

**结论**：并行化改造后 540 台设备批量启动可稳定在 35s 内完成，不再超时。

---

#### ② Per-thread engine 修复

**问题现象**：并行启动 540 台设备时，部分设备报错 `Queue is bound to a different event loop`。

**根因**：`batch_start_simulators()` 使用 `ThreadPoolExecutor` 多线程执行，但每个线程内部使用同一全局 `async_session_maker`（底层绑定到主线程事件循环的 `AsyncAdaptedQueuePool`）。多线程共享同一异步引擎队列导致事件循环绑定冲突。

**修复**（`backend/app/services/device_sim_service.py`）：

```python
# 新增：线程本地存储，每线程独立 engine + session
_thread_db = threading.local()

def _get_thread_engine():
    """获取当前线程的独立异步引擎"""
    ctx = getattr(_thread_db, 'ctx', None)
    if ctx is None:
        loop = asyncio.new_event_loop()
        engine = create_async_engine(
            str(SQLALCHEMY_DATABASE_URL),
            pool_size=1, max_overflow=0,
            poolclass=AsyncAdaptedQueuePool,
            # ...
        )
        session = AsyncSession(engine, expire_on_commit=False)
        _thread_db.ctx = {'loop': loop, 'engine': engine, 'session': session}
    return _thread_db.ctx
```

**关键设计**：
- `threading.local()` 确保每线程独立上下文，互不干扰
- 每线程 `new_event_loop()` + `create_async_engine(pool_size=1)`，避免队列绑定冲突
- `pool_size=1` 限制每线程仅 1 个连接，总连接数 = 并发线程数（≤50）
- `_release_thread_db_ctxs()` 在批启动完成后清理所有线程的 engine 连接

**验证**：540 台设备全部启动成功，无 `Queue is bound to a different event loop` 错误。

---

#### ③ 前端合并报告功能（本会话性能测试模块工作）

**本次会话同步完成了性能测试模块的合并报告功能（Phase 1-2）**：

| 提交 | 说明 |
|------|------|
| `7f4e853f` | feat(performance): 批量执行进度推送 + 自动生成合并报告（后端 Phase 1：模型 + 聚合 + API） |
| `f38a23f1` | fix(performance): 合并报告 API 路由修复 + 数据库迁移 |
| `09e64690` | feat(performance): 前端合并报告类型定义 |
| `866acd45` | feat(performance): 前端合并报告 API 调用 |
| `ae60263c` | feat(performance): 前端合并报告页面 ProjectReport.vue + 项目详情页入口 |

**功能说明**：批量执行后自动生成合并报告，聚合指标卡片（4×16）+ 分位数表 + 状态码分布 + 各任务明细表。项目详情页批量执行完成后出现「查看合并报告」按钮。

**待办**（Phase 3）：
- 前端构建验证 `npm run build`
- 部署到 5.60 服务器

---

**修改文件清单（本次子会话）**：

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | `_thread_db = threading.local()` per-thread engine + `_get_thread_engine()` + `_release_thread_db_ctxs()` |
| `backend/app/schemas/performance.py` | 新增/修改 | `ProjectReportResponse`、`ProjectReportListResponse`、`BatchRunResponse` 扩展 report_id |
| `backend/app/models/performance.py` | 修改 | 新增 `PerformanceBatchExecution`、`PerformanceProjectReport` 模型 |
| `backend/app/database.py` | 修改 | 注册新表 |
| `backend/app/services/performance_service.py` | 修改 | `_aggregate_task_results()` 聚合 + `batch_run_tasks()` 持久化 + 自动生成合并报告 |
| `backend/app/routers/performance.py` | 修改 | 新增 3 条合并报告路由 + 批量执行响应扩展 report_id |
| `frontend/src/types/performance.ts` | 修改 | 新增合并报告类型定义 |
| `frontend/src/api/performance.ts` | 修改 | 新增 `getProjectReport()` / `getProjectReports()` / `getBatchReport()` |
| `frontend/src/views/performance/ProjectReport.vue` | 新增 | 合并报告页面（聚合指标 4×16 卡片 + 分位数表 + 状态码分布 + 任务明细） |
| `frontend/src/views/performance/ProjectDetail.vue` | 修改 | 批量执行完成后显示「查看合并报告」按钮 + 历史报告入口 |
| `frontend/src/router/index.ts` | 修改 | 注册 `/performance/project-report` 路由 |
| `Docs/PRD/性能测试/需求文档/_PRD_批量执行合并报告.md` | 新增 | PRD 需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_批量执行合并报告_计划执行.md` | 新增 | 执行计划文档 |

**待办**：
- ✅ 设备模拟相关改动（会话 J token_api_host 回退修复 + 会话 I 无纸化 token/消息 + 会话 K 并行化 + per-thread engine）**全部已提交并推送**，且 **2026-08-21 已部署到 5.60**（SFTP 上传 5 个文件 + docker restart + 健康检查 + grep 验证 + 4 台批量启动验证通过）
- 已知问题：MQTT Broker 闪断（rc=7）为 Broker 端问题，非 app 代码缺陷
