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

feat(device-sim): P0~P3 全链路完成 + 部署三台 + HANDOFF 记录 + 同步 P0 修复回源码

- 门口屏模拟器 P0~P3 完成(MQTT 修复 + 动态 X-SIGN + result 解密 + MQTT-HTTP 联动)
- 部署到 5.60/5.44/5.202,容器内真实 Broker 联动验证 PASS
- 同步 P0 修复(mqtt_manager.py)回源码树
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 a761d9ce
# HANDOFF — 设备模拟模块
> **生成时间**: 2026-08-24
> ⚠️ **多窗口并行开发注意**:如需开多个 Claude Code 窗口同时开发,必须先阅读 `Docs/多窗口并行开发指南.md`(端口隔离 / 数据库错开 / Playwright 互斥 / Git 协作)。本仓库当前确有并行窗口在工作(性能测试、安全测试等模块的未提交改动与本模块无关,勿混入本模块提交)。
> **生成时间**: 2026-09-10
> **当前分支**: `platform-auto-test`
> **最近提交**: `21d65661` docs(device-sim): 更新 HANDOFF 记录设备模拟模块部署到 5.60
> **未提交改动**: 仅 `backend/data/test_platform.db`(本地 SQLite,已 gitignore)+ 新增部署/验证脚本(`check_mounts_44_202.py` / `deploy_44_202.py` / `verify_44_202.py`,均按 .gitignore 规则不提交)
> **最近提交**: `beb0f4af` fix(security): 修复5.60安全用例JSON双重编码500错误 + 部署脚本防复发(并行窗口提交)
> **本模块未提交改动**(设备模拟 P0~P3,均已部署到三台服务器,代码待提交):
> - `backend/app/services/mqtt_manager.py` — P0:connect() 复用存活连接 + `_teardown`(暂存副本 `scripts/mqtt_manager_p0_fix.py`)
> - `backend/app/simulators/door_token_client.py` — P2:动态 X-SIGN + result 解密(暂存副本 `scripts/door_token_client_p3.py`)
> - `backend/app/simulators/door_simulator.py` — P3:MQTT-HTTP 联动(暂存副本 `scripts/door_simulator_p3.py`)
> - 辅助脚本:`deploy_mqtt_fix.py` / `deploy_p3_linkage.py` / `p3_linkage_verify.py` / `diag_544_mqtt_broker.py` / `diag_544_mqtt_deep.py` / `_remote_54x_*.py`(远端回传校验副本)
> - 其余未提交文件(性能/安全/项目模块等)属并行窗口,与本模块无关
---
## 📊 会话进度记录
### 2026-09-10 会话 O:门口屏 P0~P3 全链路完成(MQTT 修复 + 动态 X-SIGN + result 解密 + MQTT-HTTP 联动,已部署三台并验证通过)
**会话目标**:按优先级完成门口屏模拟器 P0~P3 四项工作,并同步部署到 5.60 / 5.44 / 5.202。
**状态**:✅ 全部完成 + 三台部署成功 + 容器内真实 Broker 联动验证 PASS
---
#### P0:5.44 MQTT Broker 闪断(rc=7)修复
- 根因:模拟器批量断开/重建连接时 `mqtt_manager.connect()` 无条件先 disconnect 已有连接,导致同环境所有设备断流 + Broker 侧连接风暴。
- 修复:`mqtt_manager.py` connect() 已有存活连接时直接复用;增加 `_teardown` 清理路径。已按 `deploy_mqtt_fix.py` 部署三台。
#### P1:5.44 / 5.202 补传文件
- 两台缺失 `door_token_client.py` / `paperless_simulator.py`(历史部署遗漏),已补传并容器内 import 验证。
#### P2:动态 X-SIGN + 消息列表 result 解密(`door_token_client.py`)
- **动态 X-SIGN**(逆向前端 JS,与 http_client.py 算法一致):`x_random(8-16位)` + `x_timestamp(秒级)``sign_str = timestamp + JSON(params) + random`(无参数时 `timestamp + random`)→ `SHA256` → 以 `SHA256(bearer_token)` 为密钥源派生 `key=hex[16:32]``iv=hex[0:8]+hex[-8:]` 做 AES-CBC 加密;无 token 场景走 RandomCode 登录式签名(60 位随机串 + 随头回传)。
- **result 解密**(响应拦截器逆向):服务端返回 Base64 密文时,`AES-CFB` 解密,密钥派生同上;**关键:pycryptodome 必须显式 `segment_size=128`**(CryptoJS CFB 为 CFB-128,默认 8 解出乱码),ZeroPadding 去尾(`rstrip(b"\x00")`);失败时与前端 catch 同语义返回原数据不抛异常。
- 已在 5.44 `/api/company/getSystemConfigData` 真实服务端实测闭环(RandomCode 签名 → 服务端加密 → 解出明文 JSON)。解密数据缓存于 `last_message_list_data` / `last_face_page_data` / `last_global_config_data`
- ⚠️ 5.48(门口屏 /exapi 后端)不可达,消息列表真实明文样本仍缺失;解密能力已在 5.44 同源接口验证。
#### P3:消息列表结果与 MQTT 主题联动(`door_simulator.py`)
- **MQTT → HTTP**`_on_command` 收到 `meeting_message` 主题(`/meeting/message/{room_id}/`)推送时,立即起线程调用一次消息列表(模拟真实门口屏"推送触发拉取")。
- **HTTP → MQTT**:拉取成功且解密出消息时,向 `android_broadcast``/uams/android/broadcast`)发布 `message_sync` 摘要(`messageCount` + `latest`,防御性解析列表型字段)。
- `start()` 顺序调整:先取 token 回填 topic_params → 重解析主题 → MQTT 注册订阅 → 启动消息列表定时轮询。
#### 🔥 本会话新踩坑(已修复)
- **`_on_command` 内 `threading.Thread` 报 `name 'threading' is not defined`**:P3 新代码使用了 `threading` 但模块未 import,路径 A(推送触发拉取)首次容器内验证即崩。**教训:给既有大文件加并发代码时先核对 import 区**。修复后重部署三台。
#### 验证结果(5.60 容器内,真实 Broker 192.168.5.44:1883)
- 路径 A:向 meeting_message 推送 → `_on_command` 触发即时拉取 → `message_sync` 回发 ✅
- 路径 B:直接 `_call_message_list_once()``message_sync` 回发,`messageCount=2``latest` 内容正确 ✅
- 本地 CFB-128 解密回环(RandomCode 派生密钥 + ZeroPadding)✅
- 三台 health 检查均 200(5.60:80,5.44/5.202:8081)✅
- 验证脚本:`backend/scripts/p3_linkage_verify.py`;部署脚本:`backend/scripts/deploy_p3_linkage.py`(含标记校验 + 备份 + 健康检查,单台重跑:`python scripts/deploy_p3_linkage.py 560|544|5202`
#### 待办(下会话)
- 5.48 可达后:用真实消息列表密文回归 `decrypt_result` + 真实 `getMsgPageList` 全链路。
- 门口屏设备当前全部为 stopped 状态(容器重启后内存态重置,历史上报时间 08-21),如需联调在设备模拟页批量启动即可。
### 2026-08-24 会话 N:测试管理平台前后端部署到 5.44 / 5.202(已部署并验证通过)
**会话目标**:将当前测试管理平台的前后端代码更新部署到 192.168.5.44 和 192.168.5.202 两台测试管理平台。
......
......@@ -13,6 +13,7 @@ from __future__ import annotations
import json
import logging
import random
import threading
import time
from datetime import datetime
......@@ -138,14 +139,30 @@ class MqttManager:
logger.info(f"MQTT 复用已有连接: env_config_id={env_config_id}")
return True
# 存在但已断开的连接,先清理
if existing:
self.disconnect(env_config_id)
# 存在但已断开的连接:优先等待 paho 内置自动重连成功(最多 2 秒)。
# 不要立即 disconnect+重建 —— 那会与 paho 自动重连竞态,产生两个
# 并发连接互踢,触发 EMQX session takeover 死循环(rc=7 闪断根因)。
if existing and existing.client:
for _ in range(20):
if existing.connected:
logger.info(
f"MQTT 自动重连成功,继续复用: env_config_id={env_config_id}")
return True
time.sleep(0.1)
logger.warning(
f"MQTT 自动重连未恢复,重建连接: env_config_id={env_config_id}")
self._teardown(existing)
try:
conn = MqttConnection(env_config_id)
# 唯一化 client_id:同一环境每次物理连接/重连使用不同 id,
# 杜绝相同 client_id 触发 EMQX session takeover 互踢死循环
# (P0 闪断根因修复,详见 backend/scripts/diag_544_takeover.py)。
base_cid = client_id or f"sim_{env_config_id[:8]}"
unique_cid = (f"{base_cid[:48]}_{int(time.time() * 1000) % 100000000}"
f"_{random.randint(100, 999)}")
client = mqtt.Client(
client_id=client_id or f"sim_{env_config_id[:8]}",
client_id=unique_cid,
protocol=mqtt.MQTTv311
)
......@@ -161,8 +178,9 @@ class MqttManager:
if use_tls:
client.tls_set()
# 设置自动重连(paho-mqtt 内置机制)
client.reconnect_delay_set(min_delay=1, max_delay=60)
# 设置自动重连(paho-mqtt 内置机制)。上限 5 秒保证断开后快速恢复,
# 让 connect() 的"等待自动重连"窗口(2 秒内)更容易命中复用路径
client.reconnect_delay_set(min_delay=1, max_delay=5)
# 连接
client.connect(host, port, keepalive=60)
......@@ -177,7 +195,7 @@ class MqttManager:
self._connections[env_config_id] = conn
if conn.connected:
logger.info(f"MQTT 连接成功: {host}:{port}")
logger.info(f"MQTT 连接成功: {host}:{port}, cid={unique_cid}")
return True
else:
logger.warning(f"MQTT 连接超时: {host}:{port}")
......@@ -187,6 +205,27 @@ class MqttManager:
logger.error(f"MQTT 连接异常: {host}:{port}, error={e}")
return False
@staticmethod
def _teardown(conn: MqttConnection) -> None:
"""彻底关闭一个 MQTT 连接的底层网络线程。
loop_stop 会等待网络线程退出,期间把 paho 的自动重连关掉,
避免旧连接仍在自动重连、与应用层新建连接竞态互踢。
"""
client = conn.client
if not client:
return
try:
client.loop_stop()
except Exception as e:
logger.warning(f"MQTT teardown loop_stop 异常: {e}")
try:
client.disconnect()
except Exception as e:
logger.warning(f"MQTT teardown disconnect 异常: {e}")
conn.client = None
conn.connected = False
def disconnect(self, env_config_id: str) -> None:
"""
断开 MQTT 连接
......@@ -196,13 +235,10 @@ class MqttManager:
"""
with self._manager_lock:
conn = self._connections.pop(env_config_id, None)
if conn and conn.client:
try:
conn.client.loop_stop()
conn.client.disconnect()
if conn:
if conn.client:
logger.info(f"MQTT 断开连接: env_config_id={env_config_id}")
except Exception as e:
logger.warning(f"MQTT 断开异常: {e}")
self._teardown(conn)
def publish(self, env_config_id: str, topic: str, payload: dict) -> bool:
"""
......
......@@ -12,6 +12,7 @@
import json
import logging
import random
import threading
import time
from typing import Optional
......@@ -199,6 +200,21 @@ class DoorSimulator(BaseSimulator):
command = payload.get("command", "")
logger.info(f"门口屏收到指令: device_id={self.device_id}, command={command}")
# P3 联动(MQTT → HTTP):平台向会议消息主题推送新消息时,真实门口屏
# 会立即拉取一次消息列表获取完整内容,此处模拟该"推送触发拉取"行为。
meeting_msg_topic = self.get_resolved_topic("meeting_message")
if (topic and meeting_msg_topic and topic == meeting_msg_topic
and self._token_client and self._authorization):
logger.info(
f"[联动] 收到会议消息主题推送,触发即时消息拉取: "
f"device_id={self.device_id}, topic={topic}"
)
threading.Thread(
target=self._call_message_list_once,
daemon=True,
name=f"msgpush_{self.device_id}",
).start()
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
......@@ -413,16 +429,24 @@ class DoorSimulator(BaseSimulator):
authorization=self._authorization,
conference_id=conference_id,
)
summary = self._summarize_decrypted_messages(
getattr(self._token_client, "last_message_list_data", None)
)
self._notify_report(
topic=f"message_list_{'success' if success else 'failed'}",
payload={
"device_id": self.device_id,
"conference_id": conference_id,
"success": success,
"summary": summary or None,
},
direction="publish",
status="success" if success else "failed",
)
# P3 联动(HTTP → MQTT):拉取成功且解密出消息时,向真实上报主题
# 发布 message_sync 摘要(模拟设备同步到新消息后的上报行为)
if success:
self._publish_message_sync()
return success
except Exception as e:
logger.warning(f"[MsgList] 调用异常: device_id={self.device_id}, error={e}")
......@@ -435,6 +459,80 @@ class DoorSimulator(BaseSimulator):
)
return False
@staticmethod
def _summarize_decrypted_messages(data) -> dict:
"""
从解密后的消息列表数据中提取摘要(HTTP → MQTT 联动用)
真实消息列表解密后的结构未知(5.48 不可达未取得明文样本),
此处做防御性解析:识别 dict 中的列表型字段或顶层 list,
提取消息条数与最新一条的标题/时间类字段。
"""
if data is None:
return {}
items = None
if isinstance(data, list):
items = data
elif isinstance(data, dict):
# 常见分页结构:list/records/rows/data 等键存消息数组
for key in ("list", "records", "rows", "data", "msgs", "messages"):
if isinstance(data.get(key), list):
items = data[key]
break
if items is None:
# 兜底:取第一个列表型字段
for v in data.values():
if isinstance(v, list):
items = v
break
if not isinstance(items, list):
return {}
summary = {"message_count": len(items)}
if items and isinstance(items[0], dict):
first = items[0]
for key in ("title", "content", "createTime", "sendTime", "messageName"):
if first.get(key):
summary["latest"] = str(first[key])[:100]
break
return summary
def _publish_message_sync(self) -> None:
"""将消息列表解密结果摘要发布到真实 MQTT 上报主题(HTTP → MQTT 联动)"""
try:
data = getattr(self._token_client, "last_message_list_data", None)
summary = self._summarize_decrypted_messages(data)
if not summary.get("message_count"):
return
# 优先 android_broadcast(门口屏心跳同主题),其次第一个 publish 主题
topic = self.get_resolved_topic("android_broadcast")
if not topic:
publish_topics = self.get_publish_topics()
topic = publish_topics[0]["topic"] if publish_topics else None
if not topic:
return
payload = {
"type": "message_sync",
"clientId": self.device_id,
"messageCount": summary["message_count"],
}
if summary.get("latest"):
payload["latest"] = summary["latest"]
ok = self.mqtt.publish(self.env_config_id, topic, payload)
self._notify_report(
topic=topic,
payload=payload,
direction="publish",
status="success" if ok else "failed",
)
logger.info(
f"[联动] message_sync 已发布: device_id={self.device_id}, "
f"topic={topic}, count={summary['message_count']}"
)
except Exception as e:
logger.warning(
f"[联动] message_sync 发布异常: device_id={self.device_id}, error={e}"
)
def _message_list_loop(self) -> None:
"""
消息列表 + 人脸页面 + 全局配置定时拉取循环
......
......@@ -280,8 +280,13 @@ class DoorTokenClient:
if self.scheme not in ("http", "https"):
raise ValueError(f"不支持的协议: {self.scheme}")
# 唯一性分配
self.app_token = app_token or tokenizer.allocate_app_token(device_id)
# appToken 唯一性登记:显式传入时(调用方已经 allocate_app_token 登记)直接使用;
# 未传入时经登记池校验——appToken 不可自动生成,会抛出"未配置 appToken"的明确错误。
# 注意:不能调 tokenizer.allocate_app_token(不存在的方法,历史遗留 AttributeError)
if app_token:
self.app_token = app_token
else:
self.app_token = tokenizer.allocate(device_id, app_token)
# regUdid:外部传入优先(无纸化),否则内部派生(门口屏)
if reg_udid_override is not None:
self.reg_udid = reg_udid_override
......@@ -294,6 +299,12 @@ class DoorTokenClient:
# API 路径(门口屏/无纸化不同)
self._token_api_path = token_api_path
# P2:/encrypt 接口 result 解密结果缓存(供 MQTT 主题联动等后续消费)
self.last_message_list_data = None # getMsgPageList/encrypt
self.last_face_page_data = None # getTodayMeetingFacePage/encrypt
self.last_global_config_data = None # exGlobalConfig/encrypt
self.last_paperless_message_data = None # getMessageByAppToken(无 /encrypt 后缀)
logger.info(
f"TokenClient 初始化: device_id={device_id}, app_token={self.app_token}, "
f"reg_udid={self.reg_udid}, token_api_path={self._token_api_path}"
......@@ -372,16 +383,19 @@ class DoorTokenClient:
sign_str = timestamp + random(无参数时)
4. sign_hash = SHA256(sign_str)
5. aes_key_source = SHA256(bearer_token) → 64 字符 hex
bearer_token 为空时(如 token 接口本身):生成 60 位随机串
aes_key_source = SHA256(random_code),并将 random_code 作为 RandomCode 头返回
6. aes_key = aes_key_source[16:32] → 16 字符
7. aes_iv = aes_key_source[0:8] + 末尾 8 字符 → 16 字符
8. x_sign = AES.encrypt(sign_hash, aes_key, aes_iv) → Base64
Args:
params: 查询参数字典(GET 请求)
bearer_token: Bearer Token 字符串(含 "Bearer " 前缀)
bearer_token: Bearer Token 字符串(含 "Bearer " 前缀);
传空串时自动生成 RandomCode 密钥源(登录式签名)
Returns:
dict: {X-RANDOM, X-TIMESTAMP, X-SIGN}
dict: {X-RANDOM, X-TIMESTAMP, X-SIGN[, RandomCode]}
"""
x_random = self._generate_random_string(8, 16)
# 真实设备日志 X-TIMESTAMP=1787135913 为秒级,这里用秒级
......@@ -395,18 +409,89 @@ class DoorTokenClient:
sign_hash = self._sha256(sign_str)
# 用 Bearer token 做 AES 密钥源
aes_key_source = self._sha256(bearer_token)
# 用 Bearer token 做 AES 密钥源;无 token 时随机 60 位串并随请求头回传
if bearer_token:
aes_key_source = self._sha256(bearer_token)
else:
random_code = self._generate_random_string(60, 60)
aes_key_source = self._sha256(random_code)
aes_key = aes_key_source[16:32]
aes_iv = aes_key_source[0:8] + aes_key_source[-8:]
x_sign = self._aes_encrypt(sign_hash, aes_key, aes_iv)
return {
result = {
"X-RANDOM": x_random,
"X-TIMESTAMP": x_timestamp,
"X-SIGN": x_sign,
}
if not bearer_token:
result["RandomCode"] = random_code
return result
def decrypt_result(self, result: str, key_source: str):
"""
解密 /encrypt 接口返回的加密 result(前端响应拦截器逆向,已在 5.44 真实服务端验证)
前端响应拦截器(5.44 / 5.240 前端 bundle 逻辑一致):
key_source = headers.Authorization || headers.RandomCode
n = SHA256(key_source)
key = n.slice(16, 32)、iv = n.slice(0, 8) + n.slice(-8)
result = AES.decrypt(result, key, iv, 2) // mode 2 = CFB + ZeroPadding
result = JSON.parse(result)
失败时前端 catch 后"解密失败,返回原始数据";本方法同语义返回原字符串。
与签名的差别:签名是对 SHA256 摘要做 AES-CBC 加密;此处是对服务端返回的
Base64 密文做 AES-CFB 解密(CryptoJS CFB 为 CFB-128,pycryptodome 需
segment_size=128 对齐)。已在 5.44 /api/company/getSystemConfigData
实测闭环(RandomCode 登录式签名 → 服务端加密 → 本方法解出明文 JSON)。
Args:
result: 响应中的 result 字段(Base64 密文;已解密/明文时原样返回)
key_source: 解密密钥源(请求时实际发送的 Authorization 头字符串,
无 Authorization 场景为 RandomCode 头)
Returns:
解密并 JSON 解析后的对象;失败时返回原始 result(不抛异常)
"""
# 已是结构化数据(服务端未加密)时原样返回
if not result or not isinstance(result, str):
return result
if not HAS_CRYPTO:
logger.warning("[Decrypt] pycryptodome 未安装,无法解密 result,返回原文")
return result
try:
digest = self._sha256(key_source)
aes_key = digest[16:32]
aes_iv = digest[0:8] + digest[-8:]
raw = b64.b64decode(result)
cipher = AES.new(
aes_key.encode("utf-8"),
AES.MODE_CFB,
iv=aes_iv.encode("utf-8"),
segment_size=128, # CryptoJS CFB 为 CFB-128,默认 8 会解出乱码
)
plaintext = cipher.decrypt(raw).rstrip(b"\x00") # ZeroPadding 去尾
return json.loads(plaintext.decode("utf-8"))
except Exception as e:
logger.warning(f"[Decrypt] result 解密失败,返回原始数据: {e}")
return result
@staticmethod
def _result_summary(decrypted) -> str:
"""生成解密结果的简短摘要(日志用)"""
if isinstance(decrypted, dict):
parts = []
for k, v in decrypted.items():
if isinstance(v, list):
parts.append(f"{k}[{len(v)}]")
else:
parts.append(k)
return "fields=" + ",".join(parts[:10])
if isinstance(decrypted, list):
return f"list_len={len(decrypted)}"
return f"type={type(decrypted).__name__}"
def get_message_list(self, authorization: str, conference_id: str,
timeout: Optional[float] = None) -> bool:
......@@ -515,6 +600,15 @@ class DoorTokenClient:
f"[MsgList] 调用成功: device_id={self.device_id}, "
f"message={data.get('message', '')}, result_len={result_len}"
)
# P2 解密:密钥源 = 实际发送的 Authorization 头字符串(与签名同源)
decrypted = self.decrypt_result(result, auth_header)
if decrypted is not result:
self.last_message_list_data = decrypted
logger.info(
f"[MsgList] result 解密成功: device_id={self.device_id}, "
f"{self._result_summary(decrypted)}"
)
return True
def get_today_meeting_face_page(self, authorization: str, conference_id: str,
......@@ -620,6 +714,15 @@ class DoorTokenClient:
f"[FacePage] 调用成功: device_id={self.device_id}, "
f"message={data.get('message', '')}, result_len={result_len}"
)
# P2 解密:密钥源 = 实际发送的 Authorization 头字符串(与签名同源)
decrypted = self.decrypt_result(result, auth_header)
if decrypted is not result:
self.last_face_page_data = decrypted
logger.info(
f"[FacePage] result 解密成功: device_id={self.device_id}, "
f"{self._result_summary(decrypted)}"
)
return True
def get_ex_global_config(self, authorization: str, company_number: str,
......@@ -714,6 +817,15 @@ class DoorTokenClient:
f"[GlobalConfig] 调用成功: device_id={self.device_id}, "
f"message={data.get('message', '')}, result_len={result_len}"
)
# P2 解密:密钥源 = 实际发送的 Authorization 头字符串(与签名同源)
decrypted = self.decrypt_result(result, auth_header)
if decrypted is not result:
self.last_global_config_data = decrypted
logger.info(
f"[GlobalConfig] result 解密成功: device_id={self.device_id}, "
f"{self._result_summary(decrypted)}"
)
return True
def get_message_by_app_token(self, authorization: str, app_token: str,
......@@ -806,6 +918,17 @@ class DoorTokenClient:
f"[PaperlessMsg] 调用成功: device_id={self.device_id}, "
f"code={code}, message={message}"
)
# P2 解密:该接口无 /encrypt 后缀,服务端通常返回明文 result;
# decrypt_result 对非字符串原样返回,仅在真返回密文时才实际解密。
raw_result = data.get("result", "")
decrypted = self.decrypt_result(raw_result, auth_header)
if decrypted is not raw_result:
self.last_paperless_message_data = decrypted
logger.info(
f"[PaperlessMsg] result 解密成功: device_id={self.device_id}, "
f"{self._result_summary(decrypted)}"
)
return True
def _build_headers(self, x_timestamp: Optional[str] = None, x_random: Optional[str] = None) -> Dict[str, str]:
......@@ -819,15 +942,29 @@ class DoorTokenClient:
Returns:
dict: 请求头
"""
headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json, text/plain, */*",
"X-SIGN": self.x_sign,
"X-TIMESTAMP": x_timestamp or str(int(time.time())),
"X-RANDOM": x_random or "".join(
random.choices(string.ascii_letters + string.digits, k=16)
),
}
# 动态签名(默认):token 接口签名密钥源为空 → 生成 60 位 RandomCode 并随请求头发送,
# 服务端可用 RandomCode 反推 AES 密钥校验签名(与 http_client._generate_sign 完全一致)。
# 显式传入 x_timestamp/x_random 时仍用固定 X_SIGN(旧行为,兜底真实设备日志值)。
if x_timestamp is None and x_random is None:
sign = self._generate_dynamic_sign(None, "")
headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json, text/plain, */*",
"X-SIGN": sign["X-SIGN"],
"X-TIMESTAMP": sign["X-TIMESTAMP"],
"X-RANDOM": sign["X-RANDOM"],
"RandomCode": sign["RandomCode"],
}
else:
headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json, text/plain, */*",
"X-SIGN": self.x_sign,
"X-TIMESTAMP": x_timestamp or str(int(time.time())),
"X-RANDOM": x_random or "".join(
random.choices(string.ascii_letters + string.digits, k=16)
),
}
return headers
def _build_body(self) -> Dict[str, str]:
......@@ -904,15 +1041,32 @@ class DoorTokenClient:
ValueError: 参数/解析错误
"""
url = f"{self.base_url}{self._token_api_path}"
headers = self._build_headers()
body = self._build_body()
logger.info(f"[Token] 调用接口: POST {url}")
logger.info(f"[Token] 请求头: X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}")
logger.info(f"[Token] 请求体: {body}")
last_error: Optional[Exception] = None
for attempt in range(1, self.max_retries + 1):
# P2 签名完全对齐:默认动态签名(RandomCode 登录式,与 http_client 一致);
# 服务端若拒绝(签名/鉴权类错误),自动回退固定 X_SIGN(真实设备日志样本)兜底一次
sign_dynamic = True
fallback_used = False
attempt = 0
while attempt < self.max_retries:
attempt += 1
# 每次尝试重新生成签名(动态签名含秒级时间戳+随机串,重试必须刷新,
# 否则服务端可能因时间戳过期/防重放拒绝)
if sign_dynamic:
headers = self._build_headers()
else:
headers = self._build_headers(
x_timestamp=str(int(time.time())),
x_random=self._generate_random_string(16, 16),
)
logger.info(f"[Token] 第 {attempt}/{self.max_retries} 次调用: POST {url}")
logger.info(
f"[Token] 请求头: 签名模式={'动态+RandomCode' if sign_dynamic else '固定X_SIGN'}, "
f"X-SIGN={headers.get('X-SIGN', '')[:16]}..., "
f"X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}"
)
logger.info(f"[Token] 请求体: {body}")
try:
resp = self._session.post(
url,
......@@ -926,11 +1080,35 @@ class DoorTokenClient:
parsed = self._parse_response(resp)
logger.info(f"[Token] 获取成功: device_id={self.device_id}, appToken={self.app_token}")
return parsed
# 非 200 状态码:业务错误,不重试
if resp.status_code in (401, 403):
# 签名/鉴权被拒:动态模式立即回退固定 X_SIGN;固定模式则重试
last_error = ValueError(f"HTTP {resp.status_code}: {resp.text[:300]}")
if sign_dynamic and not fallback_used:
fallback_used = True
sign_dynamic = False
logger.warning("[Token] 动态 X-SIGN 被拒,回退固定 X_SIGN 重试")
else:
logger.warning(
f"[Token] HTTP {resp.status_code}(第 {attempt}/{self.max_retries} 次)")
continue
if resp.status_code == 500:
# 服务端临时故障:原样重试
last_error = ValueError(f"HTTP 500: {resp.text[:300]}")
logger.warning(f"[Token] HTTP 500(第 {attempt}/{self.max_retries} 次),重试")
if attempt < self.max_retries:
import time as _time
_time.sleep(2 ** (attempt - 1))
continue
# 其他业务错误码(如 400 参数错误):动态模式先回退固定签名试一次
raise ValueError(f"HTTP {resp.status_code}: {resp.text[:300]}")
except ValueError as e:
# 业务错误:不重试
raise e
if sign_dynamic and not fallback_used:
# 动态签名被业务拒绝 → 回退固定 X_SIGN 兜底一次
fallback_used = True
sign_dynamic = False
logger.warning(f"[Token] 动态 X-SIGN 被业务拒绝,回退固定 X_SIGN 重试: {e}")
continue
raise
except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
last_error = e
logger.warning(f"[Token] 网络错误(第 {attempt}/{self.max_retries} 次): {e}")
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论