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

feat(device-sim): 无纸化模拟器Token获取 + 消息定时拉取

- DoorTokenClient 参数化支持无纸化 API 路径和 regUdid 格式
- PaperlessSimulator.start() 四步流程: token→回填→MQTT→消息拉取
- 新增 get_message_by_app_token() 消息查询接口(HTTP 200 即成功)
- 本地真实调用验证通过(token + 消息接口均 HTTP 200)
- 更新 PRD/执行计划/HANDOFF 文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 f5b9b9df
# 执行计划:无纸化模拟器 Token 获取
> **对应 PRD**: `_PRD_需求优化_无纸化模拟器Token获取.md`
> **创建日期**: 2026-08-20
> **状态**: 待执行
---
## 阶段一:扩展 token 客户端 —— 新增无纸化 Token 获取支持
**目标**:使 `DoorTokenClient`(或新建子类)支持无纸化设备的 token 获取,处理 API 路径和 regUdid 格式差异。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 1.1 | **新增无纸化 API 路径常量**`PAPERLESS_TOKEN_API_PATH = "/api/api-android/token/getTokenInfoByToken"`(注意:比门口屏少 `/exapi` 前缀) | ✅ |
| 1.2 | **新增无纸化 regUdid 派生函数** `generate_paperless_reg_udid(device_id)``md5(device_id)[:16]`(纯 16 位 hex,无 `reg_` 前缀) | ✅ |
| 1.3 | **修改 `DoorTokenClient.__init__`**:新增可选参数 `token_api_path: str = TOKEN_API_PATH`,允许无纸化传入不同的 API 路径 | ✅ |
| 1.4 | **修改 `DoorTokenClient.get_token`**:使用 `self._token_api_path` 替代硬编码的 `TOKEN_API_PATH` 常量 | ✅ |
| 1.5 | **修改 `DoorTokenClient.__init__` 中的 regUdid 生成**:新增可选参数 `reg_udid_override: Optional[str] = None`,允许外部传入已派生的 regUdid(跳过内部 `generate_reg_udid`) | ✅ |
| 1.6 | **新增 `PaperlessTokenPool` 类或修改 `TokenPool.allocate`**:支持无纸化 appToken 格式(`ANP-xxx-xxxx`),可复用门口屏的登记池,无需单独新建 | ✅ |
### 关键设计决策
1. **复用 `DoorTokenClient`,不新建类**
- 无纸化与门口屏的请求头格式(X-SIGN/X-TIMESTAMP/X-RANDOM)完全一致
- 响应解析逻辑(`_parse_response`)完全一致
- 唯一差异:API 路径和 regUdid 格式
- 通过参数化方式支持两种模式,避免代码重复
2. **API 路径差异**
- 门口屏:`/exapi/api-android/token/getTokenInfoByToken`
- 无纸化:`/api/api-android/token/getTokenInfoByToken`
- 差异仅在 `/exapi` 前缀,通过 `token_api_path` 参数控制
3. **regUdid 格式差异**
- 门口屏:`reg_{md5(device_id)[:16]}`(含 `reg_` 前缀)
- 无纸化:`{md5(device_id)[:16]}`(纯 16 位 hex,无前缀)
- 通过 `reg_udid_override` 参数由外部派生后传入
4. **首调即验**:实现后先本地直接调用真实接口验证(appToken=ANP-NRY-0022 + regUdid=54035332b33e1e30)
---
## 阶段二:PaperlessSimulator 集成 —— start() 接入 token 获取
**目标**:无纸化模拟器启动时调用 token 接口,获取 Authorization 并回填 topic_params。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 2.1 | **修改 `PaperlessSimulator.__init__`**:新增 `_token_info: Optional[dict] = None``_token_client: Optional[object] = None``_authorization: str = ""` 属性 | ✅ |
| 2.2 | **重写 `PaperlessSimulator.start()`**:在调用父类 `start()` 之前插入 token 获取流程(参考门口屏 `DoorSimulator.start()`) | ✅ |
| 2.3 | **token 获取流程**:检查 `self.token_api_host` → 创建 `DoorTokenClient`(传入 `token_api_path=PAPERLESS_TOKEN_API_PATH` + `reg_udid_override`) → 调 `get_token()` → 保存 `_token_client`/`_authorization`/`_token_info` → 回填 `topic_params` | ✅ |
| 2.4 | **topic_params 回填**`company_id ← token.companyNumber``room_id ← token.cnum``conference_id ← conference.conferenceId``conference_name ← conference.conferenceName``app_token ← 导入时的 appToken` | ✅ |
| 2.5 | **重新解析主题**:回填 `topic_params` 后调用 `self._resolve_topics()` 重新生成 MQTT 主题 | ✅ |
| 2.6 | **错误处理**:token 获取失败 → 记录错误日志 → `_notify_report` 上报 `token_get` failed → 启动终止返回 False | ✅ |
| 2.7 | **未配置 token_api_host 时**:跳过 token 获取,仅 warning 日志,继续正常启动(向后兼容) | ✅ |
### 关键设计决策
1. **启动流程顺序**(与门口屏一致):
```
start()
① 获取 token(如配置了 token_api_host)
② 回填 topic_params
③ 重新解析主题 _resolve_topics()
④ 调用父类 start() 执行 MQTT 注册/订阅/上报
```
2. **复用门口屏的 token 客户端逻辑**
- 传入 `token_api_path="/api/api-android/token/getTokenInfoByToken"`
- 传入 `reg_udid_override=generate_paperless_reg_udid(device_id)`
3. **regUdid 派生**:在 `door_token_client.py` 中新增函数 `generate_paperless_reg_udid(device_id)`,供无纸化模拟器调用
4. **appToken 登记**:复用门口屏的 `allocate_app_token(device_id, app_token)` 全局函数(`TokenPool` 全局单例)
5. **回填时机**:token 获取成功后立即回填,再重新解析主题,确保 MQTT 订阅/发布使用正确的主题路径
---
## 阶段三:验证
| # | 验证项 | 验证方式 | 状态 |
|---|--------|----------|------|
| 3.1 | Python 语法检查 | `python -m py_compile backend/app/simulators/door_token_client.py` | ✅ |
| 3.2 | Python 语法检查 | `python -m py_compile backend/app/simulators/paperless_simulator.py` | ✅ |
| 3.3 | 本地真实调用验证 | 使用 appToken=ANP-NRY-0022 + regUdid=54035332b33e1e30 调用无纸化 token 接口 HTTP 200 | ⬜ |
| 3.4 | topic_params 回填验证 | 日志确认 company_id=CN-P7J-UBAINS、room_id=248d49ca...、conferenceId=4 | ⬜ |
| 3.5 | 未提供 appToken 验证 | 报错"未配置 appToken,不可自动生成" | ⬜ |
| 3.6 | 未配置 token_api_host 验证 | warning 日志 + 设备正常启动(不调 token 接口) | ⬜ |
### 验证过程记录(待填写)
1. 本地脚本调用验证:通过 `DoorTokenClient` + `token_api_path` 调用无纸化 token 接口
2. 确认 Authorization 为无空格格式(`BearereyJ...`
3. 确认 companyNumber=CN-P7J-UBAINS、cnum=248d49ca... 等字段正确提取
4. 端到端验证:无纸化模拟器启动 → token 获取 → topic_params 回填 → MQTT 连接成功
---
## 影响范围
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `PAPERLESS_TOKEN_API_PATH` 常量 + `generate_paperless_reg_udid()` 函数 + `DoorTokenClient.__init__` 新增 `token_api_path`/`reg_udid_override` 参数 + `get_token()` 使用实例路径 |
| `backend/app/simulators/paperless_simulator.py` | 修改 | `start()` 接入 token 获取流程 + `_token_info`/`_token_client`/`_authorization` 属性 |
| `backend/app/simulators/base_simulator.py` | 无需修改 | 已支持 `token_api_host` 参数 |
| `backend/app/simulators/__init__.py` | 无需修改 | 已透传 `token_api_host` |
| `backend/app/services/device_sim_service.py` | 无需修改 | 已从环境配置读取 `token_api_host` |
---
## 八、后续工作(本期不做)
无纸化模拟器在 token 获取成功后,还有一个**定期查询会议消息接口** `POST /api/message/getMessageByAppToken`(真实设备日志已提供),见:
- `_PRD_需求优化_无纸化模拟器消息定时拉取.md`
- `_执行计划_无纸化模拟器消息定时拉取.md`
\ No newline at end of file
# 执行计划:无纸化模拟器消息定时拉取
> **对应 PRD**: `_PRD_需求优化_无纸化模拟器消息定时拉取.md`
> **创建日期**: 2026-08-20
> **状态**: 待执行
---
## 阶段一:扩展 DoorTokenClient —— 新增 get_message_by_app_token()
**目标**:在 `door_token_client.py` 中新增无纸化消息查询接口调用能力。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 1.1 | 新增 `PAPERLESS_MESSAGE_API_PATH = "/api/message/getMessageByAppToken"` 常量 | ✅ |
| 1.2 | 新增 `get_message_by_app_token(authorization, app_token)` 实例方法:POST 请求 + form-urlencoded 体 + 动态签名头 | ✅ |
| 1.3 | 请求体构造:按 `appToken` 参数调用(日志接口名暗示),实测后可调整 | ✅ |
| 1.4 | 动态签名复用 `_generate_dynamic_sign(params, bearer_token)`(已实现) | ✅ |
| 1.5 | 响应校验:HTTP 200 即视为调用成功,业务码非 200 记录日志但不视为失败(如 A0034 会议不存在是正常业务场景) | ✅ |
| 1.6 | 错误处理:网络错误/超时不重试(下次定时再试),业务异常捕获日志 | ✅ |
### 关键设计决策
1. **HTTP 200 即算调用成功**:与门口屏不同,门口屏校验 `success=true` + `code="200"`,但无纸化此接口在**无会议时返回正常业务码 `A0034`**`success=false`),这是真实设备正常行为,不应视为调用失败
2. **请求体参数待实测确认**:日志显示 `Content-Type: application/x-www-form-urlencoded`,但未打印 body 内容。接口名 `getMessageByAppToken` 暗示按 `appToken` 查询。先实现 `appToken` 参数,实测后调整
3. **复用签名算法**`_generate_dynamic_sign()` 已支持带 params 的场景
---
## 阶段二:PaperlessSimulator 集成 —— 定时拉取线程
**目标**:无纸化模拟器启动后按上报间隔定时调用消息查询接口。
### 任务清单
| # | 任务 | 状态 |
|---|------|------|
| 2.1 | `PaperlessSimulator.__init__` 新增 `_token_client``_authorization``_msg_thread``_msg_running` 属性 | ✅ |
| 2.2 | `start()` 改造为**四步流程**:①调 token 接口(阶段一)→ ②回填 topic_params → ③解析主题 + 父类 MQTT 启动 → ④启动消息定时拉取线程 | ✅ |
| 2.3 | 新增 `_start_message_polling()`:首次立即调用 + 启动独立线程(间隔 = `self._report_interval`) | ✅ |
| 2.4 | 新增 `_call_message_once()`:单次调用 + 记录 ReportLog | ✅ |
| 2.5 | 新增 `_message_loop()`:定时循环,间隔 = `self._report_interval` | ✅ |
| 2.6 | `stop()` 覆盖:先停止消息拉取线程,再调用父类 stop | ✅ |
| 2.7 | 未配置 `token_api_host` 时跳过消息拉取(向后兼容) | ✅ |
### 关键设计决策
1. **启动流程顺序**(与门口屏一致):
```
start()
① 调 token 接口(获取 authorization + 回填 topic_params)
② 回填 topic_params + 重新解析主题
③ 调用父类 start() 执行 MQTT 注册/订阅/上报
④ 启动消息定时拉取线程(仅 authorization 存在时)
```
2. **间隔来源**:直接用 `self._report_interval`(设备导入时 Excel 填写的上报间隔),不新增配置项
3. **独立线程**:与门口屏同模式,HTTP 调用与 MQTT 上报分离
4. **失败不影响运行**:消息接口调用失败只记日志,不停止设备
---
## 阶段三:验证
| # | 验证项 | 验证方式 | 状态 |
|---|--------|----------|------|
| 3.1 | Python 语法检查 | `python -m py_compile backend/app/simulators/door_token_client.py` | ✅ |
| 3.2 | Python 语法检查 | `python -m py_compile backend/app/simulators/paperless_simulator.py` | ✅ |
| 3.3 | 本地真实调用验证:token → 消息接口 HTTP 200 | 本地脚本调用 | ⬜ |
| 3.4 | 业务码 A0034 不视为失败 | 日志记录正常,不影响定时循环 | ⬜ |
| 3.5 | 端到端验证:启动 → token → 消息定时拉取 | 完整链路 | ⬜ |
### 验证过程记录(待填写)
1. 参数探测:先按 `appToken` 参数调用,验证 body 是否必须含 `conferenceId` 等其他字段
2. 确认 `A0034 会议不存在` 响应不触发错误日志,仅记录 info
3. 确认定时循环持续进行(非单次调用)
---
## 影响范围
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `PAPERLESS_MESSAGE_API_PATH` 常量 + `get_message_by_app_token()` 方法 |
| `backend/app/simulators/paperless_simulator.py` | 修改 | `start()` 四步流程 + 新增定时拉取线程 + `stop()` 覆盖 |
| `backend/app/simulators/base_simulator.py` | 无需修改 | 已支持 `token_api_host` 参数 |
| `backend/app/simulators/__init__.py` | 无需修改 | 已透传 `token_api_host` |
| `backend/app/services/device_sim_service.py` | 无需修改 | 已从环境配置读取 `token_api_host` |
\ No newline at end of file
# PRD 需求文档:无纸化模拟器 Token 获取
> **版本**: v1.0
> **创建日期**: 2026-08-20
> **状态**: 待实现
---
## 一、需求概述
### 1.1 背景
无纸化会议设备(华为平板 BAH3-W59,App 无纸化2.0)启动后的第一个动作是调用 HTTP 接口 `POST /api/api-android/token/getTokenInfoByToken` 获取 token。
当前模拟器缺失此行为,启动时直接连接 MQTT,导致:
- 缺少真实的 Authorization 认证信息
- `topic_params` 中的 `company_id` / `room_id` / `conference_id` / `conference_name` 依赖手动配置,而非从真实接口获取
- 模拟器行为与真实设备不一致,无法为后续定时 HTTP 接口调用(消息列表、人脸页面等)提供前置条件
### 1.2 目标
无纸化模拟器在启动时调用真实 token 接口,获取 Authorization 并回填 `topic_params`,使启动行为与真实设备一致。
### 1.3 真实设备日志参考
```
08-20 09:05:33.577 21525 22510 I PL#无纸化: UbRxRetrofit -- POST https://192.168.5.240/api/api-android/token/getTokenInfoByToken
08-20 09:05:33.578 21525 22510 I PL#无纸化: UbRxRetrofit -- Body:{"appToken":"ANP-NRY-0022","regUdid":"54035332b33e1e30"}
08-20 09:05:39.501 21525 22510 I PL#无纸化: UbRxRetrofit -- Response Body:{"success":true,"code":"200","message":"OPERATION_SUCCESSFUL","result":{"Authorization":"BearereyJ...","picture":"","token":{"tokenId":206,"createUid":7,"companyNumber":"CN-P7J-UBAINS","appToken":"ANP-NRY-0022","regUdid":"54035332b33e1e30","regTime":1786670150000,"cnum":"248d49ca1c728232d0154f4ce5943148","state":1,"typeId":16,"startTime":"2026-07-27 14:13:40","endTime":"2099-12-31 12:59:59","createTime":"2026-07-27 16:07:23","annotation":"彭甘宇在2026-07-28 10:35:29绑定了设备","conference":{"conferenceId":4,"conferenceIp":"","conferenceName":"测试会议室1","conferenceNumber":"248d49ca1c728232d0154f4ce5943148",...},"company":{"companyId":444,"companyName":"招商局项目","companyNumber":"CN-P7J-UBAINS",...}}}}
```
---
## 二、功能需求
### 2.1 Token 接口调用
| 项目 | 说明 |
|------|------|
| 接口路径 | **`POST /api/api-android/token/getTokenInfoByToken`**(注意:与门口屏的 `/exapi/...` 路径不同) |
| 请求头 | `X-SIGN`(固定值,与门口屏共用)、`X-TIMESTAMP`(秒级)、`X-RANDOM`(16 位随机) |
| 请求体 | `{"appToken": "ANP-xxx-xxxx", "regUdid": "16位hex"}` |
| 响应解析 | 提取 `Authorization``token.companyNumber``token.cnum``token.conference``token.company` |
### 2.2 参数来源
| 参数 | 来源 |
|------|------|
| appToken | 设备导入时 Excel 提供的"授权码(app_token)"列(如 `ANP-NRY-0022`),**必须由用户提供,不自动生成** |
| regUdid | 由 `device_id` 确定性派生(格式:纯 16 位 hex,无 `reg_` 前缀,与无纸化真实日志格式一致) |
| X-SIGN | 复用门口屏已验证的固定值(`X_SIGN_FIXED`) |
| token_api_host | 环境配置 `EnvConfig.default_topic_params['token_api_host']`(如 `https://192.168.5.240`) |
### 2.3 regUdid 派生规则
**与门口屏的区别**
| 对比项 | 门口屏 | 无纸化 |
|--------|--------|--------|
| regUdid 格式 | `reg_{md5(device_id)[:16]}` | `{md5(device_id)[:16]}`(纯 16 位 hex,无前缀) |
| 示例 | `reg_79f18c1a9a3bcfb1` | `54035332b33e1e30` |
无纸化 `regUdid` 派生:`md5(device_id)[:16]`(确定性,同设备多次启动结果一致)。
### 2.4 响应字段回填
token 获取成功后,将以下字段回填到 `topic_params`
| topic_params 字段 | 来源 |
|-------------------|------|
| `company_id` | `token.companyNumber`(如 `CN-P7J-UBAINS`) |
| `room_id` | `token.cnum`(如 `248d49ca1c728232d0154f4ce5943148`) |
| `conference_id` | `token.conference.conferenceId`(如 `4`) |
| `conference_name` | `token.conference.conferenceName`(如 `测试会议室1`) |
| `app_token` | 导入时提供的 appToken(如 `ANP-NRY-0022`) |
### 2.5 appToken 登记池(复用门口屏机制)
- appToken **不自动生成**,来自设备导入时 Excel 提供的授权码
- 同一 appToken 在同一进程内只能被一台设备使用(去重机制)
- 未提供 appToken → 报错拒绝启动
- 重复使用 → 报错拒绝启动
### 2.6 错误处理
| 场景 | 行为 |
|------|------|
| HTTP 200 + 业务成功 | 解析响应、回填 topic_params、继续启动流程 |
| 网络错误(ConnectionError/Timeout) | 指数退避重试 3 次(1s, 2s, 4s) |
| 业务错误(ValueError) | 不重试,记录错误日志 |
| 未配置 `token_api_host` | 仅 warning 跳过,不影响启动(向后兼容) |
---
## 三、非功能需求
### 3.1 性能
- 单次调用超时 5 秒
- 重试间隔指数退避(1s, 2s, 4s)
### 3.2 兼容性
- 仅无纸化(paperless)设备需要此行为
- 未配置 `token_api_host` 时不触发(向后兼容,现有环境配置不受影响)
- 现有环境配置不需要新增字段(沿用门口屏的 `token_api_host` 配置)
---
## 四、影响范围
### 4.1 修改文件
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `PaperlessTokenClient` 类或使 `DoorTokenClient` 支持无纸化配置(API 路径 + regUdid 格式) |
| `backend/app/simulators/paperless_simulator.py` | 修改 | `start()` 接入 token 获取流程 + 回填 topic_params |
| `backend/app/simulators/base_simulator.py` | 无需修改 | 已支持 `token_api_host` 参数 |
| `backend/app/simulators/__init__.py` | 无需修改 | 已透传 `token_api_host` |
| `backend/app/services/device_sim_service.py` | 无需修改 | 已从环境配置读取 `token_api_host` |
### 4.2 不变的文件
- 前端文件 — 无改动
- 数据库/Schema — 无改动(复用门口屏的 `token_api_host` 配置)
---
## 五、依赖
- **requests**(已安装)— HTTP 请求
- **DoorTokenClient**(已实现)— 可复用其固定签名、请求会话、响应解析逻辑
- **TokenPool**(已实现)— 可复用其 appToken 登记池
---
## 六、验收标准
| # | 验收项 | 验证方式 |
|---|--------|----------|
| 1 | 配置 token_api_host 后,无纸化启动时调用 token 接口成功 | 日志查看 `Token 获取成功` |
| 2 | topic_params 正确回填(company_id / room_id / conference_id / conference_name) | 日志查看回填后的参数 |
| 3 | 未提供 appToken 时报错拒绝自动生成 | 日志查看 `未配置 appToken` |
| 4 | 重复使用 appToken 时报错拒绝 | 日志查看 `已被其他设备使用` |
| 5 | 未配置 token_api_host 时跳过 token 获取,不影响启动 | 日志 warning + 设备正常启动 |
| 6 | regUdid 格式为纯 16 位 hex(无 `reg_` 前缀),确定性派生 | 同 device_id 多次启动结果一致 |
---
## 七、后续工作(本期不做)
无纸化模拟器在 token 获取成功后,还有一个**定期查询会议消息接口** `POST /api/message/getMessageByAppToken`(真实设备日志已提供),见:
- `_PRD_需求优化_无纸化模拟器消息定时拉取.md`
- `_执行计划_无纸化模拟器消息定时拉取.md`
\ No newline at end of file
# PRD 需求文档:无纸化模拟器消息定时拉取
> **版本**: v1.0
> **创建日期**: 2026-08-20
> **状态**: 待实现
---
## 一、需求概述
### 1.1 背景
无纸化模拟器在 token 获取成功后,真实设备(无纸化2.0 应用,华为平板 BAH3-W59)还会定期调用 `POST /api/message/getMessageByAppToken` 接口拉取会议消息。
当前模拟器缺失此定时调用行为,与真实设备行为不一致。
### 1.2 目标
无纸化模拟器在 token 获取成功后,按照设备导入时配置的**上报间隔**(默认 30 秒)定时调用消息接口,仅确保**调用可达**(HTTP 200 即视为调用成功),解析并记录响应中的业务状态码(如 `A0034 会议不存在` 是正常业务响应,不代表调用失败)。
### 1.3 真实设备日志参考
```
08-20 09:05:39.546 21525 22510 I PL#无纸化: UbRxRetrofit -- POST https://192.168.5.240/api/message/getMessageByAppToken
08-20 09:05:39.546 21525 22510 I PL#无纸化: UbRxRetrofit -- Authorization: BearereyJhbGciOiJIUzUxMiJ9...
08-20 09:05:39.546 21525 22510 I PL#无纸化: UbRxRetrofit -- X-SIGN: uVQIsUeJ/jsFPrvvtl1dmMYS+8X8KXYe9AEga1w6tWVN7AveMzbh/2bvNzXqsxHPnZlnbN+JAX/TOlaWMWyEC3l0YRz1wU4gNLiyAO3oUeA=
08-20 09:05:39.546 21525 22510 I PL#无纸化: UbRxRetrofit -- X-TIMESTAMP: 1787187939
08-20 09:05:39.546 21525 22510 I PL#无纸化: UbRxRetrofit -- X-RANDOM: BBw2q1hXIKdBM2TX
08-20 09:05:39.546 21525 22510 I PL#无纸化: UbRxRetrofit -- Params:Print content-type is not supported:application/x-www-form-urlencoded
08-20 09:05:44.656 21525 22510 I PL#无纸化: UbRxRetrofit -- Response Body:{"success":false,"code":"A0034","message":"会议不存在"}
```
**日志时间线**(token 获取成功后立即调用一次):
```
09:05:33.577 → POST getTokenInfoByToken(token 获取)
09:05:39.501 → token 响应成功(耗时 5919ms)
09:05:39.546 → POST getMessageByAppToken(消息查询,token 成功后立即触发)
09:05:44.656 → 响应 {"success":false,"code":"A0034","message":"会议不存在"}
```
---
## 二、功能需求
### 2.1 消息查询接口调用
| 项目 | 说明 |
|------|------|
| 接口路径 | `POST /api/message/getMessageByAppToken`**注意:`/api/` 前缀,非 `/exapi/`**) |
| 请求方式 | POST |
| 请求头 | `Authorization: Bearer {token}`(无空格格式 `BearereyJ...`)、`X-SIGN`/`X-TIMESTAMP`/`X-RANDOM`(动态签名,与门口屏算法一致) |
| Content-Type | `application/x-www-form-urlencoded`(日志确认) |
| 请求体 | form-urlencoded 格式,参数待实测确认(接口名暗示按 `appToken` 查询,可能含 `appToken`/`conferenceId`) |
| 响应处理 | **HTTP 200 即视为调用成功**;业务状态码(如 A0034 会议不存在)记录为正常业务响应,不影响后续定时调用 |
### 2.2 定时周期
- 调用间隔 = **设备导入时填写的上报间隔**`report_config.interval`,默认 30 秒)
- 独立线程执行(与门口屏消息拉取同模式)
- 首次调用在 token 获取成功后**立即执行一次**(与真实设备日志时间线一致)
### 2.3 参数来源
| 参数 | 来源 |
|------|------|
| Authorization | `DoorTokenClient.get_token()` 返回的 `authorization` 字段(无空格格式 `BearereyJ...`) |
| appToken | token 响应 `token.appToken` 或导入时提供的授权码(已回填 `topic_params.app_token`) |
| 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(无论业务码是否 200) | 记录响应日志(含 code/message),**业务码非 200 视为正常业务响应**(如 A0034 会议不存在 = 当前无会议,真实设备同样如此) |
| HTTP 非 200 | 日志警告,不影响下次定时调用 |
| 网络错误/超时 | 日志警告,下次定时继续尝试 |
| 请求异常 | 捕获异常,不影响设备运行 |
---
## 三、非功能需求
### 3.1 性能
- 定时调用在独立线程中执行,不阻塞主上报循环
- 每次调用超时 5 秒
### 3.2 兼容性
- 仅无纸化(paperless)设备需要此行为
- 未配置 `token_api_host` 时不触发(与 token 获取逻辑一致)
- 未获取到 `authorization` 时不触发
---
## 四、影响范围
### 4.1 修改文件
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/door_token_client.py` | 修改 | 新增 `PAPERLESS_MESSAGE_API_PATH = "/api/message/getMessageByAppToken"` 常量 + `get_message_by_app_token()` 方法 |
| `backend/app/simulators/paperless_simulator.py` | 修改 | 新增定时拉取线程(`_start_message_polling()` / `_call_message_once()` / `_message_loop()` / `stop()` 覆盖),启动流程四步化 |
### 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 + 业务码 A0034(会议不存在)记录为正常响应,不影响后续调用 | 日志不报错、定时循环持续 |
| 4 | 未配置 token_api_host 时不触发 | 日志无相关调用 |
| 5 | 设备停止时线程自动退出 | 日志确认 |
\ No newline at end of file
# HANDOFF — 设备模拟模块
> **生成时间**: 2026-08-19
> **生成时间**: 2026-08-20
> **当前分支**: `platform-auto-test`
> **最近提交**: `695460eb` feat(device-sim): 门口屏补全全局配置定时拉取接口 + 更新 HANDOFF
> **未提交改动**: `deploy_door_http_final.py`(部署脚本,未提交)
> **最近提交**: `f5b9b9df` docs(device-sim): 补充部署遗漏分析文档和执行计划文档
> **未提交改动**: 无纸化模拟器 Token 获取 + 消息定时拉取(door_token_client.py / paperless_simulator.py)+ 4 份 PRD/执行计划文档 + 性能测试模块改动
---
......@@ -909,4 +909,103 @@ if existing and existing.connected and existing.client:
---
*本文档记录设备模拟模块开发状态,供下次会话快速恢复上下文。*
### 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、未部署
**背景**:无纸化会议设备(华为平板 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)
......@@ -56,6 +56,12 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 接口调用路径(真实设备日志),如 https://192.168.5.48/exapi/...
TOKEN_API_PATH = "/exapi/api-android/token/getTokenInfoByToken"
# 无纸化会议设备 Token 接口路径(与门口屏差异:无 /exapi 前缀)
PAPERLESS_TOKEN_API_PATH = "/api/api-android/token/getTokenInfoByToken"
# 无纸化会议设备消息查询接口路径
PAPERLESS_MESSAGE_API_PATH = "/api/message/getMessageByAppToken"
# 消息列表接口路径(真实设备日志):定时拉取会议消息
MESSAGE_LIST_API_PATH = "/exapi/message/getMsgPageList/encrypt"
......@@ -190,6 +196,22 @@ def generate_reg_udid(device_id: str) -> str:
return f"reg_{digest}"
def generate_paperless_reg_udid(device_id: str) -> str:
"""
由 device_id 派生无纸化设备的 regUdid(确定性,同设备多次启动一致)
与门口屏的区别:无纸化真实设备日志 regUdid 为**纯 16 位 hex,无 reg_ 前缀**
(如日志中的 54035332b33e1e30),而门口屏为 reg_{hex} 格式。
Args:
device_id: 设备 ID
Returns:
str: 16 位纯 hex regUdid(无前缀)
"""
return hashlib.md5(str(device_id).encode("utf-8")).hexdigest()[:16]
class DoorTokenClient:
"""
门口屏模拟器 token 获取客户端
......@@ -218,6 +240,8 @@ class DoorTokenClient:
timeout: float = 5.0,
max_retries: int = 3,
scheme: str = DEFAULT_SCHEME,
token_api_path: str = TOKEN_API_PATH,
reg_udid_override: Optional[str] = None,
):
"""
初始化客户端
......@@ -230,6 +254,8 @@ class DoorTokenClient:
timeout: 请求超时(秒)
max_retries: 网络错误最大重试次数(默认 3)
scheme: 协议(https 默认 / http)
token_api_path: Token 接口 API 路径(门口屏用 TOKEN_API_PATH,无纸化用 PAPERLESS_TOKEN_API_PATH)
reg_udid_override: 外部派生的 regUdid;为 None 时内部调用 generate_reg_udid()
"""
if not host:
raise ValueError("host 不能为空,需提供 token 接口主机地址")
......@@ -256,13 +282,21 @@ class DoorTokenClient:
# 唯一性分配
self.app_token = app_token or tokenizer.allocate_app_token(device_id)
self.reg_udid = generate_reg_udid(device_id)
# regUdid:外部传入优先(无纸化),否则内部派生(门口屏)
if reg_udid_override is not None:
self.reg_udid = reg_udid_override
else:
self.reg_udid = generate_reg_udid(device_id)
# 日志中的固定 X-SIGN
self.x_sign = x_sign or X_SIGN_FIXED
# API 路径(门口屏/无纸化不同)
self._token_api_path = token_api_path
logger.info(
f"TokenClient 初始化: device_id={device_id}, app_token={self.app_token}, "
f"reg_udid={self.reg_udid}"
f"reg_udid={self.reg_udid}, token_api_path={self._token_api_path}"
)
self._session = self._create_session()
......@@ -682,6 +716,98 @@ class DoorTokenClient:
)
return True
def get_message_by_app_token(self, authorization: str, app_token: str,
timeout: Optional[float] = None) -> bool:
"""
调用无纸化消息查询接口定期拉取会议消息(定时调用,仅保证调用可达)
真实设备日志参考:
POST https://192.168.5.240/api/message/getMessageByAppToken
Authorization: BearereyJ...
X-SIGN / X-TIMESTAMP / X-RANDOM(动态签名)
Content-Type: application/x-www-form-urlencoded
Body: appToken={app_token}
本方法只校验 HTTP 200,**业务码非 200 不视为失败**:
- A0034 "会议不存在"是正常业务场景(无进行中的会议),真实设备同样收到此响应
- HTTP 200 即算调用成功
Args:
authorization: token 接口返回的 Authorization(无空格格式 BearereyJ...)
app_token: 授权码(来自导入时提供的 app_token)
timeout: 请求超时(秒),默认用 self.timeout
Returns:
bool: 是否调用成功(HTTP 200 即为成功,无论业务码)
"""
if not authorization:
logger.warning(f"[PaperlessMsg] 缺少 authorization,跳过消息查询: {self.device_id}")
return False
if not app_token:
logger.warning(f"[PaperlessMsg] 缺少 app_token,跳过消息查询: {self.device_id}")
return False
url = f"{self.base_url}{PAPERLESS_MESSAGE_API_PATH}"
# Authorization 格式必须为无空格 BearereyJ...(与真实设备日志一致)
auth_header = authorization
bearer_str = authorization
# 构造请求体(form-urlencoded)
body_data = {"appToken": app_token}
# 动态签名(基于 Bearer token + body 参数)
sign_headers = self._generate_dynamic_sign(body_data, bearer_str)
headers = {
"Authorization": auth_header,
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json, text/plain, */*",
}
headers.update(sign_headers)
logger.info(
f"[PaperlessMsg] 调用接口: POST {url}, device_id={self.device_id}, "
f"appToken={app_token}, "
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"[PaperlessMsg] 网络错误: device_id={self.device_id}, error={e}")
return False
if resp.status_code != 200:
logger.warning(
f"[PaperlessMsg] HTTP 非 200: device_id={self.device_id}, "
f"status={resp.status_code}, body={resp.text[:300]}"
)
return False
# HTTP 200 即视为成功(不校验业务码,A0034 会议不存在是正常业务场景)
try:
data = resp.json()
except Exception as e:
logger.warning(f"[PaperlessMsg] 响应 JSON 解析失败: device_id={self.device_id}, error={e}")
# HTTP 200 + 无法解析 JSON 仍算调用可达
return True
code = str(data.get("code", ""))
message = data.get("message", "")
logger.info(
f"[PaperlessMsg] 调用成功: device_id={self.device_id}, "
f"code={code}, message={message}"
)
return True
def _build_headers(self, x_timestamp: Optional[str] = None, x_random: Optional[str] = None) -> Dict[str, str]:
"""
构造请求头
......@@ -777,7 +903,7 @@ class DoorTokenClient:
ConnectionError: 网络可达但业务失败(重试)后仍失败
ValueError: 参数/解析错误
"""
url = f"{self.base_url}{TOKEN_API_PATH}"
url = f"{self.base_url}{self._token_api_path}"
headers = self._build_headers()
body = self._build_body()
......@@ -897,9 +1023,12 @@ __all__ = [
"tokenizer",
"allocate_app_token",
"generate_reg_udid",
"generate_paperless_reg_udid",
"normalize_host",
"pick_default_host",
"TOKEN_API_PATH",
"PAPERLESS_TOKEN_API_PATH",
"PAPERLESS_MESSAGE_API_PATH",
"MESSAGE_LIST_API_PATH",
"FACE_PAGE_API_PATH",
"EX_GLOBAL_CONFIG_API_PATH",
......
......@@ -12,6 +12,7 @@
import json
import logging
import random
import threading
import time
from datetime import datetime
from typing import Optional
......@@ -64,6 +65,13 @@ class PaperlessSimulator(BaseSimulator):
self._file_sync_progress = 0
self._screen_sharing = False
self._current_agenda = ""
# Token 获取相关属性
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_reboot_response_payload(self) -> dict:
"""
......@@ -190,6 +198,206 @@ class PaperlessSimulator(BaseSimulator):
"deviceId": self.device_id
}
def start(self) -> bool:
"""
启动无纸化模拟器
流程(四步,与门口屏一致):
1. 调用 getTokenInfoByToken 接口获取 token
2. 回填 topic_params(company_id / room_id / conference_id / conference_name / app_token)
3. 重新解析主题 + 调用父类 start() 执行 MQTT 注册/订阅/上报
4. 启动消息定时拉取线程(getMessageByAppToken,间隔 = 上报间隔)
Returns:
bool: 是否启动成功
"""
# 第一步:获取 token
token_host = self.token_api_host
if not token_host:
logger.warning(f"无纸化未配置 token_api_host,跳过 token 获取: {self.device_id}")
else:
try:
from app.simulators.door_token_client import (
DoorTokenClient,
allocate_app_token,
generate_paperless_reg_udid,
PAPERLESS_TOKEN_API_PATH,
)
# 登记用户提供的 appToken(Excel 导入时提供的授权码,保证不重复)
app_token = allocate_app_token(self.device_id, self.topic_params.get("app_token"))
client = DoorTokenClient(
host=token_host,
device_id=self.device_id,
app_token=app_token,
token_api_path=PAPERLESS_TOKEN_API_PATH,
reg_udid_override=generate_paperless_reg_udid(self.device_id),
)
token_info = client.get_token()
# 保存 token 客户端和授权信息(供消息定时拉取使用)
self._token_client = client
self._authorization = token_info.get("authorization", "")
# 保存 token 完整信息
self._token_info = token_info
# 回填 topic_params
token_data = token_info.get("token", {})
company_number = token_data.get("companyNumber", "")
cnum = token_data.get("cnum", "")
conference = token_info.get("conference", {})
conference_id = str(conference.get("conferenceId", ""))
conference_name = conference.get("conferenceName", "")
if company_number:
self.topic_params["company_id"] = company_number
if cnum:
self.topic_params["room_id"] = cnum
if conference_id:
self.topic_params["conference_id"] = conference_id
if conference_name:
self.topic_params["conference_name"] = conference_name
self.topic_params["app_token"] = app_token
logger.info(
f"无纸化 token 获取成功: device_id={self.device_id}, "
f"company_id={company_number}, room_id={cnum}, "
f"conference_id={conference_id}, conference_name={conference_name}"
)
except Exception as e:
logger.error(f"无纸化 token 获取失败: device_id={self.device_id}, error={e}")
self._running = False
# 将错误信息通知回调
self._notify_report(
topic="token_get",
payload={"device_id": self.device_id, "error": str(e)},
direction="publish",
status="failed",
error=str(e),
)
return False
# 第二步:重新解析主题(topic_params 已回填,解析出新的 MQTT 主题)
self._resolve_topics()
# 第三步:调用父类 MQTT 启动流程
result = super().start()
if not result:
return False
# 第四步:启动消息定时拉取(仅在 token 获取成功后有 authorization 才启动)
if self._authorization:
self._start_message_polling()
else:
logger.info(f"无纸化未配置 token 或无 authorization,跳过消息定时拉取: {self.device_id}")
return True
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_polling(self) -> None:
"""
启动无纸化消息定时拉取线程(getMessageByAppToken)
间隔使用 self._report_interval(设备导入时填写的上报间隔,默认 30 秒)。
首次调用立即执行一次,之后按间隔定时调用(与真实设备日志时间线一致)。
"""
if not self._token_client or not self._authorization:
return
app_token = self.topic_params.get("app_token", "")
logger.info(
f"启动消息定时拉取: device_id={self.device_id}, "
f"interval={self._report_interval}s, appToken={app_token}"
)
# 立即执行一次
self._call_message_once()
# 启动定时线程
self._msg_running = True
self._msg_thread = threading.Thread(
target=self._message_loop,
name=f"paperless_msg_{self.device_id[:8]}",
daemon=True,
)
self._msg_thread.start()
def _call_message_once(self) -> bool:
"""
调用一次无纸化消息查询接口,记录结果到 ReportLog
HTTP 200 即视为调用成功(业务码 A0034 会议不存在是正常业务响应)。
Returns:
bool: 是否调用成功(HTTP 200)
"""
if not self._token_client or not self._authorization:
return False
app_token = self.topic_params.get("app_token", "")
try:
success = self._token_client.get_message_by_app_token(
authorization=self._authorization,
app_token=app_token,
)
self._notify_report(
topic=f"paperless_message_{'success' if success else 'failed'}",
payload={
"device_id": self.device_id,
"app_token": app_token,
"success": success,
},
direction="publish",
status="success" if success else "failed",
)
return success
except Exception as e:
logger.warning(f"[PaperlessMsg] 调用异常: device_id={self.device_id}, error={e}")
self._notify_report(
topic="paperless_message_error",
payload={"device_id": self.device_id, "error": str(e)},
direction="publish",
status="failed",
error=str(e),
)
return False
def _message_loop(self) -> None:
"""
无纸化消息定时拉取循环
在独立线程中运行,interval 秒调用一次 getMessageByAppToken。
调用失败(网络错误/超时)仅记日志,不影响下次定时执行。
"""
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_once()
logger.info(f"消息定时拉取线程结束: {self.device_id}")
def build_register_payload(self) -> dict:
"""构建设备注册消息"""
return {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论