提交 003ee7c9 authored 作者: 陈泽健's avatar 陈泽健

fix(device-sim): 修复启动无纸化后门口屏停止 + MQTT 连接复用 + 消息列表定时拉取

修复无纸化批量启动导致门口屏设备 UI 显示停止的三重问题:
- MQTT connect() 复用存活连接,不断开重建(核心修复)
- 启动时重置 DB 中残留的 running 状态为 stopped
- 减少 start_simulator 锁粒度,IO 移出锁范围

连带修复:
- database.py 补列列表追加 default_topic_params
- paperless/central/client 模拟器 __init__ 统一 token_api_host 参数

新增:
- 门口屏消息列表定时拉取(getMsgPageList/encrypt + AES 加密)
- 门口屏人脸页面定时拉取 PRD 文档与执行计划
- deploy.41 部署脚本与 docker-compose
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 d529f528
# 执行计划:门口屏模拟器今日会议人脸页面定时拉取
> **对应 PRD**: `_PRD_需求优化_门口屏今日会议人脸页面定时拉取.md`
> **创建日期**: 2026-08-19
> **状态**: 待执行
---
## 阶段一:扩展 DoorTokenClient —— 新增 get_today_meeting_face_page()
**目标**:在 `door_token_client.py` 中新增今日会议人脸页面接口调用能力。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 1.1 | 新增 `FACE_PAGE_API_PATH = "/exapi/manageUser/getTodayMeetingFacePage/encrypt"` 常量 | ⬜ |
| 1.2 | 新增 `get_today_meeting_face_page(authorization, conference_id)` 实例方法:POST 请求 + form-urlencoded 体 + 动态签名头 | ⬜ |
| 1.3 | 请求体构造:`conferenceId={token响应中的 conferenceId}`,Content-Type: `application/x-www-form-urlencoded` | ⬜ |
| 1.4 | 动态签名复用 `_generate_dynamic_sign(params, bearer_token)`(已实现,与消息列表相同) | ⬜ |
| 1.5 | 响应校验:仅判断 HTTP 200 + success=true,不解析加密 result | ⬜ |
| 1.6 | 错误处理:网络错误/超时不重试(下次定时再试),业务失败警告日志 | ⬜ |
### 关键设计决策
1. **复用现有签名算法**`_generate_dynamic_sign()` 已支持带 params 的场景,传入 `{"conferenceId": conference_id}` 即可
2. **请求体格式**:POST + form-urlencoded(与消息列表的 GET + query params 不同),需注意设置正确的 Content-Type
3. **Authorization 格式**:与消息列表一致,使用 `BearereyJ...`(无空格格式)
4. **参数只传 conferenceId**:从日志看 `Params:Print content-type is not supported:application/x-www-form-urlencoded; charset=utf-8`,说明 body 是 form-urlencoded 格式但日志打印受限,无法直接看到 body 字段。按常规分析,body 应包含 `conferenceId`(来自 token 的会议 ID)
---
## 阶段二:DoorSimulator 集成 —— 定时循环中追加人脸页面调用
**目标**:在现有消息列表定时循环中,每次循环追加一次人脸页面接口调用。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 2.1 | 新增 `_call_face_page_once()` 方法:单次调用人脸页面接口 + 记录 ReportLog | ⬜ |
| 2.2 | 修改 `_message_list_loop()`:每次循环先调 `_call_message_list_once()`,再调 `_call_face_page_once()` | ⬜ |
| 2.3 | 修改 `_start_message_list_polling()`:首次立即调用时也包含人脸页面接口 | ⬜ |
| 2.4 | 未获取 authorization 时跳过人脸页面调用(与消息列表一致) | ⬜ |
### 关键设计决策
1. **共用同一定时线程**:不新增线程,消息列表和人脸页面在同一个循环中串行调用(顺序为先消息列表后脸页面),避免线程膨胀
2. **间隔不变**:仍使用 `self._report_interval`,一次循环内串行调两个接口,总耗时≈两个 HTTP 调用时间(约 1s),不影响间隔精度
3. **失败不中断**:任一接口失败不影响另一接口的调用,也不影响下一次定时循环
4. **首次立即执行**:在 `_start_message_list_polling()` 中首次调用时,两个接口都立即执行一次
---
## 阶段三:验证
| # | 验证项 | 验证方式 | 状态 |
|---|--------|----------|------|
| 3.1 | Python 语法检查 | `python -m py_compile backend/app/simulators/door_token_client.py` | ⬜ |
| 3.2 | Python 语法检查 | `python -m py_compile backend/app/simulators/door_simulator.py` | ⬜ |
| 3.3 | 本地真实调用验证:token → 人脸页面接口 HTTP 200 + success=true | 本地脚本调用 | ⬜ |
---
## 影响范围
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `get_today_meeting_face_page()` 方法 + 常量 |
| `backend/app/simulators/door_simulator.py` | 修改 | 定时循环中追加人脸页面调用 |
\ No newline at end of file
# 执行计划:门口屏模拟器消息列表定时拉取
> **对应 PRD**: `_PRD_需求优化_门口屏消息列表定时拉取.md`
> **创建日期**: 2026-08-19
> **状态**: 执行中
---
## 阶段一:扩展 DoorTokenClient —— 新增 get_message_list() + 动态签名
**目标**:在 `door_token_client.py` 中新增消息列表接口调用能力。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 1.1 | 新增动态签名方法 `_generate_dynamic_sign()`:基于 Bearer token + 查询参数计算 X-SIGN(SHA256 + AES-CBC),复用 `http_client.py` 已验证的算法 | ⬜ |
| 1.2 | 新增 `MESSAGE_LIST_API_PATH = "/exapi/message/getMsgPageList/encrypt"` 常量 | ⬜ |
| 1.3 | 新增 `get_message_list(authorization, conference_id)` 实例方法:GET 请求 + Bearer token + 动态签名头 + query 参数 | ⬜ |
| 1.4 | 参数构造:`queryType=2, pageNum=1, pageSize=100, conferenceId={token响应}, minTime={now-24h}, maxTime={now}` | ⓧ |
| 1.5 | 响应校验:仅判断 HTTP 200 + code="200",不解析加密 result | ⬜ |
| 1.6 | 会话 GET 重试适配:`allowed_methods``["POST"]` 扩展为 `["GET", "POST"]` | ⬜ |
### 关键设计决策
1. **X-SIGN 动态计算**(不复用 token 接口的固定 X_SIGN_FIXED):
- token 接口的固定签名已实测通过,但消息接口每次参数不同(时间变化),固定签名无法复用
- 复用 `http_client.py` 的动态签名算法(SHA256 + AES-CBC,已在该系统 Web 端验证过)
- 注意:`http_client.py` 用毫秒时间戳,真实设备日志(秒级 1787135913)不同——先按 http_client 算法实现,实测验证,若服务端校验秒级再调整
2. **Authorization 格式(实测关键发现)**
- 真实设备日志 Authorization 为 **`BearereyJ...`(无空格)**
- token 接口返回的 `result.Authorization` 即为此格式,直接原样使用
- 若手动改为标准 `Bearer eyJ...`(带空格),消息接口返回 `A0076 无效token`
- 签名密钥源与请求头字符串必须一致(都用无空格格式)
3. **首调即验**:方法实现后先本地直接调用真实接口验证(appToken=AND-34H-0101 + regUdid=79f18c1a9a3bcfb1 先调 token 接口拿 Bearer,再调消息接口)
---
## 阶段二:DoorSimulator 集成 —— 定时拉取线程
**目标**:门口屏模拟器启动后按上报间隔定时调用消息列表接口。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 2.1 | `DoorSimulator.__init__` 新增 `_token_client`(复用 token 获取时的客户端实例)和 `_msg_thread`/`_msg_running` 线程控制属性 | ⬜ |
| 2.2 | `start()` token 获取成功后:立即调用一次 `get_message_list()` + 启动定时线程(间隔 = `self._report_interval`,即导入时填写的上报间隔) | ⬜ |
| 2.3 | 新增 `_message_list_loop()` 方法:循环调用 `get_message_list()`,成功/失败仅记日志 + `_notify_report` 上报记录,异常不中断 | ⬜ |
| 2.4 | `stop()` 中置 `_msg_running = False` 停止拉取线程 | ⬜ |
| 2.5 | 未配置 `token_api_host`(跳过 token 获取)时同样跳过消息拉取(向后兼容) | ⬜ |
### 关键设计决策
1. **间隔来源**:直接用 `self._report_interval`(设备导入时 Excel 填写的上报间隔),不新增配置项,与 MQTT 上报节奏一致
2. **独立线程**:消息拉取是 HTTP 调用,与 MQTT 上报循环分离,避免 HTTP 超时阻塞上报
3. **线程命名**`msg_{device_id[:8]}`,daemon=True
4. **失败不影响运行**:消息接口调用失败只记日志,不停止设备(与 token 获取失败即停止的策略不同——token 是启动前提,消息拉取是持续性行为模拟)
---
## 阶段三:验证
| # | 验证项 | 状态 |
|---|--------|------|
| 3.1 | Python 语法检查(py_compile) | ✅ 通过 |
| 3.2 | 本地真实调用验证:token → 消息列表接口 HTTP 200 | ✅ 通过(result_len=189548) |
| 3.3 | 签名算法验证:秒级时间戳 + AES 算法通过服务端校验 | ✅ 通过 |
| 3.4 | Authorization 无空格格式(BearereyJ...)验证 | ✅ 必须无空格,带空格返回 A0076 无效token |
---
## 七、后续工作(本期不做)
- 消息内容解密(服务端响应 result 加密数据的解密算法)
- 动态 X-SIGN 算法与真实设备的完全对齐(如服务端校验拒绝毫秒时间戳签名,需逆向 Android 端签名逻辑)
- 消息列表结果与 MQTT 主题上报联动
# PRD 需求文档:门口屏模拟器今日会议人脸页面定时拉取
> **版本**: v1.0
> **创建日期**: 2026-08-19
> **状态**: 待实现
---
## 一、需求概述
### 1.1 背景
在门口屏模拟器获取 token 成功后,真实设备除了定时调用消息列表接口(`getMsgPageList`),还会定时调用 `POST /exapi/manageUser/getTodayMeetingFacePage/encrypt` 接口拉取今日会议的人脸页面数据。
当前模拟器已实现 token 获取(会话 E)和消息列表定时拉取(会话 F),但缺失此接口的定时调用。
### 1.2 目标
门口屏模拟器在获取 token 成功后,与消息列表定时拉取同步,按照设备导入时配置的**上报间隔**(默认 30 秒)定时调用今日会议人脸页面接口,仅确保**调用成功**(HTTP 200),不解析/不处理加密的响应数据。
### 1.3 真实设备日志参考
```
08-19 18:38:36.518 16621 17020 I UbGview : UbRxRetrofit -- ----------Request Start----------
08-19 18:38:36.518 16621 17020 I UbGview : UbRxRetrofit -- POST https://192.168.5.48/exapi/manageUser/getTodayMeetingFacePage/encrypt
08-19 18:38:36.518 16621 17020 I UbGview : UbRxRetrofit -- Authorization: BearereyJhbGciOiJIUzUxMiJ9...
08-19 18:38:36.519 16621 17020 I UbGview : UbRxRetrofit -- X-SIGN: eKLNJLsIluCpZlUWUWnIz5dScabXc1N7Ja427ocmHWWC2uVz/pULpRsOm2uy0awHLibDRrDu23XfMhllf1Siz5b6KiCCt/kV2tBsPPJvCTk=
08-19 18:38:36.519 16621 17020 I UbGview : UbRxRetrofit -- X-TIMESTAMP: 1787135916
08-19 18:38:36.519 16621 17020 I UbGview : UbRxRetrofit -- X-RANDOM: b6UWKorg2AJp3zkN
08-19 18:38:36.519 16621 17020 I UbGview : UbRxRetrofit -- Params:Print content-type is not supported:application/x-www-form-urlencoded; charset=utf-8
08-19 18:38:36.932 16621 17020 I UbGview : UbRxRetrofit -- Response Body: {"success":true,"code":"200","message":"OPERATION_SUCCESSFUL","result":"VkjxFfHBF2tbT2CMaJPAZzZoPCEAkWr6GKTWiTuVCP9yZulJaZYPmXkfBVqvyKXWPvY6Bu+vSdmbi9zXLy5XouYGWWKXtmYOH8+Nhyxca1a1ad9cH5vfAcDscUSw586XBKXQP9sFh5ROPoGq4zXMgLCp/Yea4WblZ6kPyQXVWc8="}
08-19 18:38:36.932 16621 17020 I UbGview : UbRxRetrofit -- Time:413 ms
08-19 18:38:36.932 16621 17020 I UbGview : UbRxRetrofit -- ----------Request End----------
```
---
## 二、功能需求
### 2.1 接口调用
| 项目 | 说明 |
|------|------|
| 接口路径 | `POST /exapi/manageUser/getTodayMeetingFacePage/encrypt` |
| 请求方式 | POST |
| 请求头 | `Authorization: Bearer {token}`(无空格格式 `BearereyJ...`)、`X-SIGN`/`X-TIMESTAMP`/`X-RANDOM`(动态签名,与消息列表算法一致) |
| Content-Type | `application/x-www-form-urlencoded; charset=utf-8` |
| 请求体 | form-urlencoded 格式,含 `conferenceId`(来自 token 响应的 `conference.conferenceId`) |
| 响应处理 | 仅校验 HTTP 200 + `success=true`**不解密 result 字段** |
### 2.2 定时周期
- 调用间隔 = **设备导入时填写的上报间隔**`report_config.interval`,默认 30 秒)
- **与消息列表定时拉取共用同一定时循环**,每次循环先调消息列表接口,再调人脸页面接口(或反之,顺序不重要)
- 首次调用在 token 获取成功后立即执行一次(与消息列表首次调用同步)
### 2.3 参数来源
| 参数 | 来源 |
|------|------|
| Bearer token | `DoorTokenClient.get_token()` 返回的 `authorization` 字段(无空格格式) |
| conferenceId | token 响应中 `conference.conferenceId`(已回填到 `topic_params.conference_id`) |
| X-SIGN/X-TIMESTAMP/X-RANDOM | 动态签名算法(与消息列表 `_generate_dynamic_sign()` 一致) |
### 2.4 签名算法
与消息列表接口完全一致(复用 `DoorTokenClient._generate_dynamic_sign()`):
1. 生成随机 x_random(8-16 位字母数字)
2. x_timestamp = 当前**秒级**时间戳
3. sign_str = timestamp + JSON.stringify(params) + random(有参数时)
4. sign_hash = SHA256(sign_str)
5. aes_key_source = SHA256(bearer_token)(用完整的 Bearer 字符串)
6. aes_key = aes_key_source[16:32](16 字符)
7. aes_iv = aes_key_source[0:8] + aes_key_source[-8:](16 字符)
8. x_sign = AES-CBC 加密(sign_hash, aes_key, aes_iv) → Base64 编码
### 2.5 错误处理
| 场景 | 行为 |
|------|------|
| HTTP 200 + success=true | 日志记录成功,不处理 result |
| HTTP 非 200 | 日志警告,不影响下次定时调用 |
| 网络错误/超时 | 日志警告,下次定时继续尝试 |
| 请求异常 | 捕获异常,不影响设备运行 |
---
## 三、非功能需求
### 3.1 性能
- 与消息列表共用同一定时线程,不额外增加线程数
- 每次调用超时 5 秒
### 3.2 兼容性
- 仅门口屏(door)设备需要此行为
- 未配置 `token_api_host` 时不触发(与 token 获取逻辑一致)
- 未获取到 `authorization` 时不触发(与消息列表逻辑一致)
---
## 四、影响范围
### 4.1 修改文件
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `get_today_meeting_face_page()` 方法 |
| `backend/app/simulators/door_simulator.py` | 修改 | 在消息列表定时循环中增加人脸页面接口调用 |
### 4.2 不变的文件
- `base_simulator.py` — 不改动基类
- 前端文件 — 无改动
- 数据库/Schema — 无改动
- `__init__.py` — 无改动
---
## 五、依赖
- **pycryptodome**(已安装)— AES-CBC 加密签名
- **requests**(已安装)— HTTP 请求
- **DoorTokenClient.get_token()** 返回的 token 信息(已实现)
- **DoorTokenClient._generate_dynamic_sign()**(已实现)
---
## 六、验收标准
| # | 验收项 | 验证方式 |
|---|--------|----------|
| 1 | 启动门口屏模拟器后,token 获取成功后立即调用一次人脸页面接口 | 日志查看 |
| 2 | 之后每 N 秒(与消息列表同步)调用一次 | 日志查看时间间隔 |
| 3 | HTTP 200 响应成功,不解密 result | 日志不报错即可 |
| 4 | 未配置 token_api_host 时不触发 | 日志无相关调用 |
| 5 | 设备停止时线程自动退出 | 日志确认 |
| 6 | 真实调用验证:token → 人脸页面接口 HTTP 200 + success=true | 本地脚本验证 |
\ No newline at end of file
# PRD 需求文档:门口屏模拟器消息列表定时拉取
> **版本**: v1.0
> **创建日期**: 2026-08-19
> **状态**: 待实现
---
## 一、需求概述
### 1.1 背景
在门口屏模拟器获取 token 成功后,真实设备会定时调用 `/exapi/message/getMsgPageList/encrypt` 接口拉取消息列表。
当前模拟器只实现了 token 获取(会话 E),但缺失了后续的定时消息列表拉取行为,导致模拟器行为与真实设备不一致。
### 1.2 目标
门口屏模拟器在获取 token 成功后,按照设备导入时配置的**上报间隔**(默认 30 秒)定时调用消息列表接口,仅确保**调用成功**(HTTP 200),不解析/不处理加密的响应数据。
### 1.3 真实设备日志参考
```
GET https://192.168.5.48/exapi/message/getMsgPageList/encrypt
?queryType=2
&pageNum=1
&conferenceId=50
&pageSize=100
&maxTime=2026-08-19%2023%3A59%3A59
&minTime=2026-08-18%2018%3A38%3A33
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
X-SIGN: 6QO4bEnbqwun/VqgV7iqLMHNw8pYdMMe1E4mW7tnOKk...
X-TIMESTAMP: 1787135913
X-RANDOM: RF8cFOj0PD1uJIxN
Response: {"success":true,"code":"200","message":"OPERATION_SUCCESSFUL",
"result":"VkjxFfHBF2tbT2CMaJPAZzZoPCEAknf0..."}
```
---
## 二、功能需求
### 2.1 消息列表接口调用
| 项目 | 说明 |
|------|------|
| 接口路径 | `GET /exapi/message/getMsgPageList/encrypt` |
| 请求头 | `Authorization: Bearer {token}`(来自 getTokenInfoByToken 的响应) |
| 签名 | 动态 X-SIGN/X-TIMESTAMP/X-RANDOM(与 token 接口不同,需根据 Bearer token 和请求参数实时计算) |
| 查询参数 | `queryType=2``pageNum=1``conferenceId={来自token响应}``pageSize=100``maxTime={当前时间}``minTime={24小时前}` |
| 响应处理 | 仅校验 HTTP 200 + `success=true`**不解密 result 字段** |
### 2.2 定时周期
- 调用间隔 = **设备导入时填写的上报间隔**`report_config.interval`,默认 30 秒)
- 与定时上报线程共用同一间隔值,保证行为一致
- 首次调用在 token 获取成功后立即执行一次
### 2.3 参数来源
| 参数 | 来源 |
|------|------|
| Bearer token | `DoorTokenClient.get_token()` 返回的 `authorization` 字段 |
| conferenceId | token 响应中 `conference.conferenceId`(已回填到 `topic_params.conference_id`) |
| queryType | 固定值 `2` |
| pageSize | 固定值 `100` |
| minTime | 当前时间 - 24 小时(格式 `YYYY-MM-DD HH:mm:ss`) |
| maxTime | 当前时间(格式 `YYYY-MM-DD HH:mm:ss`) |
### 2.4 签名算法
`http_client.py` 中已有的签名算法一致(前端 JS 逆向):
1. 生成随机 x_random(8-16 位字母数字)
2. x_timestamp = 当前**毫秒**时间戳
3. sign_str = timestamp + JSON.stringify(params) + random(有查询参数时)
4. sign_hash = SHA256(sign_str)
5. aes_key_source = SHA256(bearer_token)(用完整的 Bearer 字符串,含 "Bearer " 前缀)
6. aes_key = aes_key_source[16:32](16 字符)
7. aes_iv = aes_key_source[0:8] + aes_key_source[-8:](16 字符)
8. x_sign = AES-CBC 加密(sign_hash, aes_key, aes_iv) → Base64 编码
### 2.5 错误处理
| 场景 | 行为 |
|------|------|
| HTTP 200 + success=true | 日志记录成功,不处理 result |
| HTTP 非 200 | 日志警告,不影响下次定时调用 |
| 网络错误/超时 | 日志警告,下次定时继续尝试 |
| 请求异常 | 捕获异常,不影响设备运行 |
---
## 三、非功能需求
### 3.1 性能
- 定时调用在独立线程中执行,不阻塞主上报循环
- 每次调用超时 5 秒
### 3.2 兼容性
- 仅门口屏(door)设备需要此行为
- 未配置 token_api_host 时不触发(与 token 获取逻辑一致)
---
## 四、影响范围
### 4.1 修改文件
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `get_message_list()` 方法 + 签名算法 |
| `backend/app/simulators/door_simulator.py` | 修改 | 新增定时消息列表拉取线程 |
### 4.2 不变的文件
- `base_simulator.py` — 不改动基类
- 前端文件 — 无改动
- 数据库/Schema — 无改动
- `__init__.py` — 无改动
---
## 五、依赖
- **pycryptodome**(已安装)— AES-CBC 加密签名
- **requests**(已安装)— HTTP 请求
- **DoorTokenClient.get_token()** 返回的 token 信息(已实现)
---
## 六、验收标准
| # | 验收项 | 验证方式 |
|---|--------|----------|
| 1 | 启动门口屏模拟器后,token 获取成功后立即调用一次消息列表接口 | 日志查看 |
| 2 | 之后每 N 秒(与上报间隔一致)调用一次 | 日志查看时间间隔 |
| 3 | HTTP 200 响应成功,不解密 result | 日志不报错即可 |
| 4 | 未配置 token_api_host 时不触发 | 日志无相关调用 |
| 5 | 设备停止时线程自动退出 | 日志确认 |
\ No newline at end of file
......@@ -144,6 +144,86 @@
---
### 2026-08-19 会话 F:门口屏启动后无纸化全部启动导致门口屏停止的问题修复(已全部完成,本地验证通过,未部署)
**会话目标**:修复设备模拟模块中,门口屏设备全部启动后,切换到无纸化页面点击"全部启动"时,门口屏设备在 UI 上显示为"已停止"的问题。
**状态**:✅ 代码实现完成 + 本地 API 验证通过;⚠️ 未提交 git、未部署到 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`
---
**修改文件清单(本次会话)**
| 文件 | 变更类型 | 说明 |
......@@ -646,4 +726,74 @@
---
### 2026-08-19 会话 F:门口屏消息列表定时拉取(已实现 + 真实调用验证通过,未提交未部署)
**会话目标**:门口屏模拟器获取 token 成功后,按设备导入时填写的上报间隔(默认 30 秒)定时调用 `GET /exapi/message/getMsgPageList/encrypt` 拉取消息列表,仅保证调用成功,不解密响应数据。
**状态**:✅ 代码实现完成 + 真实接口调用验证通过(HTTP 200 + 189548 字节 result);⚠️ 未提交 git、未部署
**背景**:真实门口屏设备(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)
---
*本文档记录设备模拟模块开发状态,供下次会话快速恢复上下文。*
......@@ -135,6 +135,8 @@ async def _ensure_columns(conn) -> None:
("executions", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 设备模拟:环境配置多主题字段(旧库升级)
("device_env_configs", "topics", "JSON"),
# 设备模拟:环境配置默认主题参数(含 token 授权码等,旧库升级)
("device_env_configs", "default_topic_params", "JSON"),
# 性能测试:登录接口压测需签名(旧库升级)
("performance_tasks", "sign_request", "BOOLEAN DEFAULT 0"),
# 性能测试:响应捕获规则(旧库升级)
......
......@@ -58,6 +58,25 @@ async def lifespan(app: FastAPI):
await init_db()
logger.info("数据库初始化完成")
# 重置失效的模拟设备运行状态:进程重启后 _running_simulators 内存表已清空,
# 所有 DB 中 status='running' 的设备实际上都已停止,避免列表显示“运行中”异常、
# 以及误导致 UI 显示混乱。
try:
from app.models.device_sim import DeviceSimulator
from app.database import async_session_maker
from sqlalchemy import update
async with async_session_maker() as session:
result = await session.execute(
update(DeviceSimulator)
.where(DeviceSimulator.status == "running")
.values(status="stopped")
)
await session.commit()
if result.rowcount:
logger.info(f"启动时重置失效的模拟设备运行状态: 共 {result.rowcount} 台置为 stopped")
except Exception as e:
logger.warning(f"重置模拟设备运行状态失败(非致命): {e}")
# 初始化默认登录模板
try:
from app.services.login_template_service import LoginTemplateService
......
......@@ -137,8 +137,15 @@ class MqttManager:
bool: 是否连接成功
"""
with self._manager_lock:
# 如果已有连接,先断开
if env_config_id in self._connections:
# 已有存活连接时直接复用,避免断开重建导致已运行设备断流。
# paho 的 on_disconnect 会自动重连,connected 标记也能反映断开状态。
existing = self._connections.get(env_config_id)
if existing and existing.connected and existing.client:
logger.info(f"MQTT 复用已有连接: env_config_id={env_config_id}")
return True
# 存在但已断开的连接,先清理
if existing:
self.disconnect(env_config_id)
try:
......
......@@ -112,9 +112,10 @@ class CentralSimulator(BaseSimulator):
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
topic_params: Optional[dict] = None,
token_api_host: Optional[str] = None):
super().__init__(device_id, env_config_id, "central", mqtt_manager,
report_config, topics, topic_params)
report_config, topics, topic_params, token_api_host)
self._sub_devices = {}
self._current_scene = "power_off"
self._init_sub_devices()
......
......@@ -44,7 +44,8 @@ class ClientSimulator(BaseSimulator):
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
topic_params: Optional[dict] = None,
token_api_host: Optional[str] = None):
"""
初始化集控客户端模拟器
......@@ -55,9 +56,10 @@ class ClientSimulator(BaseSimulator):
report_config: 上报配置,如 {'interval': 5, 'enabled': True}
topics: 多主题前缀映射
topic_params: 主题动态参数
token_api_host: Token 接口主机地址(预留)
"""
super().__init__(device_id, env_config_id, "client", mqtt_manager,
report_config, topics, topic_params)
report_config, topics, topic_params, token_api_host)
self._version = random.choice(self._VERSIONS)
self._heartbeat_count = 0
self._log_level = "info"
......
......@@ -60,6 +60,11 @@ class DoorSimulator(BaseSimulator):
self._battery_level = random.randint(60, 100)
self._call_active = False
self._token_info: Optional[dict] = None
# 消息列表定时拉取控制
self._token_client: Optional[object] = None
self._authorization: str = ""
self._msg_thread: Optional[threading.Thread] = None
self._msg_running = False
def build_register_payload(self) -> dict:
"""构建设备注册消息"""
......@@ -121,6 +126,10 @@ class DoorSimulator(BaseSimulator):
)
token_info = client.get_token()
# 保存 token 客户端和授权信息(供消息列表定时拉取使用)
self._token_client = client
self._authorization = token_info.get("authorization", "")
# 保存 token 完整信息
self._token_info = token_info
......@@ -164,7 +173,17 @@ class DoorSimulator(BaseSimulator):
self._resolve_topics()
# 第三步:调用父类 MQTT 启动流程
return super().start()
result = super().start()
if not result:
return False
# 第四步:启动消息列表定时拉取(仅在 token 获取成功后有 authorization 才启动)
if self._authorization:
self._start_message_list_polling()
else:
logger.info(f"门口屏未配置 token 或无 authorization,跳过消息列表拉取: {self.device_id}")
return True
def _on_command(self, topic: str, payload: dict) -> None:
"""
......@@ -272,6 +291,138 @@ class DoorSimulator(BaseSimulator):
self.mqtt.publish(self.env_config_id, status_topic, self.build_status_payload())
t.Thread(target=_recover, daemon=True).start()
def stop(self) -> bool:
"""
停止门口屏模拟器
先停止消息列表定时拉取线程,再调用父类停止 MQTT 流程。
"""
# 先停止消息列表拉取线程
self._msg_running = False
if self._msg_thread and self._msg_thread.is_alive():
self._msg_thread.join(timeout=3)
self._msg_thread = None
return super().stop()
def _start_message_list_polling(self) -> None:
"""
启动消息列表 + 今日会议人脸页面定时拉取线程
间隔使用 self._report_interval(设备导入时填写的上报间隔,默认 30 秒)。
首次调用立即执行一次,之后按间隔定时调用。
"""
if not self._token_client or not self._authorization:
return
conference_id = self.topic_params.get("conference_id", "0")
logger.info(
f"启动消息列表 + 人脸页面定时拉取: device_id={self.device_id}, "
f"interval={self._report_interval}s, conferenceId={conference_id}"
)
# 立即执行一次
self._call_message_list_once()
self._call_face_page_once()
# 启动定时线程
self._msg_running = True
self._msg_thread = threading.Thread(
target=self._message_list_loop,
name=f"msg_{self.device_id[:8]}",
daemon=True,
)
self._msg_thread.start()
def _call_face_page_once(self) -> bool:
"""调用一次今日会议人脸页面接口,记录结果到 ReportLog"""
if not self._token_client or not self._authorization:
return False
conference_id = self.topic_params.get("conference_id", "0")
try:
success = self._token_client.get_today_meeting_face_page(
authorization=self._authorization,
conference_id=conference_id,
)
self._notify_report(
topic=f"face_page_{'success' if success else 'failed'}",
payload={
"device_id": self.device_id,
"conference_id": conference_id,
"success": success,
},
direction="publish",
status="success" if success else "failed",
)
return success
except Exception as e:
logger.warning(f"[FacePage] 调用异常: device_id={self.device_id}, error={e}")
self._notify_report(
topic="face_page_error",
payload={"device_id": self.device_id, "error": str(e)},
direction="publish",
status="failed",
error=str(e),
)
return False
def _call_message_list_once(self) -> bool:
"""调用一次消息列表接口,记录结果到 ReportLog"""
if not self._token_client or not self._authorization:
return False
conference_id = self.topic_params.get("conference_id", "0")
try:
success = self._token_client.get_message_list(
authorization=self._authorization,
conference_id=conference_id,
)
self._notify_report(
topic=f"message_list_{'success' if success else 'failed'}",
payload={
"device_id": self.device_id,
"conference_id": conference_id,
"success": success,
},
direction="publish",
status="success" if success else "failed",
)
return success
except Exception as e:
logger.warning(f"[MsgList] 调用异常: device_id={self.device_id}, error={e}")
self._notify_report(
topic="message_list_error",
payload={"device_id": self.device_id, "error": str(e)},
direction="publish",
status="failed",
error=str(e),
)
return False
def _message_list_loop(self) -> None:
"""
消息列表 + 今日会议人脸页面定时拉取循环
在独立线程中运行,interval 秒调用一次:
1. _call_message_list_once() — 消息列表接口
2. _call_face_page_once() — 今日会议人脸页面接口
调用失败仅记日志,不影响下次定时执行。
"""
logger.info(f"消息列表/人脸页面定时线程启动: {self.device_id}, 间隔={self._report_interval}s")
while self._msg_running:
for _ in range(self._report_interval):
if not self._msg_running:
break
time.sleep(1)
if not self._msg_running:
break
self._call_message_list_once()
self._call_face_page_once()
logger.info(f"消息列表/人脸页面定时线程结束: {self.device_id}")
def trigger_event(self, event_type: str = "random") -> bool:
"""
触发门禁事件
......
......@@ -24,17 +24,28 @@ HTTP 接口 POST /exapi/api-android/token/getTokenInfoByToken 获取 token。
"""
import hashlib
import json
import logging
import random
import re
import string
import threading
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import requests
import urllib3
# 尝试导入 pycryptodome(动态签名 AES-CBC 加密用)
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import base64 as b64
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
logger = logging.getLogger(__name__)
# 关闭测试环境自签名证书告警
......@@ -45,6 +56,12 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 接口调用路径(真实设备日志),如 https://192.168.5.48/exapi/...
TOKEN_API_PATH = "/exapi/api-android/token/getTokenInfoByToken"
# 消息列表接口路径(真实设备日志):定时拉取会议消息
MESSAGE_LIST_API_PATH = "/exapi/message/getMsgPageList/encrypt"
# 今日会议人脸页面接口路径(真实设备日志):定时拉取今日会议人脸数据
FACE_PAGE_API_PATH = "/exapi/manageUser/getTodayMeetingFacePage/encrypt"
# 固定 X-SIGN(真实设备日志样本,已实测可调用成功 HTTP 200)
# 真实设备日志:X-TIMESTAMP=1787131139(秒级), X-RANDOM=13位
X_SIGN_FIXED = "mjDLL13nJnDM9sigjWxkLksYK4EpWOkaxv2QbIDhK6BCuqiN40J1D14arFdDYXXSG+Lv37btG/eKym7WoZ+5wZsKX7aDS3QPVIa05J2Al8g="
......@@ -262,7 +279,7 @@ class DoorTokenClient:
total=self.max_retries,
backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["POST"],
allowed_methods=["GET", "POST"],
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
......@@ -270,6 +287,293 @@ class DoorTokenClient:
session.mount("https://", adapter)
return session
@staticmethod
def _generate_random_string(min_len: int = 8, max_len: int = 16) -> str:
"""生成指定长度的随机字符串(字母+数字)"""
length = random.randint(min_len, max_len)
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
@staticmethod
def _sha256(text: str) -> str:
"""计算 SHA256 哈希值"""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@staticmethod
def _aes_encrypt(plaintext: str, key_str: str, iv_str: str) -> str:
"""
AES CBC 模式加密(与前端 CryptoJS / http_client.py 行为一致)
CryptoJS 传入字符串时按 UTF-8 编码为密钥/IV,不是 hex 解析。
Args:
plaintext: 明文字符串(SHA256 后的 hex 字符串)
key_str: 16 字符 AES 密钥字符串
iv_str: 16 字符 AES IV 字符串
Returns:
str: Base64 编码的密文
"""
if not HAS_CRYPTO:
logger.warning("pycryptodome 未安装,X-SIGN 将降级为明文(可能导致服务端校验失败)")
return plaintext
key_bytes = key_str.encode("utf-8") # 16 bytes (AES-128)
iv_bytes = iv_str.encode("utf-8") # 16 bytes
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
padded = pad(plaintext.encode("utf-8"), AES.block_size)
encrypted = cipher.encrypt(padded)
return b64.b64encode(encrypted).decode("utf-8")
def _generate_dynamic_sign(self, params: Optional[dict], bearer_token: str) -> Dict[str, str]:
"""
根据前端 JS 逆向的签名算法生成动态请求签名(用于消息列表接口)
与 http_client.py 中已验证的算法一致:
1. x_random = 随机字符串(8-16 位字母数字)
2. x_timestamp = 当前秒级时间戳(与真实设备日志一致)
3. sign_str = timestamp + JSON.stringify(params) + random(有参数时)
sign_str = timestamp + random(无参数时)
4. sign_hash = SHA256(sign_str)
5. aes_key_source = SHA256(bearer_token) → 64 字符 hex
6. aes_key = aes_key_source[16:32] → 16 字符
7. aes_iv = aes_key_source[0:8] + 末尾 8 字符 → 16 字符
8. x_sign = AES.encrypt(sign_hash, aes_key, aes_iv) → Base64
Args:
params: 查询参数字典(GET 请求)
bearer_token: Bearer Token 字符串(含 "Bearer " 前缀)
Returns:
dict: {X-RANDOM, X-TIMESTAMP, X-SIGN}
"""
x_random = self._generate_random_string(8, 16)
# 真实设备日志 X-TIMESTAMP=1787135913 为秒级,这里用秒级
x_timestamp = str(int(time.time()))
if params is not None:
params_str = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
sign_str = x_timestamp + params_str + x_random
else:
sign_str = x_timestamp + x_random
sign_hash = self._sha256(sign_str)
# 用 Bearer token 做 AES 密钥源
aes_key_source = self._sha256(bearer_token)
aes_key = aes_key_source[16:32]
aes_iv = aes_key_source[0:8] + aes_key_source[-8:]
x_sign = self._aes_encrypt(sign_hash, aes_key, aes_iv)
return {
"X-RANDOM": x_random,
"X-TIMESTAMP": x_timestamp,
"X-SIGN": x_sign,
}
def get_message_list(self, authorization: str, conference_id: str,
timeout: Optional[float] = None) -> bool:
"""
调用消息列表接口拉取会议消息(定时调用,仅保证调用成功)
真实设备日志参考:
GET /exapi/message/getMsgPageList/encrypt
?queryType=2&pageNum=1&conferenceId=50&pageSize=100
&maxTime=2026-08-19 23:59:59&minTime=2026-08-18 18:38:33
Authorization: Bearer {token}
X-SIGN / X-TIMESTAMP / X-RANDOM(动态签名)
本方法只校验 HTTP 200 + success=true,**不解析/不解密 result 加密内容**。
Args:
authorization: token 接口返回的 Authorization(不含 Bearer 前缀,
本方法会自动补 "Bearer " 前缀)
conference_id: 会议 ID(来自 token 响应,已回填 topic_params.conference_id)
timeout: 请求超时(秒),默认用 self.timeout
Returns:
bool: 是否调用成功(HTTP 200 + 业务成功)
"""
if not authorization:
logger.warning(f"[MsgList] 缺少 authorization,跳过消息列表调用: {self.device_id}")
return False
url = f"{self.base_url}{MESSAGE_LIST_API_PATH}"
# 注意:真实设备日志中 Authorization 格式为 "BearereyJ..."(无空格)
# 服务端仅接受此格式。token 接口返回的 authorization 已符合此格式。
# 签名密钥源和 Authorization 请求头必须使用同一字符串
bearer_str = authorization
auth_header = authorization
# 构造查询参数(minTime = 当前时间 - 24 小时,maxTime = 当前时间)
now = datetime.now()
min_time = now - timedelta(hours=24)
time_fmt = "%Y-%m-%d %H:%M:%S"
# 服务端真实日志:URL 编码后的空格为 %20,这里交给 requests 自动编码
params = {
"queryType": "2",
"pageNum": "1",
"conferenceId": str(conference_id) if conference_id else "0",
"pageSize": "100",
"maxTime": now.strftime(time_fmt),
"minTime": min_time.strftime(time_fmt),
}
# 动态签名(基于 Bearer token + 查询参数)
sign_headers = self._generate_dynamic_sign(params, bearer_str)
headers = {
"Authorization": auth_header,
"Accept": "application/json, text/plain, */*",
}
headers.update(sign_headers)
logger.info(
f"[MsgList] 调用接口: GET {url}, device_id={self.device_id}, "
f"conferenceId={params['conferenceId']}, "
f"X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}"
)
try:
resp = self._session.get(
url,
params=params,
headers=headers,
verify=False,
timeout=timeout or self.timeout,
)
except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
logger.warning(f"[MsgList] 网络错误: device_id={self.device_id}, error={e}")
return False
if resp.status_code != 200:
logger.warning(
f"[MsgList] HTTP 非 200: device_id={self.device_id}, "
f"status={resp.status_code}, body={resp.text[:300]}"
)
return False
# 仅校验业务成功,不解析加密 result
try:
data = resp.json()
except Exception as e:
logger.warning(f"[MsgList] 响应 JSON 解析失败: device_id={self.device_id}, error={e}")
return False
code = str(data.get("code", ""))
success = data.get("success", False)
if code not in ("200", 200) or not success:
logger.warning(
f"[MsgList] 业务失败: device_id={self.device_id}, "
f"code={code}, message={data.get('message', '')}"
)
return False
# 仅记录 result 长度,不解密
result = data.get("result", "")
result_len = len(str(result)) if result else 0
logger.info(
f"[MsgList] 调用成功: device_id={self.device_id}, "
f"message={data.get('message', '')}, result_len={result_len}"
)
return True
def get_today_meeting_face_page(self, authorization: str, conference_id: str,
timeout: Optional[float] = None) -> bool:
"""
调用今日会议人脸页面接口拉取人脸数据(定时调用,仅保证调用成功)
真实设备日志参考:
POST https://192.168.5.48/exapi/manageUser/getTodayMeetingFacePage/encrypt
Authorization: BearereyJ...
X-SIGN / X-TIMESTAMP / X-RANDOM(动态签名)
Body: conferenceId={conference_id}(form-urlencoded)
本方法只校验 HTTP 200 + success=true,**不解析/不解密 result 加密内容**。
Args:
authorization: token 接口返回的 Authorization(无空格格式 BearereyJ...)
conference_id: 会议 ID(来自 token 响应,已回填 topic_params.conference_id)
timeout: 请求超时(秒),默认用 self.timeout
Returns:
bool: 是否调用成功(HTTP 200 + 业务成功)
"""
if not authorization:
logger.warning(f"[FacePage] 缺少 authorization,跳过人脸页面调用: {self.device_id}")
return False
url = f"{self.base_url}{FACE_PAGE_API_PATH}"
# Authorization 格式必须为无空格 BearereyJ...(与真实设备日志一致)
auth_header = authorization
bearer_str = authorization
# 构造请求体(form-urlencoded)
body_data = {"conferenceId": str(conference_id) if conference_id else "0"}
# 动态签名(基于 Bearer token + body 参数)
sign_headers = self._generate_dynamic_sign(body_data, bearer_str)
headers = {
"Authorization": auth_header,
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"Accept": "application/json, text/plain, */*",
}
headers.update(sign_headers)
logger.info(
f"[FacePage] 调用接口: POST {url}, device_id={self.device_id}, "
f"conferenceId={body_data['conferenceId']}, "
f"X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}"
)
try:
resp = self._session.post(
url,
data=body_data,
headers=headers,
verify=False,
timeout=timeout or self.timeout,
)
except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
logger.warning(f"[FacePage] 网络错误: device_id={self.device_id}, error={e}")
return False
if resp.status_code != 200:
logger.warning(
f"[FacePage] HTTP 非 200: device_id={self.device_id}, "
f"status={resp.status_code}, body={resp.text[:300]}"
)
return False
# 仅校验业务成功,不解析加密 result
try:
data = resp.json()
except Exception as e:
logger.warning(f"[FacePage] 响应 JSON 解析失败: device_id={self.device_id}, error={e}")
return False
code = str(data.get("code", ""))
success = data.get("success", False)
if code not in ("200", 200) or not success:
logger.warning(
f"[FacePage] 业务失败: device_id={self.device_id}, "
f"code={code}, message={data.get('message', '')}"
)
return False
# 仅记录 result 长度,不解密
result = data.get("result", "")
result_len = len(str(result)) if result else 0
logger.info(
f"[FacePage] 调用成功: device_id={self.device_id}, "
f"message={data.get('message', '')}, result_len={result_len}"
)
return True
def _build_headers(self, x_timestamp: Optional[str] = None, x_random: Optional[str] = None) -> Dict[str, str]:
"""
构造请求头
......@@ -488,6 +792,8 @@ __all__ = [
"normalize_host",
"pick_default_host",
"TOKEN_API_PATH",
"MESSAGE_LIST_API_PATH",
"FACE_PAGE_API_PATH",
"X_SIGN_FIXED",
"DEFAULT_APP_TOKEN_PREFIX",
"DEFAULT_APP_TOKEN_SEGMENT",
......
......@@ -55,9 +55,10 @@ class PaperlessSimulator(BaseSimulator):
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
topic_params: Optional[dict] = None,
token_api_host: Optional[str] = None):
super().__init__(device_id, env_config_id, "paperless", mqtt_manager,
report_config, topics, topic_params)
report_config, topics, topic_params, token_api_host)
self._meeting_status = "idle"
self._signed_in = False
self._file_sync_progress = 0
......
#!/bin/bash
set -e
echo "============================================"
echo " 平台自动化测试系统 - 部署到 192.168.5.41"
echo " 架构: 合一应用容器(8081) + MySQL(3307)"
echo "============================================"
echo ""
# 检查 Docker
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker 未安装"
exit 1
fi
# 检查 Docker Compose 插件
if ! docker compose version &> /dev/null; then
echo "ERROR: Docker Compose 插件未安装"
exit 1
fi
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="/data/third_party/plat-auto-test"
echo "deploy dir: $SCRIPT_DIR"
echo "target dir: $PROJECT_DIR"
echo ""
# 创建数据目录
echo ">>> 创建数据目录..."
mkdir -p "$PROJECT_DIR/data/screenshots" "$PROJECT_DIR/data/reports"
mkdir -p "$PROJECT_DIR/mysql/data"
mkdir -p "$PROJECT_DIR/logs"
echo " OK"
# 检查 .env 文件
ENV_FILE="$SCRIPT_DIR/.env"
if [ ! -f "$ENV_FILE" ]; then
echo "WARNING: .env 不存在,从 .env.example 复制..."
cp "$SCRIPT_DIR/.env.example" "$ENV_FILE"
fi
# 检查后端代码
if [ ! -d "$PROJECT_DIR/backend/app" ]; then
echo "ERROR: 后端代码不存在 $PROJECT_DIR/backend/"
exit 1
fi
# 检查前端构建产物
if [ ! -f "$PROJECT_DIR/frontend/dist/index.html" ]; then
echo "ERROR: 前端构建产物不存在 $PROJECT_DIR/frontend/dist/"
exit 1
fi
# 设置端口变量(供 docker-compose 使用)
export APP_PORT=8081
export MYSQL_PORT=3307
# 检查端口是否可用
if ss -tlnp | grep -q ":$APP_PORT "; then
echo "WARNING: 端口 $APP_PORT 已被占用,请检查"
fi
if ss -tlnp | grep -q ":$MYSQL_PORT "; then
echo "WARNING: 端口 $MYSQL_PORT 已被占用,请检查"
fi
# 构建镜像
echo ""
echo ">>> 构建 Docker 镜像..."
cd "$SCRIPT_DIR"
docker compose -f docker-compose.41.yml build
echo " OK"
# 先启动 MySQL(等 MySQL 就绪后再启动 app,避免内存峰值)
echo ""
echo ">>> 启动 MySQL 容器..."
docker compose -f docker-compose.41.yml up -d mysql
echo " OK"
# 等待 MySQL 就绪
echo ""
echo ">>> 等待 MySQL 就绪..."
MAX_RETRIES=30
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
if docker exec plat-auto-test-mysql mysqladmin ping -h localhost -uroot -p"${MYSQL_ROOT_PASSWORD:-Ubains@13579}" --silent 2>/dev/null; then
echo " MySQL 就绪"
break
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo " 等待 MySQL... ($RETRY_COUNT/$MAX_RETRIES)"
sleep 3
done
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "WARNING: MySQL 就绪超时,继续尝试启动 app"
fi
# 启动应用容器
echo ""
echo ">>> 启动应用容器..."
docker compose -f docker-compose.41.yml up -d app
echo " OK"
# 等待应用就绪
echo ""
echo ">>> 等待应用就绪..."
MAX_RETRIES=20
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$APP_PORT/health 2>/dev/null || echo "000")
if [ "$HEALTH" = "200" ]; then
echo " 应用健康检查通过 (HTTP 200)"
break
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo " 等待应用就绪... ($RETRY_COUNT/$MAX_RETRIES)"
sleep 5
done
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "WARNING: 应用健康检查超时,请检查日志: docker compose logs app"
fi
# 检查前端
FRONTEND_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$APP_PORT/ 2>/dev/null || echo "000")
if [ "$FRONTEND_CODE" = "200" ]; then
echo " 前端可访问 (HTTP 200)"
else
echo " 前端响应码: $FRONTEND_CODE"
fi
# 输出访问信息
echo ""
echo "============================================"
echo " 部署完成!"
echo "============================================"
echo ""
echo " 前端: http://192.168.5.41:$APP_PORT"
echo " API 文档: http://192.168.5.41:$APP_PORT/docs"
echo " 健康检查: http://192.168.5.41:$APP_PORT/health"
echo " MySQL 端口: $MYSQL_PORT"
echo ""
echo " 查看日志: docker compose -f docker-compose.41.yml logs -f"
echo " 停止服务: docker compose -f docker-compose.41.yml down"
echo " 重启应用: docker compose -f docker-compose.41.yml restart app"
echo ""
# 检查容器状态
echo ">>> 容器状态:"
docker compose -f docker-compose.41.yml ps
\ No newline at end of file
version: "3.8"
services:
# ==================== 应用容器(前后端合一) ====================
# 复用宿主机已有的 umysql 容器(host 网络,3306 端口),无需额外 MySQL 容器
app:
image: deploy-app:latest
container_name: plat-auto-test-app
restart: unless-stopped
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--loop", "asyncio"]
environment:
# 连接宿主机已有的 umysql(host 网络模式,可直接通过宿主机 IP 访问)
- DATABASE_URL=mysql+aiomysql://platapp:PlatApp2026@192.168.5.41:3306/plat_auto_test?charset=utf8mb4
- PLAYWRIGHT_HEADLESS=true
- PLAYWRIGHT_TIMEOUT=30000
- LOG_LEVEL=INFO
- TZ=Asia/Shanghai
volumes:
- /data/third_party/plat-auto-test/backend:/app
- /data/third_party/plat-auto-test/frontend/dist:/app/frontend/dist
- /data/third_party/plat-auto-test/data:/app/data
- /data/third_party/plat-auto-test/logs:/app/logs
ports:
- "8081:80" # 端口映射 8081,避免与已有服务冲突
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论