提交 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 @@ ...@@ -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 @@ ...@@ -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: ...@@ -135,6 +135,8 @@ async def _ensure_columns(conn) -> None:
("executions", "case_type", "VARCHAR(20) DEFAULT 'ui'"), ("executions", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 设备模拟:环境配置多主题字段(旧库升级) # 设备模拟:环境配置多主题字段(旧库升级)
("device_env_configs", "topics", "JSON"), ("device_env_configs", "topics", "JSON"),
# 设备模拟:环境配置默认主题参数(含 token 授权码等,旧库升级)
("device_env_configs", "default_topic_params", "JSON"),
# 性能测试:登录接口压测需签名(旧库升级) # 性能测试:登录接口压测需签名(旧库升级)
("performance_tasks", "sign_request", "BOOLEAN DEFAULT 0"), ("performance_tasks", "sign_request", "BOOLEAN DEFAULT 0"),
# 性能测试:响应捕获规则(旧库升级) # 性能测试:响应捕获规则(旧库升级)
......
...@@ -58,6 +58,25 @@ async def lifespan(app: FastAPI): ...@@ -58,6 +58,25 @@ async def lifespan(app: FastAPI):
await init_db() await init_db()
logger.info("数据库初始化完成") 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: try:
from app.services.login_template_service import LoginTemplateService from app.services.login_template_service import LoginTemplateService
......
...@@ -137,8 +137,15 @@ class MqttManager: ...@@ -137,8 +137,15 @@ class MqttManager:
bool: 是否连接成功 bool: 是否连接成功
""" """
with self._manager_lock: 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) self.disconnect(env_config_id)
try: try:
......
...@@ -112,9 +112,10 @@ class CentralSimulator(BaseSimulator): ...@@ -112,9 +112,10 @@ class CentralSimulator(BaseSimulator):
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_config: Optional[dict] = None, mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: 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, 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._sub_devices = {}
self._current_scene = "power_off" self._current_scene = "power_off"
self._init_sub_devices() self._init_sub_devices()
......
...@@ -44,7 +44,8 @@ class ClientSimulator(BaseSimulator): ...@@ -44,7 +44,8 @@ class ClientSimulator(BaseSimulator):
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_config: Optional[dict] = None, mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: 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): ...@@ -55,9 +56,10 @@ class ClientSimulator(BaseSimulator):
report_config: 上报配置,如 {'interval': 5, 'enabled': True} report_config: 上报配置,如 {'interval': 5, 'enabled': True}
topics: 多主题前缀映射 topics: 多主题前缀映射
topic_params: 主题动态参数 topic_params: 主题动态参数
token_api_host: Token 接口主机地址(预留)
""" """
super().__init__(device_id, env_config_id, "client", mqtt_manager, 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._version = random.choice(self._VERSIONS)
self._heartbeat_count = 0 self._heartbeat_count = 0
self._log_level = "info" self._log_level = "info"
......
...@@ -60,6 +60,11 @@ class DoorSimulator(BaseSimulator): ...@@ -60,6 +60,11 @@ class DoorSimulator(BaseSimulator):
self._battery_level = random.randint(60, 100) self._battery_level = random.randint(60, 100)
self._call_active = False self._call_active = False
self._token_info: Optional[dict] = None 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: def build_register_payload(self) -> dict:
"""构建设备注册消息""" """构建设备注册消息"""
...@@ -121,6 +126,10 @@ class DoorSimulator(BaseSimulator): ...@@ -121,6 +126,10 @@ class DoorSimulator(BaseSimulator):
) )
token_info = client.get_token() token_info = client.get_token()
# 保存 token 客户端和授权信息(供消息列表定时拉取使用)
self._token_client = client
self._authorization = token_info.get("authorization", "")
# 保存 token 完整信息 # 保存 token 完整信息
self._token_info = token_info self._token_info = token_info
...@@ -164,7 +173,17 @@ class DoorSimulator(BaseSimulator): ...@@ -164,7 +173,17 @@ class DoorSimulator(BaseSimulator):
self._resolve_topics() self._resolve_topics()
# 第三步:调用父类 MQTT 启动流程 # 第三步:调用父类 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: def _on_command(self, topic: str, payload: dict) -> None:
""" """
...@@ -272,6 +291,138 @@ class DoorSimulator(BaseSimulator): ...@@ -272,6 +291,138 @@ class DoorSimulator(BaseSimulator):
self.mqtt.publish(self.env_config_id, status_topic, self.build_status_payload()) self.mqtt.publish(self.env_config_id, status_topic, self.build_status_payload())
t.Thread(target=_recover, daemon=True).start() 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: def trigger_event(self, event_type: str = "random") -> bool:
""" """
触发门禁事件 触发门禁事件
......
...@@ -55,9 +55,10 @@ class PaperlessSimulator(BaseSimulator): ...@@ -55,9 +55,10 @@ class PaperlessSimulator(BaseSimulator):
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_config: Optional[dict] = None, mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: 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, 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._meeting_status = "idle"
self._signed_in = False self._signed_in = False
self._file_sync_progress = 0 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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论