提交 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 — 设备模拟模块 # HANDOFF — 设备模拟模块
> **生成时间**: 2026-08-19 > **生成时间**: 2026-08-20
> **当前分支**: `platform-auto-test` > **当前分支**: `platform-auto-test`
> **最近提交**: `695460eb` feat(device-sim): 门口屏补全全局配置定时拉取接口 + 更新 HANDOFF > **最近提交**: `f5b9b9df` docs(device-sim): 补充部署遗漏分析文档和执行计划文档
> **未提交改动**: `deploy_door_http_final.py`(部署脚本,未提交) > **未提交改动**: 无纸化模拟器 Token 获取 + 消息定时拉取(door_token_client.py / paperless_simulator.py)+ 4 份 PRD/执行计划文档 + 性能测试模块改动
--- ---
...@@ -909,4 +909,103 @@ if existing and existing.connected and existing.client: ...@@ -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) ...@@ -56,6 +56,12 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 接口调用路径(真实设备日志),如 https://192.168.5.48/exapi/... # 接口调用路径(真实设备日志),如 https://192.168.5.48/exapi/...
TOKEN_API_PATH = "/exapi/api-android/token/getTokenInfoByToken" 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" MESSAGE_LIST_API_PATH = "/exapi/message/getMsgPageList/encrypt"
...@@ -190,6 +196,22 @@ def generate_reg_udid(device_id: str) -> str: ...@@ -190,6 +196,22 @@ def generate_reg_udid(device_id: str) -> str:
return f"reg_{digest}" 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: class DoorTokenClient:
""" """
门口屏模拟器 token 获取客户端 门口屏模拟器 token 获取客户端
...@@ -218,6 +240,8 @@ class DoorTokenClient: ...@@ -218,6 +240,8 @@ class DoorTokenClient:
timeout: float = 5.0, timeout: float = 5.0,
max_retries: int = 3, max_retries: int = 3,
scheme: str = DEFAULT_SCHEME, scheme: str = DEFAULT_SCHEME,
token_api_path: str = TOKEN_API_PATH,
reg_udid_override: Optional[str] = None,
): ):
""" """
初始化客户端 初始化客户端
...@@ -230,6 +254,8 @@ class DoorTokenClient: ...@@ -230,6 +254,8 @@ class DoorTokenClient:
timeout: 请求超时(秒) timeout: 请求超时(秒)
max_retries: 网络错误最大重试次数(默认 3) max_retries: 网络错误最大重试次数(默认 3)
scheme: 协议(https 默认 / http) 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: if not host:
raise ValueError("host 不能为空,需提供 token 接口主机地址") raise ValueError("host 不能为空,需提供 token 接口主机地址")
...@@ -256,13 +282,21 @@ class DoorTokenClient: ...@@ -256,13 +282,21 @@ class DoorTokenClient:
# 唯一性分配 # 唯一性分配
self.app_token = app_token or tokenizer.allocate_app_token(device_id) 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 # 日志中的固定 X-SIGN
self.x_sign = x_sign or X_SIGN_FIXED self.x_sign = x_sign or X_SIGN_FIXED
# API 路径(门口屏/无纸化不同)
self._token_api_path = token_api_path
logger.info( logger.info(
f"TokenClient 初始化: device_id={device_id}, app_token={self.app_token}, " 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() self._session = self._create_session()
...@@ -682,6 +716,98 @@ class DoorTokenClient: ...@@ -682,6 +716,98 @@ class DoorTokenClient:
) )
return True 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]: def _build_headers(self, x_timestamp: Optional[str] = None, x_random: Optional[str] = None) -> Dict[str, str]:
""" """
构造请求头 构造请求头
...@@ -777,7 +903,7 @@ class DoorTokenClient: ...@@ -777,7 +903,7 @@ class DoorTokenClient:
ConnectionError: 网络可达但业务失败(重试)后仍失败 ConnectionError: 网络可达但业务失败(重试)后仍失败
ValueError: 参数/解析错误 ValueError: 参数/解析错误
""" """
url = f"{self.base_url}{TOKEN_API_PATH}" url = f"{self.base_url}{self._token_api_path}"
headers = self._build_headers() headers = self._build_headers()
body = self._build_body() body = self._build_body()
...@@ -897,9 +1023,12 @@ __all__ = [ ...@@ -897,9 +1023,12 @@ __all__ = [
"tokenizer", "tokenizer",
"allocate_app_token", "allocate_app_token",
"generate_reg_udid", "generate_reg_udid",
"generate_paperless_reg_udid",
"normalize_host", "normalize_host",
"pick_default_host", "pick_default_host",
"TOKEN_API_PATH", "TOKEN_API_PATH",
"PAPERLESS_TOKEN_API_PATH",
"PAPERLESS_MESSAGE_API_PATH",
"MESSAGE_LIST_API_PATH", "MESSAGE_LIST_API_PATH",
"FACE_PAGE_API_PATH", "FACE_PAGE_API_PATH",
"EX_GLOBAL_CONFIG_API_PATH", "EX_GLOBAL_CONFIG_API_PATH",
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
import json import json
import logging import logging
import random import random
import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
...@@ -64,6 +65,13 @@ class PaperlessSimulator(BaseSimulator): ...@@ -64,6 +65,13 @@ class PaperlessSimulator(BaseSimulator):
self._file_sync_progress = 0 self._file_sync_progress = 0
self._screen_sharing = False self._screen_sharing = False
self._current_agenda = "" 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: def build_reboot_response_payload(self) -> dict:
""" """
...@@ -190,6 +198,206 @@ class PaperlessSimulator(BaseSimulator): ...@@ -190,6 +198,206 @@ class PaperlessSimulator(BaseSimulator):
"deviceId": self.device_id "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: def build_register_payload(self) -> dict:
"""构建设备注册消息""" """构建设备注册消息"""
return { return {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论