From f80b3b634c54a14ce7835fd609ee01cdf4de886b Mon Sep 17 00:00:00 2001 From: 12451 <12451@example.com> Date: Sun, 20 Sep 2026 00:36:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BA=94=E8=A7=92=E5=BA=A6=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E5=90=8E=E7=9A=84=E5=AE=89=E5=85=A8/=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E6=80=A7/=E5=A5=91=E7=BA=A6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=88=E5=90=AB=20order=5Ftimeout=20=E5=8F=8D=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=80=81=E8=BF=9E=E6=8E=A5=E6=8E=A5=E7=AE=A1=E3=80=81?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=85=9C=E5=BA=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 严重问题 - order_timeout: 超时条件方向写反(`start_time < TIMESTAMPADD(HOUR,10,NOW())` 等价于 `start_time < NOW()+10h`,条件恒真)→ 活跃订单会被当超时单提前结掉。改为 `TIMESTAMPADD(HOUR, ?, start_time) < NOW()`,并补"刚建单不命中/超期命中"双向断言 - C1 device-server 连接池可被未验签的报文 dev_id 顶替(覆盖式 insert + 断连无条件 remove): 改为 conn_id 归属模型 —— 唯一连接标识、remove_if/touch/is_authenticated 均带 conn 归属校验、 接管时用 oneshot 信号主动驱逐旧连接、每条上行前用 is_owner 复核持有者身份 - C2 POST /api/devices/{imei}/command 零归属校验 + act 自由字符串 + 把 IMEI 塞进协议 id: 补 scope_guard 归属校验 + act 白名单 + id 从 params 显式取(拒绝 15 位 IMEI) - 非总管理员权限集恒为空(user_roles 从不写入且无分配入口)→ 全站 403: user_roles 为空时按 users.role 回退内置角色权限 + 建/改用户幂等同步映射(仅角色变化时同步) ## 订单生命周期并发正确性 - finalize_order 先取电量累加器后抢结束权 → off/bat_out 成对到达时 energy 被写 0: 改为先原子抢占(WHERE compartment_id=? AND status=0 ORDER BY id DESC LIMIT 1), 抢到者才 take_and_reset 取电量;抢不到直接返回且不碰累加器 - 建单"先查后插"无锁无唯一约束 → 同仓体两笔活跃单: 条件插入(INSERT ... SELECT ... WHERE NOT EXISTS)+ 按仓体进程内锁 + DB 层 active_flag 生成列 + UNIQUE 唯一索引(幂等迁移,有重复则记 ERROR 跳过,绝不阻断启动) - handle_bat_out: updated==0 时不再复位占用;通道登记删除改为 battery_id 一致才删; 新增 should_finalize_bat_out 挡住"迟到 bat_out 结束新单" - 电压/电流加量程夹取(0–100000 mV/mA 之外按 0 计并 WARN),防位翻转导致积分暴增 ## 协议/契约 - spec 绝不可能是 "0000"(生产 42/68 台拓扑为空):新增 build_spec 三级兜底 (真实拓扑 → cabinets.cell×6 → 6×6),兜底生效记 WARN 报出拓扑数据 - /api/cabinets/realtime-status 改读真实字段(原来读的设备链路从不写的旧字段,恒返回 idle/空), 用 derive_channel_view 推导旧字段以保持响应字段名不变;修掉"把通道号当板号"的幽灵板问题 - 小程序通道详情/PC 详情改读通道 Hash(弃用无写入方的 compartment:status:{id}), 取不到返回 null 而非假的 0;补 voltage/current/temp/batt_status/pow_status - 反馈列表补权限码 operation_log:view + status 筛选真正落到 SQL - 小程序柜温换算(29800 曾被显示成 29800°C);签名注释 7→8 位;msg_id 序列化省略 null; parse_compartment_id 与 status_post 补板号 1–15/通道 1–8 量程校验(不再写 board:0 垃圾 key) ## 文档 - docs/柜控板固件对接文档.md:确立数值口径统一规则(hex 字节流=原始硬件单位·平台换算; JSON 数值字段=平台单位·设备换算)、修正 A.5.3 的 id 说明、作废变更记录原位标注(不删历史)、 spec 表述改为"按拓扑现算+兜底、恒 4 位不会是 0000"、修复文首 7 项表格被引用块切断丢表头、 修正 §10 误引 channels[].channel、写明无电池兜底阈值与 #16 风险 ## 运维加固(服务器侧,非代码) - 关闭 MySQL/Redis 公网暴露:nginx stream 由 0.0.0.0 改为只监听 127.0.0.1 + ZeroTier - worker_shutdown_timeout 30s(原先每次 reload 泄漏永不退出的 worker) - 测试证书改指向 app 证书(原测试证书 9 天后过期且无续期机制);补测试环境 uploads 目录;.env 0600 ## 验收 - cargo check 两服务 0 error / 0 warning;cargo test api 88 + device 22 全过 - 路由 79 → 79 逐条一致;web tsc 干净 - 端到端联调(测试环境)13/13,订单结算与重构前逐字段一致;唯一索引已由迁移真实建出 - 生产已部署并验证:服务 active、health 200、ERROR 0、消费链路正常、迁移已建索引 --- docs/柜控板固件对接文档.md | 146 ++- software/api-server/src/commands.rs | 187 ++- software/api-server/src/db/migrate.rs | 115 ++ software/api-server/src/middleware/auth.rs | 164 +++ .../api-server/src/routes/cabinet_tree.rs | 421 +++++-- software/api-server/src/routes/cabinets.rs | 229 +++- software/api-server/src/routes/h5.rs | 346 ++++- software/api-server/src/routes/mod.rs | 2 +- software/api-server/src/routes/users.rs | 90 ++ .../api-server/src/workers/order_timeout.rs | 8 +- .../api-server/src/workers/report_worker.rs | 1114 +++++++++++++++-- software/device-server/src/main.rs | 7 +- software/device-server/src/tcp/connection.rs | 386 +++++- software/device-server/src/tcp/protocol.rs | 41 +- software/device-server/src/tcp/server.rs | 142 ++- .../src/pages/compartment-detail/index.vue | 4 +- .../src/pages/device-detail/index.vue | 9 +- software/h5-uniapp/src/pages/home/index.vue | 40 +- software/h5-uniapp/src/utils/deviceStatus.ts | 81 ++ software/web/src/App.tsx | 27 +- software/web/src/api/devices.ts | 32 +- software/web/src/api/request.ts | 76 +- software/web/src/components/NoPermission.tsx | 35 + software/web/src/pages/batteries/index.tsx | 31 +- .../web/src/pages/charge-orders/index.tsx | 43 +- .../pages/charge-records/DownloadCenter.tsx | 168 ++- .../web/src/pages/charge-records/index.tsx | 30 +- software/web/src/pages/device-logs/index.tsx | 13 +- .../web/src/pages/devices/CabinetGrid.tsx | 5 +- software/web/src/pages/devices/cabinet.tsx | 16 +- software/web/src/pages/devices/index.tsx | 67 +- software/web/src/pages/energy/index.tsx | 17 +- software/web/src/pages/settings/feedback.tsx | 12 +- .../web/src/pages/settings/operation-logs.tsx | 17 +- software/web/src/utils/deviceStatus.ts | 15 + 35 files changed, 3668 insertions(+), 468 deletions(-) create mode 100644 software/web/src/components/NoPermission.tsx diff --git a/docs/柜控板固件对接文档.md b/docs/柜控板固件对接文档.md index 3b70050..7a8a165 100644 --- a/docs/柜控板固件对接文档.md +++ b/docs/柜控板固件对接文档.md @@ -19,17 +19,17 @@ | # | 事项 | 现状(本文当前写法) | 需硬件确认 | 影响范围 | |---|------|---------------------|-----------|----------| | **13** | **`energy`(单订单累计充电量,Wh)** | 本协议**暂未包含** `energy` 字段 | 硬件是否有该数据源?**若无**,订单电量由平台侧按 `电压 × 电流 × 时间` 积分计算 | §5.5 / §5.6 / §5.7 是否补该字段 | +| **14** | **代码表的取值内容** | 仅知 `厂家代码 01=星恒`、`电池型号 01=磷酸铁锂` | ① 四个字段(厂家代码 / 电池型号 / 电芯材料 / 电芯节数)的**完整取值表** ② 「电芯材料」与「电池型号」**是否写反**("型号"的值给的是"磷酸铁锂",那是材料) ③ 代码**几位、几进制**(`1` 还是 `01`) | 仅影响 §5.5 代码字段能否显示为中文名 | +| **15** | **电芯数据的字节数 / 项数** | 硬件文档写「电芯数据 **52 个字节**」,但其示例为「**20 节电芯(20×2)**」,按字段算 `20×2+2+2+2+2 = 48` | 到底是 **48 字节 / 20 项**,还是 **52 字节 / 22 项**,或 52 字节里含未列出的 4 字节字段? | §5.5 `cells` 的解析与缓冲分配 | +| **16** | **仓控板离线(485 芯片坏 / 线松 / 板子掉电)如何上报?** | **本文暂未定义**:4G 层读不到该板数据时**该怎么报**还没有约定(省略这块板?照报一块数值全 `0` 的板?还是另有在线/离线标志?)。硬件字典的「柜体状态1/2」只覆盖柜级(断电/烟/水/倾倒/门/风扇/接触器/喷淋/声光/门超时),**没有"仓控板通信状态"** | 硬件侧**能提供什么形式的"仓控板在线/离线"信息**?例如:
① 每块仓控板一个在线位(如放在 `boards[]` 里)
② 柜体数据里加一个「**仓控板离线位图**」(15 块板各一位)
③ 柜控板给出"应在线 N 块 / 实际失联 M 块"
④ 或明确回答:**硬件无法检测**
**并请说明:板离线时 4G 层是省略该板、还是照报全 0 的板?** | §5.3 `status_post` 需新增字段;**平台「仓控板故障」告警完全依赖它**;**并且该答复会直接影响 §5.6 的「无电池」状态兜底** —— 若"板离线时照报全 0 的板",平台会把整板离线误判为"电池被拔走"并提前异常结单(详见 §5.6 / §10 第 14 条) | +| **17** | **直流停电是否还需要 `pow_fail_dc` 这条指令?** | 现在直流停电有**两条路径**:① **事件指令** `pow_fail_dc`(接触器分闸时抢发,见 §5.4.1)② `status_post` 的 `cabinet.status1` **bit0「断电」**(每 60 秒一个状态位)。而**直流停电时柜控板本身仍有电**,状态位理论上足以表达 | **直流停电能不能只用 `status1` bit0 表达、取消 `pow_fail_dc` 这条指令?**
请确认:**接触器分闸时,`status1` bit0 是否会置 1?**
· 若会 → `pow_fail_dc` 冗余,建议删掉(协议少一条 `act`)
· 若不会(bit0 只表示外部市电断)→ `pow_fail_dc` 必须保留 | §5.4.1 / §4.1 / §10 / 附 A.2.2;平台停电状态的数据来源 | +| **18** | **人在没充满时把电池直接拔走(强制拔出)能否检测?** | §5.6 已把「**无论是否充满,被拔时必须先 `off` 再 `bat_out`**」列为**硬性要求**;但**未充满时人为直接拔走**,MCU 不一定能感知 | ① MCU 能否检测**未充满时的强制拔出**(电池状态 bit7 由 1→0)?
② 检测到后**能否保证先发 `off` 再发 `bat_out`**?(平台订单正确结束依赖它)
③ 若检测不到,请说明 —— 平台侧已实现**兜底**:`status_post` 的 `batt_status` **bit7 连续 2 次为 0(约 2 分钟)**即判定"电池已被取走",**提前异常结单**(`forced`),并提供 **10 小时订单超时**双保险(详见 §5.6) | §5.6;**订单能否正常结束**(漏报时由平台兜底) | +| **19** | **停电恢复后如何兜底?** | 停电期间设备**完全失电**,期间发生的**电池拔出/插入**等事件**无法上报**;恢复后设备重启,平台侧会留下不一致(例如挂着未结束的订单、台账里的电池已不在柜内) | 停电恢复后硬件侧**如何处理**?例如:
① 设备恢复后**补报**停电期间发生的事件?
② 还是**以当前实际状态为准**——重连后发一次全量 `status_post`,由平台自行对账?
③ 或硬件不做特殊处理(平台侧靠 **10 小时订单超时**兜底) | §5.5 / §5.6 订单;**掉电期间的状态对账机制** | > **关于第 13 项的兜底已落地**:平台侧**已按"积分计算"实现**该兜底 —— > 每条 `status_post` 的通道电压/电流按 **60 秒**积分累加(协议固定 60 秒一次上报), > 订单结束时写入订单电量并生成充电记录。**硬件侧无需为此改协议**; > 若硬件后续确认能提供 `energy` 字段,平台改为**直接采用硬件值**即可(不影响报文结构以外的任何东西)。 -| **14** | **代码表的取值内容** | 仅知 `厂家代码 01=星恒`、`电池型号 01=磷酸铁锂` | ① 四个字段(厂家代码 / 电池型号 / 电芯材料 / 电芯节数)的**完整取值表** ② 「电芯材料」与「电池型号」**是否写反**("型号"的值给的是"磷酸铁锂",那是材料) ③ 代码**几位、几进制**(`1` 还是 `01`) | 仅影响 §5.5 代码字段能否显示为中文名 | -| **15** | **电芯数据的字节数 / 项数** | 硬件文档写「电芯数据 **52 个字节**」,但其示例为「**20 节电芯(20×2)**」,按字段算 `20×2+2+2+2+2 = 48` | 到底是 **48 字节 / 20 项**,还是 **52 字节 / 22 项**,或 52 字节里含未列出的 4 字节字段? | §5.5 `cells` 的解析与缓冲分配 | -| **16** | **仓控板离线(485 芯片坏 / 线松 / 板子掉电)如何上报?** | **本文暂未定义**:4G 层读不到该板数据时**该怎么报**还没有约定(省略这块板?照报一块数值全 `0` 的板?还是另有在线/离线标志?)。硬件字典的「柜体状态1/2」只覆盖柜级(断电/烟/水/倾倒/门/风扇/接触器/喷淋/声光/门超时),**没有"仓控板通信状态"** | 硬件侧**能提供什么形式的"仓控板在线/离线"信息**?例如:
① 每块仓控板一个在线位(如放在 `boards[]` 里)
② 柜体数据里加一个「**仓控板离线位图**」(15 块板各一位)
③ 柜控板给出"应在线 N 块 / 实际失联 M 块"
④ 或明确回答:**硬件无法检测**
**并请说明:板离线时 4G 层是省略该板、还是照报全 0 的板?** | §5.3 `status_post` 需新增字段;**平台「仓控板故障」告警完全依赖它** | -| **17** | **直流停电是否还需要 `pow_fail_dc` 这条指令?** | 现在直流停电有**两条路径**:① **事件指令** `pow_fail_dc`(接触器分闸时抢发,见 §5.4.1)② `status_post` 的 `cabinet.status1` **bit0「断电」**(每 60 秒一个状态位)。而**直流停电时柜控板本身仍有电**,状态位理论上足以表达 | **直流停电能不能只用 `status1` bit0 表达、取消 `pow_fail_dc` 这条指令?**
请确认:**接触器分闸时,`status1` bit0 是否会置 1?**
· 若会 → `pow_fail_dc` 冗余,建议删掉(协议少一条 `act`)
· 若不会(bit0 只表示外部市电断)→ `pow_fail_dc` 必须保留 | §5.4.1 / §4.1 / §10 / 附 A.2.2;平台停电状态的数据来源 | -| **18** | **人在没充满时把电池直接拔走(强制拔出)能否检测?** | §5.6 已把「**无论是否充满,被拔时必须先 `off` 再 `bat_out`**」列为**硬性要求**;但**未充满时人为直接拔走**,MCU 不一定能感知 | ① MCU 能否检测**未充满时的强制拔出**(电池状态 bit7 由 1→0)?
② 检测到后**能否保证先发 `off` 再发 `bat_out`**?(平台订单正确结束依赖它)
③ 若检测不到,请说明——平台只能靠 `status_post` bit7 归零或 **10 小时订单超时**兜底 | §5.6;**订单能否正常结束** | -| **19** | **停电恢复后如何兜底?** | 停电期间设备**完全失电**,期间发生的**电池拔出/插入**等事件**无法上报**;恢复后设备重启,平台侧会留下不一致(例如挂着未结束的订单、台账里的电池已不在柜内) | 停电恢复后硬件侧**如何处理**?例如:
① 设备恢复后**补报**停电期间发生的事件?
② 还是**以当前实际状态为准**——重连后发一次全量 `status_post`,由平台自行对账?
③ 或硬件不做特殊处理(平台侧靠 **10 小时订单超时**兜底) | §5.5 / §5.6 订单;**掉电期间的状态对账机制** | > **关于第 14 项**:「代码 → 名称」的**映射责任已定稿** —— **4G 层按字符串原值透传(不补零、不转义、不改写),映射在平台侧完成**(见第 10 节第 7 条)。 > 所以第 14 项**只缺"取值内容"**,不改变 `brand_code` / `model_code` / `material_code` 的字段格式,**不阻塞 4G 层实现**。 @@ -114,12 +114,12 @@ - 设备未被重置、且平台仍持有安全码时,请求 `auth_str` 返回: ```json -{"act":"auth_str","msg_id":N,"suc":0,"err":"auth_str already issued"} +{"act":"auth_str","msg_id":1,"suc":0,"err":"auth_str already issued"} ``` 此时**停止重试**,改为按重连间隔周期性重试 `login`,等待平台管理员重置。 -- 重新申领有频率限制:同一 IMEI **5 次 / 5 分钟**,超限返回 `{"suc":0,...,"err":"rate limit exceeded"}`,需退避后重试。 +- 重新申领有频率限制:同一 IMEI **5 次 / 5 分钟**,超限返回 `{"act":"auth_str","msg_id":1,"suc":0,"err":"rate limit exceeded"}`,需退避后重试。 **⚠️ `login` 失败后平台会主动断开 TCP 连接** @@ -172,7 +172,7 @@ | `timestamp` | uint64 | 仅 `login` | 秒级 Unix 时间戳 | | `sign` | string | 仅 `login` | 8 位小写十六进制签名 | | `auth_str` | string | 仅 `auth_str` 的应答 | 8 位安全码 `[A-Za-z0-9]`,**仅下发一次**,须持久保存 | -| `spec` | string(4 位) | 仅 `login` 的应答 | **柜子规格**:前 2 位 = 仓控板数、后 2 位 = 每板通道数。`"0606"` = 6 板 × 每板 6 通道 | +| `spec` | string(4 位) | 仅 `login` 的应答 | **柜子规格**:前 2 位 = 仓控板数、后 2 位 = 每板通道数。`"0606"` = 6 板 × 每板 6 通道。**由平台按该柜已配置拓扑在每次 `login` 时现算**,**恒为 4 位数字、不会是 `"0000"`**(见 5.2) | | `work_mode` | string | 仅 `login` 的应答 | **充电工作模式**:`"0"`=平台指令模式(默认)/ `"1"`=自动充电工作模式。**平台侧可配置**,见 5.2 | | `rssi` | int32 | `status_post` | 4G 信号强度,**整数 0–99,数值越大信号越好**(**不是 dBm,不可为负**) | | `cabinet` | object | `status_post` | 柜体状态块,见 5.3 | @@ -182,6 +182,17 @@ > 平台会**忽略未定义的字段**;固件解析下行报文时也**必须忽略未知字段**(平台应答中会额外附带 `id`)。 +#### 📏 数值口径的统一规则(全文唯一口径,务必按此实现) + +| 数据形态 | 出现在哪 | 数值口径 | **谁负责换算** | +|---|---|---|---| +| **hex 字节流** | `status_post.channels[]` 元素、`bat_in`/`bat_out`/`off` 的 `info`、`bat_in` 的 `cells` | **原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K …,多字节大端) | **平台换算**(×10 / ×10 / ×10 / ×100,见 5.3 字节布局表)。**固件原样拼接 hex,不做任何换算** | +| **JSON 数值字段** | `cabinet.temp` / `cabinet.meter` / `battery.rated_voltage` / `battery.rated_capacity` 等 | **平台单位**(K×100 / Wh / mV / mAh …) | **设备换算后上报**。平台**原样存库、不再换算** | + +> **一句话记住**:**hex 字节流里的数值 = 原始硬件单位,由平台换算;JSON 数值字段 = 平台单位,由设备换算。** +> 本文示例都按各自口径给出,不要互相套用:`"temp":29800` = **已是换算结果**(设备送的 K×100); +> `channels` 里的 `0143` = **硬件原始值**(1K 的 323),由**平台** ×100 得到 32300(K×100)。 + ### 3.2 `act` —— 双向必带 - **上行**:缺少 `act` 的报文会被平台丢弃,且**不返回任何应答**(固件会一直等不到回复)。 @@ -457,13 +468,19 @@ | 字段 | 类型 | 说明 | |------|------|------| | `suc` | int | `1` = 登录成功 | -| **`spec`** | string(**固定 4 位**) | **柜子规格**:**前 2 位 = 仓控板数量,后 2 位 = 每块板的通道数**。`"0606"` = **6 板 × 每板 6 通道**(即 6 块仓控板、每板 6 仓)。由平台按该柜配置下发 | +| **`spec`** | string(**固定 4 位**) | **柜子规格**:**前 2 位 = 仓控板数量,后 2 位 = 每块板的通道数**。`"0606"` = **6 板 × 每板 6 通道**(即 6 块仓控板、每板 6 仓)。**由平台在每次 `login` 时按该柜已配置的仓控板 / 通道拓扑现算**;**恒为 4 位数字、不会是 `"0000"`**(拓扑未配置时平台按柜型兜底,见下方说明) | | **`work_mode`** | string | **充电工作模式**(平台侧可配置,**仅这两种、不再扩展**):
`"0"` = **平台指令模式**(默认)——充电由平台下发 `on` 指令发起,设备执行后回执
`"1"` = **自动充电工作模式**——设备检测到电池后**自行开始充电**,并**主动上报** `on`(不带 `suc`) | **固件必须按 `spec` / `work_mode` 行事** -- `spec`:**平台对该柜的规格定义(即柜子型号)**,如 `"0606"` = 6 块仓控板 × 每板 6 仓。 - **它与仓控板实际能否通信无关** —— 某块板扫不到(485 芯片坏 / 线松)属于**硬件故障**,不由本字段表达。 +- `spec`:**平台按该柜已配置的仓控板 / 通道拓扑实时生成**(**不是**"设备型号"的持久存储字段),如 `"0606"` = 6 块仓控板 × 每板 6 仓。生成规则: + - 该柜的仓控板 / 通道拓扑**已配置** → 按拓扑统计(仓控板数 × 每板通道数); + - 拓扑**未配置 / 不完整** → **平台按柜型兜底**(按该柜的仓控板数量 × 每板 6 通道生成)。 + - 因此 **`spec` 恒为 4 位数字,永远不会是 `"0000"`** —— 固件**不需要**为"收到 0000"做任何特殊处理。 +- **固件必须以收到的 `spec` 决定上报结构**:**前 2 位 = 上报几块仓控板,后 2 位 = 每板上报几路通道**(`"0606"` → `boards[]` 6 项、每项 `channels[]` 6 个元素)。 + - 若 `spec` 与本机**实际读到的**板数 / 通道数**不一致**:**先按 `spec` 的结构上报**(板数与每板通道数都按 `spec`),把该不一致记入本地日志并经**运维渠道**反馈平台。 + - ⛔ **不要自造"规格异常"上行指令** —— `act` 取值是本协议的一部分,平台只识别清单内的 `act`(见 2.3)。 +- `spec` **与仓控板实际能否通信无关** —— 某块板扫不到(485 芯片坏 / 线松)属于**硬件故障**,不由本字段表达。 - `work_mode`: - `"0"` → **等平台 `on` 指令**再开始充电;收到后执行并**回执** - `"1"` → **检测到电池即自行开始充电**,并**主动上报 `on`**(不带 `suc`);平台仍可能下发 `on`,此时按回执处理 @@ -519,12 +536,17 @@ |------|------|------| | `status1` | hex 字符串(2 位) | 柜体状态1 字节,位定义见 **3.8** | | `status2` | hex 字符串(2 位) | 柜体状态2 字节,位定义见 **3.8** | -| `temp` | 整数 | 柜体温度,硬件 1K **×100** → K×100(`29800` = 298.00K) | -| `meter` | 整数 | 电表读数,硬件 0.1kWh **×100** → Wh(`125500` = 125.5 kWh) | +| `temp` | 整数 | 柜体温度。**⚠️ 这是 JSON 数值字段 = 平台单位,由设备换算后上报**:送 **K×100**(即硬件 1K 的读数 ×100,`29800` = `298.00K`)。平台**原样存库、不再 ×100**;管理端按 `temp/100 − 273.15` 显示为 ℃ | +| `meter` | 整数 | 电表读数。**⚠️ 这是 JSON 数值字段 = 平台单位,由设备换算后上报**:送 **Wh**(即硬件 0.1kWh 的读数 ×100,`125500` = `125.5 kWh` = 125500 Wh)。平台**原样存库、不再 ×100** | + +> ⚠️ **`temp` / `meter` 与 `channels[]` 不是同一套口径,千万别混**(全文统一规则见 **3.1**): +> - **`temp` / `meter` 是 JSON 数值字段 → 设备换算**:示例里的 `29800` / `125500` **就是最终值**,**固件不要再乘 100**。 +> - **`channels[]` 是 hex 字节流 → 平台换算**:同一条报文里的 `0143` 是**硬件原始值**(323K),平台会 **×100** 得到 32300(K×100)。 #### `channels` —— 每通道 **18 个 hex 字符**(9 字节),**原样透传** > 一个**通道** = 一个仓(可能**空着**、也可能插着电池)。**不拆字段、直接拼接 hex**——4G 层把硬件那 9 个字节按顺序拼成 18 个 hex 字符即可,**无需做任何转换**。 +> ⚠️ 因此 **hex 里的数值一律是原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**换算在平台侧做**(见 **3.1** 统一规则)。 | 项 | 规则 | |---|---| @@ -532,13 +554,15 @@ | **大小写** | 平台解析**不区分大小写**(`0e10` 与 `0E10` 等价);**建议固件统一小写** | | **空仓** | **byte0 = `00`(无电池)**;**byte1 照实报电源状态**(电源与是否有电池**无关**);byte2–8 数值全 `0`。
例:空仓但电源正常 → `"008200000000000000"` | | **⚠️ 两个状态相互独立** | **电池状态(byte0)与电源状态(byte1)是两回事**,平台前端也**分两个指示分开显示**——不要因为"没电池"就把 电源状态 写成 0 | -| **顺序** | **数组下标 = 通道号**:第 1 个元素 = **通道 1**,第 2 个 = 通道 2 …… | -| **长度** | 数组元素个数 = 该板通道数(当前 36 仓柜型为 **6**);顺序**不得打乱** | +| **顺序(通道号怎么来)** | **通道号由数组下标决定,下标从 0 开始**:**第 1 个元素(下标 0)= 通道 1**,第 2 个元素(下标 1)= 通道 2 …… 依此类推。**数组顺序不得打乱** | +| **长度** | 数组元素个数 = 该板通道数(当前 36 仓柜型为 **6**),由 `login` 应答的 `spec` 后 2 位决定 | | 端序 | 多字节字段一律**大端**(高字节在前) | **每 18 个字符的字节布局**(与硬件「充电动态数据」9 字节逐字节一致) -| 字节位(hex 字符位) | 含义 | 硬件单位 | 平台解析 | +> 左半是**设备侧**(照抄硬件原始字节),右半是**平台侧**换算(统一规则见 **3.1**)。 + +| 字节位(hex 字符位) | 含义 | 设备侧:硬件单位(原样拼进 hex) | 平台侧:换算 | |---|---|---|---| | byte0(第 1–2 字符) | **电池状态** | — | 位定义见 **3.8** `batt_status` | | byte1(第 3–4 字符) | **电源状态** | — | 位定义见 **3.8** `pow_status` | @@ -559,6 +583,8 @@ 0143 温度 = 323K ``` +> 以上**全是硬件原始值**(设备侧原样拼接,不换算)。平台换算后的入库值为:SOC `850‰`、电压 `54200mV`、电流 `5000mA`、温度 `32300`(K×100)。 + > `cabinet.status1` / `cabinet.status2`,以及通道 hex 里的 **byte0 / byte1**(电池状态、电源状态)——这 4 个字节的位定义与解析示例统一见 **3.8 状态字节(hex)解析表**。 > ⚠️ **仓控板离线如何上报 —— 待硬件侧确认(待确认事项第 16 项)** @@ -566,6 +592,8 @@ > **这种情况下 4G 层应如何上报,本文暂未定义** —— 例如:省略这块板?照报一块数值全 `0` 的板?还是另有在线/离线标志? > **平台能否把"某块板离线"与"该板通道都空着"区分开,完全取决于硬件侧怎么报** —— **以硬件答复为准,本文不做任何预设。** > **在硬件答复前,平台侧「仓控板故障」这类告警不做实现。** +> ⚠️ 该答复还会直接影响 §5.6 的**「无电池」状态兜底**:若选择"**板离线时照报一块数值全 0 的板**", +> 平台会把**整板离线**误判成"**电池被拔走**"(详见 §5.6),**请一并确认**。 ### 5.4 `pow_fail_dc` / `pow_fail_ac` —— 停电上报(**两条独立指令**) @@ -641,14 +669,14 @@ | `model_code` | string | 电池型号,**原值透传**,由平台解析为名称(`"1"` = 磷酸铁锂) | | `material_code` | string | 电芯材料,**原值透传**,由平台解析 | | `cell_count` | 整数 | 电芯节数 | -| `rated_voltage` | 整数 | 额定电压,硬件 0.1V **×100** → mV(`48000` = 48.0V) | -| `rated_capacity` | 整数 | 额定容量,硬件 0.1AH **×100** → mAh(`24000` = 24.0Ah) | +| `rated_voltage` | 整数 | 额定电压。**JSON 数值字段 = 平台单位,由设备换算后上报**:送 **mV**(硬件 0.1V 的读数 ×100,`48000` = `48.0V`)。平台原样存库 | +| `rated_capacity` | 整数 | 额定容量。**JSON 数值字段 = 平台单位,由设备换算后上报**:送 **mAh**(硬件 0.1AH 的读数 ×100,`24000` = `24.0Ah`)。平台原样存库 | **动态数据**(硬件「充电动态数据」9 字节) | 字段 | 类型 | 说明 | |------|------|------| -| `info` | string(**恰好 18 个 hex 字符**) | 该通道的 9 字节**电池信息**(硬件「充电动态数据」)。**布局与解析规则完全同 5.3 的 `channels` 元素**:byte0 电池状态 / byte1 电源状态 / byte2 SOC / byte3–4 电压 / byte5–6 电流 / byte7–8 电芯最高温度,多字节**大端** | +| `info` | string(**恰好 18 个 hex 字符**) | 该通道的 9 字节**电池信息**(硬件「充电动态数据」)。**布局与解析规则完全同 5.3 的 `channels` 元素**:byte0 电池状态 / byte1 电源状态 / byte2 SOC / byte3–4 电压 / byte5–6 电流 / byte7–8 电芯最高温度,多字节**大端**。
⚠️ **hex 内是原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**由平台换算**(见 3.1 统一规则),**固件不换算** | > `info` 与 `status_post.channels[]` 里的元素是**同一种 18 字符 hex**,只是这里只针对"刚插入的这个通道"。 @@ -660,13 +688,15 @@ **`cells` 的段布局**(按硬件「电芯数据」表顺序,多字节一律**大端**) -| 段 | 长度(hex 字符) | 含义 | 硬件单位 | +> ⚠️ `cells` 也是 **hex 字节流 = 原始硬件单位**(电压、温度均为硬件原值),**由平台换算,固件只负责按 `cell_count` 顺序拼接**(见 3.1 统一规则)。 + +| 段 | 长度(hex 字符) | 含义 | 设备侧:硬件单位(原值) | |---|---|---|---| -| 电芯电压 × N | **N × 4**(N = `cell_count`) | 各电芯电压,依次排列 | 2 字节/节 | -| 最高电压 | 4 | 所有电芯中的最高电压 | 2 字节 | -| 最低电压 | 4 | 最低电压 | 2 字节 | -| 最高温度 | 4 | 电芯最高温度 | 1K | -| 最低温度 | 4 | 电芯最低温度 | 1K | +| 电芯电压 × N | **N × 4**(N = `cell_count`) | 各电芯电压,依次排列,每节 2 字节 | 硬件原值(示例按 mV 对照) | +| 最高电压 | 4 | 所有电芯中的最高电压,2 字节 | 硬件原值 | +| 最低电压 | 4 | 最低电压,2 字节 | 硬件原值 | +| 最高温度 | 4 | 电芯最高温度,2 字节 | 1K | +| 最低温度 | 4 | 电芯最低温度,2 字节 | 1K | **长度对照**(`cell_count × 4 + 16`) @@ -681,9 +711,11 @@ ``` "0e100e150e0e0e120e170e0b0e110e140e0f0e130e160e0d0e100e120e0e | 0e17 | 0e0b | 012a | 0128" └────────── 15 节电芯电压(每节 4 字符,共 60 字符)──────────┘ 最高 最低 最高温 最低温 - 0e10 = 3600mV 0e15 = 3605mV 0e0e = 3598mV … 0e17 0e0b 298K 296K + 0e10 = 3600 0e15 = 3605 0e0e = 3598 …(硬件原始值) =3607 =3595 =298K =296K ``` +> 上例各段均为**硬件原始值**,平台侧再按 3.1 的规则换算(温度 ×100 → K×100)。 + > ⚠️ **待确认第 15 项**:硬件文档写「电芯数据 **52 个字节**」,但其示例写「**20 节电芯**」——按 20 节算是 **48 字节**,若按 **22 节**算正好 **52 字节**。 > **固件请按实际 `cell_count` 拼接,不要按固定 52 字节处理。** @@ -722,7 +754,7 @@ MCU 约定:**先 `off` 再 `bat_out`**。 ← {"act":"bat_out","msg_id":6,"suc":1} ``` -- `battery` 至少带 `battery_id`;建议同时带末帧电池信息 `info`(18 字符 hex,规则见 5.3)。 +- `battery` 至少带 `battery_id`;建议同时带末帧电池信息 `info`(18 字符 hex,规则见 5.3;**hex 内是原始硬件单位,由平台换算**)。 - 平台结束该仓体活跃订单(**`reason='user_stop'`**),并把电池台账置空闲。 **平台兜底:订单不会永远挂着** @@ -732,7 +764,18 @@ MCU 约定:**先 `off` 再 `bat_out`**。 | 兜底 | 规则 | |---|---| | **订单超时兜底(主要)** | **充电订单**处于"进行中"**超过 10 小时**未结束 → 平台**自动结束该订单**(`reason='timeout'`)。
依据:**充电不可能超过 10 小时**(再久电池会出问题) | -| 状态兜底(可选,待确认 #16) | 若该通道有活跃订单、而 `status_post` 报该通道 **`batt_status` bit7 = 0(无电池)** → 说明电池已被取走,可提前结束。
⚠️ 是否可用取决于 #16(板离线时的上报方式) | +| **「无电池」状态兜底(次要,平台侧已实现并生效,固件无需实现)** | 该通道**已有活跃订单**、而 `status_post` 报该通道 **`batt_status` bit7 = 0(无电池)连续 2 次** → 平台判定"**电池已被取走**"并**提前结束该订单**。阈值、后果与风险见下表 | + +**「无电池」状态兜底细则(平台侧行为,固件不需要做任何事)** + +| 项 | 内容 | +|---|---| +| **判定依据** | `status_post` 里该通道 18 字符 hex 的 **byte0 `batt_status` bit7 = 0**(0=无电池 / 1=有电池,位定义见 **3.8**) | +| **阈值** | **连续 2 次**上报为 0。`status_post` 固定每 60 秒一次 → **约 2 分钟**;平台对账任务每 **5 分钟**跑一次,故实际**最迟约 5–7 分钟**内结单。单次瞬时抖动**不会**触发 | +| **前提** | 该通道**已存在活跃订单**(空仓、没建单的通道不受影响) | +| **后果** | 平台把该订单**提前结束**,结束原因记为 **`forced`(异常结束)**,充电记录状态记为**异常**;电池台账置空闲。人工可在充电记录里识别这批异常单 | +| **与待确认 #18 的关系** | 这正是**第 18 项(未充满强制拔出能否检测)**的平台侧兜底:MCU 检测不到、发不出 `off`/`bat_out` 时,靠它避免订单一直挂到 10 小时超时 | +| ⚠️ **与待确认 #16 的关系(给硬件侧的输入)** | 若 #16 最终选择「**板离线时照报一块数值全 0 的板**」,则**该板所有通道的 bit7 都会是 0** → 平台会把「**整板离线**」误判成「**电池被拔走**」,**把该板上所有活跃订单提前异常结单**。
若选择「**省略该板**」,平台读不到该板通道 → 兜底不触发(该通道订单退回 10 小时超时兜底)。
**请在答复 #16 时一并说明离线时的上报方式。** | > **固件侧不必为"补报历史事件"负责**:漏报的结束事件由上面两条兜底收尾。 > 但**停电恢复后**是否需要设备/平台做一次状态对账,仍待硬件侧答复(见文首待确认事项第 19 项)。 @@ -791,7 +834,7 @@ MCU 约定:**先 `off` 再 `bat_out`**。 ← {"act":"off","msg_id":8,"suc":1} ``` -> 上例 `info="81026415540000012a"` 解析:byte0 `81` = 有电池 + 充满;byte1 `02` = 电源打开;SOC `64` = 100%;电压 `1554` = 5460×0.01V = 54.60V;电流 `0000`;温度 `012a` = 298K。(位定义见 **3.8**) +> 上例 `info="81026415540000012a"` 解析:byte0 `81` = 有电池 + 充满;byte1 `02` = 电源打开;SOC `64` = 100%;电压 `1554` = 5460×0.01V = 54.60V;电流 `0000`;温度 `012a` = 298K。(位定义见 **3.8**;**以上均为 hex 内的原始硬件值,平台再按 3.1 换算**) **`off` 是「订单结束」的触发之一**(另一个是 `bat_out`)。 @@ -1024,7 +1067,9 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 - [ ] 用 6.2 节向量验签,期望值逐字符相等(`3957b959` / `269c249d`) - [ ] `sign` 取 **8 位**(`hex[56..64]`) - [ ] `timestamp` 为**秒**级,与平台时差在 **±60 秒内** -- [ ] `login` 成功收到 `spec`、`work_mode`,且 `spec` 与实际柜型一致(如 6 板 × 6 仓 → `"0606"`) +- [ ] `login` 成功收到 `spec`、`work_mode`;`spec` **恒为 4 位数字、不会是 `"0000"`**(平台按该柜拓扑生成,拓扑未配置时按柜型兜底) +- [ ] **以收到的 `spec` 决定上报结构**:前 2 位 = 上报几块仓控板、后 2 位 = 每板上报几路通道(如 `"0606"` → 6 块板 × 每板 6 通道) +- [ ] `spec` 与本机实际读到的板数 / 通道数不符时:**先按 `spec` 的结构上报**,并记日志反馈平台(**不得自造新的 `act`**,见 2.3) - [ ] 按 `work_mode` 行事:`"0"` 等平台 `on` 指令;`"1"` 检测到电池即自行充电并主动上报 `on` **应答处理** @@ -1036,9 +1081,9 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 - [ ] 超时后迟到的应答被丢弃、不补配;已作废的 `msg_id` 不被立即复用 **状态上报** -- [ ] `cabinet.status1` / `status2` 按 **3.8** 位定义构造(hex 2 位小写) -- [ ] `cabinet.status1` / `status2` 与通道 hex 的 **byte0 / byte1** 按 **3.8** 位定义构造 -- [ ] 每个通道 hex **恰好 18 个字符**、数组顺序 = 通道号 1→8、多字节**大端** +- [ ] `cabinet.status1` / `status2`(hex 2 位小写)与通道 hex 的 **byte0 / byte1** 均按 **3.8** 位定义构造 +- [ ] 每个通道 hex **恰好 18 个字符**、多字节**大端**;**通道号由数组下标决定(第 1 个元素 = 通道 1)**,数组顺序不得打乱 +- [ ] **数值口径**(见 **3.1** 统一规则):`cabinet.temp` 送 **K×100**、`cabinet.meter` 送 **Wh**(**设备换算**);`channels[]` / `info` / `cells` 一律是 **hex 原始硬件单位**(**4G 层零换算**,换算由平台做) - [ ] 上报间隔 **固定 60 秒/次**,且每次为**全量**(所有仓控板 × 全部通道,无增量、无分片) - [ ] **空仓也上报**:byte0 = `00`(无电池),**byte1 照实报电源状态**(如 `82`),数值全 `0` @@ -1048,6 +1093,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 - [ ] 收到 `on` 指令并执行后回执 - [ ] 充满上报 `off` - [ ] `bat_out` 上报 +- [ ] 拔出电池时**先 `off` 再 `bat_out`**;若未充满强制拔出检测不到,平台会在**该通道连续 2 次(约 2 分钟)报无电池**后**提前异常结单**(`forced`,见 §5.6 状态兜底) - [ ] 断线重连后能重新 `login` 并继续上报 --- @@ -1059,17 +1105,19 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 | # | 事项 | 定稿值 | |---|------|--------| | 1 | **接入地址** | 测试 **`118.31.119.206:3001`**;正式 **`118.31.119.206:9001`**。TCP 由平台 TCP 服务直接监听,**不经域名/nginx** | -| 2 | **`status_post` 的板号与通道** | `boards[].board` 取 **1–15**;`channels[].channel` 取 **1–8**(当前 36 仓柜型为 6 板 × 每板 6 通道,8/15 是产品设计冗余) | +| 2 | **`status_post` 的板号与通道** | `boards[].board` 取 **1–15**(当前 36 仓柜型实装 6 块,按 `spec` 前 2 位上报)。
**`channels` 是字符串数组**(每个元素 = 该通道的 **18 字符 hex**),**元素本身没有 `channel` 字段** —— **通道号由数组下标决定**:**第 1 个元素(下标 0)= 通道 1**,第 2 个元素(下标 1)= 通道 2 ……,数组长度 = 每板通道数(当前柜型为 6,即 1–8 / 1–15 是产品设计冗余),**顺序不得打乱** | | 3 | **状态字节格式** | 柜体状态 2 字节(`cabinet.status1` / `status2`)用 **hex 字符串、固定 2 位小写**;通道状态则**整体为一串 18 字符 hex**(其中 **byte0 = 电池状态、byte1 = 电源状态**)。位定义与解析示例见 **3.8**;通道 hex 的字节布局见 **5.3**。保留位填 0 或 1 平台均忽略 | | 4 | **上报节奏与内容** | **`status_post` 固定每 60 秒一次,每次全量上报**(所有仓控板 × 全部通道;不区分空闲/充电,不做增量、不分片)。任意上行间隔必须 < 300 秒(平台 300 秒无上行即判离线) | | 5 | **柜门与充电的关系** | **本产品为工厂用集中充电柜,柜门开 / 关是独立动作,与充电逻辑互不联动**:开门不触发充电;充电启停也不依赖柜门状态。柜门实际状态由 `status_post` 的 `cabinet.status1` bit4 上报(0=关闭 / 1=打开) | | 6 | **柜门"开 / 关"的实现方式** | **开**:平台下发 `open`(**解锁**),门由**人拉开**——门不会自动打开;**关**:**人手动关闭**。本协议**不提供"关门"指令,任何柜型均如此**。开 / 关状态都经 `status_post` 回传,**最大延迟 60 秒**,平台侧不得当作实时量 | | 7 | **代码类字段的映射责任** | `brand_code` / `model_code` / `material_code`:**4G 层按字符串原值透传,不做任何转换**;**「代码 → 名称」的映射在平台侧完成**。映射表由平台维护(可热更新,不硬编码进固件);4G 层只须保证**原值不丢、不改写、不补零、不转义** | | 8 | **充电启动的两种工作模式** | **① 平台指令模式(默认)**:平台下发 `on` → 设备执行后**回执**(带 `suc`)。**② 自动充电工作模式**:设备检测到电池后**自行开始充电** → 设备**主动上报 `on`(不带 `suc`)** → 平台按新请求处理并回包。两种形态一律靠**有无 `suc`** 区分(见 3.5),与 `off` 规则完全相同。**当前模式由 `login` 成功应答的 `work_mode` 下发**(`"0"`=平台指令模式 / `"1"`=自动充电工作模式),**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 | -| 9 | **`login` 应答下发柜子规格与工作模式** | `spec`(**固定 4 位字符串**)= **前 2 位仓控板数 + 后 2 位每板通道数**;当前 36 仓柜型为 **`"0606"`**(6 块仓控板 × 每板 6 仓)。由平台按该柜配置下发并作为**柜子型号存储**。旧的 `cell`(仓控板数量)**已由 `spec` 取代**,不再下发 | +| 9 | **`login` 应答下发柜子规格与工作模式** | `spec`(**固定 4 位字符串、恒为数字、不会是 `"0000"`**)= **前 2 位仓控板数 + 后 2 位每板通道数**;当前 36 仓柜型为 **`"0606"`**(6 块仓控板 × 每板 6 仓)。**平台在每次 `login` 时按该柜已配置的仓控板 / 通道拓扑现算**(**不是**"设备型号的持久存储"):拓扑已配置 → 按拓扑统计;**拓扑未配置 / 不完整 → 按柜型兜底**(该柜仓控板数 × 每板 6 通道)。
**固件必须以收到的 `spec` 决定上报几块板 / 每板几路通道**(前 2 位 = 板数、后 2 位 = 每板通道数);若与实际不符,**先按 `spec` 上报**并记日志反馈平台,**不得自造新 `act`**。
旧的 `cell`(仓控板数量)**不再下发给设备**(平台仅在生成 `spec` 兜底时内部使用),`work_mode`(`"0"`=平台指令模式 / `"1"`=自动充电工作模式)**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 | | 10 | **连接登录门控** | **只有 `auth_str` / `login` 可以在未登录状态发送**;**其余所有 `act`(含 `status_post`、`bat_in`、`on`/`off`、回执等)必须先 `login` 成功**,否则平台**丢弃且不回任何应答**。**未登录的 TCP 连接不予保持**:连接建立后 **30 秒内**未 `login` 成功,平台**主动断开**(固件应建连后立即 `auth_str`/`login`) | | 11 | **停电上报是两条独立指令** | **没有 `pow_fail` 这条指令,也没有 `info` 字段**。停电上报分两条独立 `act`:
`pow_fail_dc` = **直流停电** —— 柜内**接触器分闸**(含远程 `pow_off`)导致**仓控板下电**,柜控板仍有电、可正常上报;
`pow_fail_ac` = **交流停电** —— **柜控板自身断电**(市电断),靠**板载电容余电抢发最后一条**,**与接触器无关**。
两条均**无附加字段**,**事件上报**:平台收到即**记录并置为该柜的停电状态**,**前端显示**(不与其他业务逻辑联动);**供电恢复后消除该状态** | -| 12 | **通道数据用 hex 透传,不拆字段** | `status_post.channels[]` 的每个元素 = **恰好 18 个 hex 字符**(硬件「充电动态数据」9 字节**原样拼接**,4G 层**不做任何转换**);`bat_in` 里电池信息用同格式的 **`info`**,电芯数据用 **`cells`**(整段拼接 hex,长度 = `cell_count × 4 + 16`)。
**字节布局**:byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3–4 电压(0.01V) / byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K),多字节一律**大端**。
**数组顺序 = 通道号 1→8**;**空仓** = byte0 `00`(无电池)+ **byte1 照实报电源状态** + 数值全 `0`(例 `"008200000000000000"`)。
**电池状态与电源状态相互独立**,两回事,前端分开显示 | +| 12 | **通道数据用 hex 透传,不拆字段** | `status_post.channels[]` 的每个元素 = **恰好 18 个 hex 字符**(硬件「充电动态数据」9 字节**原样拼接**,4G 层**不做任何转换**);`bat_in` 里电池信息用同格式的 **`info`**,电芯数据用 **`cells`**(整段拼接 hex,长度 = `cell_count × 4 + 16`)。
**字节布局**:byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3–4 电压(0.01V) / byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K),多字节一律**大端**。
**通道号由数组下标决定**(第 1 个元素 = 通道 1),数组顺序不得打乱;**空仓** = byte0 `00`(无电池)+ **byte1 照实报电源状态** + 数值全 `0`(例 `"008200000000000000"`)。
**电池状态与电源状态相互独立**,两回事,前端分开显示 | +| 13 | **数值口径:hex 由平台换算、JSON 由设备换算** | **统一规则(见 3.1,全文唯一口径)**:
① **hex 字节流**(`channels[]` 元素、`info`、`cells`)= **原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**平台换算**(×10/×10/×10/×100),**固件原样拼接、不做任何换算**;
② **JSON 数值字段**(`cabinet.temp` / `cabinet.meter` / `battery.rated_voltage` / `battery.rated_capacity`)= **平台单位**,**设备换算后上报**:`temp` 送 **K×100**(`29800` = 298.00K,管理端按 `temp/100 − 273.15` 显示 ℃)、`meter` 送 **Wh**(`125500` = 125.5 kWh)、`rated_voltage` 送 **mV**、`rated_capacity` 送 **mAh**。平台对 JSON 数值**原样存库、不再换算** | +| 14 | **订单结束的兜底(平台侧行为,固件无需实现)** | ① **订单超时(主要)**:订单"进行中"超过 **10 小时**未结束 → 平台自动结束(`reason='timeout'`)。
② **「无电池」状态兜底(已实现并生效)**:该通道**已有活跃订单**、而 `status_post` 的 **`batt_status` bit7 = 0(无电池)连续 2 次**(`status_post` 每 60 秒一次 → **约 2 分钟**;平台对账每 5 分钟一次,故最迟约 5–7 分钟)→ 平台判定"**电池已被取走**",**提前结束该订单**:原因 **`forced`(异常)**,充电记录状态记为**异常**。
⚠️ **风险(待确认第 16 项)**:若"**板离线时照报全 0 的板**",该板所有通道 bit7 均为 0 → 平台会把**整板离线**误判为**电池被拔走**,**把该板上所有活跃订单提前异常结单**;若"**省略该板**"则不触发(退回 10 小时超时兜底)。**请在答复第 16 项时一并说明离线时的上报方式**(详见 §5.6) | **尚未定稿(7 项)**:`energy` 数据源(第 13 项)、代码表取值内容(第 14 项)、电芯数据字节数(第 15 项)、仓控板离线如何上报(第 16 项)、**直流停电是否需要 `pow_fail_dc`(第 17 项)**、**强制拔出能否检测(第 18 项)**、**停电恢复后如何兜底(第 19 项)**。 **完整清单、现状与影响范围见文首「⚠️ 待确认事项」** —— 此处不再重复列一遍,避免两处漂移。 @@ -1185,14 +1233,15 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 // rssi : 4G 信号强度,整数 0–99(不是 dBm,不可为负) // cabinet : 柜体状态,柜级只带一次 // status1 / status2 → 柜体状态 2 字节(hex 2 位,位定义见 §3.8) -// temp → 柜体温度 K×100(29800 = 298.00K) -// meter → 电表读数 Wh(125500 = 125.5 kWh) +// temp → 柜体温度 K×100(29800 = 298.00K)——【JSON 字段,由设备换算】 +// meter → 电表读数 Wh(125500 = 125.5 kWh)——【JSON 字段,由设备换算】 // boards[] : 所有仓控板(board 1–15),每板带全部通道 // channels = **每通道 18 个 hex 字符**(9 字节原样透传) -// 数组顺序 = 通道号 1→8;多字节**大端** +// 通道号由**数组下标**决定(第 1 个元素 = 通道 1);多字节**大端** // byte0 电池状态 / byte1 电源状态(位定义见 §3.8) // byte2 SOC(1%) / byte3–4 电压(0.01V) // byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K) +// ⚠️ hex 内是【原始硬件单位】,换算由平台做(见 §3.1) // 空仓 = byte0 "00"(无电池)+ byte1 照实报电源状态 + 数值全 0 // 例:"008200000000000000" = 空仓、但电源有电且已打开 // 节奏:固定每 60 秒一次,每次全量(不区分空闲/充电、不做增量) @@ -1244,20 +1293,20 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 // : 硬件代码,**字符串原值透传**,由平台解析成名称 // ("1"=星恒 / "1"=磷酸铁锂;全套代码表待硬件提供) // cell_count: 电芯节数(示例为 15 串磷酸铁锂:15×3.6V = 54.0V,与 info 里的电压自洽) -// rated_voltage : mV(48000 = 48.0V,硬件 0.1V ×100) -// rated_capacity : mAh(24000 = 24.0Ah,硬件 0.1AH ×100) +// rated_voltage : mV(48000 = 48.0V)——【JSON 字段,设备按硬件 0.1V ×100 换算后上报】 +// rated_capacity : mAh(24000 = 24.0Ah)——【JSON 字段,设备按硬件 0.1AH ×100 换算后上报】 // info : **18 个 hex 字符** = 该通道的 9 字节电池信息(布局同 §5.3) // "80825515180320012a" 解析: // byte0 80 = 有电池、尚未充电(位定义见 §3.8) // byte1 82 = 有电源 + 电源打开(位定义见 §3.8) // SOC 55 = 85% 电压 1518 = 54.00V // 电流 0320 = 8.00A 温度 012a = 298K +// ⚠️ 以上都是 hex 内的【原始硬件单位】,换算由平台做(见 §3.1) // ⚠️ 插入首帧时平台还没下发 on,故不应是"正在充电" // cells : 电芯数据整段拼接 hex,长度 = cell_count × 4 + 16 // (示例 15 节 → 76 字符:15 节电压 + 最高 + 最低 + 最高温 + 最低温) -// 只随 bat_in 带一次,不进周期上报 -// soc/voltage/current/temp : 换算同 A.2.1 -// cells : 电芯数据,**只随 bat_in 带一次**;项数须与 cell_count 相同 +// 只随 bat_in 带一次,不进周期上报;段内同为【原始硬件值】,平台换算 +// (SOC / 电压 / 电流 / 温度不再单独成字段:都在 info 的 18 字符 hex 里,见上) // ⚠️ energy(累计充电量)数据源待硬件确认,确认后再补入本报文 ← {"act":"bat_in","msg_id":5,"suc":1} @@ -1271,7 +1320,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 → {"act":"bat_out","msg_id":6,"id":"01-3", "battery":{"battery_id":"SN123456","info":"80005f15180000012a"}} // MCU 约定:先发 off,再发 bat_out -// battery 至少带 battery_id,建议带末帧动态(换算同 A.2.1) +// battery 至少带 battery_id,建议带末帧动态 info(18 字符 hex,规则同 §5.3;hex 内为原始硬件值,平台换算) ← {"act":"bat_out","msg_id":6,"suc":1} // 平台动作:结束订单(reason='user_stop')、电池台账置空闲 @@ -1338,7 +1387,8 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 ```jsonc ← {"act":"open","msg_id":1} -// id : 柜级指令只写 IMEI(15 位) +// id : 【柜级指令不带 id】(省略该字段)—— id 只表示【动作目标】、不含 IMEI, +// 设备身份由本 TCP 连接确定(见 §3.7);平台内部路由字段不下发到设备 // 含义 : 对【整柜唯一的柜门】执行【解锁】—— 门不会自动打开,需人拉开 // 协议没有"按仓体开门"的指令,也没有"关门"指令 // 固件动作:解开门锁 → 回执;失败时回执带 err(如 "door lock not engaged") @@ -1441,5 +1491,5 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 | 版本 | 日期 | 变更内容 | |------|------|----------| -| v1.1 | 2026-09-14 | ① 签名定稿为 **8 位**(`hex[56..64]`),自测向量更新为 `3957b959` / `269c249d` ② 明确 `act` 双向必带、`suc=0` 必带 `err` ③ 报文性质判定改为**按有无 `suc`**,配对要求 `msg_id` + `act` 双条件 ④ 明确**每个交互 60 秒超时、超时作废、迟到应答丢弃、不重试** ⑤ 新增 `login` 失败分类处理与**安全码自愈闭环**(重置后设备自动重新申领)⑥ 接入端口定稿:测试 `3001`、正式 `9001`,明确 TCP **不经 nginx/域名** ⑦ 第 10 节遗留项全部定稿;新增附 A 带注释报文示例 ⑧ **`login` 失败后平台主动断开 TCP 连接**,设备须重连后重新握手 ⑨ 安全码存储明确为 **DB 权威 + Redis 缓存**(缓存未命中回源 DB,Redis 故障不再导致设备无法登录)⑩ 上报节奏定稿:`status_post` **固定每 60 秒一次、每次全量**(取消原"空闲 60s / 充电 15s"与按板分片设想);单行上限 **4096 → 65536 字节** ⑪ **上报字段按硬件权威数据字典重构**:`status_post` 改为 `cabinet`(柜体状态2 字节 + 柜温 + 电表)+ `boards[].batteries[]`(2 个状态字节 + SOC/电压/电流/温度);`bat_in` / `bat_out` / `off` 的 `bms` 改为 `battery`(属性 + 动态 + 电芯);新增 **§3.8 状态字节 hex 解析表**;`id` 量程改为仓控板 **1–15** / 电池组 **1–8**;`soh` / `power` 移除 ⑫ **柜门整章定稿**:柜门与充电**完全解耦**(§7.2/§7.3 拆分)、`open` 明确为**解锁**(门由人拉开)、**无"关门"指令(任何柜型)**、柜门状态经 `status_post` ≤60 秒回传;`rssi` 口径由 dBm 改为 **0–99 信号等级** ⑬ **挂起 4 项待确认**(`pow_fail` `info` 语义、`energy` 数据源、代码表取值内容、电芯数据字节数)并在**文首新增「⚠️ 待确认事项」**集中列出;删除内部重复文档 `docs/柜控板-平台TCP接口文档.md` ⑭ **`login` 成功应答新增 `spec` 与 `work_mode`**:`spec`(4 位,前 2 位仓控板数 + 后 2 位每板通道数,当前柜型 `"0606"`)作为**柜子规格/型号**由平台下发;`work_mode`(`"0"`=平台指令模式 / `"1"`=自动充电工作模式)下发**充电工作模式**,固件据此决定是否等平台 `on` 指令——**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 ⑮ **新增连接登录门控**:**只有 `auth_str` / `login` 可在未登录状态发送**,其余 `act` 必须先 `login` 成功,否则平台丢弃且不回包;**未登录的 TCP 连接不予保持**(建连后 30 秒未 `login` 成功即主动断开)。同时 `err` 文案全部改为**英文小写短语**(§3.3 新增取值全集表),`id` 段数规则明确(一段=柜级 / 两段=仅 `reset` 单板 / 三段=仓体级) ⑯ **补充 `pow_on` / `pow_off` 业务语义**:远程通断柜内总电(接触器)——**`pow_off` 分闸后仓控板随之下电**(仓体/电池数据不可用,并因此产生 `pow_fail` `info:"0"` 直流停电),属运维动作;`pow_fail` 与 `cabinet.status1` bit0 的关系(事件 vs 状态)在 §5.4 说明 ⑰ **`pow_fail` 的 `info` 区分定稿**(判定依据 = **"谁掉电"**):`"0"`=直流停电(**接触器分闸**→仓控板下电,柜控板仍有电)/ `"1"`=交流停电(**柜控板自身断电**,靠**板载电容余电抢发最后一条**,与接触器无关);该条由「待确认」转入「约定定稿」第 11 条,待确认事项由 4 项缩减为 **3 项** ⑱ **停电上报更正为两条独立指令**:**取消 `pow_fail`(及 `info` 字段)**,改为 **`pow_fail_dc`**(直流停电:接触器分闸→仓控板下电,柜控板仍有电)与 **`pow_fail_ac`**(交流停电:柜控板自身断电,靠板载电容余电抢发最后一条,与接触器无关)两条独立 `act`,**均无附加字段**;`act` 名与平台 Redis 字段 `pow_fail_dc` / `pow_fail_ac` 一一对应。代码侧 `report_worker` 分发与 `handle_pow_fail` 同步改为按 `act` 判定 ⑲ **新增待确认第 12 项**:**仓控板离线(485 芯片坏 / 线松 / 掉电)如何上报** —— **4G 层读不到该板数据时该怎么报尚未约定**(省略该板?照报全 0 的板?另有在线标志?),**以硬件答复为准、本文不预设结论**(§5.3 同步加警示) ⑳ **`login` 失败统一为单一应答**:取消 `invalid timestamp` / `auth_str not found`,**只返回 `invalid signature`**(不区分失败原因,避免外部试探系统状态——安全设计);同时修正 `spec` 的定位为「平台对柜子的规格定义(型号)」,**与仓控板实际能否通信无关**;`work_mode` 明确**仅两种、不再扩展** ㉑ **`status_post` 字段改名**:`batteries[]` → **`channels[]`**、内层 `group` → **`channel`**(那是**仓板的通道**、可能空着,不是纯电池信息;同时与平台侧 `channel_id` 对齐);`id` 第三段说明改为「通道」;全文「电池组」按语境改为「通道」(硬件表名「电池组属性」保留) ㉒ **通道状态改为 hex 透传(大幅瘦身)**:`status_post.channels[]` 由"每通道 6 个命名字段"改为 **每通道 18 个 hex 字符**(9 字节原样拼接,**4G 层零转换**),**数组下标 = 通道号**;`bat_in`/`bat_out`/`off` 的动态数据同步改为 `info`(同一种 18 字符 hex);定字节布局与**大端**规则,§3.8/§5.3/§5.5/§7/§9/§10/附A 全面同步。体积:6 板 × 6 通道由 ~3.4KB 降至 **~0.8KB** ㉓ **修正空仓写法**:**空仓 ≠ 全 0** —— byte0 = `00`(无电池)但 **byte1 照实报电源状态**(电源与有无电池无关),例 `"008200000000000000"`;明确**电池状态与电源状态是两个独立维度**,平台前端**分两个指示分开显示** ㉔ **字段改名**:`bat_in`/`bat_out`/`off` 里的电池数据字段 `dyn` → **`info`**(它是**这块电池的信息**,不是"充电数据");同时明确 hex **大小写不敏感**(平台两种都解析,建议固件统一小写),示例 hex 全部改小写 ㉕ **`cells` 同样改拼接 hex**:由"JSON 数组 + 4 个统计字段"改为**整段拼接**的 hex 字符串,长度 = **`cell_count × 4 + 16`**(N 节电压 + 最高/最低电压 + 最高/最低温度,大端),15 节 = 76 字符、20 节 = 96、22 节 = 104(**与硬件文档「52 字节」吻合**) ㉖ **`battery_id` 允许为空串**:BMS 读不到序列号时传 `""`,**不影响充电与订单记录**;平台侧 `battery_id` 为空时**不做"换电池"判断、跳过电池台账**,订单照常创建(只是不知道是哪块电池) ㉗ **`bat_in` 重复上报不新建订单**:明确 **`bat_out` 是订单结束标志**——没有 `bat_out` 就说明订单还在进行;因此该通道已有活跃订单且 `battery_id` **相同**(含两边都为空串)时**不新建订单**,仅当 `battery_id` **不同且都非空**才 `forced` 结束旧订单并新建。**依据:一个通道只可能有一块电池**,`bat_in` 带不同 `battery_id` 必然是换了电池 → 老订单必须先结束(⚠️ 该条后续在 ㉝ 修正:`bat_in` 最终定为**与订单无关**,建单只在 `on`) ㉘ **新增订单超时兜底**:设备可能**停电/离线期间电池被取走**而永远发不出 `bat_out` → 平台侧**充电订单"进行中"超过 10 小时即自动结束**(`reason='timeout'`,依据"充电不可能超过 10 小时");固件**不必补报历史事件** ㉙ **补充两条待确认(第 18/19 项)**:**① 人在没充满时强制拔电池**——MCU 能否检测、能否保证"先 `off` 再 `bat_out`"(§5.6 已把该顺序列为**硬性要求**);**② 停电恢复后如何兜底**——掉电期间事件无法上报,恢复后是补报、还是以当前状态为准由平台对账 ㉚ **报文瘦身:`dev_id` 只在 `auth_str` / `login` 带** —— 这两条用于把 TCP 连接与设备绑定,**之后所有报文都不再带 `dev_id`**(设备身份由连接确定;断开重连才需重新 `login`)。同步更新 §3.1 字段总表、§5.3~§5.8、§7 时序、§8.1、§9 清单、附 A 全部示例;平台侧 `device-server` 转发时按连接注入设备身份(内部改动、固件无感) ㉛ **`id` 也去掉 IMEI 前缀**:既然设备身份来自连接,`id` 只表示**动作目标** —— **不带**=柜级、**一段**=单板(仅 `reset`,如 `"01"`)、**两段**=仓体级(如 `"01-3"`);仓体级段数校验由 `≠3` 改为 `≠2`;§3.7 / §4.2 / §5.5 / 附 A 全部示例与注释同步。平台侧 `device-server` 下发时需按目标连接**补回 IMEI 前缀**(内部改动) ㉜ **`msg_id` 编号规则写实**:**两个方向各自独立计数、都每连接从 `1` 起自增**(`0` 不使用),重连归零;**明确不约定号段**(方向靠有无 `suc` 区分),并说明"同一数字在两个方向同时出现是正常的"。原示例中的大号(`2147483648` 等,共 25 处)全部改为从 `1` 起的小号;删除原先"平台从高位起"的不实描述 ㉝ **订单生命周期定稿(关键修正)**:**`bat_in` 与订单无关**(只登记电池台账,**不建单**);**建立订单的唯一入口是 `on`**(`work_mode="0"` 平台下发 `on` 时建单、`work_mode="1"` 设备上报 `on` 时建单、**回执形态不建单**);**结束** = `bat_out` / `off` / **10 小时订单超时**。建单判定:该通道无活跃订单→新建;有活跃订单且**电池 id 没变**→不新建;**变了**→老订单 `forced` + 新建(依据"一个通道只可能有一块电池");起始 SOC 取最近一次 `bat_in` 的 `info`。**`off` 到达时无活跃订单 → 平台不处理但照常回 `suc:1`**。并在 §5.7 加注释**告知硬件侧**:除充满外,**故障 / 过温 / 电池异常等停止也应上报 `off`** ㉞ **`reset` 回执语义明确**:`reset` 只是**一个动作**,回执表示"**已收到并开始执行(重启)**"= **下发成功**,**不表示重启成功**;固件**必须先回执、再重启**(整柜重启后就没机会回执);**重启是否成功平台不看回执**,而是看设备**是否重新 `login`**;回执的 `id` 明确为**可选**(平台配对只靠 `msg_id` + `act`) ㉟ **平台应答补上 `act`(实现对齐协议)**:`ServerResponse` 新增 `act`(**必带,与请求一致**),新增 `success_for` / `error_for` 从**请求报文**取 `act`+`msg_id`(协议 §3.1 要求 `act` 双向必带、§3.4 要求配对含 `act`,原实现应答无 `act` 属实现缺陷);连带修复 device-server 的「应答 vs 指令」判据(**只看有没有 `suc`**,不再看 `act`)与**敏感指令门控**(只拦指令 —— 否则 `auth_str` 的应答会被自己拦掉 → 首次配网死锁);并新增**内部路由字段 `_dev_id`**(`id` 去掉 IMEI 后 device-server 无法再从 `id` 推导目标设备,改用该字段;下发前摘除、**设备侧不可见**,§8.3 同步说明) ㊱ **补两条边界说明**:① **未登记的 IMEI 发 `auth_str` 时平台静默丢弃**(不发码/不写缓存/不回包,见 §5.1 与 §8.1 表)—— 合法设备必然已建档,正常固件不会遇到;② **电池属性按原值入库**(`brand_code`/`model_code`/`material_code`/`cell_count`/`rated_voltage`/`rated_capacity` 均入电池台账,映射出的中文名留待代码表确认后回填,与 §10 定稿第 7 条一致) | +| v1.1 | 2026-09-14 | ① 签名定稿为 **8 位**(`hex[56..64]`),自测向量更新为 `3957b959` / `269c249d` ② 明确 `act` 双向必带、`suc=0` 必带 `err` ③ 报文性质判定改为**按有无 `suc`**,配对要求 `msg_id` + `act` 双条件 ④ 明确**每个交互 60 秒超时、超时作废、迟到应答丢弃、不重试** ⑤ 新增 `login` 失败分类处理与**安全码自愈闭环**(重置后设备自动重新申领)⑥ 接入端口定稿:测试 `3001`、正式 `9001`,明确 TCP **不经 nginx/域名** ⑦ 第 10 节遗留项全部定稿;新增附 A 带注释报文示例 ⑧ **`login` 失败后平台主动断开 TCP 连接**,设备须重连后重新握手 ⑨ 安全码存储明确为 **DB 权威 + Redis 缓存**(缓存未命中回源 DB,Redis 故障不再导致设备无法登录)⑩ 上报节奏定稿:`status_post` **固定每 60 秒一次、每次全量**(取消原"空闲 60s / 充电 15s"与按板分片设想);单行上限 **4096 → 65536 字节** ⑪ **上报字段按硬件权威数据字典重构**:`status_post` 改为 `cabinet`(柜体状态2 字节 + 柜温 + 电表)+ `boards[].batteries[]`(2 个状态字节 + SOC/电压/电流/温度);`bat_in` / `bat_out` / `off` 的 `bms` 改为 `battery`(属性 + 动态 + 电芯);新增 **§3.8 状态字节 hex 解析表**;`id` 量程改为仓控板 **1–15** / 电池组 **1–8**;`soh` / `power` 移除 ⑫ **柜门整章定稿**:柜门与充电**完全解耦**(§7.2/§7.3 拆分)、`open` 明确为**解锁**(门由人拉开)、**无"关门"指令(任何柜型)**、柜门状态经 `status_post` ≤60 秒回传;`rssi` 口径由 dBm 改为 **0–99 信号等级** ⑬ **挂起 4 项待确认**(`pow_fail` `info` 语义、`energy` 数据源、代码表取值内容、电芯数据字节数)并在**文首新增「⚠️ 待确认事项」**集中列出;删除内部重复文档 `docs/柜控板-平台TCP接口文档.md` ⑭ **`login` 成功应答新增 `spec` 与 `work_mode`**:`spec`(4 位,前 2 位仓控板数 + 后 2 位每板通道数,当前柜型 `"0606"`)作为**柜子规格/型号**由平台下发;`work_mode`(`"0"`=平台指令模式 / `"1"`=自动充电工作模式)下发**充电工作模式**,固件据此决定是否等平台 `on` 指令——**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 ⑮ **新增连接登录门控**:**只有 `auth_str` / `login` 可在未登录状态发送**,其余 `act` 必须先 `login` 成功,否则平台丢弃且不回包;**未登录的 TCP 连接不予保持**(建连后 30 秒未 `login` 成功即主动断开)。同时 `err` 文案全部改为**英文小写短语**(§3.3 新增取值全集表),`id` 段数规则明确(一段=柜级 / 两段=仅 `reset` 单板 / 三段=仓体级)**(⚠️ 该 `id` 段数规则与最终定稿完全相反,已被 ㉛ 修正并经 ㉟ 确认,作废;当前生效规则见 §3.7)** ⑯ **补充 `pow_on` / `pow_off` 业务语义**:远程通断柜内总电(接触器)——**`pow_off` 分闸后仓控板随之下电**(仓体/电池数据不可用,并因此产生 `pow_fail` `info:"0"` 直流停电),属运维动作;`pow_fail` 与 `cabinet.status1` bit0 的关系(事件 vs 状态)在 §5.4 说明 **(⚠️ 本条中的 `pow_fail` / `info:"0"` 已被 ⑱ 取代,作废)** ⑰ **`pow_fail` 的 `info` 区分定稿**(判定依据 = **"谁掉电"**):`"0"`=直流停电(**接触器分闸**→仓控板下电,柜控板仍有电)/ `"1"`=交流停电(**柜控板自身断电**,靠**板载电容余电抢发最后一条**,与接触器无关);该条由「待确认」转入「约定定稿」第 11 条,待确认事项由 4 项缩减为 **3 项** **(⚠️ 已被 ⑱ 取代,作废:`pow_fail` 与 `info` 字段均已取消)** ⑱ **停电上报更正为两条独立指令**:**取消 `pow_fail`(及 `info` 字段)**,改为 **`pow_fail_dc`**(直流停电:接触器分闸→仓控板下电,柜控板仍有电)与 **`pow_fail_ac`**(交流停电:柜控板自身断电,靠板载电容余电抢发最后一条,与接触器无关)两条独立 `act`,**均无附加字段**;`act` 名与平台 Redis 字段 `pow_fail_dc` / `pow_fail_ac` 一一对应。代码侧 `report_worker` 分发与 `handle_pow_fail` 同步改为按 `act` 判定 ⑲ **新增待确认第 12 项**(**该编号后调整为第 16 项,见文首「待确认事项」**):**仓控板离线(485 芯片坏 / 线松 / 掉电)如何上报** —— **4G 层读不到该板数据时该怎么报尚未约定**(省略该板?照报全 0 的板?另有在线标志?),**以硬件答复为准、本文不预设结论**(§5.3 同步加警示) ⑳ **`login` 失败统一为单一应答**:取消 `invalid timestamp` / `auth_str not found`,**只返回 `invalid signature`**(不区分失败原因,避免外部试探系统状态——安全设计);同时修正 `spec` 的定位为「平台对柜子的规格定义(型号)」,**与仓控板实际能否通信无关** **(⚠️ 其中"型号"的措辞已在 ㊲ 更正:`spec` 由平台按拓扑现算、**不承诺"型号存储"**,见 §5.2 / §10 第 9 条)**;`work_mode` 明确**仅两种、不再扩展** ㉑ **`status_post` 字段改名**:`batteries[]` → **`channels[]`**、内层 `group` → **`channel`**(那是**仓板的通道**、可能空着,不是纯电池信息;同时与平台侧 `channel_id` 对齐)**(⚠️ 该 `channel` 字段已在 ㉒ 随之取消:`channels[]` 改为**字符串数组**,元素内不含任何字段名,**通道号由数组下标决定**,见 §5.3 / §10 第 2 条)**;`id` 第三段说明改为「通道」;全文「电池组」按语境改为「通道」(硬件表名「电池组属性」保留) ㉒ **通道状态改为 hex 透传(大幅瘦身)**:`status_post.channels[]` 由"每通道 6 个命名字段"改为 **每通道 18 个 hex 字符**(9 字节原样拼接,**4G 层零转换**),**数组下标 = 通道号**(**精确定义:数组下标从 0 开始,第 1 个元素 = 通道 1**,见 §5.3 / §10 第 2 条);`bat_in`/`bat_out`/`off` 的动态数据同步改为 `info`(同一种 18 字符 hex);定字节布局与**大端**规则,§3.8/§5.3/§5.5/§7/§9/§10/附A 全面同步。体积:6 板 × 6 通道由 ~3.4KB 降至 **~0.8KB** ㉓ **修正空仓写法**:**空仓 ≠ 全 0** —— byte0 = `00`(无电池)但 **byte1 照实报电源状态**(电源与有无电池无关),例 `"008200000000000000"`;明确**电池状态与电源状态是两个独立维度**,平台前端**分两个指示分开显示** ㉔ **字段改名**:`bat_in`/`bat_out`/`off` 里的电池数据字段 `dyn` → **`info`**(它是**这块电池的信息**,不是"充电数据");同时明确 hex **大小写不敏感**(平台两种都解析,建议固件统一小写),示例 hex 全部改小写 ㉕ **`cells` 同样改拼接 hex**:由"JSON 数组 + 4 个统计字段"改为**整段拼接**的 hex 字符串,长度 = **`cell_count × 4 + 16`**(N 节电压 + 最高/最低电压 + 最高/最低温度,大端),15 节 = 76 字符、20 节 = 96、22 节 = 104(**与硬件文档「52 字节」吻合**) ㉖ **`battery_id` 允许为空串**:BMS 读不到序列号时传 `""`,**不影响充电与订单记录**;平台侧 `battery_id` 为空时**不做"换电池"判断、跳过电池台账**,订单照常创建(只是不知道是哪块电池) ㉗ **`bat_in` 重复上报不新建订单**:明确 **`bat_out` 是订单结束标志**——没有 `bat_out` 就说明订单还在进行;因此该通道已有活跃订单且 `battery_id` **相同**(含两边都为空串)时**不新建订单**,仅当 `battery_id` **不同且都非空**才 `forced` 结束旧订单并新建。**依据:一个通道只可能有一块电池**,`bat_in` 带不同 `battery_id` 必然是换了电池 → 老订单必须先结束(⚠️ 该条后续在 ㉝ 修正:`bat_in` 最终定为**与订单无关**,建单只在 `on`) ㉘ **新增订单超时兜底**:设备可能**停电/离线期间电池被取走**而永远发不出 `bat_out` → 平台侧**充电订单"进行中"超过 10 小时即自动结束**(`reason='timeout'`,依据"充电不可能超过 10 小时");固件**不必补报历史事件** ㉙ **补充两条待确认(第 18/19 项)**:**① 人在没充满时强制拔电池**——MCU 能否检测、能否保证"先 `off` 再 `bat_out`"(§5.6 已把该顺序列为**硬性要求**);**② 停电恢复后如何兜底**——掉电期间事件无法上报,恢复后是补报、还是以当前状态为准由平台对账 ㉚ **报文瘦身:`dev_id` 只在 `auth_str` / `login` 带** —— 这两条用于把 TCP 连接与设备绑定,**之后所有报文都不再带 `dev_id`**(设备身份由连接确定;断开重连才需重新 `login`)。同步更新 §3.1 字段总表、§5.3~§5.8、§7 时序、§8.1、§9 清单、附 A 全部示例;平台侧 `device-server` 转发时按连接注入设备身份(内部改动、固件无感) ㉛ **`id` 也去掉 IMEI 前缀**:既然设备身份来自连接,`id` 只表示**动作目标** —— **不带**=柜级、**一段**=单板(仅 `reset`,如 `"01"`)、**两段**=仓体级(如 `"01-3"`);仓体级段数校验由 `≠3` 改为 `≠2`;§3.7 / §4.2 / §5.5 / 附 A 全部示例与注释同步。平台侧 `device-server` 下发时需按目标连接**补回 IMEI 前缀**(内部改动) **(⚠️ 仅本条末句被 ㉟ 取代、作废:`id` 不下发 IMEI,"补回 IMEI 前缀"的做法已废弃,改由**内部路由字段 `_dev_id`** 定位目标设备;`id` 只表示动作目标、不含 IMEI 的规则本身仍然生效,见 §3.7)** ㉜ **`msg_id` 编号规则写实**:**两个方向各自独立计数、都每连接从 `1` 起自增**(`0` 不使用),重连归零;**明确不约定号段**(方向靠有无 `suc` 区分),并说明"同一数字在两个方向同时出现是正常的"。原示例中的大号(`2147483648` 等,共 25 处)全部改为从 `1` 起的小号;删除原先"平台从高位起"的不实描述 ㉝ **订单生命周期定稿(关键修正)**:**`bat_in` 与订单无关**(只登记电池台账,**不建单**);**建立订单的唯一入口是 `on`**(`work_mode="0"` 平台下发 `on` 时建单、`work_mode="1"` 设备上报 `on` 时建单、**回执形态不建单**);**结束** = `bat_out` / `off` / **10 小时订单超时**。建单判定:该通道无活跃订单→新建;有活跃订单且**电池 id 没变**→不新建;**变了**→老订单 `forced` + 新建(依据"一个通道只可能有一块电池");起始 SOC 取最近一次 `bat_in` 的 `info`。**`off` 到达时无活跃订单 → 平台不处理但照常回 `suc:1`**。并在 §5.7 加注释**告知硬件侧**:除充满外,**故障 / 过温 / 电池异常等停止也应上报 `off`** ㉞ **`reset` 回执语义明确**:`reset` 只是**一个动作**,回执表示"**已收到并开始执行(重启)**"= **下发成功**,**不表示重启成功**;固件**必须先回执、再重启**(整柜重启后就没机会回执);**重启是否成功平台不看回执**,而是看设备**是否重新 `login`**;回执的 `id` 明确为**可选**(平台配对只靠 `msg_id` + `act`) ㉟ **平台应答补上 `act`(实现对齐协议)**:`ServerResponse` 新增 `act`(**必带,与请求一致**),新增 `success_for` / `error_for` 从**请求报文**取 `act`+`msg_id`(协议 §3.1 要求 `act` 双向必带、§3.4 要求配对含 `act`,原实现应答无 `act` 属实现缺陷);连带修复 device-server 的「应答 vs 指令」判据(**只看有没有 `suc`**,不再看 `act`)与**敏感指令门控**(只拦指令 —— 否则 `auth_str` 的应答会被自己拦掉 → 首次配网死锁);并新增**内部路由字段 `_dev_id`**(`id` 去掉 IMEI 后 device-server 无法再从 `id` 推导目标设备,改用该字段;下发前摘除、**设备侧不可见**,§8.3 同步说明) **(✅ 本条取代 ㉛ 末句"下发时需按目标连接补回 IMEI 前缀";当前生效口径:`id` 只表示动作目标、**不含 IMEI**,设备身份由 TCP 连接确定,平台内部路由字段一律不下发 —— 与 §3.7 / §8.3 一致)** ㊱ **补两条边界说明**:① **未登记的 IMEI 发 `auth_str` 时平台静默丢弃**(不发码/不写缓存/不回包,见 §5.1 与 §8.1 表)—— 合法设备必然已建档,正常固件不会遇到;② **电池属性按原值入库**(`brand_code`/`model_code`/`material_code`/`cell_count`/`rated_voltage`/`rated_capacity` 均入电池台账,映射出的中文名留待代码表确认后回填,与 §10 定稿第 7 条一致) ㊲ **交付前一致性修正(本文与实现对齐,不改动任何已冻结的协议行为)**:
① **新增 §3.1「数值口径的统一规则」并全文贯彻** —— **hex 字节流(`channels[]` / `info` / `cells`)= 原始硬件单位、由平台换算;JSON 数值字段(`cabinet.temp` / `cabinet.meter` / `rated_voltage` / `rated_capacity`)= 平台单位、由设备换算**;据此改正 §5.3 `temp`/`meter`(设备送 K×100 / Wh,平台原样存库)、§5.5 `info`/`cells`/额定值的说明,使"字段说明"与"JSON 示例"完全自洽。
② **`spec` 口径更正**:由平台**每次 `login` 按该柜已配置的仓控板/通道拓扑现算**(**不再**写"作为柜子型号存储"),**拓扑未配置/不完整时按柜型兜底**,**恒为 4 位数字、不会是 `"0000"`**;§5.2 / §9 自测清单 / §10 第 9 条同步补上"**固件应以收到的 `spec` 决定上报几块板、每板几路通道**;收到与预期不符应先按 `spec` 上报并记日志反馈平台(不得自造新 `act`)"。
③ **附 A.5.3 注释更正**:`open` 为柜级指令**不带 `id`**(原注释"只写 IMEI"与 §3.7 矛盾);并明确 ㉛ 末句"下发时补回 IMEI 前缀"**已被 ㉟ 取代(作废)**,⑮ 的 `id` 段数规则、⑯/⑰ 的 `pow_fail` `info` 规则均**在原处标注作废**、保留可追溯;⑲ 的"第 12 项"标注为**后调整为第 16 项**。
④ **§10 第 2 条更正**:`channels` 是**字符串数组**、元素**没有 `channel` 字段**,**通道号由数组下标决定**(第 1 个元素 = 通道 1);§5.3 同段措辞统一为同一句话。
⑤ **「无电池」状态兜底写实**(§5.6 与 §10 新增第 14 条):判定依据 `batt_status` bit7、**连续 2 次(约 2 分钟)**、后果为 **`forced` 异常结单**,并写明与待确认第 16/18 项的关系及"照报全 0 的板会误判"的风险。
⑥ **结构修复**:文首「⚠️ 待确认事项」表中被引用块切断的 7 行表格恢复连续(引用块移至表格之后);§5.1 示例中不可解析的 `msg_id:N` 占位改为具体编号;附 A.3.1 清理陈旧注释(`soc/voltage/current/temp`、重复的 `cells` 行) | | v1.0 | 2026-09-14 | 首版:基于平台 TCP 服务实现逐项核对生成 | diff --git a/software/api-server/src/commands.rs b/software/api-server/src/commands.rs index b8a30c1..1beec5c 100644 --- a/software/api-server/src/commands.rs +++ b/software/api-server/src/commands.rs @@ -273,20 +273,103 @@ pub async fn send_device_response( Ok(()) } +/// 本接口放行的**柜级/仓体级指令白名单**。 +/// +/// 为什么必须有白名单:`act` 是请求体里的自由字符串。原先不加限制 + 不做归属校验, +/// 任何持有 `device:operate` 的低权限用户都能对**全平台任意柜机**下发协议里的 +/// **任何**动作(开门 / 断电 / 重启),属于越权高危操作(C2)。 +/// +/// 只放行管理端确有需要的动作: +/// - `open` / `pow_on` / `pow_off`:**柜级**(协议不带 `id`) +/// - `reset`:柜级;单仓控板时由调用方显式传 `id = "01"` +/// - `on` / `off`:**仓体级**,调用方必须显式传 `id = "01-3"`(协议要求) +const CABINET_COMMAND_ACTS: &[&str] = &["on", "off", "open", "pow_on", "pow_off", "reset"]; + +/// 协议 `id` 的合法形态校验:`id` 只表示**动作目标** —— +/// 一段 = 单仓控板(`"01"`)/ 两段 = 仓体级(`"01-3"`),每段 1~2 位数字。 +/// +/// 协议定稿后 `id` **不含 IMEI**(设备身份由 TCP 连接确定),因此 15 位 IMEI 一律非法。 +fn is_valid_command_target(id: &str) -> bool { + /// 单段:1~2 位数字(仓控板 `"1"`/`"01"`,通道 `"3"`) + fn segment_ok(seg: &str) -> bool { + !seg.is_empty() && seg.len() <= 2 && seg.bytes().all(|b| b.is_ascii_digit()) + } + + let mut parts = id.split('-'); + match (parts.next(), parts.next(), parts.next()) { + (Some(a), None, _) => segment_ok(a), + (Some(a), Some(b), None) => segment_ok(a) && segment_ok(b), + // 空串、三段及以上(含 IMEI 这类无分隔的长数字)一律拒绝 + _ => false, + } +} + +/// 从请求体参数里取出协议 `id`(动作目标),并从 `params` 中**摘除**。 +/// +/// - 取不到(柜级指令本就不带 `id`)→ `None` +/// - 由调用方显式给出:单板 `reset` → `"01"`;仓体级 `on`/`off` → `"01-3"` +/// - **绝不允许再用 IMEI 当 `id`**:下行路由靠内部字段 +/// [`ROUTE_DEV_ID_FIELD`](`_dev_id`),协议 `id` 与设备身份无关 +/// +/// 为什么必须摘除:`DeviceCommand::params` 是 `#[serde(flatten)]`, +/// 若 `params` 里仍带 `id`,序列化时会与结构体显式 `id` 字段互相覆盖, +/// 可能把未校验的值(甚至非字符串)写进下行报文。 +fn take_command_target(params: &mut Option) -> Result, AppError> { + let raw = params + .as_mut() + .and_then(|p| p.as_object_mut()) + .and_then(|obj| obj.remove("id")); + + match raw { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => { + if !is_valid_command_target(&s) { + return Err(AppError::BadRequest(format!( + "指令目标 id 非法: {}(应形如 \"01\" 或 \"01-3\",且不含 IMEI)", + s + ))); + } + Ok(Some(s)) + } + Some(_) => Err(AppError::BadRequest("指令目标 id 必须是字符串".into())), + } +} + /// POST /api/devices/:dev_id/command — 向指定设备下发指令 +/// +/// 安全链路(C2 加固,缺一不可): +/// 1. `dev_id`(IMEI) 反查 `cabinets.id`,查不到 → `NotFound` +/// 2. **组织归属校验** `verify_cabinet_ownership`(与 `routes/cabinets.rs` 同一套隔离规则; +/// 总管理员 role_level=2 豁免) +/// 3. 设备禁用校验 +/// 4. `act` 白名单(见 [`CABINET_COMMAND_ACTS`]) +/// 5. 设备必须在线(与 `/api/h5/charge/start` 一致,复用 `NodeRegistry::is_device_online`) +/// 6. 协议 `id` 只表示动作目标、由调用方显式给出,**绝不再填 IMEI** pub async fn send_command( user: CurrentUser, State(state): State, Path(dev_id): Path, - Json(req): Json, + Json(mut req): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:operate")?; - // 检查设备是否被禁用 + // 1. IMEI → 柜子ID(归属校验必须落在 cabinets.id 上,故先反查) + let cabinet_id: Option = sqlx::query_scalar("SELECT id FROM cabinets WHERE imei = ?") + .bind(&dev_id) + .fetch_optional(&state.mysql) + .await + .map_err(AppError::Database)?; + let cabinet_id = + cabinet_id.ok_or_else(|| AppError::NotFound(format!("设备 {} 不存在", dev_id)))?; + + // 2. 组织数据隔离:非总管理员只能操作本组织项目下的柜子 + crate::routes::scope_guard::verify_cabinet_ownership(&state.mysql, &user, cabinet_id).await?; + + // 3. 设备禁用校验 let disabled: bool = sqlx::query_scalar( - r#"SELECT COUNT(*) > 0 FROM cabinets WHERE imei = ? AND status = 2"#, + r#"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2"#, ) - .bind(&dev_id) + .bind(cabinet_id) .fetch_one(&state.mysql) .await .map_err(AppError::Database)?; @@ -294,18 +377,44 @@ pub async fn send_command( return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into())); } - let cmd = DeviceCommand { - act: req.act, - msg_id: req.msg_id, - id: Some(dev_id.clone()), - params: req.params, - }; + // 4. `act` 白名单 + if !CABINET_COMMAND_ACTS.contains(&req.act.as_str()) { + return Err(AppError::BadRequest(format!( + "不支持的指令 act={},本接口仅允许: {}", + req.act, + CABINET_COMMAND_ACTS.join("/") + ))); + } + + // 5. 设备必须在线(平台对不在线的设备一律不投递) + if !state.nodes.is_device_online(&dev_id).await { + return Err(AppError::BadRequest("设备不在线".into())); + } + + // 6. 协议 `id`(动作目标):只从请求体显式取,**不再把 IMEI 塞进去** + let id = take_command_target(&mut req.params)?; + + // 仓体级动作(on/off)必须带目标,否则设备侧无法定位仓体(协议要求 `id` = 仓控板-通道) + if matches!(req.act.as_str(), "on" | "off") && id.is_none() { + return Err(AppError::BadRequest(format!( + "指令 act={} 必须显式给出目标 id(仓控板-通道,如 \"01-3\")", + req.act + ))); + } + + let cmd = DeviceCommand { act: req.act, msg_id: req.msg_id, id, params: req.params }; send_device_command(&state.redis, &dev_id, &cmd) .await .map_err(AppError::Internal)?; - tracing::info!("[command] 已推入队列 dev_id={} act={}", dev_id, cmd.act); + tracing::info!( + "[command] 已推入队列 dev_id={} cabinet_id={} act={} id={:?}", + dev_id, + cabinet_id, + cmd.act, + cmd.id + ); Ok(Json(json!({ "suc": 1 }))) } @@ -320,3 +429,59 @@ pub async fn device_stats( "node_count": nodes.len(), }))) } + +// --------------------------------------------------------------------------- +// 单元测试:C2 协议 `id`(动作目标)的校验与摘除 +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 合法形态:一段 = 单仓控板(`"01"`)、两段 = 仓体级(`"01-3"`),每段 1~2 位数字 + #[test] + fn test_is_valid_command_target_accepts_board_and_compartment() { + for ok in ["01", "1", "6", "01-3", "1-3", "12-34"] { + assert!(is_valid_command_target(ok), "{} 应为合法目标", ok); + } + } + + /// IMEI(15 位纯数字)与其它畸形值一律非法 —— 协议 `id` **不含 IMEI** + #[test] + fn test_is_valid_command_target_rejects_imei_and_malformed() { + for bad in ["864720562557021", "", "-", "01-", "-3", "01-3-2", "0a", "123", "01 -3"] { + assert!(!is_valid_command_target(bad), "{} 应为非法目标", bad); + } + } + + /// 显式给出 `id` 时取出,并把它从 `params` 中摘除 + /// (否则 `#[serde(flatten)]` 会在序列化时覆盖结构体的显式 `id` 字段) + #[test] + fn test_take_command_target_extracts_and_removes_id() { + let mut params = Some(json!({ "id": "01-3", "duration": 60 })); + let id = take_command_target(&mut params).unwrap(); + assert_eq!(id.as_deref(), Some("01-3")); + + let obj = params.as_ref().and_then(|p| p.as_object()).unwrap(); + assert!(!obj.contains_key("id"), "id 必须从 params 摘除"); + assert_eq!(obj["duration"], 60, "其它参数必须原样保留"); + } + + /// 柜级指令不带 `id` → `None`(**绝不回填 IMEI**) + #[test] + fn test_take_command_target_absent_returns_none() { + assert_eq!(take_command_target(&mut None).unwrap(), None); + assert_eq!(take_command_target(&mut Some(json!({ "id": null }))).unwrap(), None); + assert_eq!(take_command_target(&mut Some(json!({ "board": 1 }))).unwrap(), None); + } + + /// 非法目标(IMEI / 非字符串)必须报错,不得下发 + #[test] + fn test_take_command_target_rejects_invalid_id() { + let mut imei = Some(json!({ "id": "864720562557021" })); + assert!(matches!(take_command_target(&mut imei), Err(AppError::BadRequest(_)))); + + let mut numeric = Some(json!({ "id": 3 })); + assert!(matches!(take_command_target(&mut numeric), Err(AppError::BadRequest(_)))); + } +} diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index a3bcb6f..41f34d5 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -277,6 +277,18 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { // 兼容已有数据库:新增字段(幂等,列已存在则跳过) add_columns_if_not_exists(pool).await?; + // P0-2 的 DB 层兜底:**同一仓体最多一笔活跃订单**(`status = 0`)的唯一约束。 + // + // ⚠️ 这一步的失败**绝不能中断启动**:`run()` 返回 Err 会让 main panic 退出(生产事故), + // 而业务侧已经有「按仓体的进程内锁 + 条件插入」两层防护,DB 约束只是第三层兜底。 + // 因此这里只记 ERROR 后继续(与上方 `ALTER TABLE cabinets ...` 的容错口径一致)。 + if let Err(e) = ensure_active_order_unique(pool).await { + tracing::error!( + "[migrate] 活跃订单唯一约束迁移失败(服务继续启动;业务侧仍有进程内锁+条件插入兜底)err={}", + e + ); + } + // 初始化默认权限码(INSERT IGNORE 避免重复插入) seed_permissions(pool).await?; // 初始化默认角色 @@ -325,6 +337,109 @@ async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> { Ok(()) } +/// DB 层兜底:**同一仓体最多只能有一笔活跃订单**(`status = 0`)的唯一约束。 +/// +/// # 为什么需要(P0-2) +/// `workers/report_worker.rs` 的 `ensure_charge_order` 原本是"先查活跃单、再插入": +/// 判定与插入之间没有事务、没有锁、也没有唯一键,并发两次建单 +/// (H5 双击/重试、两人同时点、设备上报 `on` 与平台下发 `on` 撞在一起) +/// 会各插一笔 `status = 0`。后果:结单只收最新一笔、老单**永久卡在 `status = 0`**, +/// 还会被 `order_timeout` 扫到后**把正在充电的新单当超时单结束**, +/// 一次充电会话产生两条 `charge_records`("今日充电次数"虚增)。 +/// +/// 代码侧已加「按仓体的进程内锁 + 条件插入」,本迁移是**第三层(DB 层)兜底**: +/// 即使将来有人绕过封装直接 `INSERT`,也写不出第二笔活跃订单。 +/// +/// # 方案:生成列 + 唯一索引(MySQL 不支持条件唯一索引) +/// ```sql +/// ALTER TABLE charge_orders ADD COLUMN active_flag BIGINT +/// GENERATED ALWAYS AS (CASE WHEN status = 0 THEN compartment_id ELSE NULL END) STORED; +/// CREATE UNIQUE INDEX uk_charge_orders_active ON charge_orders(active_flag); +/// ``` +/// `status <> 0`(已完成/异常)的行 `active_flag = NULL`,而 **MySQL 唯一索引不约束 NULL** +/// (多行 NULL 可以共存)—— 正是需要的语义:历史订单可以有任意多笔, +/// **活跃订单同一仓体只能有一笔**。 +/// +/// # 幂等与安全(三步各自独立可重入) +/// 1. 索引已存在 ⇒ 直接返回(OLD 库/第二次启动都不会重复 DDL,避免每次启动重建整表); +/// 2. 加约束前**先预检重复活跃单**:存在重复就**只记 ERROR、放弃本次 DDL** +/// (强行加约束会报 1062;脏数据必须先人工清理,不能让迁移失败); +/// 3. 生成列已存在但索引缺失(上次"加列成功、建索引失败")⇒ 只补建索引,不重复 `ALTER`。 +/// +/// 失败一律上抛给 [`run`],由 `run` 记 ERROR 后**继续启动**(绝不让服务起不来)。 +async fn ensure_active_order_unique(pool: &MySqlPool) -> Result<(), sqlx::Error> { + /// 目标表名(仅用于 information_schema 查询与日志) + const TABLE: &str = "charge_orders"; + /// 生成列名(活跃订单哨兵) + const COLUMN: &str = "active_flag"; + /// 唯一索引名(幂等判定依据) + const INDEX: &str = "uk_charge_orders_active"; + + // 1) 索引已存在 ⇒ 幂等跳过 + let index_exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM information_schema.statistics \ + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", + ) + .bind(TABLE) + .bind(INDEX) + .fetch_one(pool) + .await?; + + if index_exists.0 > 0 { + return Ok(()); + } + + // 2) 预检:已存在重复活跃单时**不能**加唯一约束,只能记 ERROR 交人工处理 + let duplicates: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM ( \ + SELECT compartment_id FROM charge_orders WHERE status = 0 \ + GROUP BY compartment_id HAVING COUNT(*) > 1 \ + ) AS dup", + ) + .fetch_one(pool) + .await?; + + if duplicates.0 > 0 { + tracing::error!( + "[migrate] 检测到 {} 个仓体存在多笔活跃订单(charge_orders.status=0),\ + 已跳过 {} 唯一约束;请先人工清理重复活跃单(保留最新一笔、其余置为结束态)再重启服务", + duplicates.0, + INDEX + ); + return Ok(()); + } + + // 3) 生成列(幂等:列已存在则只补建索引) + let column_exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + ) + .bind(TABLE) + .bind(COLUMN) + .fetch_one(pool) + .await?; + + if column_exists.0 == 0 { + sqlx::query( + "ALTER TABLE charge_orders ADD COLUMN active_flag BIGINT \ + GENERATED ALWAYS AS (CASE WHEN status = 0 THEN compartment_id ELSE NULL END) STORED \ + COMMENT '活跃订单唯一性哨兵:status=0 时=compartment_id,否则 NULL(NULL 不参与唯一性)'", + ) + .execute(pool) + .await?; + tracing::info!("[migrate] 新增生成列 {}.{}", TABLE, COLUMN); + } + + sqlx::query("CREATE UNIQUE INDEX uk_charge_orders_active ON charge_orders(active_flag)") + .execute(pool) + .await?; + tracing::info!( + "[migrate] 已创建活跃订单唯一约束 {}(同一仓体最多一笔 status=0)", + INDEX + ); + Ok(()) +} + /// 列级幂等迁移:列不存在时才 ALTER TABLE 补列 /// /// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」, diff --git a/software/api-server/src/middleware/auth.rs b/software/api-server/src/middleware/auth.rs index d76935c..f854507 100644 --- a/software/api-server/src/middleware/auth.rs +++ b/software/api-server/src/middleware/auth.rs @@ -160,7 +160,48 @@ pub fn check_permission(user: &CurrentUser, code: &str) -> Result<(), AppError> } } +/// 总管理员角色等级(`users.role`,与 `roles` 表的 `super_admin` 对应) +pub const SUPER_ADMIN_ROLE_LEVEL: i32 = 2; + +/// 内置角色名常量(与 `db/migrate.rs::insert_default_roles` 的种子角色名严格一致) +const ROLE_NAME_NORMAL_USER: &str = "normal_user"; +const ROLE_NAME_ORG_ADMIN: &str = "org_admin"; +const ROLE_NAME_SUPER_ADMIN: &str = "super_admin"; + +/// `users.role` → 内置角色名(纯函数,便于单测) +/// +/// 映射关系与 `db/migrate.rs` 的种子角色一一对应: +/// - `0` 操作员 → `normal_user` +/// - `1` 企业管理员 → `org_admin` +/// - `2` 总管理员 → `super_admin` +/// - 其它值 → `None`(不产生映射,由调用方决定兜底策略) +/// +/// 为什么需要它:`users.role` 是**权威的角色等级列**(登录、越权校验都用它), +/// 而 `user_roles` 只是"多角色扩展"的关联表;两者必须能互相推导, +/// 否则一旦关联表为空,用户就会掉权限(见 [`load_user_permissions`] 的兜底说明)。 +pub fn builtin_role_name(role_level: i32) -> Option<&'static str> { + match role_level { + 0 => Some(ROLE_NAME_NORMAL_USER), + 1 => Some(ROLE_NAME_ORG_ADMIN), + 2 => Some(ROLE_NAME_SUPER_ADMIN), + _ => None, + } +} + /// 加载用户权限码集合(公开接口,供路由处理器调用) +/// +/// **两条来源,互为兜底:** +/// 1. 首选 `user_roles` → `role_permissions` → `permissions`(精确的角色分配, +/// 即 `routes/users_perm.rs::set_user_roles` 写入的结果,**优先级最高**); +/// 2. 若 (1) 查不到任何权限码(`user_roles` 中该用户没有任何行), +/// 则按 `users.role` 回退到对应内置角色的权限集。 +/// +/// **为什么必须有 (2) 这个兜底(不是"顺手加的功能"):** +/// 历史数据里 `user_roles` 大量为空 —— 早先的 `POST/PUT /api/users` 只写 `users.role`, +/// 从不写 `user_roles`;于是新建的「操作员(role=0)/企业管理员(role=1)」登录后权限集为空, +/// 所有接口都回 403「缺少权限: xxx」,前端按钮全被隐藏(小程序微信登录同理)。 +/// 回退到 `users.role` 对应的内置角色集后,历史数据无需刷库即可恢复可用; +/// 而总管理员(`role >= 2`)直接返回全部权限码,与 `check_permission` 的短路语义一致。 pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result, sqlx::Error> { let rows: Vec<(String,)> = sqlx::query_as( r#" @@ -175,6 +216,59 @@ pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result= 2`(总管理员)→ 全部权限码(与 `check_permission` 的短路一致, +/// 也让 `/api/users/:id/permissions` 能如实展示超管的完整权限); +/// - `role = 0/1` → 查同名内置角色在 `role_permissions` 里配置的权限码; +/// - 用户不存在 / 未知 role 值 → 空集(失败安全:不给任何权限)。 +async fn load_builtin_role_permissions( + pool: &MySqlPool, + user_id: i64, +) -> Result, sqlx::Error> { + let role_level: Option<(i32,)> = sqlx::query_as("SELECT role FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + let Some((role_level,)) = role_level else { + return Ok(HashSet::new()); + }; + + if role_level >= SUPER_ADMIN_ROLE_LEVEL { + let rows: Vec<(String,)> = sqlx::query_as("SELECT code FROM permissions") + .fetch_all(pool) + .await?; + return Ok(rows.into_iter().map(|(code,)| code).collect()); + } + + let Some(role_name) = builtin_role_name(role_level) else { + tracing::warn!(user_id, role_level, "未知角色等级,按空权限集处理"); + return Ok(HashSet::new()); + }; + + let rows: Vec<(String,)> = sqlx::query_as( + r#" + SELECT DISTINCT p.code + FROM permissions p + INNER JOIN role_permissions rp ON rp.permission_id = p.id + INNER JOIN roles r ON r.id = rp.role_id + WHERE r.name = ? + "#, + ) + .bind(role_name) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(code,)| code).collect()) } @@ -354,4 +448,74 @@ mod tests { let result = verify_password("test123", "not-a-valid-hash"); assert!(result.is_err(), "无效哈希应返回错误"); } + + // ----------------------------------------------------------------------- + // users.role → 内置角色名(权限兜底映射) + // ----------------------------------------------------------------------- + + /// role=0(操作员)应映射到内置角色 normal_user + #[test] + fn test_builtin_role_name_operator_maps_to_normal_user() { + assert_eq!(builtin_role_name(0), Some("normal_user")); + } + + /// role=1(企业管理员)应映射到内置角色 org_admin + #[test] + fn test_builtin_role_name_org_admin_maps_to_org_admin() { + assert_eq!(builtin_role_name(1), Some("org_admin")); + } + + /// role=2(总管理员)应映射到内置角色 super_admin + #[test] + fn test_builtin_role_name_super_admin_maps_to_super_admin() { + assert_eq!(builtin_role_name(2), Some("super_admin")); + } + + /// 非法 role 值(负数/越界)应返回 None,不得误映射到某个内置角色 + #[test] + fn test_builtin_role_name_invalid_level_returns_none() { + assert_eq!(builtin_role_name(-1), None); + assert_eq!(builtin_role_name(3), None); + assert_eq!(builtin_role_name(i32::MAX), None); + } + + /// 映射表覆盖 0..=2 全部合法角色等级(防止将来漏改分支) + #[test] + fn test_builtin_role_name_covers_all_valid_levels() { + for level in 0..=SUPER_ADMIN_ROLE_LEVEL { + assert!( + builtin_role_name(level).is_some(), + "合法角色等级 {} 必须有内置角色映射", + level + ); + } + } + + /// 角色等级 >=2 的权限集兜底分支:check_permission 必须先短路放行, + /// 且超管等级常量与内置角色名映射保持一致 + #[test] + fn test_super_admin_role_level_constant_matches_mapping() { + assert_eq!(builtin_role_name(SUPER_ADMIN_ROLE_LEVEL), Some("super_admin")); + let user = CurrentUser { + user_id: 1, + phone: "13800138000".into(), + role_level: SUPER_ADMIN_ROLE_LEVEL, + organization_id: None, + permissions: HashSet::new(), + }; + assert!(check_permission(&user, "device:view").is_ok(), "超管应短路放行"); + } + + /// 非超管的空权限集仍必须被拒绝(兜底不能变成"人人放行") + #[test] + fn test_check_permission_operator_empty_permissions_forbidden() { + let user = CurrentUser { + user_id: 4, + phone: "13800138003".into(), + role_level: 0, + organization_id: Some(1), + permissions: HashSet::new(), + }; + assert!(check_permission(&user, "device:view").is_err(), "空权限集应拒绝"); + } } diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index 66dc934..8ff1f94 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -8,7 +8,7 @@ use axum::extract::{State, Query}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode}; use crate::error::AppError; @@ -507,102 +507,146 @@ async fn load_device_status( }) } -/// 扫描并组装全部仓控板状态(板级 + 通道级) +/// 通道级 key `device:{imei}:board:{板号}:ch:{通道号}` 的段数(板号/通道号均十进制不补零) +const CHANNEL_KEY_SEGMENTS: usize = 6; +/// 仓控板号量程:协议 §3.7「仓控板 1–15」 +const BOARD_ID_MIN: i64 = 1; +const BOARD_ID_MAX: i64 = 15; +/// 通道号量程:协议 §3.7「通道 1–8」(不是 0–7) +const CHANNEL_ID_MIN: i64 = 1; +const CHANNEL_ID_MAX: i64 = 8; + +/// 从通道级 key 解析 `(板号, 通道号)`:段数为 6 / 第 3 段是 `board` / 第 5 段是 `ch` / +/// 板号在 1–15、通道号在 1–8 才算合法,否则 `None`。 +/// +/// 为什么必须按段解析:`KEYS device:{imei}:board:*:ch:*` 会把内部键 +/// `...:ch:{通道号}:bat`(电池台账)与 `...:ch:{通道号}:order`(订单电量累加器)也扫出来, +/// 按段校验能把它们挡掉,**不会生成幽灵板/幽灵通道**。 +fn parse_channel_key(key: &str) -> Option<(i64, i64)> { + let parts: Vec<&str> = key.split(':').collect(); + if parts.len() != CHANNEL_KEY_SEGMENTS + || parts.get(2) != Some(&"board") + || parts.get(4) != Some(&"ch") + { + return None; + } + let board_no: i64 = parts.get(3)?.parse().ok()?; + let channel_no: i64 = parts.get(5)?.parse().ok()?; + if !(BOARD_ID_MIN..=BOARD_ID_MAX).contains(&board_no) + || !(CHANNEL_ID_MIN..=CHANNEL_ID_MAX).contains(&channel_no) + { + return None; + } + Some((board_no, channel_no)) +} + +/// 扫描并组装全部仓控板状态(板级 + 通道级)。 +/// +/// ⚠️ 设备上报链路**只写通道级 key**(`device:{imei}:board:{板号}:ch:{通道号}`, +/// 见 `workers/report_worker.rs::write_channel_state`),**新协议里根本没有板级 hash** +/// (老实现的 `device:{imei}:board:{板号}.status_hex` 已不复存在)。 +/// 旧代码按 `KEYS device:{imei}:board:*` 取"板级 key",实际扫到的全是 `...:ch:{通道号}` +/// 这类 key,把**通道号当成了板号** → 返回一堆幽灵板(每个还都没有 channels), +/// 这也是本接口"永远返回空"的另一半原因。 +/// 现在改为扫描通道级 key、**按解析出的板号分组**,再由通道数据组装板级视图。 async fn load_boards_status( conn: &mut redis::aio::MultiplexedConnection, imei: &str, ) -> Value { - // 扫描板级key: device:{imei}:board:* - let board_pattern = format!("device:{}:board:*", imei); - let board_keys: Vec = redis::cmd("KEYS") - .arg(&board_pattern) - .query_async(&mut *conn) - .await - .unwrap_or_default(); - - let mut boards = json!({}); - for board_key in &board_keys { - // 提取board_idx - let board_idx = board_key.split(":").last().unwrap_or(""); - if board_idx.is_empty() { - continue; - } - - boards[board_idx] = load_single_board(conn, board_key).await; - } - - boards -} - -/// 读取单块仓控板的板级字段,并扫描其下的通道级 key -async fn load_single_board( - conn: &mut redis::aio::MultiplexedConnection, - board_key: &str, -) -> Value { - // 获取板级字段 - let board_fields = vec!["status_hex"]; - let board_values: Vec> = redis::cmd("HMGET") - .arg(board_key) - .arg(&board_fields) - .query_async(&mut *conn) - .await - .unwrap_or_default(); - - let mut board_data = json!({ - "status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), - }); - - // 扫描通道级key: device:{imei}:board:{idx}:ch:* - let ch_pattern = format!("{}:ch:*", board_key); + let ch_pattern = format!("device:{}:board:*:ch:*", imei); let ch_keys: Vec = redis::cmd("KEYS") .arg(&ch_pattern) .query_async(&mut *conn) .await .unwrap_or_default(); - let mut channels = json!({}); - for ch_key in &ch_keys { - let ch_idx = ch_key.split(":").last().unwrap_or(""); - if ch_idx.is_empty() { - continue; + // 板号 → [(通道号, key)]:BTreeMap 保证板号升序,输出顺序稳定 + let mut grouped: BTreeMap> = BTreeMap::new(); + for key in ch_keys { + if let Some((board_no, channel_no)) = parse_channel_key(&key) { + grouped.entry(board_no).or_default().push((channel_no, key)); } - - channels[ch_idx] = load_channel_status(conn, ch_key).await; } - board_data["channels"] = channels; - board_data + let mut boards = json!({}); + for (board_no, mut entries) in grouped { + entries.sort_by_key(|(channel_no, _)| *channel_no); + let board = load_board_status(conn, &entries).await; + boards[board_no.to_string().as_str()] = board; + } + + boards } -/// 读取单个通道状态(状态映射优先级:fault > full > on > idle) -async fn load_channel_status( +/// 由**已排序**的通道级 key 组装单块仓控板的响应(板级 + 通道级)。 +async fn load_board_status( conn: &mut redis::aio::MultiplexedConnection, - ch_key: &str, + entries: &[(i64, String)], ) -> Value { - let ch_fields = vec!["on", "full", "fault", "hex", "temp"]; - let ch_values: Vec> = redis::cmd("HMGET") - .arg(ch_key) - .arg(&ch_fields) - .query_async(&mut *conn) - .await - .unwrap_or_default(); + let mut channels = json!({}); + let mut status_hex = String::new(); + for (channel_no, ch_key) in entries { + let channel = load_channel_status(conn, ch_key).await; + // 板级 `status_hex` 已废弃(新协议板级没有状态字节): + // 用各通道的 `batt_status + pow_status`(各 2 位 hex)按**通道号升序**拼回, + // 与旧格式 `0001000200...` 同构,语义完全由真实字段解释 —— **字段名保持不变**。 + if let Some(hex) = channel.get("hex").and_then(|v| v.as_str()) { + status_hex.push_str(hex); + } + channels[channel_no.to_string().as_str()] = channel; + } - // 状态映射:fault > full > on > idle - let fault = ch_values - .get(0) - .and_then(|v| v.as_ref()) - .map(|v| v == "1") - .unwrap_or(false); - let full = ch_values - .get(1) - .and_then(|v| v.as_ref()) - .map(|v| v == "1") - .unwrap_or(false); - let on = ch_values - .get(2) - .and_then(|v| v.as_ref()) - .map(|v| v == "1") - .unwrap_or(false); + json!({ + "status_hex": status_hex, + "channels": channels, + }) +} + +/// 电池状态位(协议 §3.8 `batt_status`):bit0 充满 / bit1 正在充电 / +/// bit4 过欠压 / bit5 过流 / bit6 热失控 / bit7 有电池 +const BATT_FULL_MASK: u8 = 0x01; +const BATT_CHARGING_MASK: u8 = 0x02; +const BATT_FAULT_MASK: u8 = 0x10 | 0x20 | 0x40; +/// 电源状态位(协议 §3.8 `pow_status`):bit0 电源故障 +const POW_FAULT_MASK: u8 = 0x01; + +/// 2 位(兼容 1 位)hex → u8;字段缺失 / 长度超 2 / 非 hex → `None` +/// (与 `web/src/utils/deviceStatus.ts::parseHexByte` 口径一致) +fn parse_hex_byte(raw: Option<&str>) -> Option { + let text = raw?.trim(); + if text.is_empty() || text.len() > 2 { + return None; + } + u8::from_str_radix(text, 16).ok() +} + +/// 纯函数:把通道的**真实字段**组装成响应 JSON。 +/// +/// **响应字段名与旧实现完全一致**(前端在消费),但取值改为由真实字段推导: +/// - `on` = `batt_status` bit1(0x02 正在充电) +/// - `full` = `batt_status` bit0(0x01 充满) +/// - `fault` = `batt_status` bit4/5/6(过欠压 / 过流 / 热失控)**或** `pow_status` bit0(电源故障) +/// - `hex` = `batt_status` + `pow_status`(即通道 18 字符 hex 的前 4 个字符;缺任一字节则为空串) +/// - `status` = `fault` > `full` > `charging` > `idle`(与旧实现的优先级顺序一致) +/// - `temp` = 原样透传(单位 **K×100**,由前端换算,见 `web/.../cabinet.tsx`) +/// 另外**新增** `batt_status` / `pow_status` / `soc`(‰)/ `voltage`(mV)/ `current`(mA) +/// 便于管理端展示真实语义(新增字段不影响既有字段名)。 +/// 位定义与前端 `web/src/utils/deviceStatus.ts` 完全一致(两端口径必须一致)。 +fn derive_channel_view( + batt_raw: Option<&str>, + pow_raw: Option<&str>, + soc: Option, + voltage: Option, + current: Option, + temp: Option, +) -> Value { + let batt = parse_hex_byte(batt_raw); + let pow = parse_hex_byte(pow_raw); + + let full = batt.is_some_and(|b| b & BATT_FULL_MASK != 0); + let on = batt.is_some_and(|b| b & BATT_CHARGING_MASK != 0); + let fault = batt.is_some_and(|b| b & BATT_FAULT_MASK != 0) + || pow.is_some_and(|p| p & POW_FAULT_MASK != 0); let status = if fault { "fault" @@ -614,12 +658,227 @@ async fn load_channel_status( "idle" }; + // 统一输出小写 2 位 hex(与 `report_worker` 的写入格式一致) + let batt_status = batt.map(|b| format!("{:02x}", b)).unwrap_or_default(); + let pow_status = pow.map(|p| format!("{:02x}", p)).unwrap_or_default(); + let hex = if batt_status.is_empty() || pow_status.is_empty() { + String::new() + } else { + format!("{}{}", batt_status, pow_status) + }; + json!({ "status": status, "on": on, "full": full, "fault": fault, - "hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), - "temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()), + "hex": hex, + "temp": temp, + "batt_status": batt_status, + "pow_status": pow_status, + "soc": soc, + "voltage": voltage, + "current": current, }) } + +/// 读取单个通道状态。 +/// +/// ⚠️ 旧实现读的是 `on` / `full` / `fault` / `hex`,但设备上报链路 +/// (`workers/report_worker.rs::write_channel_state`)**只写**: +/// `batt_status` / `pow_status`(各 2 位 hex)/ `soc`(‰)/ `voltage`(mV)/ +/// `current`(mA)/ `temp`(K×100)—— 旧字段根本不存在,于是本接口恒返回 +/// `status:"idle"` + 三个 `false` + `hex:""`,管理端"设备实时详情"一直是坏的。 +/// 现在读**真实存在**的字段,再由它们推导旧字段(字段名保持不变,推导规则见 [`derive_channel_view`])。 +async fn load_channel_status( + conn: &mut redis::aio::MultiplexedConnection, + ch_key: &str, +) -> Value { + let ch_fields = vec!["batt_status", "pow_status", "soc", "voltage", "current", "temp"]; + let ch_values: Vec> = redis::cmd("HMGET") + .arg(ch_key) + .arg(&ch_fields) + .query_async(&mut *conn) + .await + .unwrap_or_default(); + + let text = |idx: usize| -> Option<&str> { + ch_values.get(idx).and_then(|v| v.as_ref()).map(|v| v.as_str()) + }; + let number = |idx: usize| -> Option { text(idx).and_then(|v| v.parse::().ok()) }; + + derive_channel_view( + text(0), + text(1), + number(2), + number(3), + number(4), + number(5), + ) +} + +// --------------------------------------------------------------------------- +// 单元测试:通道状态推导(协议 §3.8,口径必须与 web 端 deviceStatus.ts 一致) +// +// 只测纯函数(不连 Redis / DB):这一层是"设备实时详情"接口正确性的关键, +// 因为它负责把 `batt_status` / `pow_status` 翻译成前端消费的旧字段名。 +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + + /// 交付文档 §5.3 示例 `828255152c01f40143`:有电池 + 正在充电、电源已打开 + #[test] + fn test_derive_channel_view_charging_channel_sets_on_true() { + let view = derive_channel_view(Some("82"), Some("82"), Some(850), Some(54200), Some(5000), Some(32300)); + assert_eq!(view["status"], "charging"); + assert_eq!(view["on"], true); + assert_eq!(view["full"], false); + assert_eq!(view["fault"], false); + assert_eq!(view["hex"], "8282"); + assert_eq!(view["batt_status"], "82"); + assert_eq!(view["pow_status"], "82"); + assert_eq!(view["soc"], 850); + assert_eq!(view["voltage"], 54200); + assert_eq!(view["current"], 5000); + assert_eq!(view["temp"], 32300); + } + + /// 充满(`batt_status` bit0)+ 仍在报充电位 → `status` 取「充满」(优先级 full > charging) + #[test] + fn test_derive_channel_view_full_battery_prefers_full_over_charging() { + let view = derive_channel_view(Some("83"), Some("82"), None, None, None, None); + assert_eq!(view["full"], true); + assert_eq!(view["on"], true); + assert_eq!(view["status"], "full"); + } + + /// `batt_status` 的过欠压 / 过流 / 热失控位(bit4/5/6)任一置位 → 故障 + #[test] + fn test_derive_channel_view_batt_fault_bits_map_to_fault() { + for (batt, other) in [("10", "00"), ("20", "00"), ("40", "00"), ("10", "82")] { + let view = derive_channel_view(Some(batt), Some(other), None, None, None, None); + assert_eq!(view["fault"], true, "batt={batt} pow={other}"); + assert_eq!(view["status"], "fault", "batt={batt} pow={other}"); + } + // 故障优先级最高:即使同时"充满 + 正在充电",仍是 fault + let view = derive_channel_view(Some("43"), Some("00"), None, None, None, None); + assert_eq!(view["fault"], true); + assert_eq!(view["status"], "fault"); + } + + /// `pow_status` bit0 = 电源故障 → 通道判故障(与电池状态相互独立) + #[test] + fn test_derive_channel_view_pow_fault_maps_to_fault() { + let view = derive_channel_view(Some("82"), Some("01"), None, None, None, None); + assert_eq!(view["fault"], true); + assert_eq!(view["status"], "fault"); + assert_eq!(view["hex"], "8201"); + } + + /// **空仓**:无电池(batt `00`)+ 电源已打开(pow `82`)→ 语义仍是 idle, + /// 但电源状态照实返回(两维度独立,协议 §3.8) + #[test] + fn test_derive_channel_view_empty_channel_keeps_power_status() { + let view = derive_channel_view(Some("00"), Some("82"), Some(0), Some(0), Some(0), Some(0)); + assert_eq!(view["status"], "idle"); + assert_eq!(view["on"], false); + assert_eq!(view["full"], false); + assert_eq!(view["fault"], false); + assert_eq!(view["hex"], "0082"); + assert_eq!(view["pow_status"], "82"); + } + + /// 字段整体缺失(老数据 / 通道刚建立)→ 全部落空,**不得 panic、不得拼出半个字节** + #[test] + fn test_derive_channel_view_missing_fields_yields_idle_and_empty_hex() { + let view = derive_channel_view(None, None, None, None, None, None); + assert_eq!(view["status"], "idle"); + assert_eq!(view["on"], false); + assert_eq!(view["full"], false); + assert_eq!(view["fault"], false); + assert_eq!(view["hex"], ""); + assert_eq!(view["batt_status"], ""); + assert_eq!(view["pow_status"], ""); + assert_eq!(view["soc"], Value::Null); + assert_eq!(view["temp"], Value::Null); + } + + /// 只有一个字节(例如电源状态缺失)→ `hex` 必须为空串,而不是只拼一半 + #[test] + fn test_derive_channel_view_single_byte_yields_empty_hex() { + let view = derive_channel_view(Some("82"), None, None, None, None, None); + assert_eq!(view["hex"], ""); + assert_eq!(view["batt_status"], "82"); + assert_eq!(view["status"], "charging"); + } + + /// hex 大小写不敏感 + 兼容 1 位写法,输出统一补零小写(与 web 端 `parseHexByte` 一致) + #[test] + fn test_derive_channel_view_normalizes_hex_case_and_padding() { + let view = derive_channel_view(Some("8A"), Some("A"), None, None, None, None); + assert_eq!(view["batt_status"], "8a"); + assert_eq!(view["pow_status"], "0a"); + assert_eq!(view["hex"], "8a0a"); + } + + /// hex 解析:长度超 2 / 非 hex / 空白 → `None` + #[test] + fn test_parse_hex_byte_rejects_malformed_values() { + assert_eq!(parse_hex_byte(None), None); + assert_eq!(parse_hex_byte(Some("")), None); + assert_eq!(parse_hex_byte(Some(" ")), None); + assert_eq!(parse_hex_byte(Some("123")), None); + assert_eq!(parse_hex_byte(Some("zz")), None); + assert_eq!(parse_hex_byte(Some("82")), Some(0x82)); + assert_eq!(parse_hex_byte(Some(" 82 ")), Some(0x82)); + } + + // ---- parse_channel_key:通道级 key 分组(板号 1–15 / 通道号 1–8)------- + + /// 正常通道 key → (板号, 通道号) + #[test] + fn test_parse_channel_key_accepts_real_channel_keys() { + assert_eq!( + parse_channel_key("device:864720562557021:board:1:ch:3"), + Some((1, 3)) + ); + assert_eq!( + parse_channel_key("device:864720562557021:board:15:ch:8"), + Some((15, 8)) + ); + } + + /// **内部键必须被挡掉**:`...:ch:{通道号}:bat`(电池台账)/ `...:ch:{通道号}:order`(电量累加器) + /// 若被当成通道,会生成幽灵板(旧实现就是按 KEYS 末段取"板号"的) + #[test] + fn test_parse_channel_key_rejects_internal_keys() { + assert_eq!( + parse_channel_key("device:864720562557021:board:1:ch:3:bat"), + None + ); + assert_eq!( + parse_channel_key("device:864720562557021:board:1:ch:3:order"), + None + ); + // 旧实现会扫到这种"板级 key"(其实不存在),现在也必须拒绝 + assert_eq!(parse_channel_key("device:864720562557021:board:1"), None); + assert_eq!(parse_channel_key("device:864720562557021"), None); + assert_eq!(parse_channel_key(""), None); + } + + /// 量程与结构校验:板号 0/16、通道号 0/9、非数字、段内关键字写错都拒绝 + #[test] + fn test_parse_channel_key_rejects_out_of_range_and_malformed() { + assert_eq!(parse_channel_key("device:864720562557021:board:0:ch:3"), None); + assert_eq!(parse_channel_key("device:864720562557021:board:16:ch:3"), None); + assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:0"), None); + assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:9"), None); + assert_eq!(parse_channel_key("device:864720562557021:board:a:ch:3"), None); + assert_eq!(parse_channel_key("device:864720562557021:board:1:xx:3"), None); + assert_eq!( + parse_channel_key("device:864720562557021:brd:1:ch:3"), + None + ); + } +} diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index b5897ea..b7be46f 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -589,6 +589,9 @@ pub async fn get_cabinet_detail( "rssi": realtime.rssi, "pow_fail_dc": realtime.pow_fail_dc, "pow_fail_ac": realtime.pow_fail_ac, + // 柜温:Redis `device:{imei}.temp`,语义 K×100(与柜级 temp 口径一致); + // 取不到为 null(前端显示 `--°C`),不落 0 + "temp": realtime.temp, "address": cabinet.address, "latitude": cabinet.latitude, "longitude": cabinet.longitude, @@ -603,17 +606,47 @@ pub async fn get_cabinet_detail( // --------------------------------------------------------------------------- /// 详情页实时状态快照;Redis 不可用时整体为默认空值 +/// +/// 所有字段一律 `Option`:Redis 里取不到就保持 `None`(响应 JSON 为 `null`), +/// **绝不落成 0/空串** —— 0 是"真实读数为 0",与"无数据"必须区分。 #[derive(Default)] struct CabinetRealtime { rssi: Option, pow_fail_dc: Option, pow_fail_ac: Option, + /// 柜温,平台单位 K×100(Redis `device:{imei}` 的 `temp`) + temp: Option, /// 仓体 id -> (SOC, 电池ID) soc_map: std::collections::HashMap, Option)>, - /// 仓体 id -> (温度, batt_status, pow_status) - ch_map: std::collections::HashMap, Option, Option)>, + /// 仓体 id -> 通道级实时状态 + ch_map: std::collections::HashMap, } +/// 单通道实时状态(与 `report_worker::write_channel_state` 写入的 Hash 字段一一对应) +/// +/// 单位口径与 `docs/API.md` 一致:`soc` ‰ / `voltage` mV / `current` mA / `temp` K×100, +/// `batt_status` / `pow_status` 为 2 位 hex 字符串。 +#[derive(Default, Clone, Debug)] +struct ChannelRealtime { + /// 电芯最高温度,单位 K×100 + temp: Option, + /// 电池状态(2 位 hex) + batt_status: Option, + /// 电源状态(2 位 hex) + pow_status: Option, + /// 充电电压,单位 mV + voltage: Option, + /// 充电电流,单位 mA + current: Option, +} + +/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数:rssi / pow_fail_dc / pow_fail_ac / temp。 +/// 与 [`fetch_cabinet_realtime`] 下发的命令顺序、[`parse_cabinet_realtime`] 的下标解析 +/// **三者必须同步修改**,故抽成常量。 +const CABINET_REALTIME_FIELDS: usize = 4; +/// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current +const CHANNEL_REALTIME_FIELDS: usize = 5; + /// 查询仓控板与仓体,构建带实时字段占位的 JSON /// /// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`, @@ -658,6 +691,11 @@ async fn load_boards_with_compartments( "status": c.status, "batt_status": Value::Null, "pow_status": Value::Null, + // 通道级实时字段占位:Redis 缺失时保持 null(**不落 0**, + // 前端 `c.voltage ?? '-'` 据此显示"-"而不是"-mV") + "voltage": Value::Null, + "current": Value::Null, + "temp": Value::Null, }) }) .collect(); @@ -675,8 +713,12 @@ async fn load_boards_with_compartments( /// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态 /// -/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。 +/// 为什么放一起:详情接口要求低延迟,柜级 4 个字段 + 每通道 5 个字段共用一次网络往返。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 +/// +/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段: +/// 设备上报(`report_worker::write_device_state` / `write_channel_state`)早就写进了 Redis, +/// 只是接口没取,导致 PC 端柜温恒为 `--°C`、电压电流恒为 `-mV -mA`。 async fn fetch_cabinet_realtime( redis_url: &str, imei: &str, @@ -703,7 +745,8 @@ async fn fetch_cabinet_realtime( let mut pipe = redis::pipe(); pipe.cmd("HGET").arg(&key).arg("rssi") .cmd("HGET").arg(&key).arg("pow_fail_dc") - .cmd("HGET").arg(&key).arg("pow_fail_ac"); + .cmd("HGET").arg(&key).arg("pow_fail_ac") + .cmd("HGET").arg(&key).arg("temp"); for cid in comp_ids { pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); } @@ -712,7 +755,9 @@ async fn fetch_cabinet_realtime( let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id); pipe.cmd("HGET").arg(&rkey).arg("temp") .cmd("HGET").arg(&rkey).arg("batt_status") - .cmd("HGET").arg(&rkey).arg("pow_status"); + .cmd("HGET").arg(&rkey).arg("pow_status") + .cmd("HGET").arg(&rkey).arg("voltage") + .cmd("HGET").arg(&rkey).arg("current"); } // 用类型标注而非 turbofish:query_async 的泛型首参是连接类型 @@ -727,7 +772,10 @@ async fn fetch_cabinet_realtime( } } -/// 解析 pipeline 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status) +/// 解析 pipeline 结果:柜级 [`CABINET_REALTIME_FIELDS`] 项 + 每仓体 1 项(SOC JSON) +/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 项(temp/batt_status/pow_status/voltage/current) +/// +/// 任何一项缺失或解析失败 → 对应字段保持 `None`(**不落 0**)。 fn parse_cabinet_realtime( vals: &[Option], comp_ids: &[i64], @@ -737,16 +785,21 @@ fn parse_cabinet_realtime( fn pick(vals: &[Option], i: usize) -> Option { vals.get(i).and_then(|v| v.as_ref()).cloned() } + /// 取出第 i 项并解析为 i64(空值/非数字 → None) + fn pick_i64(vals: &[Option], i: usize) -> Option { + pick(vals, i).and_then(|v| v.trim().parse::().ok()) + } let mut rt = CabinetRealtime { rssi: pick(vals, 0).and_then(|v| v.parse().ok()), pow_fail_dc: pick(vals, 1).map(|v| v == "1"), pow_fail_ac: pick(vals, 2).map(|v| v == "1"), + temp: pick_i64(vals, 3), ..Default::default() }; for (i, cid) in comp_ids.iter().enumerate() { - if let Some(data) = pick(vals, 3 + i) { + if let Some(data) = pick(vals, CABINET_REALTIME_FIELDS + i) { if let Ok(v) = serde_json::from_str::(&data) { let soc = v.get("soc").and_then(|s| s.as_i64()); let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string); @@ -755,20 +808,26 @@ fn parse_cabinet_realtime( } } - // 通道级字段紧随 SOC 段之后,每通道 3 项 - let base = 3 + comp_ids.len(); + // 通道级字段紧随 SOC 段之后,每通道 CHANNEL_REALTIME_FIELDS 项 + let base = CABINET_REALTIME_FIELDS + comp_ids.len(); for (i, (cid, _)) in comp_to_board.iter().enumerate() { - let off = base + i * 3; - let temp = pick(vals, off).and_then(|v| v.parse::().ok()); - let batt_status = pick(vals, off + 1); - let pow_status = pick(vals, off + 2); - rt.ch_map.insert(*cid, (temp, batt_status, pow_status)); + let off = base + i * CHANNEL_REALTIME_FIELDS; + rt.ch_map.insert( + *cid, + ChannelRealtime { + temp: pick_i64(vals, off), + batt_status: pick(vals, off + 1), + pow_status: pick(vals, off + 2), + voltage: pick_i64(vals, off + 3), + current: pick_i64(vals, off + 4), + }, + ); } rt } -/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串 +/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串,也绝不落 0 fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { for board in cabin_boards.iter_mut() { let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else { @@ -786,16 +845,22 @@ fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { comp["battery_id"] = json!(b); } } - if let Some((temp, batt_status, pow_status)) = rt.ch_map.get(&cid) { - if let Some(t) = temp { + if let Some(ch) = rt.ch_map.get(&cid) { + if let Some(t) = ch.temp { comp["temp"] = json!(t); } - if let Some(bs) = batt_status { + if let Some(bs) = &ch.batt_status { comp["batt_status"] = json!(bs); } - if let Some(ps) = pow_status { + if let Some(ps) = &ch.pow_status { comp["pow_status"] = json!(ps); } + if let Some(v) = ch.voltage { + comp["voltage"] = json!(v); + } + if let Some(c) = ch.current { + comp["current"] = json!(c); + } } } } @@ -911,3 +976,129 @@ pub async fn list_compartment_options( Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } +// --------------------------------------------------------------------------- +// 单元测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// pipeline 结果解析:柜级 4 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局, + /// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取) + #[test] + fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() { + let comp_ids = vec![11_i64, 12_i64]; + let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))]; + let vals: Vec> = vec![ + // 柜级:rssi / pow_fail_dc / pow_fail_ac / temp + Some("85".into()), + Some("1".into()), + Some("0".into()), + Some("29815".into()), + // 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None) + None, + None, + // 通道 11:temp / batt_status / pow_status / voltage / current + Some("29815".into()), + Some("82".into()), + Some("03".into()), + Some("53200".into()), + Some("1500".into()), + // 通道 12 + Some("30015".into()), + Some("02".into()), + Some("01".into()), + Some("48000".into()), + Some("0".into()), + ]; + + let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); + + assert_eq!(rt.rssi, Some(85)); + assert_eq!(rt.pow_fail_dc, Some(true)); + assert_eq!(rt.pow_fail_ac, Some(false)); + assert_eq!(rt.temp, Some(29815), "柜温应被取出"); + + let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态"); + assert_eq!(ch11.voltage, Some(53200), "电压应被取出"); + assert_eq!(ch11.current, Some(1500), "电流应被取出"); + assert_eq!(ch11.temp, Some(29815)); + assert_eq!(ch11.batt_status.as_deref(), Some("82")); + assert_eq!(ch11.pow_status.as_deref(), Some("03")); + + let ch12 = rt.ch_map.get(&12).expect("通道 12 应有实时状态"); + // 0 是**真实读数**,必须原样保留(不能与"无数据"混为一谈) + assert_eq!(ch12.current, Some(0)); + assert_eq!(ch12.voltage, Some(48000)); + } + + /// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0) + #[test] + fn test_parse_cabinet_realtime_missing_fields_stay_none() { + let comp_ids = vec![7_i64]; + let comp_to_board = vec![(7_i64, (1_i8, 1_i8))]; + // 柜级 4 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 + let vals: Vec> = vec![ + None, + None, + None, + None, + None, + Some("29815".into()), + Some("82".into()), + Some("03".into()), + Some("abc".into()), + None, + ]; + + let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); + + assert_eq!(rt.temp, None); + assert_eq!(rt.rssi, None); + let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)"); + assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0"); + assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0"); + assert_eq!(ch.temp, Some(29815)); + } + + /// apply_realtime:只覆盖取到的值,取不到的字段保持占位 null + #[test] + fn test_apply_realtime_keeps_null_when_missing() { + let mut wrap = vec![json!({ + "compartments": [{ + "id": 11, + "channel_id": 1, + "status": 0, + "batt_status": Value::Null, + "pow_status": Value::Null, + "voltage": Value::Null, + "current": Value::Null, + "temp": Value::Null, + }] + })]; + + let mut ch_map = std::collections::HashMap::new(); + ch_map.insert( + 11_i64, + ChannelRealtime { + temp: None, + batt_status: Some("82".into()), + pow_status: None, + voltage: Some(53200), + current: None, + }, + ); + let rt = CabinetRealtime { ch_map, ..Default::default() }; + + apply_realtime(&mut wrap, &rt); + + let comp = &wrap[0]["compartments"][0]; + assert_eq!(comp["batt_status"], json!("82")); + assert_eq!(comp["voltage"], json!(53200)); + assert_eq!(comp["current"], Value::Null, "缺失电流必须保持 null"); + assert_eq!(comp["temp"], Value::Null, "缺失温度必须保持 null"); + assert_eq!(comp["pow_status"], Value::Null); + } +} + diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index f883061..48b8c00 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -879,6 +879,11 @@ const COMPARTMENT_BY_ID_SQL: &str = "SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?"; /// 仓体所属仓控板 → 柜子 const CABIN_BOARD_CABINET_ID_SQL: &str = "SELECT cabinet_id FROM cabin_boards WHERE id = ?"; +/// 仓体所属仓控板号 + 柜子 IMEI(拼通道级 Redis key 用) +const CABIN_BOARD_REDIS_TARGET_SQL: &str = r#"SELECT cb.board_id, c.imei + FROM cabin_boards cb + INNER JOIN cabinets c ON c.id = cb.cabinet_id + WHERE cb.id = ?"#; /// 仓体最近 10 条充电记录 const COMPARTMENT_CHARGE_RECORDS_SQL: &str = r#"SELECT id, start_time, end_time, energy FROM charge_records @@ -908,8 +913,26 @@ pub async fn get_compartment_detail( // 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限 verify_compartment_access(&user, cabin_board_id, pool).await?; - // 从 Redis 获取实时数据 - let realtime = load_compartment_realtime(&state, comp_id).await; + // 通道级实时数据 key 需要(IMEI, 仓控板号, 通道号); + // 关联缺失时记录并整体降级为「无数据」(字段 null),不影响 MySQL 主体数据 + let redis_target: Option<(i8, String)> = sqlx::query_as(CABIN_BOARD_REDIS_TARGET_SQL) + .bind(cabin_board_id) + .fetch_optional(pool) + .await?; + + let realtime = match redis_target { + Some((board_id, imei)) => { + load_compartment_realtime(&state, &imei, board_id, channel_id).await + } + None => { + tracing::warn!( + comp_id, + cabin_board_id, + "[h5] 仓体缺少柜子/仓控板关联,实时数据按「无数据」返回" + ); + CompartmentRealtime::default() + } + }; // 查询最近充电记录 let records: Vec = sqlx::query_as::<_, ChargeRecordBriefRow>( @@ -926,13 +949,23 @@ pub async fn get_compartment_detail( "channel_id": channel_id, "status": status, "cabin_board_id": cabin_board_id, - "voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0), - "current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0), - "energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0), - "soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0), - "cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])), - "cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])), - "bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])), + // 以下实时字段全部取自设备上报真实写入的 Redis key; + // 取不到时返回 null(**不落 0**),避免把"无数据"伪装成 0 + "voltage": realtime.voltage, + "current": realtime.current, + // energy 没有实时数据源(通道级 Hash 不含电量累计),如实返回 null + "energy": Value::Null, + "soc": realtime.soc, + "temp": realtime.temp, + // 电池状态 / 电源状态:两个相互独立的维度(协议 §3.8), + // 小程序 `v-if="data.batt_status != null || data.pow_status != null"` 依赖它们 + "batt_status": realtime.batt_status, + "pow_status": realtime.pow_status, + // 以下 3 个字段当前**没有任何写入方**(协议未上报 BMS 单体明细), + // 保持空数组以维持既有字段类型(前端类型为 number[]/string[]) + "cell_voltages": json!([]), + "cell_temps": json!([]), + "bms_alerts": json!([]), "charge_records": map_charge_records(records), }, "message": "ok" @@ -957,16 +990,137 @@ async fn verify_compartment_access( Ok(()) } -/// 从 Redis 读取仓体实时数据(key: `compartment:status:{comp_id}`) -async fn load_compartment_realtime(state: &AppState, comp_id: i64) -> Value { - if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { - // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection - if let Ok(mut conn) = client.get_multiplexed_async_connection().await { - let key = format!("compartment:status:{}", comp_id); - let val: Option = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None); - val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})) - } else { json!({}) } - } else { json!({}) } +/// 仓体详情实时状态快照(全部来自**真实写入**的 Redis key) +/// +/// 所有字段一律 `Option`:取不到就是 `None`(响应 JSON 为 `null`),**绝不落 0** —— +/// 0 是"真实读数为 0",与"无数据"必须区分(旧实现把所有缺失都回落成 0, +/// 小程序上表现为 SOC 0.0% / 0.0V / 0.0A 这种"看起来有值但其实是假的 0")。 +#[derive(Default)] +struct CompartmentRealtime { + /// 电池状态(2 位 hex,协议 §3.8) + batt_status: Option, + /// 电源状态(2 位 hex,与电池状态相互独立) + pow_status: Option, + /// SOC,平台单位 ‰ + soc: Option, + /// 充电电压,单位 mV + voltage: Option, + /// 充电电流,单位 mA + current: Option, + /// 电芯最高温度,单位 K×100 + temp: Option, +} + +/// 通道级 Hash 字段名(顺序即 HMGET 结果下标,与 `report_worker::write_channel_state` 的写入一致) +const COMPARTMENT_REALTIME_FIELDS: [&str; 6] = + ["batt_status", "pow_status", "soc", "voltage", "current", "temp"]; + +/// 从 Redis 读取仓体实时数据 +/// +/// **数据来源(均为真实写入的 key):** +/// - 通道级 Hash `device:{imei}:board:{板号}:ch:{通道号}`: +/// `batt_status` / `pow_status` / `soc`(‰) / `voltage`(mV) / `current`(mA) / `temp`(K×100) +/// —— 由 `workers/report_worker.rs::write_channel_state` 在每次 `status_post` 时写入; +/// - `bat_in` 登记键 `device:{imei}:board:{板号}:ch:{通道号}:bat` 的 `soc`: +/// 仅当通道级 Hash 里没有 `soc`(设备长时间未上报 / Hash 过期)时作为**兜底**, +/// 它是"最近一次插电池时的起始 SOC",语义上弱于实时值。 +/// +/// **为什么不再读 `compartment:status:{id}`:** 该 key 在全仓**只有读取方、没有任何写入方**, +/// 取值恒为空 → 旧实现把它回落成 0,导致 SOC/电压/电流/能耗永远显示 0。 +/// +/// 板号/通道号为十进制不补零,与上报写入侧保持一致;任何 Redis 异常都只降级为 `None`, +/// 不让详情接口整体失败。 +async fn load_compartment_realtime( + state: &AppState, + imei: &str, + board_id: i8, + channel_id: i8, +) -> CompartmentRealtime { + let client = match redis::Client::open(state.redis_url.as_str()) { + Ok(c) => c, + Err(e) => { + tracing::warn!("[h5] Redis 客户端创建失败,仓体实时数据降级 imei={} err={}", imei, e); + return CompartmentRealtime::default(); + } + }; + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = match client.get_multiplexed_async_connection().await { + Ok(c) => c, + Err(e) => { + tracing::warn!("[h5] Redis 连接失败,仓体实时数据降级 imei={} err={}", imei, e); + return CompartmentRealtime::default(); + } + }; + + let ch_key = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id); + + // 1) 通道级 Hash:设备上报的真实实时值 + let values: Result>, redis::RedisError> = redis::cmd("HMGET") + .arg(&ch_key) + .arg(&COMPARTMENT_REALTIME_FIELDS) + .query_async(&mut conn) + .await; + + let mut rt = match values { + Ok(values) => parse_compartment_realtime(&values), + Err(e) => { + tracing::warn!( + "[h5] 通道状态读取失败,实时数据降级 key={} err={}", + ch_key, + e + ); + CompartmentRealtime::default() + } + }; + + // 2) SOC 兜底:通道 Hash 没值时才回读 `bat_in` 登记键(起始 SOC) + if rt.soc.is_none() { + let bat_key = format!("{}:bat", ch_key); + let registered: Result, redis::RedisError> = redis::cmd("HGET") + .arg(&bat_key) + .arg("soc") + .query_async(&mut conn) + .await; + match registered { + Ok(Some(soc)) => { + rt.soc = soc.trim().parse::().ok(); + tracing::debug!("[h5] 通道无实时 SOC,回退 bat_in 登记值 key={}", bat_key); + } + Ok(None) => {} + Err(e) => { + tracing::warn!("[h5] bat_in 登记读取失败 key={} err={}", bat_key, e); + } + } + } + + rt +} + +/// HMGET 结果(顺序同 [`COMPARTMENT_REALTIME_FIELDS`])→ 实时状态快照 +/// +/// 缺失项或非数字项一律 `None`(**不落 0**)。 +fn parse_compartment_realtime(values: &[Option]) -> CompartmentRealtime { + /// 取第 i 项的非空字符串(空白串视为"无数据",避免前端把空 hex 当有效状态渲染) + fn pick(values: &[Option], i: usize) -> Option { + values + .get(i) + .and_then(|v| v.as_ref()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + } + /// 取第 i 项并解析为 i64(空值/非数字 → None) + fn pick_i64(values: &[Option], i: usize) -> Option { + pick(values, i).and_then(|v| v.parse::().ok()) + } + + CompartmentRealtime { + batt_status: pick(values, 0), + pow_status: pick(values, 1), + soc: pick_i64(values, 2), + voltage: pick_i64(values, 3), + current: pick_i64(values, 4), + temp: pick_i64(values, 5), + } } /// 充电记录行 → 响应 JSON(`energy_kwh` / `cost` 语义不变) @@ -1482,6 +1636,9 @@ pub async fn submit_feedback( pub(crate) struct FeedbackListQuery { page: Option, page_size: Option, + /// 处理状态筛选:0=待处理 1=已处理;缺省/不传 = 全部。 + /// 必须显式声明,否则 serde 会**静默丢弃**前端传来的 `status`(前端筛选点了没反应)。 + status: Option, } #[derive(Debug, sqlx::FromRow)] @@ -1497,31 +1654,58 @@ struct FeedbackRow { /// GET /api/h5/feedback — 反馈列表(管理端) /// -/// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。 +/// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据, +/// 因此不能只校验登录态 —— 复用权限种子里真实存在的 `operation_log:view` +/// (`db/migrate.rs::seed_roles`:总管理员全量、企业管理员含该码、操作员**没有**该码), +/// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近; +/// 操作员即使登录也会被 403 拦住。 +/// +/// `status` 为可选筛选条件,落到 SQL(不影响未传参时的原有查询语义)。 pub async fn list_feedback( - _user: CurrentUser, + user: CurrentUser, State(state): State, Query(query): Query, ) -> Result, AppError> { + auth::check_permission(&user, "operation_log:view")?; + let pool = &state.mysql; let page = query.page.unwrap_or(1).max(1); let page_size = query.page_size.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * page_size; - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback") - .fetch_one(pool) - .await?; + let status = query.status; + // 状态值口径与 update_feedback_status 保持一致(0待处理 / 1已处理) + if let Some(s) = status { + if s != 0 && s != 1 { + return Err(AppError::BadRequest("状态值必须为0(待处理)/1(已处理)".into())); + } + } - let rows: Vec = sqlx::query_as::<_, FeedbackRow>( - r#"SELECT id, user_id, content, contact, images, status, created_at - FROM user_feedback - ORDER BY created_at DESC - LIMIT ? OFFSET ?"#, - ) - .bind(page_size) - .bind(offset) - .fetch_all(pool) - .await?; + // `filter` 只可能是下面这两个常量字面量之一(不含任何用户输入), + // 参数依旧全部走 bind,不拼接外部数据 + let filter = if status.is_some() { " WHERE status = ?" } else { "" }; + + let count_sql = format!("SELECT COUNT(*) FROM user_feedback{}", filter); + let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); + if let Some(s) = status { + count_query = count_query.bind(s); + } + let total: i64 = count_query.fetch_one(pool).await?; + + let list_sql = format!( + "SELECT id, user_id, content, contact, images, status, created_at + FROM user_feedback{} ORDER BY created_at DESC LIMIT ? OFFSET ?", + filter + ); + let mut list_query = sqlx::query_as::<_, FeedbackRow>(&list_sql); + if let Some(s) = status { + list_query = list_query.bind(s); + } + let rows: Vec = list_query + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; let list: Vec = rows.into_iter().map(|r| { let images: Vec = r.images @@ -1620,6 +1804,10 @@ pub async fn update_feedback_status( Path(id): Path, Json(body): Json, ) -> Result, AppError> { + // 与 list_feedback 同一权限码:能改处理状态的人必须能看反馈列表 + // (`operation_log:view` 在 `db/migrate.rs` 的权限种子里真实存在) + auth::check_permission(&user, "operation_log:view")?; + let pool = &state.mysql; if body.status != 0 && body.status != 1 { @@ -1686,3 +1874,93 @@ async fn verify_cabinet_access( Ok(()) } + +// --------------------------------------------------------------------------- +// 单元测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// 通道级 HMGET 六字段(batt_status/pow_status/soc/voltage/current/temp)解析 + #[test] + fn test_parse_compartment_realtime_reads_all_fields() { + let values: Vec> = vec![ + Some("82".into()), + Some("03".into()), + Some("855".into()), + Some("53200".into()), + Some("1500".into()), + Some("29815".into()), + ]; + let rt = parse_compartment_realtime(&values); + + assert_eq!(rt.batt_status.as_deref(), Some("82")); + assert_eq!(rt.pow_status.as_deref(), Some("03")); + assert_eq!(rt.soc, Some(855)); + assert_eq!(rt.voltage, Some(53200)); + assert_eq!(rt.current, Some(1500)); + assert_eq!(rt.temp, Some(29815)); + } + + /// 无数据(key 不存在 → 全是 None)时必须返回 None,而不是 0 + /// (这是 ② 的核心回归点:旧实现把"无数据"回落成 0.0% / 0.0V / 0.0A) + #[test] + fn test_parse_compartment_realtime_no_data_returns_none_not_zero() { + let values: Vec> = vec![None, None, None, None, None, None]; + let rt = parse_compartment_realtime(&values); + + assert_eq!(rt.soc, None, "无数据不能落 0"); + assert_eq!(rt.voltage, None, "无数据不能落 0"); + assert_eq!(rt.current, None, "无数据不能落 0"); + assert_eq!(rt.temp, None); + assert!(rt.batt_status.is_none()); + assert!(rt.pow_status.is_none()); + } + + /// 真实的 0 必须原样保留(0 与"无数据"不能混为一谈) + #[test] + fn test_parse_compartment_realtime_real_zero_is_kept() { + let values: Vec> = vec![ + Some("00".into()), + Some("00".into()), + Some("0".into()), + Some("0".into()), + Some("0".into()), + Some("0".into()), + ]; + let rt = parse_compartment_realtime(&values); + + assert_eq!(rt.soc, Some(0)); + assert_eq!(rt.voltage, Some(0)); + assert_eq!(rt.current, Some(0)); + assert_eq!(rt.temp, Some(0)); + assert_eq!(rt.batt_status.as_deref(), Some("00")); + } + + /// 非数字垃圾值/结果长度不足时按无数据处理,绝不 panic、绝不落 0 + #[test] + fn test_parse_compartment_realtime_garbage_is_none() { + let values: Vec> = vec![Some("abc".into()), Some(" ".into())]; + let rt = parse_compartment_realtime(&values); + + assert_eq!(rt.soc, None); + assert_eq!(rt.voltage, None); + assert_eq!(rt.current, None); + assert_eq!(rt.temp, None); + // batt_status / pow_status 是 hex 字符串,原样透传(不在这里解释位含义) + assert_eq!(rt.batt_status.as_deref(), Some("abc")); + } + + /// HMGET 字段名顺序必须与解析下标一致(防止将来改字段顺序导致错位) + #[test] + fn test_compartment_realtime_fields_order_matches_indexes() { + assert_eq!(COMPARTMENT_REALTIME_FIELDS[0], "batt_status"); + assert_eq!(COMPARTMENT_REALTIME_FIELDS[1], "pow_status"); + assert_eq!(COMPARTMENT_REALTIME_FIELDS[2], "soc"); + assert_eq!(COMPARTMENT_REALTIME_FIELDS[3], "voltage"); + assert_eq!(COMPARTMENT_REALTIME_FIELDS[4], "current"); + assert_eq!(COMPARTMENT_REALTIME_FIELDS[5], "temp"); + } +} diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index c16b157..d30a4d2 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -29,7 +29,7 @@ mod organizations; mod projects; mod roles; mod users; -mod scope_guard; +pub(crate) mod scope_guard; mod users_perm; mod wechat; mod maps; diff --git a/software/api-server/src/routes/users.rs b/software/api-server/src/routes/users.rs index 1390f0b..e15f5d3 100644 --- a/software/api-server/src/routes/users.rs +++ b/software/api-server/src/routes/users.rs @@ -113,6 +113,78 @@ async fn verify_org_admin_scope( Ok(target_org) } +// --------------------------------------------------------------------------- +// 辅助函数: users.role ↔ user_roles 同步 +// --------------------------------------------------------------------------- + +/// 把 `users.role` 同步写入 `user_roles`(幂等) +/// +/// **为什么必须写**:`middleware/auth.rs::load_user_permissions` 的权限唯一来源是 +/// `user_roles → role_permissions`;早先的创建/编辑用户只写 `users.role`, +/// 导致新建的操作员/企业管理员登录后权限集为空、全站 403。 +/// 认证侧虽已加「按 `users.role` 回退内置角色」的兜底(历史数据无需刷库), +/// 但写入侧也必须补齐,否则: +/// - `/api/users/:id/permissions` 查不到角色、前端角色展示为空; +/// - `set_user_roles` 的显式分配与 `users.role` 长期不一致,回退反而会掩盖真实角色。 +/// +/// **幂等策略**:先 `DELETE` 该用户既有映射,再插一条新映射 +/// (`user_roles` 主键为 `(user_id, role_id)`,可用 `INSERT IGNORE` 兜底重复; +/// 同一 `users.role` 只对应一个内置角色,故不会丢掉"多角色"语义之外的信息 —— +/// 需要多角色的场景走 `PUT /api/users/:id/roles`)。 +/// +/// 失败时记 `tracing::error!` 并返回错误,**绝不静默**:角色映射写不进去, +/// 该用户的权限就只能依赖认证侧回退,属于必须暴露的异常。 +async fn sync_user_role_mapping( + pool: &sqlx::MySqlPool, + user_id: i64, + role_level: i32, +) -> Result<(), AppError> { + let role_name = auth::builtin_role_name(role_level).ok_or_else(|| { + tracing::error!(user_id, role_level, "同步用户角色失败: 未知角色等级,无内置角色可映射"); + AppError::BadRequest(format!("未知角色等级: {},无法同步角色权限", role_level)) + })?; + + let role: Option<(i64,)> = sqlx::query_as("SELECT id FROM roles WHERE name = ?") + .bind(role_name) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!(user_id, role_level, role_name, err = %e, "同步用户角色失败: 查询内置角色出错"); + AppError::Database(e) + })?; + + let Some((role_id,)) = role else { + tracing::error!(user_id, role_level, role_name, "同步用户角色失败: 内置角色不存在"); + return Err(AppError::Internal(format!( + "内置角色 {} 不存在,无法同步用户角色", + role_name + ))); + }; + + // 先清空该用户既有映射(幂等的关键:重复调用/改角色都不会留下旧映射) + sqlx::query("DELETE FROM user_roles WHERE user_id = ?") + .bind(user_id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!(user_id, role_name, err = %e, "同步用户角色失败: 清理 user_roles 出错"); + AppError::Database(e) + })?; + + sqlx::query("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)") + .bind(user_id) + .bind(role_id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!(user_id, role_name, role_id, err = %e, "同步用户角色失败: 写入 user_roles 出错"); + AppError::Database(e) + })?; + + tracing::info!(user_id, role_level, role_name, role_id, "已同步 users.role 到 user_roles"); + Ok(()) +} + /// 记录一条操作日志到 operation_logs 表 async fn log_operation( pool: &sqlx::MySqlPool, @@ -442,6 +514,9 @@ pub async fn create_user( let user_id = result.last_insert_id() as i64; + // 同步写入 user_roles(权限唯一来源),失败即返回错误 —— 不静默吞掉 + sync_user_role_mapping(pool, user_id, role).await?; + // 记录操作日志 let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-")); log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await; @@ -556,6 +631,21 @@ pub async fn update_user( return Err(AppError::NotFound("用户不存在".into())); } + // 角色**发生变化**时**才**把 users.role 同步到 user_roles(幂等全量替换)。 + // + // 为什么加 `new_role != old_role` 这个条件(而不是无条件同步): + // `PUT /api/users/:id` 同时承担"改姓名/改组织"的职责,而无条件 `DELETE + INSERT` + // 会把管理员通过 `PUT /api/users/:id/roles`(users_perm.rs)显式分配的**多角色** + // 一并抹掉 —— 那才是"破坏 set_user_roles 语义"。只在角色真的变了时以 + // `users.role` 为准重建映射;角色没变时保持 user_roles 原样, + // 历史空映射由认证侧「按 users.role 回退内置角色」兜底,权限一样正确。 + // + // 若此处失败:users.role 已更新、user_roles 已被清空 → 认证侧回退会接管, + // 权限仍然正确;但仍要把错误暴露出来以便排查。 + if new_role != old_role { + sync_user_role_mapping(pool, user_id, new_role).await?; + } + // 记录操作日志 let detail = format!( "编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}", diff --git a/software/api-server/src/workers/order_timeout.rs b/software/api-server/src/workers/order_timeout.rs index d5b372b..d44bb2d 100644 --- a/software/api-server/src/workers/order_timeout.rs +++ b/software/api-server/src/workers/order_timeout.rs @@ -50,10 +50,16 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq // 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的, // 交给数据库算差值可避免本地时区换算偏差; // 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。 + // + // ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"start_time 早于 NOW() - 10 小时"。 + // - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是 + // **未来时刻** `NOW()+10h`,于是"start_time < 未来"对**几乎所有订单恒真**, + // 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算。 + // - ✅ 正确:把 start_time 往前推 10 小时再与 NOW() 比较(下面这种写法)。 let expired: Vec<(i64, i64, Option)> = sqlx::query_as( "SELECT id, compartment_id, start_time FROM charge_orders \ WHERE status = 0 AND start_time IS NOT NULL \ - AND start_time < TIMESTAMPADD(HOUR, ?, NOW()) \ + AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \ ORDER BY start_time ASC LIMIT ?", ) .bind(TIMEOUT_HOURS) diff --git a/software/api-server/src/workers/report_worker.rs b/software/api-server/src/workers/report_worker.rs index 3840737..b9b7024 100644 --- a/software/api-server/src/workers/report_worker.rs +++ b/software/api-server/src/workers/report_worker.rs @@ -30,9 +30,9 @@ use redis::aio::ConnectionManager; use sha2::{Digest, Sha256}; use sqlx::MySqlPool; use std::collections::HashMap; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, OwnedMutexGuard}; use crate::error::AppError; use crate::protocol::ServerResponse; @@ -70,6 +70,29 @@ const COMPARTMENT_ID_SEGMENTS: usize = 2; const CHANNEL_BAT_TTL_SECS: u32 = 48 * 3600; /// `cabinet.status1` bit0 = 断电报警位(0 正常 / 1 报警,协议 §3.8) const CABINET_STATUS1_POWER_FAIL_MASK: u8 = 0x01; +/// 单次采样里**电压**的合理量程上界(平台单位 mV)。 +/// +/// 为什么需要:协议 §5.3 的电压是 **16 位大端**字段(硬件 0.01V → 平台 ×10), +/// 满量程可达 `0xFFFF × 10 = 655350 mV`,而真实电池是 **54V(54000 mV)** 量级。 +/// 位翻转(或固件把未接通道的随机值报上来)会让 `电压 × 电流 × 60s` 积分出天文数字, +/// 直接把该单 `energy` 与当日能耗统计污染掉 —— 电量直接关系电费,**宁可少算不可虚增**。 +/// 取 100000 mV(100V)作为上界:覆盖 72V/96V 等高压平台,同时把明显是脏数据的值挡在外面。 +const MAX_VOLTAGE_MV: i64 = 100_000; +/// 单次采样里**电流**的合理量程上界(平台单位 mA)。理由同 [`MAX_VOLTAGE_MV`]: +/// 协议电流也是 16 位(满量程 655350 mA ≈ 655A),真实充电电流是 8A(8000 mA)量级。 +/// 取 100000 mA(100A)覆盖大电流快充机型。 +const MAX_CURRENT_MA: i64 = 100_000; +/// 每块仓控板的通道数:AGENTS.md「每仓控板管 6 仓」,协议 §10 第 9 条的当前 36 仓柜型 +/// 也是 **6 板 × 每板 6 通道**(`"0606"`),故兜底时按每板 6 通道算。 +const CHANNELS_PER_BOARD: i64 = 6; +/// `spec` 兜底用的默认仓控板数量(= 6,即 36 仓柜型): +/// 依据 `db/migrate.rs` 里 `cabinets.cell` 的默认值 `TINYINT DEFAULT 6`(含义=仓控板数量) +/// 与协议 §10 第 9 条 `"0606"` 示例;`cell` 也为空/0 时用它,**绝不下发 `"0000"`**。 +const DEFAULT_BOARD_COUNT: i64 = 6; +/// 仓控板号上界:协议 §3.7「仓控板地址 **1–15**」(下发时 2 位补零) +const MAX_BOARD_ID: i64 = 15; +/// 通道号上界:协议 §3.7「通道号 **1–8**」(当前柜型每板 6 通道,不是 0–7) +const MAX_CHANNEL_ID: i64 = 8; /// 占用状态对账周期:与 `order_timeout` 的订单超时扫描同频(300 秒),避免额外数据库压力 const RECONCILE_INTERVAL_SECS: u64 = 300; @@ -491,8 +514,63 @@ async fn mark_device_online(redis: &ConnectionManager, dev_id: &str) { } } +/// `spec` 的取值来源(仅用于日志与单元测试断言) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpecSource { + /// 真实拓扑现算(`cabin_boards` / `compartments`),最准 + Topology, + /// 拓扑不完整 → 用 `cabinets.cell`(仓控板数)× 每板通道数 + LegacyCell, + /// `cell` 也为空 / 0 → 用常量默认 [`DEFAULT_BOARD_COUNT`] × [`CHANNELS_PER_BOARD`] + Default, +} + +/// 纯函数:算出 `spec`(**固定 4 位**:前 2 位仓控板数 + 后 2 位每板通道数,协议 §5.2 / §10 第 9 条)。 +/// +/// 优先级:**真实拓扑 > `cabinets.cell` > 常量默认**;无论输入多离谱, +/// **任何分支都不会返回 `"0000"`**(`"0000"` 会让固件认为"0 板 × 0 通道"→ 一块板都不上报)。 +/// 兜底值只做「协议量程内」收敛(仓控板 1–15,协议 §3.7),保证结果恒为 4 位。 +fn build_spec( + board_count: i64, + compartment_count: i64, + legacy_cell: Option, +) -> (String, SpecSource) { + // 1) 真实拓扑:仓控板数与每板通道数都必须 > 0 才算"拓扑完整" + let per_board = if board_count > 0 { compartment_count / board_count } else { 0 }; + if board_count > 0 && per_board > 0 { + return (format!("{:02}{:02}", board_count, per_board), SpecSource::Topology); + } + + // 2) 拓扑不完整 → 兜底:优先用遗留列 `cabinets.cell`(仓控板数量), + // 每板通道数取常量 6(见 [`CHANNELS_PER_BOARD`] 的依据) + let cell = legacy_cell.unwrap_or(0); + let (boards, source) = if cell > 0 { + (cell, SpecSource::LegacyCell) + } else { + // 3) 连 cell 都没有 → 常量默认 6 × 6 = "0606" + (DEFAULT_BOARD_COUNT, SpecSource::Default) + }; + let boards = boards.clamp(1, MAX_BOARD_ID); + (format!("{:02}{:02}", boards, CHANNELS_PER_BOARD), source) +} + /// 该柜规格 `spec`(固定 4 位):前 2 位仓控板数 + 后 2 位每板通道数, -/// 如 `"0606"` = 6 块仓控板 × 每板 6 仓(协议 §5.2 / §10 第 9 条) +/// 如 `"0606"` = 6 块仓控板 × 每板 6 仓(协议 §5.2 / §10 第 9 条)。 +/// +/// # 为什么必须兜底(**绝不下发 `"0000"`**) +/// 固件**按 `spec` 行事**(协议 §10 第 9 条):一旦下发 `"0000"`,固件会认为 +/// 「0 块仓控板 × 0 通道」,于是**一块板都不上报**,平台侧完全看不到任何通道状态。 +/// 而生产实测 **68 台柜子里有 42 台 `cabin_boards` 记录数为 0**(拓扑压根没配), +/// 好在这些柜子遗留的 `cabinets.cell`(含义=仓控板数量,见 `db/migrate.rs`)**全部 = 6**, +/// 且 AGENTS.md 明确「每仓控板管 6 仓」。故取值顺序为: +/// 1. **真实拓扑**(`cabin_boards` / `compartments` 现算)—— 最准,优先; +/// 2. 拓扑不完整(仓控板数为 0 或每板通道数为 0)→ `cabinets.cell`(仓控板数)× 6, +/// 例如 `cell = 6` → `"0606"`; +/// 3. `cell` 也为空 / 0 → 常量 [`DEFAULT_BOARD_COUNT`](6)× [`CHANNELS_PER_BOARD`](6)→ `"0606"`; +/// 4. **只要走了兜底就记 `tracing::warn!`**,文案带上 `cabin_boards` / `compartments` / `cell` +/// 与最终 spec,让运维一眼看出「该柜拓扑未配置,spec 使用兜底值」。 +/// +/// 计算细节见纯函数 [`build_spec`](单测覆盖)。 async fn fetch_spec(mysql: &MySqlPool, dev_id: &str) -> Result { let (board_count, compartment_count): (i64, i64) = sqlx::query_as( "SELECT COUNT(DISTINCT cb.id), COUNT(comp.id) @@ -509,8 +587,36 @@ async fn fetch_spec(mysql: &MySqlPool, dev_id: &str) -> Result AppError::Internal("查询仓控板数量失败".into()) })?; - let per_board = if board_count > 0 { compartment_count / board_count } else { 0 }; - Ok(format!("{:02}{:02}", board_count, per_board)) + // 兜底数据源:`cabinets.cell` = 仓控板数量(老字段,已被 `spec` 取代,但仍是存量里最可靠的配置)。 + // 读取失败或列为 NULL 都当作「无兜底值」,继续退到常量默认,**不阻断登录**。 + let legacy_cell: Option = match sqlx::query_scalar::<_, Option>( + "SELECT cell FROM cabinets WHERE imei = ?", + ) + .bind(dev_id) + .fetch_optional(mysql) + .await + { + Ok(v) => v.flatten().map(i64::from), + Err(e) => { + tracing::warn!("[login] 读取 cabinets.cell 失败 dev_id={} err={}", dev_id, e); + None + } + }; + + let (spec, source) = build_spec(board_count, compartment_count, legacy_cell); + if source != SpecSource::Topology { + tracing::warn!( + "[login] 该柜拓扑未配置(cabin_boards={} compartments={} cabinets.cell={:?}),\ + spec 使用兜底值 spec={} 来源={:?} dev_id={}", + board_count, + compartment_count, + legacy_cell, + spec, + source, + dev_id + ); + } + Ok(spec) } /// 读取充电工作模式:`"0"`=平台指令模式(默认)/"1"=自动充电工作模式。 @@ -658,9 +764,49 @@ fn normalize_hex_byte(raw: Option<&str>) -> Option { u8::from_str_radix(raw, 16).ok().map(|b| format!("{:02x}", b)) } +/// 纯函数:电量积分的**输入量程校验**。 +/// +/// `Some(v)` = `v` 落在 `[0, max]` 内、可以送进积分器;`None` = 超量程(调用方按 0 计并记 WARN)。 +/// 负数与超大值同样拒绝:协议字段是无符号 16 位(`parse_channel_hex` 只可能产出 ≥ 0), +/// 出现负数说明上游换算出错,同样不能拿去积分。 +fn sanitize_sample_value(value: i64, max: i64) -> Option { + if (0..=max).contains(&value) { + Some(value) + } else { + None + } +} + +/// 判定 sqlx 错误是否为**唯一键冲突**(MySQL `ER_DUP_ENTRY` / 1062)。 +/// +/// 为什么需要:`charge_orders.active_flag` 上的唯一键是订单并发防护的**第三层兜底**, +/// 它拦下的场景与"条件插入返回 0 行"是**同一件事**(该仓体已有活跃订单), +/// 因此必须走同一个分支(记 WARN + 视为未建单),而不是上抛成 ERROR 让上层以为平台故障。 +/// +/// 用 sqlx 的 [`sqlx::error::DatabaseError::is_unique_violation`] 而不是比对 SQLSTATE: +/// 外键违约(FK violation)的 SQLSTATE 同样是 `23000`,只比 SQLSTATE 会把"仓体不存在" +/// 这类真错误误判成"重复活跃单"从而被静默吞掉;sqlx 按 MySQL 错误号(1062 等)映射到 +/// [`sqlx::error::ErrorKind::UniqueViolation`],粒度正好。 +fn is_unique_violation(err: &sqlx::Error) -> bool { + err.as_database_error() + .map(|db_err| db_err.is_unique_violation()) + .unwrap_or(false) +} + +/// 仓控板号是否在协议量程内:**1–15**(协议 §3.7) +fn is_valid_board_id(board_id: i64) -> bool { + (1..=MAX_BOARD_ID).contains(&board_id) +} + +/// 通道号是否在协议量程内:**1–8**(协议 §3.7,注意**不是 0–7**) +fn is_valid_channel_id(channel_id: i64) -> bool { + (1..=MAX_CHANNEL_ID).contains(&channel_id) +} + /// 解析仓体级 `id`(`仓控板-通道`,**段数必须恰好为 2**,协议 §3.7)→ (仓控板号, 通道号)。 /// -/// 仓控板 1–15、通道 1–8;`03` 这类补零写法兼容,段数/数字非法 → `None`(调用方回 `invalid id`)。 +/// 量程校验:仓控板 **1–15**、通道 **1–8**(协议 §3.7);`03` 这类补零写法兼容。 +/// 段数 / 数字 / 量程非法 → `None`(调用方回 `invalid id`,**避免拿越界号去查库或写 Redis key**)。 fn parse_compartment_id(msg: &serde_json::Value) -> Option<(i8, i8)> { let id = msg.get("id").and_then(|v| v.as_str())?; let parts: Vec<&str> = id.split('-').collect(); @@ -669,7 +815,7 @@ fn parse_compartment_id(msg: &serde_json::Value) -> Option<(i8, i8)> { } let board_id: i8 = parts.first()?.trim().parse().ok()?; let channel_id: i8 = parts.get(1)?.trim().parse().ok()?; - if board_id < 1 || channel_id < 1 { + if !is_valid_board_id(i64::from(board_id)) || !is_valid_channel_id(i64::from(channel_id)) { return None; } Some((board_id, channel_id)) @@ -920,6 +1066,16 @@ async fn write_board_channels(redis: &ConnectionManager, dev_id: &str, board: &s return; } }; + // 量程校验(协议 §3.7:仓控板 1–15):越界会写出 `...:board:0` / `...:board:16` 这类垃圾 key, + // 故整块板直接跳过(只记 WARN,不影响其余板) + if !is_valid_board_id(board_no) { + tracing::warn!( + "[status_post] 板号越界已跳过(协议 §3.7 允许 1–15)dev_id={} board={}", + dev_id, + board_no + ); + return; + } let channels = match board.get("channels").and_then(|v| v.as_array()) { Some(v) => v, None => { @@ -951,6 +1107,16 @@ async fn write_board_channels(redis: &ConnectionManager, dev_id: &str, board: &s }; // 数组下标 = 通道号 - 1(协议 §5.3:第 1 个元素 = 通道 1) let channel_no = idx as i64 + 1; + // 量程校验(协议 §3.7:通道 1–8):固件多报的通道只跳过该条,不写 `...:ch:0` / `...:ch:9` + if !is_valid_channel_id(channel_no) { + tracing::warn!( + "[status_post] 通道号越界已跳过(协议 §3.7 允许 1–8)dev_id={} board={} ch={}", + dev_id, + board_no, + channel_no + ); + continue; + } write_channel_state(redis, dev_id, board_no, channel_no, &status).await; } } @@ -986,13 +1152,46 @@ async fn write_channel_state( let battery_present = u8::from_str_radix(&status.batt_status, 16) .map(|b| b & 0x80 != 0) .unwrap_or(true); + + // ⚠️ **积分前的量程校验**(见 [`MAX_VOLTAGE_MV`]):电压/电流是协议里的 16 位字段, + // 位翻转或未接通道的随机值会让积分结果暴增,直接污染该单电量与当日能耗统计。 + // 注意:**只夹取送进积分器的值**,写回 Redis 的仍是设备原值(不改变实时状态的契约)。 + let voltage_mv = match sanitize_sample_value(status.voltage, MAX_VOLTAGE_MV) { + Some(v) => v, + None => { + tracing::warn!( + "[status_post] 电压超量程,按 0 积分 dev_id={} board={} ch={} voltage={}mV(上限 {}mV)", + dev_id, + board_no, + channel_no, + status.voltage, + MAX_VOLTAGE_MV + ); + 0 + } + }; + let current_ma = match sanitize_sample_value(status.current, MAX_CURRENT_MA) { + Some(v) => v, + None => { + tracing::warn!( + "[status_post] 电流超量程,按 0 积分 dev_id={} board={} ch={} current={}mA(上限 {}mA)", + dev_id, + board_no, + channel_no, + status.current, + MAX_CURRENT_MA + ); + 0 + } + }; + energy::accumulate( redis, dev_id, board_no, channel_no, - status.voltage, - status.current, + voltage_mv, + current_ma, status.temp, battery_present, ) @@ -1162,6 +1361,106 @@ async fn delete_channel_battery(redis: &ConnectionManager, dev_id: &str, board_i } } +/// 清除通道电池登记 —— **仅当登记的 `battery_id` 与本次拔出的 `battery_id` 一致**。 +/// +/// 为什么必须比对:协议 §5.6 要求 MCU 拔电池时"先发 `off` 再发 `bat_out`",两条报文几乎 +/// 同时进 `device:reports` 并由 `tokio::spawn` 并发处理,`bat_out` 完全可能晚于 +/// "新电池插入(`bat_in` 覆盖登记)+ 新单建立(`on`)"到达。无条件 `DEL` 会把**新电池**的 +/// 登记删掉,导致下一单丢掉 `battery_id` / 起始 SOC(协议 §5.7 的兜底数据源)。 +/// +/// - 登记不存在(键已过期 / 已被清)→ 无需清理; +/// - 登记的 id 一致(**含两边都是空串**,即 BMS 读不到序列号的场景)→ 删除; +/// - 登记的 id 不同 → **保留登记**并记 WARN(宁可留一条会在下次 `bat_in` 被覆盖的旧登记, +/// 也不能删掉新电池的登记); +/// - Redis 读取失败 → 保守不删(见 [`load_channel_battery`] 的返回值语义)。 +async fn delete_channel_battery_if_matches( + redis: &ConnectionManager, + dev_id: &str, + board_id: i8, + channel_id: i8, + battery_id: &str, +) { + match load_channel_battery(redis, dev_id, board_id, channel_id).await { + None => {} + Some(registered) if registered.battery_id == battery_id => { + delete_channel_battery(redis, dev_id, board_id, channel_id).await; + } + Some(registered) => { + tracing::warn!( + "[bat_out] 通道电池登记已是另一块电池,保留登记 dev_id={} board={} ch={} \ + registered={} bat_out={}", + dev_id, + board_id, + channel_id, + registered.battery_id, + battery_id + ); + } + } +} + +// --------------------------------------------------------------------------- +// 按仓体的进程内串行锁(订单生命周期) +// --------------------------------------------------------------------------- + +/// 订单生命周期锁表:`compartment_id -> 该仓体的互斥锁`。 +/// +/// **为什么选「进程内锁」而不是 Redis `SET NX PX` 分布式锁**: +/// 本项目是**单实例部署**(systemd 单进程,见 AGENTS.md 部署架构), +/// 订单生命周期的全部并发来源都在**同一个进程内**: +/// `report_worker::start` 对每条上报 `tokio::spawn` 并发处理、`order_timeout` 定时扫描、 +/// `reconcile_usage_loop` 周期对账。进程内互斥即可完全覆盖,且没有额外失败模式。 +/// 若改用 Redis 锁,因为 Redis 是经 nginx stream 代理访问阿里云 RDS 的、客户端又是 +/// 多路复用的 `ConnectionManager`,反而要额外处理"锁过期时间、误删他人锁、获取失败降级" +/// 等问题,收益为零、风险更高。 +/// ⚠️ **若将来改成多实例部署,这里必须升级为 Redis 锁或数据库行锁**(届时单进程锁不再够用)。 +/// +/// 锁键就是 `compartment_id` 本身(仓体全局 ID,AGENTS.md「仓体全局ID」),不做任何再映射 —— +/// 任何映射都只会多出"两个不同仓体撞同一把锁"的出错面。构造见 [`compartment_lock_key`]。 +/// +/// 覆盖的临界区(**所有"改订单状态"的路径都必须在同一把锁下**): +/// 1. [`ensure_charge_order`]:查活跃单 → 判定 → (换电池时收尾老单)→ 条件插入新单; +/// 2. [`finalize_order`]:抢占结束权 → 取电量 → 写回 → 生成充电记录; +/// 3. [`finalize_timeout_order_by_id`]:按 order_id 收尾超时订单。 +static COMPARTMENT_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// 锁表条目数超过该值时清理一次"无人持有"的条目。 +/// +/// 防御性的内存上限:仓体数量本来有限(同柜 6~36 个),正常永远到不了这个阈值; +/// 保留它是为了避免"仓体被删了又建、id 不断变化"时锁表无限增长。 +const COMPARTMENT_LOCK_REGISTRY_MAX: usize = 4096; + +/// 锁键构造(**纯函数**,单测覆盖):仓体 ID → 锁表键。 +/// +/// 单独抽出来是为了让"同一个仓体必然拿到同一把锁、不同仓体拿不同的锁"这条契约 +/// 可以被单测直接证明(而不是埋在锁管理层里无法验证)。 +fn compartment_lock_key(compartment_id: i64) -> i64 { + compartment_id +} + +/// 取得某仓体的互斥锁,**一直持有到返回的 guard 被 drop**。 +/// +/// ⚠️ 调用方必须用 `let _guard = acquire_compartment_lock(id).await;` 绑定住返回值, +/// 写成 `acquire_compartment_lock(id).await;` 会立刻释放锁(等于没加锁)。 +/// +/// 注意:本函数**不可重入**。已经持有某仓体锁的代码路径(如 [`ensure_charge_order`]) +/// 必须调用对应的 `*_locked` 无锁内核,否则会自己锁死自己。 +async fn acquire_compartment_lock(compartment_id: i64) -> OwnedMutexGuard<()> { + let key = compartment_lock_key(compartment_id); + let lock = { + let mut registry = COMPARTMENT_LOCKS.lock().await; + // 防御性清理:只移除**仅被锁表引用**(`strong_count == 1` ⇒ 无人持有也无人等待)的条目。 + // 移除"有人持有/等待"的条目会让后续 acquire 拿到一把**全新的锁**, + // 从而破坏互斥性,所以这里必须用 strong_count 过滤。 + if registry.len() > COMPARTMENT_LOCK_REGISTRY_MAX { + registry.retain(|_, lock| Arc::strong_count(lock) > 1); + } + Arc::clone(registry.entry(key).or_insert_with(|| Arc::new(Mutex::new(())))) + }; + lock.lock_owned().await +} + // --------------------------------------------------------------------------- // 充电订单(建单唯一入口 = on,协议 §5.7) // --------------------------------------------------------------------------- @@ -1201,6 +1500,25 @@ pub fn decide_order_action(active_battery: Option<&str>, new_battery: &str) -> O } } +/// 纯函数:这条 `bat_out` 是否应该收尾该仓体当前的活跃订单。 +/// +/// - `active_battery` = 该仓体活跃订单的电池 id(`None` = 没有活跃订单); +/// - `bat_out_battery` = 本次 `bat_out` 报文里的电池 id。 +/// +/// 返回 `false` **仅当**"双方 id 都已知且不同" —— 依据与 [`decide_order_action`] 同一条规则 +/// (**一个通道只可能有一块电池**):活跃订单的电池已经是另一块,说明这条 `bat_out` +/// 是**上一块电池的迟到报文**(协议 §5.6 要求先发 `off` 再发 `bat_out`,两条报文并发处理, +/// 期间人可能已经换上新电池并建了新单)。收尾会把**新单**结束掉。 +/// +/// 其余情况一律返回 `true`(保守):任一方 id 为空串时无法证明"换了电池", +/// 宁可照常收尾(最坏是收掉一笔已无电池的订单),也不能因为判错而让订单一直挂到 10 小时超时。 +fn should_finalize_bat_out(active_battery: Option<&str>, bat_out_battery: &str) -> bool { + match active_battery { + None => true, + Some(active) => active.is_empty() || bat_out_battery.is_empty() || active == bat_out_battery, + } +} + /// 确保该通道有一笔活跃充电订单(**协议里建单的唯一入口是 `on`**,见 §5.7)。 /// 建单判定见 [`decide_order_action`](依据"一个通道只可能有一块电池")。 /// @@ -1224,6 +1542,17 @@ pub async fn ensure_charge_order( } }; + // 「查活跃单 → 判定 → 结束老单 → 插入新单」**整段按仓体串行化**(进程内锁,见 + // [`acquire_compartment_lock`])。没有这段锁时,并发两次建单(H5 双击/重试、两人同时点、 + // 设备 `on` 与平台下发 `on` 撞一起)会各自查到"无活跃单"并各插一笔 `status=0`: + // 同仓体两笔活跃订单 ⇒ 结单只收最新一笔、老单永久 `status=0`, + // 还会被 `order_timeout` 扫到后**把正在充电的新单当超时单结束**。 + // + // 三层防护:**进程内锁(本处)→ 条件插入([`insert_charge_order`])→ DB 唯一键 + // (`charge_orders.active_flag`,见 `db/migrate.rs`)**。任一层单独都不够: + // 锁只护住本进程、条件插入在"读快照"实现里仍有窗口、唯一键只能兜底报错。 + let _guard = acquire_compartment_lock(compartment_id).await; + let active = find_active_order(pool, compartment_id).await?; // 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变) @@ -1275,7 +1604,7 @@ pub async fn ensure_charge_order( .await?; tracing::info!( - "[order] 已建充电订单 compartment_id={} battery_id={} start_soc={}", + "[order] 建单流程结束 compartment_id={} battery_id={} start_soc={}", compartment_id, battery_id, start_soc @@ -1302,6 +1631,10 @@ async fn find_active_order( /// /// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。 /// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。 +/// +/// ⚠️ 调用方 [`ensure_charge_order`] **已持有该仓体的进程内锁**,所以这里必须走 +/// **无锁内核** [`finalize_order_locked`];若调 [`finalize_order`](它会再取一次同一把锁), +/// tokio 的 `Mutex` 不可重入 ⇒ **直接自锁死**。 async fn end_replaced_order( pool: &MySqlPool, redis: &ConnectionManager, @@ -1313,7 +1646,7 @@ async fn end_replaced_order( order_id: i64, old_battery: &str, ) -> Result<(), sqlx::Error> { - finalize_order( + finalize_order_locked( pool, redis, imei, @@ -1344,6 +1677,14 @@ async fn end_replaced_order( /// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。 /// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。 /// +/// **条件插入**:只有该仓体**当前确实没有活跃订单**时才插入(判定与插入在同一条语句里, +/// 由 InnoDB 求值)。为什么不用"先 `SELECT` 再 `INSERT`":两者之间的任何并发调度 +/// (另一条上报的 `tokio::spawn` 任务、H5 的重复请求)都会让两次建单**同时通过检查**, +/// 于是同一仓体出现两笔 `status=0` —— 这正是 P0-2。 +/// +/// 返回值:`true` = 本单真的建成了(已 mark 占用、已清零累加器); +/// `false` = 已有活跃单,**什么都没做**(调用方不得再按"已建单"处理)。 +/// /// 同时**清零该通道的电量累加器**(`energy::reset`)—— 保证本单积分从 0 开始, /// 不会把上一单的余量算进来(累加器是"通道维度"的,靠这里划清每单边界)。 async fn insert_charge_order( @@ -1356,23 +1697,56 @@ async fn insert_charge_order( cabinet_id: i64, battery_id: &str, start_soc: i64, -) -> Result<(), sqlx::Error> { - sqlx::query( +) -> Result { + let result = sqlx::query( "INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_time, start_soc, status) - VALUES (?, ?, ?, NOW(), ?, 0)", + SELECT ?, ?, ?, NOW(), ?, 0 FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM charge_orders WHERE compartment_id = ? AND status = 0 + )", ) .bind(compartment_id) .bind(cabinet_id) .bind(battery_id) .bind(start_soc) + .bind(compartment_id) .execute(pool) - .await?; + .await; + + let affected = match result { + Ok(done) => done.rows_affected(), + // DB 唯一键(`charge_orders.active_flag`)拦下:并发的一方在本语句求值之后才提交。 + // 语义与"条件插入返回 0 行"**完全相同**(该仓体已有活跃订单),所以不抛错、 + // 不 mark 占用、不 reset 累加器 —— 让三层防护的结果口径统一。 + Err(e) if is_unique_violation(&e) => { + tracing::warn!( + "[order] 唯一键拦下重复活跃单(该仓体已有活跃订单)compartment_id={} battery_id={}", + compartment_id, + battery_id + ); + 0 + } + Err(e) => return Err(e), + }; + + if affected == 0 { + // 该仓体已有活跃订单(并发的另一方先插入,或 `active_flag` 唯一键拦下)⇒ + // **绝不** mark 占用状态、**绝不** `energy::reset`: + // 那会把**对方那笔正在充电的会话**的仓体占用与电量累加器一起清掉(电量直接丢失)。 + tracing::warn!( + "[order] 该仓体已有活跃订单,本次不建单 compartment_id={} battery_id={}", + compartment_id, + battery_id + ); + return Ok(false); + } + energy::reset(redis, imei, board_id, channel_id).await; mark_compartment_status(pool, compartment_id, 1).await; if !battery_id.is_empty() { mark_battery_status(pool, battery_id, 1).await; } - Ok(()) + Ok(true) } /// 充电记录 `status` 取值(与 `charge_records.status` 注释一致) @@ -1382,17 +1756,33 @@ pub const RECORD_STATUS_ABNORMAL: i8 = 1; /// 充电记录:手动停止(用户主动拔电池) pub const RECORD_STATUS_MANUAL_STOP: i8 = 2; -/// **统一结算订单**:写回电量、生成充电记录、复位占用状态。**所有"订单结束"的路径都必须走它**。 +/// **统一结算订单(对外入口)**:先抢"结束权",**只有抢到的那一方才去取电量**。 /// /// 为什么统一:`energy` 只能在这里落库 —— 平台侧按 `电压 × 电流 × 时间` 积分 /// (**待确认第 13 项的兜底方案**,见 [`crate::workers::energy`])。若某条结束路径漏了它, /// 那条订单的电量就永远是 0,且充电记录页/小程序统计会缺一条。 /// -/// 步骤: -/// 1. 从 Redis 累加器**取出并清零**本次充电的电量与最高温(原子,见 `energy::take_and_reset`) -/// 2. `UPDATE charge_orders` 收尾(带 `status = 0` 条件,避免与其它路径重复结算) -/// 3. 仅当本次真的改动了行(`rows_affected > 0`)才 `INSERT charge_records`, -/// 从而**天然幂等**:重复调用不会产生重复记录 +/// # 为什么必须"先抢结束权、后取电量"(P0-1) +/// 协议 §5.6 **硬性要求** MCU 拔电池时"先发 `off` 再发 `bat_out`",两条报文几乎同时进 +/// `device:reports`,而每条上报都由 [`start`] 里的 `tokio::spawn` **并发**处理 ⇒ +/// 两个任务都会走到本函数。旧实现是"**先** `energy::take_and_reset` 取电量、**再**按 id `UPDATE`": +/// 两个任务都先取(谁先取谁拿到电量,另一个拿到 0),再各自 UPDATE —— +/// **拿到电量的那个若没抢到 UPDATE(InnoDB 行锁让另一个先提交),落库的就是 0**, +/// 真实电量被静默丢弃(旧代码 `affected == 0` 时直接 `return`)。 +/// 而"拔电池"恰恰是**最常见的结束路径**,于是电量与统计直接错。 +/// +/// # 新顺序 +/// 1. 按 `compartment_id` **原子抢占结束权**: +/// `UPDATE ... WHERE compartment_id = ? AND status = 0 ORDER BY id DESC LIMIT 1` +/// (语义与旧实现"按该仓体**最新**活跃单收尾"完全一致,只是把"先 `SELECT id` 再按 id UPDATE" +/// 合并为一条语句,消除"查与改之间"的窗口;`LIMIT 1` 保证一次只收尾一笔); +/// 2. `rows_affected == 0` ⇒ 已被其它路径(另一条 `off`/`bat_out`、超时兜底、对账兜底)结单 +/// ⇒ **直接返回 0,且绝不碰电量累加器**(理由见函数内注释);⇐ **P0-1 的修复点** +/// 3. `rows_affected > 0` ⇒ **这时才** `take_and_reset` 取电量与最高温,写回该行 `energy`, +/// 再生成 `charge_records`。 +/// +/// 整个"抢占 → 取电量 → 写回 → 建记录"都在该仓体的**进程内锁**下执行 +/// ([`acquire_compartment_lock`],选型理由见该函数的文档),因此同一仓体不会交错。 /// /// `end_soc` 为 `None` 时保持库中原值不变(用 `COALESCE` 语义写在 SQL 里)。 async fn finalize_order( @@ -1407,42 +1797,177 @@ async fn finalize_order( reason: &str, record_status: i8, ) -> Result { - // 1) 结算电量(平台侧积分结果) - let (mwh, max_temp) = energy::take_and_reset(redis, imei, board_id, channel_id).await; - let energy_wh = energy::mwh_to_wh(mwh); - - // 2) 找到该仓体当前活跃订单(若无则说明已被别的路径结算,直接返回) - let order_id: Option = sqlx::query_scalar( - "SELECT id FROM charge_orders WHERE compartment_id = ? AND status = 0 \ - ORDER BY id DESC LIMIT 1", + let _guard = acquire_compartment_lock(compartment_id).await; + finalize_order_locked( + pool, + redis, + imei, + board_id, + channel_id, + compartment_id, + end_soc, + order_status, + reason, + record_status, ) - .bind(compartment_id) - .fetch_optional(pool) - .await?; - let Some(order_id) = order_id else { - return Ok(0); - }; + .await +} +/// [`finalize_order`] 的**无锁内核**:调用方**必须已持有该 `compartment_id` 的进程内锁**。 +/// +/// 单独拆出来的原因只有一个:[`ensure_charge_order`](换电池分支 → [`end_replaced_order`]) +/// 在**持锁状态下**也要收尾老订单,而 tokio 的 `Mutex` **不可重入** —— +/// 那条路径若再调 [`finalize_order`],会自己把自己锁死。 +async fn finalize_order_locked( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + compartment_id: i64, + end_soc: Option, + order_status: i8, + reason: &str, + record_status: i8, +) -> Result { + // 1) **原子抢占"结束权"**:一条语句按该仓体收尾(不再需要"先 SELECT id 再按 id UPDATE")。 + // `status = 0` 条件同时保证幂等:重复调用不会二次结算。 let affected = sqlx::query( "UPDATE charge_orders SET status = ?, end_time = NOW(), - end_soc = COALESCE(?, end_soc), energy = ?, reason = ? - WHERE id = ? AND status = 0", + end_soc = COALESCE(?, end_soc), reason = ? + WHERE compartment_id = ? AND status = 0 + ORDER BY id DESC LIMIT 1", ) .bind(order_status) .bind(end_soc) - .bind(energy_wh) .bind(reason) - .bind(order_id) + .bind(compartment_id) .execute(pool) .await? .rows_affected(); if affected == 0 { + // 本任务**没有抢到结束权**(该仓体已无活跃订单:已被另一条 `off`/`bat_out`、 + // 超时兜底或对账兜底结单)。 + // + // ⚠️ 这里**绝不能** `energy::take_and_reset`:累加器是"通道维度"的,属于 + // **下一条真正收尾的路径**。抢不到结束权还去取电量,等于把电量从胜出者手里抢走 + // (胜出者随后写回 0,或者本任务取到的 0 覆盖真实值)—— 那正是 P0-1 的症状。 + // 保持累加器原样不动是最安全的选择:下一个会话建单时 `energy::reset` 会划清边界。 + tracing::info!( + "[order] 未抢到结束权(已被其它路径结单),不取电量 compartment_id={} reason={}", + compartment_id, + reason + ); return Ok(0); } - // 3) 生成充电记录(供后台「充电记录」页 / 导出 / 小程序今日统计使用) - // 字段从订单行直接 SELECT 过来,避免再查一次仓体与柜子 + // 2) 抢到结束权之后**才**取电量(谁结束谁结算) + let order_id = match find_just_finished_order(pool, compartment_id, order_status).await? { + Some(id) => id, + None => { + // 理论上不可达:上一条 UPDATE 至少改动了 1 行。留作防御 —— + // 宁可不写电量,也绝不把电量写到别的订单行上。 + tracing::error!( + "[order] 刚收尾的订单定位失败,跳过电量写回 compartment_id={} reason={}", + compartment_id, + reason + ); + return Ok(affected); + } + }; + + // 3) 写回电量 + 生成充电记录 + settle_finished_order( + pool, + redis, + imei, + board_id, + channel_id, + order_id, + reason, + true, + record_status, + ) + .await?; + + Ok(affected) +} + +/// 定位**刚被本次抢占收尾**的那一行订单 id。 +/// +/// 前置条件:调用方刚成功执行了"按 `compartment_id` 抢占收尾"的 `UPDATE`。 +/// 依据:订单 id 自增,且同一仓体同一时刻只允许一笔活跃订单(`active_flag` 唯一键 + +/// [`acquire_compartment_lock`]),因此"该仓体 id 最大、状态为本次 ending status 的行" +/// 就是刚被收尾的那一行。 +async fn find_just_finished_order( + pool: &MySqlPool, + compartment_id: i64, + order_status: i8, +) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT id FROM charge_orders + WHERE compartment_id = ? AND status = ? + ORDER BY id DESC LIMIT 1", + ) + .bind(compartment_id) + .bind(order_status) + .fetch_optional(pool) + .await +} + +/// 为**已收尾**的订单写回电量并生成充电记录(**必须在订单已被置为结束态之后调用**)。 +/// +/// - `take_energy = true`:本单是该通道电量累加器的**归属者**(正常路径)⇒ +/// 从 Redis 取出并清零累加器(原子,见 `energy::take_and_reset`),把电量/最高温落到该单; +/// - `take_energy = false`:本单**不是**归属者(历史遗留的重复活跃单被单独收尾)⇒ +/// 只收尾、不取电量 —— 累加器属于**当前正在充电的新单**,取走会把新单的电量算到它头上。 +/// +/// 充电记录写失败只记 ERROR:订单已经正确收尾,不能因为记录写失败而回滚/重试整笔业务。 +/// +/// 参数与 [`finalize_order`] 同源(本项目订单结算的固有维度:设备定位 + 订单定位 + 结算口径), +/// 拆成结构体只是把参数搬家、并无实际收益,故保持平铺。 +async fn settle_finished_order( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + order_id: i64, + reason: &str, + take_energy: bool, + record_status: i8, +) -> Result<(), sqlx::Error> { + let (mwh, max_temp) = if take_energy { + energy::take_and_reset(redis, imei, board_id, channel_id).await + } else { + tracing::warn!( + "[order] 该单不是通道累加器的归属者(历史的重复活跃单),只收尾不取电量 order_id={}", + order_id + ); + (0, 0) + }; + let energy_wh = energy::mwh_to_wh(mwh); + + if take_energy { + // 只改**已经收尾**的那一行(`status <> 0`):任何情况下都不碰活跃单 + let updated = sqlx::query("UPDATE charge_orders SET energy = ? WHERE id = ? AND status <> 0") + .bind(energy_wh) + .bind(order_id) + .execute(pool) + .await? + .rows_affected(); + if updated == 0 { + tracing::error!( + "[order] 电量写回未命中订单行(订单未处于结束态?)order_id={} energy={}Wh", + order_id, + energy_wh + ); + } + } + + // 生成充电记录(供后台「充电记录」页 / 导出 / 小程序今日统计使用) + // 字段从订单行直接 SELECT 过来,避免再查一次仓体与柜子 if let Err(e) = sqlx::query( "INSERT INTO charge_records (compartment_id, cabinet_id, start_time, end_time, energy, max_temperature, status) @@ -1456,7 +1981,6 @@ async fn finalize_order( .execute(pool) .await { - // 记录缺失只记 ERROR:订单已经正确收尾,不能因为记录写失败而回滚/重试整笔业务 tracing::error!( "[order] 充电记录写入失败 order_id={} err={}(订单已正常收尾)", order_id, @@ -1465,15 +1989,16 @@ async fn finalize_order( } tracing::info!( - "[order] 订单结算 order_id={} reason={} energy={}Wh(积分 {}mWh)最高温={}", + "[order] 订单结算 order_id={} reason={} energy={}Wh(积分 {}mWh)最高温={} 取电量={}", order_id, reason, energy_wh, mwh, - max_temp + max_temp, + take_energy ); - Ok(affected) + Ok(()) } /// 复位 / 置位仓体占用状态(0 空闲 / 1 充电中)。失败只记 ERROR,不影响设备流程。 @@ -1610,14 +2135,18 @@ async fn reconcile_no_battery_orders( continue; } - // 连续无电池 ⇒ 电池已被取走,提前结单(电量取回累加器后再清) - let affected = finalize_order( + // 连续无电池 ⇒ 电池已被取走,提前结单(**按扫描到的 order_id 精确收尾**)。 + // 这里刻意不走 [`finalize_order`](它按"该仓体最新活跃单"收尾):本轮扫描的列表是 + // 快照,逐条处理又夹着 Redis 往返,期间该仓体可能已被结单并建了新单 —— + // 只按 compartment 收尾会把**正在充电的新单**当"无电池"单结束(同 P0-2 的误结路径)。 + let affected = finalize_order_by_id( mysql, redis, &imei, board_id.into(), channel_id.into(), compartment_id, + order_id, None, 2, "forced", @@ -1642,17 +2171,76 @@ async fn reconcile_no_battery_orders( Ok(()) } -/// 供 `workers/order_timeout.rs` 调用:**按仓体结算一笔超时订单**(10 小时兜底)。 +/// 订单超时阈值(小时):与 `workers/order_timeout.rs` 的 `TIMEOUT_HOURS` **必须保持一致**。 /// -/// 存在的意义:超时结单也必须走 [`finalize_order`],否则那条订单的电量永远是 0、 -/// 充电记录也会缺一条(`order_timeout` 模块只有 `compartment_id`, -/// 所以在这里补一次"仓体 → 设备/板/通道"的定位)。 +/// 为什么这里也留一份:`order_timeout.rs` 不在本次改动范围内(其 `TIMEOUT_HOURS` 是私有常量), +/// 而 [`finalize_timeout_order`] 必须自己复核"这笔订单是不是真的超时了"(见该函数说明)。 +/// 两处都改才算真正调整阈值。 +pub const ORDER_TIMEOUT_HOURS: i64 = 10; + +/// `charge_orders.status`:1 = 已完成(正常结束 / 超时兜底结单) +const ORDER_STATUS_DONE: i8 = 1; + +/// 供 `workers/order_timeout.rs` 调用(**兼容旧签名**):结算某仓体的超时订单。 /// -/// 返回实际结算的订单数(0 = 该仓体已无活跃订单,无需处理)。 +/// ⚠️ 与旧实现的两点关键区别(P0-2): +/// 1. **按 order_id 精确收尾**,不再"按仓体找最新活跃单": +/// `order_timeout` 的扫描结果是**快照**(最坏早于结单 5 分钟),到真正结单时那笔可能 +/// 已被 `bat_out`/`off` 结束、而该仓体又建了一笔**正在充电的新单**; +/// 旧实现会把**新单**当超时单结束(`reason=timeout`、电量乱、还多一条充电记录)。 +/// 2. **自己复核"确实已超时"**:先定位该仓体 `start_time` 早于 `NOW() - 10h` 的活跃订单 +/// (按 `start_time` 升序取最早一笔)。找不到就返回 0 —— +/// 这样**刚建的新单绝不会被误结**:即使调用方的扫描条件写错(该条件的方向极易搞反: +/// 写成 `start_time < TIMESTAMPADD(HOUR, 10, NOW())` 会命中**所有**活跃订单), +/// 或者快照过期(扫描到结单之间该仓体已换了一笔新单),本函数也不会误结新单。 +/// +/// 返回实际结算的订单数(0 = 该仓体没有可结算的超时订单,无需处理)。 pub async fn finalize_timeout_order( pool: &MySqlPool, redis: &ConnectionManager, compartment_id: i64, +) -> Result { + let order_id: Option = sqlx::query_scalar( + "SELECT id FROM charge_orders + WHERE compartment_id = ? AND status = 0 AND start_time IS NOT NULL + AND start_time < TIMESTAMPADD(HOUR, ?, NOW()) + ORDER BY start_time ASC, id ASC LIMIT 1", + ) + .bind(compartment_id) + .bind(-ORDER_TIMEOUT_HOURS) + .fetch_optional(pool) + .await?; + + match order_id { + Some(order_id) => { + finalize_timeout_order_by_id(pool, redis, order_id, compartment_id).await + } + None => { + tracing::debug!( + "[order_timeout] 该仓体没有已超时的活跃订单,跳过 compartment_id={}", + compartment_id + ); + Ok(0) + } + } +} + +/// 供 `workers/order_timeout.rs` 使用(**推荐改用它**,可直接拿到扫描出的 `order_id`): +/// **按 order_id 精确收尾**一笔超时订单。 +/// +/// 为什么需要 order_id:见 [`finalize_timeout_order`] 的说明(扫描结果是快照, +/// 只按 `compartment_id` 收尾会误结正在充电的新单)。 +/// +/// 电量归属:通道电量累加器属于该仓体**最新的活跃订单**(新单建单时会 `energy::reset`), +/// 所以只有"本单仍是累加器归属者"时才取电量;否则只收尾、不取电量 +/// (避免把正在充电的新单的电量算到这笔历史订单头上)。 +/// +/// 返回实际结算的订单数(0 = 该单已被其它路径结束 / 已无活跃订单)。 +pub async fn finalize_timeout_order_by_id( + pool: &MySqlPool, + redis: &ConnectionManager, + order_id: i64, + compartment_id: i64, ) -> Result { let location: Option<(String, i8, i8)> = sqlx::query_as( "SELECT cab.imei, cb.board_id, comp.channel_id @@ -1667,27 +2255,107 @@ pub async fn finalize_timeout_order( let Some((imei, board_id, channel_id)) = location else { tracing::warn!( - "[order_timeout] 仓体定位失败,跳过结算 compartment_id={}", - compartment_id + "[order_timeout] 仓体定位失败,跳过结算 compartment_id={} order_id={}", + compartment_id, + order_id ); return Ok(0); }; - finalize_order( + finalize_order_by_id( pool, redis, &imei, i64::from(board_id), i64::from(channel_id), compartment_id, + order_id, None, - 1, + ORDER_STATUS_DONE, "timeout", RECORD_STATUS_ABNORMAL, ) .await } +/// **按 order_id 精确收尾一笔订单**(自取仓体锁):所有"手里已经有 order_id"的结束路径都走它。 +/// +/// 与 [`finalize_order`] 的区别:`finalize_order` 按"该仓体最新活跃单"抢占收尾(设备上报 +/// `off`/`bat_out` 的场景,报文里只有仓体),而本函数只认 `order_id` —— +/// 用于**扫描结果可能过期**的路径(超时兜底、无电池对账兜底):若那笔单已被别的路径结束, +/// `WHERE id = ? AND status = 0` 会返回 0 行,**绝不会误结该仓体后来新建的订单**。 +/// +/// 电量归属:通道电量累加器属于该仓体**最新的活跃订单**(新单建单时会 `energy::reset`)。 +/// 因此只有"本单仍是该仓体最新活跃单"时才取电量;否则只收尾、不取电量。 +async fn finalize_order_by_id( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + compartment_id: i64, + order_id: i64, + end_soc: Option, + order_status: i8, + reason: &str, + record_status: i8, +) -> Result { + // 与建单/结单共用同一把仓体锁:保证"判断归属者 → 收尾 → 取电量"不被建单/结单穿插 + let _guard = acquire_compartment_lock(compartment_id).await; + + // 该单是否仍是通道累加器的**归属者**(= 该仓体当前最新的活跃订单)? + // 若不是(历史上曾出现重复活跃单,本单是较老的那笔),累加器属于更新的一笔, + // 取电量会把新单的电量算到它头上,故只收尾不取电量。 + let owner: Option = sqlx::query_scalar( + "SELECT id FROM charge_orders WHERE compartment_id = ? AND status = 0 + ORDER BY id DESC LIMIT 1", + ) + .bind(compartment_id) + .fetch_optional(pool) + .await?; + let owns_accumulator = owner == Some(order_id); + + // **按 order_id 精确收尾**(不再是"按仓体找最新活跃单") + let affected = sqlx::query( + "UPDATE charge_orders SET status = ?, end_time = NOW(), + end_soc = COALESCE(?, end_soc), reason = ? + WHERE id = ? AND status = 0", + ) + .bind(order_status) + .bind(end_soc) + .bind(reason) + .bind(order_id) + .execute(pool) + .await? + .rows_affected(); + + if affected == 0 { + // 已被设备上报的正常结束(bat_out/off)或其它兜底路径结单 ⇒ 不重复结算 + tracing::debug!( + "[order] 按 id 收尾未命中(已被其它路径结束)order_id={} compartment_id={} reason={}", + order_id, + compartment_id, + reason + ); + return Ok(0); + } + + settle_finished_order( + pool, + redis, + imei, + board_id, + channel_id, + order_id, + reason, + owns_accumulator, + record_status, + ) + .await?; + + Ok(affected) +} + /// 按(柜子 ID、仓控板号、通道号)查仓体 ID async fn find_compartment_id( pool: &MySqlPool, @@ -1883,37 +2551,66 @@ async fn handle_bat_out( .and_then(parse_channel_hex) .map(|c| c.soc); - // 结束该通道活跃订单(统一结算:写回平台侧积分电量 + 生成充电记录) - let updated = finalize_order( - mysql, - redis, - dev_id, - i64::from(board_id), - i64::from(channel_id), - compartment_id, - end_soc, - 1, - "user_stop", - RECORD_STATUS_MANUAL_STOP, - ) - .await - .map_err(|e| { - tracing::error!("[bat_out] 结束订单失败 dev_id={} err={}", dev_id, e); - e - })?; - if updated == 0 { + // 结束该通道活跃订单(统一结算:写回平台侧积分电量 + 生成充电记录)。 + // + // 先判定"这条 bat_out 是不是当前这笔活跃订单的电池"(见 [`should_finalize_bat_out`]): + // `bat_out` 可能是**上一块电池的迟到报文**(协议 §5.6 要求先发 off 再发 bat_out, + // 两条报文由 `tokio::spawn` 并发处理,中间人还可能已经换上新电池并建了新单), + // 那种情况下按仓体收尾会把**正在充电的新单**结束掉(reason 记成 user_stop、电量错位)。 + let active = find_active_order(mysql, compartment_id).await?; + let active_battery = active.as_ref().map(|(_, battery)| battery.as_str()); + let updated = if should_finalize_bat_out(active_battery, battery_id) { + finalize_order( + mysql, + redis, + dev_id, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + end_soc, + 1, + "user_stop", + RECORD_STATUS_MANUAL_STOP, + ) + .await + .map_err(|e| { + tracing::error!("[bat_out] 结束订单失败 dev_id={} err={}", dev_id, e); + e + })? + } else { tracing::warn!( - "[bat_out] 无活跃订单可结束 dev_id={} compartment_id={}", + "[bat_out] 该仓体活跃订单已属于另一块电池,不收尾(上一轮的迟到报文)\ + dev_id={} compartment_id={} active_battery={:?} bat_out_battery={}", + dev_id, + compartment_id, + active_battery, + battery_id + ); + 0 + }; + + if updated == 0 { + // 没有活跃订单可结束(重发 bat_out / 已被 off 或超时路径结单)⇒ + // **不复位占用状态**(与 [`handle_device_off`] 一致):此时仓体/电池可能已经属于 + // **新的一单**(新电池插入 + `on` 建单),盲目复位会把新单的占用标记清掉。 + tracing::warn!( + "[bat_out] 无活跃订单可结束,不复位占用状态 dev_id={} compartment_id={}", dev_id, compartment_id ); + } else { + // 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账) + reset_usage_status(mysql, compartment_id, battery_id).await; } - // 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账) - reset_usage_status(mysql, compartment_id, battery_id).await; - - // 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id - delete_channel_battery(redis, dev_id, board_id, channel_id).await; + // 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id。 + // + // ⚠️ 这里**刻意不按 `updated > 0` 门控**(虽然占用复位是门控的):`off` + `bat_out` + // 成对到达时,胜出的往往是 `off` ⇒ `bat_out` 拿到的 `updated == 0`, + // 而**清理通道登记只有 `bat_out` 这一条路径**,门控会让登记永远残留。 + // 真正的保护是"**登记的 battery_id 必须与本次 bat_out 的 battery_id 一致**": + // 若该通道已经插入了新电池(新 `bat_in` 覆盖了登记),就不会误删新电池的登记。 + delete_channel_battery_if_matches(redis, dev_id, board_id, channel_id, battery_id).await; log_bat_out(mysql, dev_id, msg, compartment_id, battery_id, end_soc).await; @@ -2407,6 +3104,123 @@ mod tests { assert_eq!(parse_compartment_id(&json!({"id": "-3"})), None); // 空首段 } + /// **量程上界**(协议 §3.7):仓控板 1–15、通道 1–8 —— 越界一律 `invalid id` + #[test] + fn test_parse_compartment_id_rejects_out_of_range() { + assert_eq!(parse_compartment_id(&json!({"id": "16-1"})), None); // 仓控板 > 15 + assert_eq!(parse_compartment_id(&json!({"id": "1-9"})), None); // 通道 > 8 + assert_eq!(parse_compartment_id(&json!({"id": "99-99"})), None); + // i8 能量出来但远超协议量程(如旧的 16 板/16 仓柜型数据)也必须拒绝 + assert_eq!(parse_compartment_id(&json!({"id": "127-8"})), None); + assert_eq!(parse_compartment_id(&json!({"id": "15-127"})), None); + // 量程边界内的两个端点必须**通过** + assert_eq!(parse_compartment_id(&json!({"id": "15-8"})), Some((15, 8))); + } + + /// status_post 的 board / ch 与 `parse_compartment_id` 共用同一套量程判定 + /// (防止写出 `board:0` / `ch:9` 这类垃圾 Redis key) + #[test] + fn test_board_channel_id_range_guards() { + assert!(is_valid_board_id(1) && is_valid_board_id(6) && is_valid_board_id(15)); + assert!(!is_valid_board_id(0) && !is_valid_board_id(-1) && !is_valid_board_id(16)); + assert!(is_valid_channel_id(1) && is_valid_channel_id(6) && is_valid_channel_id(8)); + assert!(!is_valid_channel_id(0) && !is_valid_channel_id(-1) && !is_valid_channel_id(9)); + // 常量本身必须与协议 §3.7 一致 + assert_eq!((MAX_BOARD_ID, MAX_CHANNEL_ID), (15, 8)); + } + + // ---- build_spec:spec 兜底(协议 §5.2 / §10 第 9 条)------------------- + + /// 拓扑完整时**必须**用真实拓扑(最准),且与 `cabinets.cell` 冲突时以拓扑为准 + #[test] + fn test_build_spec_complete_topology_prefers_real_topology() { + // 36 仓柜型:6 板 × 每板 6 仓 + assert_eq!( + build_spec(6, 36, Some(6)), + ("0606".to_string(), SpecSource::Topology) + ); + // 最小柜型:1 板 × 6 仓 + assert_eq!( + build_spec(1, 6, Some(1)), + ("0106".to_string(), SpecSource::Topology) + ); + // 拓扑与遗留 cell 不一致 → 以拓扑为准(cell=6 只是遗留值,实际配了 2 板) + assert_eq!( + build_spec(2, 12, Some(6)), + ("0206".to_string(), SpecSource::Topology) + ); + // cell 缺失但拓扑完整 → 依然是拓扑 + assert_eq!( + build_spec(3, 18, None), + ("0306".to_string(), SpecSource::Topology) + ); + } + + /// **生产实测场景**:`cabin_boards` 记录数为 0、`cabinets.cell = 6` + /// → 必须兜底成 `"0606"`,**绝不能是 `"0000"`** + #[test] + fn test_build_spec_incomplete_topology_falls_back_to_legacy_cell() { + assert_eq!( + build_spec(0, 0, Some(6)), + ("0606".to_string(), SpecSource::LegacyCell) + ); + // cell 换成别的仓控板数也照实换算(每板固定 6 通道) + assert_eq!( + build_spec(0, 0, Some(3)), + ("0306".to_string(), SpecSource::LegacyCell) + ); + // 有仓控板记录但一仓都没配(compartment_count = 0)也算拓扑不完整 + assert_eq!( + build_spec(6, 0, Some(6)), + ("0606".to_string(), SpecSource::LegacyCell) + ); + // 仓数不足每板 1 个(6 板 5 仓 → 整除得 0)同样兜底 + assert_eq!( + build_spec(6, 5, Some(4)), + ("0406".to_string(), SpecSource::LegacyCell) + ); + } + + /// `cell` 为空 / 0 / 负数 → 退到常量默认 6 板 × 6 通道 + #[test] + fn test_build_spec_no_cell_uses_constant_default() { + for cell in [None, Some(0i64), Some(-1i64)] { + assert_eq!( + build_spec(0, 0, cell), + ("0606".to_string(), SpecSource::Default), + "cell={cell:?}" + ); + } + } + + /// 兜底值收敛到协议量程(协议 §3.7 仓控板 1–15),保证结果恒为 4 位 + #[test] + fn test_build_spec_legacy_cell_clamped_into_protocol_range() { + assert_eq!( + build_spec(0, 0, Some(127)), + ("1506".to_string(), SpecSource::LegacyCell) + ); + } + + /// **绝不允许下发 `"0000"`**:遍历各种输入组合,spec 必须恒为 4 位且两段都非 0 + #[test] + fn test_build_spec_never_produces_all_zero_spec() { + for board_count in [0i64, 1, 6, 16] { + for compartment_count in [0i64, 5, 6, 36] { + for legacy_cell in [None, Some(0i64), Some(1), Some(6), Some(15)] { + let (spec, _) = build_spec(board_count, compartment_count, legacy_cell); + let ctx = format!( + "board_count={board_count} compartment_count={compartment_count} cell={legacy_cell:?}" + ); + assert_eq!(spec.len(), 4, "spec 必须固定 4 位:{ctx} got={spec}"); + assert_ne!(spec, "0000", "{ctx}"); + assert_ne!(&spec[0..2], "00", "仓控板数不得为 0:{ctx}"); + assert_ne!(&spec[2..4], "00", "每板通道数不得为 0:{ctx}"); + } + } + } + } + // ---- Redis key 契约(跨模块:device-server 写入 / cabinets.rs 读取)----- /// 通道实时状态键:板号/通道号**十进制不补零**,与 `routes/cabinets.rs` 读取端一致 @@ -2515,4 +3329,140 @@ mod tests { ); } } + + // ---- bat_out 迟到报文的收尾判定(P0-3 的核心规则)--------------------- + + /// 同电池 / 任一方未知 / 无活跃订单 → **照常收尾**(保守,避免订单挂到 10 小时超时) + #[test] + fn test_should_finalize_bat_out_when_same_or_unknown_battery() { + assert!(should_finalize_bat_out(None, "SN1"), "无活跃订单时照常调用(finalize 自身返回 0)"); + assert!(should_finalize_bat_out(None, ""), "无活跃订单 + 空电池 id"); + assert!(should_finalize_bat_out(Some("SN1"), "SN1"), "同一块电池"); + assert!(should_finalize_bat_out(Some(""), "SN2"), "活跃订单电池未知 → 无法证明换了电池"); + assert!(should_finalize_bat_out(Some("SN1"), ""), "报文电池未知 → 无法证明换了电池"); + assert!(should_finalize_bat_out(Some(""), ""), "两边都未知"); + } + + /// 迟到报文:活跃订单已是**新电池**,来的却是**上一块电池**的 `bat_out` ⇒ 必须跳过, + /// 否则会把正在充电的新单结束掉(`reason=user_stop`、电量错位) + #[test] + fn test_should_finalize_bat_out_skips_late_report_of_previous_battery() { + assert!(!should_finalize_bat_out(Some("SN_NEW"), "SN_OLD")); + } + + // ---- 电量积分的量程校验(防止位翻转把电量积分暴增)--------------------- + + /// 量程内(含真实 54V / 8A 与上界本身)必须原样通过 + #[test] + fn test_sanitize_sample_value_accepts_in_range() { + assert_eq!(sanitize_sample_value(0, MAX_VOLTAGE_MV), Some(0)); + assert_eq!(sanitize_sample_value(54_000, MAX_VOLTAGE_MV), Some(54_000)); // 真实 54V + assert_eq!(sanitize_sample_value(8_000, MAX_CURRENT_MA), Some(8_000)); // 真实 8A + // 上界本身算合法(闭区间) + assert_eq!( + sanitize_sample_value(MAX_VOLTAGE_MV, MAX_VOLTAGE_MV), + Some(MAX_VOLTAGE_MV) + ); + } + + /// 超量程 / 负数一律拒绝(调用方按 0 计并记 WARN) + #[test] + fn test_sanitize_sample_value_rejects_out_of_range() { + // 协议 16 位满量程 = 655350 mV / mA(位翻转最坏情况),必须挡掉 + assert_eq!(sanitize_sample_value(655_350, MAX_VOLTAGE_MV), None); + assert_eq!(sanitize_sample_value(655_350, MAX_CURRENT_MA), None); + assert_eq!(sanitize_sample_value(MAX_VOLTAGE_MV + 1, MAX_VOLTAGE_MV), None); + assert_eq!(sanitize_sample_value(-1, MAX_CURRENT_MA), None); + assert_eq!(sanitize_sample_value(i64::MAX, MAX_CURRENT_MA), None); + } + + /// 端到端口径:异常采样被夹成 0 后,**积分增量为 0**;正常采样仍有增量。 + /// (积分公式在 `workers/energy.rs`,此处验证"夹取 → 积分"的衔接语义) + #[test] + fn test_out_of_range_sample_contributes_zero_energy() { + let flipped = 655_350i64; // 位翻转后的满量程读数 + let bad_voltage = sanitize_sample_value(flipped, MAX_VOLTAGE_MV).unwrap_or(0); + assert_eq!(bad_voltage, 0); + assert_eq!(energy::mwh_delta(bad_voltage, 8_000, energy::ASSUMED_INTERVAL_SECS), 0); + let bad_current = sanitize_sample_value(flipped, MAX_CURRENT_MA).unwrap_or(0); + assert_eq!(energy::mwh_delta(54_000, bad_current, energy::ASSUMED_INTERVAL_SECS), 0); + + // 对照组:正常 54V × 8A × 60s = 7.2Wh = 7200mWh(若此处也变 0 说明夹取写错了) + let good_voltage = sanitize_sample_value(54_000, MAX_VOLTAGE_MV).unwrap_or(0); + let good_current = sanitize_sample_value(8_000, MAX_CURRENT_MA).unwrap_or(0); + assert_eq!( + energy::mwh_delta(good_voltage, good_current, energy::ASSUMED_INTERVAL_SECS), + 7_200 + ); + } + + // ---- 订单生命周期的串行化锁(P0-1 / P0-2)--------------------- + + /// 锁键就是仓体 ID 本身(不做任何再映射:映射只会引入"撞锁"的额外风险) + #[test] + fn test_compartment_lock_key_is_identity() { + assert_eq!(compartment_lock_key(1), 1); + assert_eq!(compartment_lock_key(0), 0); + assert_eq!(compartment_lock_key(9_999_999), 9_999_999); + } + + /// **同一仓体互斥**:这里直接验证锁语义(不碰 DB/Redis), + /// 也就是 P0-1 里"两个并发任务只有一个能拿到结束权"的底层保证。 + /// 用负数 id 与真实仓体隔离(同一测试进程内多个测试共享同一张锁表)。 + #[tokio::test] + async fn test_compartment_lock_excludes_same_compartment() { + let compartment_id = -101_i64; + let guard = acquire_compartment_lock(compartment_id).await; + + let waiter = tokio::spawn(async move { + let _guard = acquire_compartment_lock(compartment_id).await; + }); + // 给被 spawn 的任务足够的调度机会:若锁没生效,它早就跑完了 + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !waiter.is_finished(), + "同一仓体的锁必须互斥:持锁期间等待方不得完成" + ); + + drop(guard); + assert!( + waiter.await.is_ok(), + "释放锁后等待方必须能取得同一仓体的锁" + ); + } + + /// **不同仓体不互相阻塞**:锁必须按仓体粒度隔离,否则整柜的建单/结单会串行化 + #[tokio::test] + async fn test_compartment_lock_allows_different_compartments() { + let guard_a = acquire_compartment_lock(-201).await; + let acquired_b = tokio::time::timeout( + Duration::from_millis(500), + acquire_compartment_lock(-202), + ) + .await; + assert!( + acquired_b.is_ok(), + "不同仓体不应互相阻塞(否则柜内并发会被无谓串行化)" + ); + drop(guard_a); + } + + /// 超时兜底的"安全线":阈值必须**往回** 10 小时(`TIMESTAMPADD` 绑定负数)。 + /// 若写成 `+10`,`start_time < NOW() + 10h` 会命中**所有**活跃订单, + /// 刚建的新单也会被当成超时单(`workers/order_timeout.rs` 现存缺陷); + /// [`finalize_timeout_order`] 正是靠这个负号自保。 + #[test] + fn test_order_timeout_bound_goes_backwards() { + assert_eq!(ORDER_TIMEOUT_HOURS, 10); + assert_eq!(-ORDER_TIMEOUT_HOURS, -10); + } + + /// 非"数据库错误"的 sqlx 错误**不能**被当成唯一键冲突(否则会把真错误静默吞掉)。 + /// 正例(MySQL 1062 → `ErrorKind::UniqueViolation`)由 sqlx-mysql 的错误号映射保证, + /// 且已用真实 MySQL 8.0.36 验证过 `active_flag` 唯一键会报 1062(见交付报告)。 + #[test] + fn test_is_unique_violation_ignores_non_database_errors() { + assert!(!is_unique_violation(&sqlx::Error::RowNotFound)); + assert!(!is_unique_violation(&sqlx::Error::PoolClosed)); + } } diff --git a/software/device-server/src/main.rs b/software/device-server/src/main.rs index 5d17224..61f7429 100644 --- a/software/device-server/src/main.rs +++ b/software/device-server/src/main.rs @@ -42,8 +42,8 @@ async fn main() { .expect("Redis 连接失败"); tracing::info!("Redis 连接成功"); - // 初始化设备连接池(注入 Redis:下发指令时写挂起登记 `pending:{dev_id}`) - let device_pool = tcp::connection::ConnectionPool::new(redis_conn.clone()); + // 初始化设备连接池(纯内存结构:`dev_id` → 连接,增删按 `conn_id` 校验归属) + let device_pool = tcp::connection::ConnectionPool::new(); // 通过 Redis 注册节点信息 register_node(&redis_conn, &cfg).await; @@ -227,7 +227,8 @@ async fn consume_commands( } _ => { // 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景) - match pool.send_with_msgid(&dev_id, &cmd_json).await { + // Redis 由调用方传入(仅用于挂起登记 `pending:{dev_id}`,失败不影响下发) + match pool.send_with_msgid(&redis, &dev_id, &cmd_json).await { Ok(()) => { tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id); } diff --git a/software/device-server/src/tcp/connection.rs b/software/device-server/src/tcp/connection.rs index 8e292cb..9747e53 100644 --- a/software/device-server/src/tcp/connection.rs +++ b/software/device-server/src/tcp/connection.rs @@ -2,12 +2,40 @@ //! //! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。 //! `ConnectionPool` 以 `dev_id`(IMEI)为键,支持并发读写。 +//! +//! # 安全模型(C1 加固,2026-09) +//! +//! `dev_id` 来自**报文**,且在签名校验之前就已到达(应答必须经连接池下发, +//! 先注册才能回复),因此它**不可作为"池条目归属"的依据**。原实现的两个缺陷: +//! 1. `register` 是覆盖式 `insert`:任意未认证连接用受害 IMEI 发一条 +//! `auth_str` 就能顶掉真机的池条目(真机后续上行被判"未登录"全部丢弃); +//! 2. 断连时无条件 `remove(dev_id)`:攻击者断开即可删掉**真机**的池条目 → +//! 平台永久判定设备离线(指令全进 `device:commands:dead`), +//! 每 25 秒重连一次即可维持永久 DoS。 +//! +//! 修法:每条 TCP 连接分配一个进程内唯一的 `conn_id`,池的关键操作都带 conn_id 校验: +//! - [`ConnectionPool::register`]:**允许接管**同 `dev_id` 的旧条目(未认证占位 / 已认证连接), +//! 接管即**主动驱逐**旧连接(MQTT 同 clientId 接管语义);**不允许**拒绝新连接 +//! - [`ConnectionPool::remove_if`]:只删除 conn_id 与本连接一致的那条条目 +//! - [`ConnectionPool::is_authenticated`] / [`ConnectionPool::is_owner`]:归属同样按 `conn_id` 判定 +//! - [`ConnectionPool::touch`]:只有当前持有者能刷新 `last_seen` +//! +//! 为什么"接管"而不是"拒绝覆盖已认证连接":4G 设备**静默掉线**(NAT 超时 / 信号丢失)时, +//! 旧 socket 在服务端可能仍是 ESTABLISHED、池中条目仍是 `authenticated`。若拒绝新连接, +//! 真机重连后将**发什么都进不了业务链路**,只能等旧 socket 因写失败或内核 TCP 重传超时被清理 +//! (分钟级、最坏十几分钟)—— 这是正常网络抖动下的可用性事故。接管 + 主动驱逐则让真机 +//! 立即恢复,且**不需要新增任何协议报文**(协议冻结)。 +//! +//! 连接池本身是**纯内存**结构(不含 Redis);下发指令时的挂起登记 +//! (`pending:{dev_id}`)由调用方把 Redis 连接传进来,便于单元测试构造无外部依赖的池。 use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use tokio::sync::RwLock; use tokio::sync::mpsc; +use tokio::sync::oneshot; /// 敏感指令列表(未认证设备不得执行) const SENSITIVE_ACTS: &[&str] = &["open", "pow_on", "pow_off", "reset", "auth_str"]; @@ -20,8 +48,22 @@ const PENDING_TTL_SECS: u32 = 120; /// 单台设备的连接状态。 pub struct DeviceConnection { + /// 本 TCP 连接的进程内唯一标识(见 [`next_conn_id`])。 + /// + /// 池条目的增删都以此校验归属:`dev_id` 可伪造,`conn_id` 不可 —— + /// 它由服务端在连接建立时分配,报文里没有对应字段(协议不变)。 + pub conn_id: u64, /// 向设备发送指令的通道。 pub tx: mpsc::Sender, + /// **主动驱逐句柄**:本条目被新连接接管时,连接池调用 [`DeviceConnection::evict`] + /// 发出信号 → 对应连接任务 `select!` 里的接收端立刻就绪,旧连接立即关闭 socket + /// 并走断连清理。 + /// + /// 为什么是"服务端内部信号"而不是发报文:协议冻结,不允许用 `ping` 之类自造帧 + /// 去通知设备;而且要驱逐的是**服务端侧的旧 socket**,与设备无关。 + /// 为什么不是"关掉 `tx`":`tx` 只是**写通道的克隆**,连接任务自己还持有一份, + /// 丢掉池里这份并不会让通道关闭,旧任务不会因此退出。 + close: oneshot::Sender<()>, /// 最后活跃时间(收到任意消息时更新)。 pub last_seen: Instant, /// 是否已完成登录验证。 @@ -31,13 +73,27 @@ pub struct DeviceConnection { next_msg_id: u32, } +/// `conn_id` 自增源。`0` 保留为"无效/未知",因此从 `1` 起发号。 +static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1); + +/// 分配一个进程内唯一的连接标识。 +/// +/// 用 `AtomicU64` 自增(进程重启后从 1 重新开始):只要**同一进程内**不会重复即可 —— +/// 池是进程内结构,重启后池也一并清空,无需全局唯一。 +pub fn next_conn_id() -> u64 { + NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed) +} + impl DeviceConnection { /// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。 - pub fn new(tx: mpsc::Sender) -> Self { + /// + /// `conn_id` 由 [`next_conn_id`] 在 TCP 连接建立时分配(每条连接一个,不重复使用); + /// `close` 是**主动驱逐句柄**,连接任务持有配对的接收端(见结构体字段注释)。 + pub fn new(tx: mpsc::Sender, conn_id: u64, close: oneshot::Sender<()>) -> Self { let now = Instant::now(); // 初值 1:协议定稿「两个方向各自独立计数,都每连接从 1 起自增,0 不使用」; // 且 api-server 多处用 `unwrap_or(0)` 兜底,用 0 起会与"缺号"混淆 - Self { tx, last_seen: now, authenticated: false, next_msg_id: 1 } + Self { conn_id, tx, close, last_seen: now, authenticated: false, next_msg_id: 1 } } /// 标记活跃时间。 @@ -51,39 +107,129 @@ impl DeviceConnection { self.next_msg_id += 1; id } + + /// **主动驱逐**本连接:向持有本条目驱逐句柄的连接任务发信号,让它立即退出。 + /// + /// 由连接池在"条目被新连接接管(未认证占位 / 已认证连接)"时调用 —— + /// 对端任务在 `select!` 里收到信号后关闭 socket 并走断连清理。 + /// **不新增任何协议报文**(协议冻结,不能自造帧去通知设备)。 + /// 发送失败表示对端已结束或已不再监听,属正常情况,忽略即可。 + fn evict(self) { + let _ = self.close.send(()); + } } -/// 线程安全的设备连接池。 +/// [`ConnectionPool::register`] 的结果。 +/// +/// 三种结果**都表示本连接已接管该 `dev_id`**(`server.rs` 据此绑定 `current_dev_id`); +/// 区别只在"顶掉了谁",用于日志区分(接管已认证连接是排查"设备反复掉线/被顶替"的关键信号)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegisterOutcome { + /// 池中原本没有该设备 → 新登记 + Inserted, + /// 池中原本是**未认证**占位(攻击者占位,或真机重连而旧连接尚未被清理) + /// → 已顶替并驱逐该占位,本连接接管该 `dev_id` + ReplacedUnauthenticated { + /// 被顶替的连接标识(仅用于日志) + evicted_conn_id: u64, + }, + /// 池中原本是**已认证**连接 → 已接管并驱逐(MQTT 同 clientId 接管语义)。 + /// + /// 典型场景:设备**静默掉线**(NAT 超时 / 4G 信号丢失)后重连,而服务端侧旧 socket + /// 仍被视为 ESTABLISHED、旧条目仍是 `authenticated`。此时必须让真机立即接管, + /// 否则真机发什么都进不了业务链路(可用性事故)。 + /// 代价:攻击者也能用受害 IMEI 顶掉真机连接 —— 但**顶不掉数据可信性**: + /// 新条目 `authenticated` 从 `false` 重新开始,而 `login` 签名由 api-server 校验, + /// 攻击者过不了这一关;真机随即重连即可再接管回来(协议「IMEI 即身份」的固有代价)。 + ReplacedAuthenticated { + /// 被接管的连接标识(仅用于日志;其连接已被主动驱逐) + evicted_conn_id: u64, + }, +} + +/// 线程安全的设备连接池(纯内存,无外部依赖)。 #[derive(Clone)] pub struct ConnectionPool { inner: Arc>>, - /// 挂起登记用的 Redis 连接(`pending:{dev_id}`)。 - /// 仅用于辅助登记,**任何 Redis 失败都不得影响指令下发**。 - redis: redis::aio::ConnectionManager, } impl ConnectionPool { - pub fn new(redis: redis::aio::ConnectionManager) -> Self { - Self { inner: Arc::new(RwLock::new(HashMap::new())), redis } + pub fn new() -> Self { + Self { inner: Arc::new(RwLock::new(HashMap::new())) } } - /// 注册设备连接。如果已存在则替换旧连接。 - pub async fn register(&self, dev_id: String, conn: DeviceConnection) { + /// 注册设备连接(`dev_id` = IMEI,来自报文,**不可信**)。 + /// + /// 语义(C1 加固,替代原覆盖式 `insert`): + /// - 池中尚无该 `dev_id` → [`RegisterOutcome::Inserted`] + /// - 池中已有**未认证**占位 → [`RegisterOutcome::ReplacedUnauthenticated`] + /// - 池中已有**已认证**连接 → [`RegisterOutcome::ReplacedAuthenticated`](接管,不拒绝) + /// + /// **三条路径都不拒绝新连接**,旧条目一律被换出池并被**主动驱逐** + /// (调用 [`DeviceConnection::evict`] → 旧连接任务立即关闭 socket, + /// 不依赖设备下次上行,也不新增协议报文)。 + /// + /// 注意:注册发生在**签名校验之前**(`login`/`auth_str` 的应答必须经本池下发), + /// 所以归属只能靠 `conn_id`;身份真实性由 api-server 的签名校验保证, + /// 本层只保证"条目归属唯一 + 旧连接被驱逐"。 + pub async fn register(&self, dev_id: String, conn: DeviceConnection) -> RegisterOutcome { let mut map = self.inner.write().await; - map.insert(dev_id, conn); + match map.get(&dev_id) { + None => { + map.insert(dev_id, conn); + RegisterOutcome::Inserted + } + Some(existing) if existing.authenticated => { + let evicted_conn_id = existing.conn_id; + // 接管即驱逐:旧条目被换出 → 立即通知旧连接退出 + if let Some(evicted) = map.insert(dev_id, conn) { + evicted.evict(); + } + RegisterOutcome::ReplacedAuthenticated { evicted_conn_id } + } + Some(existing) => { + let evicted_conn_id = existing.conn_id; + if let Some(evicted) = map.insert(dev_id, conn) { + evicted.evict(); + } + RegisterOutcome::ReplacedUnauthenticated { evicted_conn_id } + } + } } - /// 移除设备连接(断连时调用)。 - pub async fn remove(&self, dev_id: &str) { + /// 按 `conn_id` 归属移除连接(断连时调用)。 + /// + /// **只有**池中该 `dev_id` 的条目 `conn_id` 与本连接一致时才删除,返回是否删除。 + /// 这是防 DoS 的关键:若无条件 `remove(dev_id)`,攻击者用受害 IMEI 占位后断开 + /// 就会把**真机**的池条目一并删掉 → 平台永久判定该设备离线。 + /// 已认证条目被接管后,旧连接调用本方法返回 `false`(条目已归新连接)。 + pub async fn remove_if(&self, dev_id: &str, conn_id: u64) -> bool { let mut map = self.inner.write().await; - map.remove(dev_id); + let owned_by_this_conn = matches!(map.get(dev_id), Some(c) if c.conn_id == conn_id); + if owned_by_this_conn { + map.remove(dev_id); + } + owned_by_this_conn } - /// 查询该设备连接是否已通过 `login` 认证。 - /// 连接不存在或尚未认证时返回 `false`。 - pub async fn is_authenticated(&self, dev_id: &str) -> bool { + /// 池中该 `dev_id` 的条目是否仍属于 `conn_id` 这条连接(不看认证态)。 + /// + /// 用途:连接任务在处理每条上行前确认"我还是这个 `dev_id` 的持有者吗"—— + /// 已被接管(superseded)的连接据此立刻断开,不再处理任何上行。 + pub async fn is_owner(&self, dev_id: &str, conn_id: u64) -> bool { let map = self.inner.read().await; - map.get(dev_id).map(|c| c.authenticated).unwrap_or(false) + map.get(dev_id).map(|c| c.conn_id == conn_id).unwrap_or(false) + } + + /// 查询该设备连接是否已通过 `login` 认证,且池条目仍属于 `conn_id` 这条连接。 + /// + /// 连接不存在、尚未认证、或池条目已被别的连接接管时返回 `false` + /// —— 最后一个条件很重要:被接管的连接不能"借用"新连接的已认证身份伪造该 IMEI 的上行。 + pub async fn is_authenticated(&self, dev_id: &str, conn_id: u64) -> bool { + let map = self.inner.read().await; + map.get(dev_id) + .map(|c| c.conn_id == conn_id && c.authenticated) + .unwrap_or(false) } /// 向指定设备发送指令并注入自增 msg_id。 @@ -97,13 +243,21 @@ impl ConnectionPool { /// /// 指令在注入 `msg_id` 后写入 Redis 挂起表 `pending:{dev_id}`, /// 供 api-server 把设备回执配对回具体指令(见 `register_pending`)。 - pub async fn send_with_msgid(&self, dev_id: &str, json: &str) -> Result<(), &'static str> { + /// + /// `redis` 由调用方(`main.rs` 的指令消费循环)传入:连接池本身是纯内存结构, + /// 挂起登记只是**辅助功能** —— 任何 Redis 失败都不得影响指令下发。 + pub async fn send_with_msgid( + &self, + redis: &redis::aio::ConnectionManager, + dev_id: &str, + json: &str, + ) -> Result<(), &'static str> { let pending = self.prepare_outbound(dev_id, json).await?; // 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。 // 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。 if let Some((msg_id, act)) = pending { - register_pending(&self.redis, dev_id, msg_id, &act).await; + register_pending(redis, dev_id, msg_id, &act).await; } Ok(()) } @@ -162,11 +316,16 @@ impl ConnectionPool { Ok(pending) } - /// 更新设备活跃时间。 - pub async fn touch(&self, dev_id: &str) { + /// 更新设备活跃时间(**仅当前持有者**可刷新)。 + /// + /// 必须带 `conn_id` 校验:否则被接管的旧连接能替持有人刷新 `last_seen`, + /// 让"最后活跃时间"失去意义(例如将来基于 `last_seen` 做陈旧接管判断时会被绕过)。 + pub async fn touch(&self, dev_id: &str, conn_id: u64) { let mut map = self.inner.write().await; if let Some(conn) = map.get_mut(dev_id) { - conn.touch(); + if conn.conn_id == conn_id { + conn.touch(); + } } } } @@ -225,22 +384,42 @@ async fn register_pending( } // --------------------------------------------------------------------------- -// 单元测试:下行编号与内部字段(协议 §3.4 / §3.5) +// 单元测试:下行编号与内部字段(协议 §3.4 / §3.5)+ 连接池归属(C1) // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; - use tokio::sync::mpsc; + use tokio::sync::{mpsc, oneshot}; - fn conn() -> DeviceConnection { + /// 构造一条连接状态(丢弃驱逐信号接收端:用例不关心旧连接是否被驱逐) + fn conn(conn_id: u64) -> DeviceConnection { + conn_with_close(conn_id).0 + } + + /// 构造一条连接状态,并返回其**驱逐信号接收端** —— + /// 条目离开池(被接管)时该接收端会立刻以 `Err(RecvError)` 就绪 + fn conn_with_close(conn_id: u64) -> (DeviceConnection, oneshot::Receiver<()>) { let (tx, _rx) = mpsc::channel(4); - DeviceConnection::new(tx) + let (close_tx, close_rx) = oneshot::channel::<()>(); + (DeviceConnection::new(tx, conn_id, close_tx), close_rx) + } + + /// 构造一条**已认证**连接(模拟池中已有的真机连接) + fn authed_conn(conn_id: u64) -> DeviceConnection { + authed_conn_with_close(conn_id).0 + } + + /// 已认证连接 + 驱逐信号接收端 + fn authed_conn_with_close(conn_id: u64) -> (DeviceConnection, oneshot::Receiver<()>) { + let (mut c, rx) = conn_with_close(conn_id); + c.authenticated = true; + (c, rx) } /// 协议定稿:两个方向各自独立计数,**都每连接从 `1` 起自增**(`0` 不使用) #[test] fn test_alloc_msg_id_starts_at_one_and_increments() { - let mut c = conn(); + let mut c = conn(1); assert_eq!(c.alloc_msg_id(), 1); assert_eq!(c.alloc_msg_id(), 2); assert_eq!(c.alloc_msg_id(), 3); @@ -249,6 +428,155 @@ mod tests { /// 新建连接默认未认证 —— 未认证时敏感指令会被拦、业务报文会被丢弃 #[test] fn test_new_connection_is_unauthenticated() { - assert!(!conn().authenticated); + assert!(!conn(1).authenticated); + } + + /// `conn_id` 由进程内自增分配:严格不重复(池条目归属的唯一依据) + #[test] + fn test_next_conn_id_is_unique_and_increasing() { + let a = next_conn_id(); + let b = next_conn_id(); + assert_ne!(a, b); + assert!(b > a); + } + + /// C1-①:已认证条目**可以**被新连接接管(MQTT 同 clientId 语义), + /// 接管后归属与认证态都转给新连接,旧连接被**主动驱逐**。 + /// + /// 为什么必须允许接管:4G 设备静默掉线(NAT 超时 / 信号丢失)时旧 socket 在服务端 + /// 可能仍是 ESTABLISHED、条目仍是 `authenticated`;若拒绝新连接,真机重连后 + /// 发什么都进不了业务链路(可用性事故)。 + #[tokio::test] + async fn test_register_takes_over_authenticated_connection() { + let pool = ConnectionPool::new(); + let (old, old_close_rx) = authed_conn_with_close(1); + assert_eq!(pool.register("imei-owner".to_string(), old).await, RegisterOutcome::Inserted); + + // 真机重连(conn_id=2)→ 必须接管成功,而不是被拒绝 + let outcome = pool.register("imei-owner".to_string(), authed_conn(2)).await; + assert_eq!( + outcome, + RegisterOutcome::ReplacedAuthenticated { evicted_conn_id: 1 }, + "已认证条目必须允许被新连接接管" + ); + + // 旧连接收到**主动驱逐信号**(其条目离开池 → 驱逐句柄 drop): + // 旧任务会因此立即关闭 socket,无需设备配合、也无新增协议报文 + assert!(old_close_rx.await.is_ok(), "旧连接必须收到主动驱逐信号"); + + // 归属与认证态都归属于新连接 + assert!(!pool.is_owner("imei-owner", 1).await, "旧连接不再持有条目"); + assert!(pool.is_owner("imei-owner", 2).await, "新连接成为持有者"); + assert!(!pool.is_authenticated("imei-owner", 1).await, "旧连接不再算已认证"); + assert!(pool.is_authenticated("imei-owner", 2).await, "认证态归新连接"); + + // 旧连接再也不能删条目(否则会把真机条目删掉 → 平台永久判离线) + assert!(!pool.remove_if("imei-owner", 1).await, "被接管的连接无权删除条目"); + assert!(pool.is_owner("imei-owner", 2).await, "条目仍在(属于新连接)"); + } + + /// C1-②:未认证占位**会**被新连接替换(真机重连必须能顶掉攻击者占的位), + /// 且占位连接同样被主动驱逐。 + #[tokio::test] + async fn test_register_replaces_unauthenticated_placeholder() { + let pool = ConnectionPool::new(); + let (placeholder, placeholder_close_rx) = conn_with_close(11); + assert_eq!( + pool.register("imei-victim".to_string(), placeholder).await, + RegisterOutcome::Inserted + ); + + // 真机(新连接)连上来 → 顶替未认证占位 + let outcome = pool.register("imei-victim".to_string(), conn(12)).await; + assert_eq!( + outcome, + RegisterOutcome::ReplacedUnauthenticated { evicted_conn_id: 11 }, + "未认证占位必须允许被顶替" + ); + assert!(placeholder_close_rx.await.is_ok(), "占位连接必须被主动驱逐"); + + // 归属已转移:旧占位连接既不能删条目,也不能借用新连接的认证态 + assert!(!pool.remove_if("imei-victim", 11).await, "被顶替的连接已失去条目归属"); + assert!(!pool.is_authenticated("imei-victim", 12).await, "新连接尚未认证"); + assert!(pool.remove_if("imei-victim", 12).await, "新连接已接管该 dev_id"); + } + + /// C1-③:接管后旧连接**被拒绝继续上行**(等价于 superseded 标记): + /// 它既不持有条目、也不被算作已认证,`server.rs` 的上行门控会直接丢弃其报文; + /// 同时驱逐信号已发出(连接任务会退出)。 + #[tokio::test] + async fn test_evicted_connection_is_rejected_for_further_uplink() { + let pool = ConnectionPool::new(); + let (old, old_close_rx) = authed_conn_with_close(41); + assert_eq!(pool.register("imei-super".to_string(), old).await, RegisterOutcome::Inserted); + + // 新连接接管 + assert_eq!( + pool.register("imei-super".to_string(), authed_conn(42)).await, + RegisterOutcome::ReplacedAuthenticated { evicted_conn_id: 41 } + ); + + assert!(old_close_rx.await.is_ok(), "驱逐信号必须已发出"); + assert!(!pool.is_owner("imei-super", 41).await, "旧连接不再是持有者 → 上行前检查即断开"); + assert!( + !pool.is_authenticated("imei-super", 41).await, + "旧连接的上行必须被拒(认证态已不归它)" + ); + assert!(pool.is_authenticated("imei-super", 42).await, "新连接的上行正常放行"); + } + + /// C1-④:`touch` 必须按 conn_id 校验归属 —— + /// 被接管的旧连接不得替持有人刷新 `last_seen`(否则"最后活跃时间"失去意义)。 + #[tokio::test] + async fn test_touch_requires_conn_ownership() { + let pool = ConnectionPool::new(); + assert_eq!( + pool.register("imei-touch".to_string(), authed_conn(51)).await, + RegisterOutcome::Inserted + ); + let snapshot = pool.inner.read().await.get("imei-touch").map(|c| c.last_seen); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + // 非持有者(旧连接 50 / 无关连接 52)刷新:无效 + pool.touch("imei-touch", 50).await; + pool.touch("imei-touch", 52).await; + let after_wrong = pool.inner.read().await.get("imei-touch").map(|c| c.last_seen); + assert_eq!(after_wrong, snapshot, "非持有者不得刷新 last_seen"); + + // 持有者刷新:生效 + pool.touch("imei-touch", 51).await; + let after_right = pool.inner.read().await.get("imei-touch").map(|c| c.last_seen); + assert!(after_right > after_wrong, "持有者应刷新 last_seen"); + } + + /// C1-⑤:`remove_if` 用**错误**的 conn_id 时**不删除**条目 + /// (否则攻击者断开就能删掉真机条目 → 平台永久判定设备离线) + #[tokio::test] + async fn test_remove_if_wrong_conn_id_keeps_entry() { + let pool = ConnectionPool::new(); + assert_eq!( + pool.register("imei-live".to_string(), authed_conn(21)).await, + RegisterOutcome::Inserted + ); + + // 攻击者连接(conn_id=999,从未注册)断连 + assert!(!pool.remove_if("imei-live", 999).await); + // 真机条目必须原封不动 + assert!(pool.is_authenticated("imei-live", 21).await, "错误 conn_id 不得删除条目"); + } + + /// C1-⑥:`remove_if` 用**正确**的 conn_id 时删除条目(正常断连清理) + #[tokio::test] + async fn test_remove_if_matching_conn_id_removes_entry() { + let pool = ConnectionPool::new(); + assert_eq!( + pool.register("imei-bye".to_string(), authed_conn(31)).await, + RegisterOutcome::Inserted + ); + + assert!(pool.remove_if("imei-bye", 31).await); + assert!(!pool.is_authenticated("imei-bye", 31).await, "条目已删除"); + // 删除后同一连接再删一次不应成功(幂等,不误伤后续新连接) + assert!(!pool.remove_if("imei-bye", 31).await); } } diff --git a/software/device-server/src/tcp/protocol.rs b/software/device-server/src/tcp/protocol.rs index 37d2632..6bbf0c9 100644 --- a/software/device-server/src/tcp/protocol.rs +++ b/software/device-server/src/tcp/protocol.rs @@ -21,12 +21,21 @@ pub struct DeviceMessage { /// **其余上行一律不带** —— 连接建立后设备身份即由该 TCP 连接确定。 /// 内部转发到 Redis `device:reports` 时由 handler 补写该字段(api-server 依赖它识别设备)。 pub dev_id: Option, + /// 报文序号:协议要求 **uint32 双向必带**(应答与请求的 `msg_id` 必须一致)。 + /// + /// `None` 时**不序列化该字段**:否则会向下游输出 `"msg_id":null`, + /// 与"必带 uint32"的契约不符(解析侧不受影响,缺字段即 `None`)。 + #[serde(skip_serializing_if = "Option::is_none")] pub msg_id: Option, /// 登录签名时的时间戳(秒级) pub timestamp: Option, - /// SHA256 签名尾巴 7 位 + /// SHA256 签名尾巴 **8 位**(v1.1 定稿:取 `hex[56..64]`) pub sign: Option, - /// 485 子设备 ID(仓控板 ID 1-6) + /// ⚠️ **已废弃**:485 子设备 ID(历史字段)。 + /// + /// 新协议已不用它寻址 —— 动作目标统一走 `id`(`仓控板-通道`,仓控板 **1–15**、通道 **1–8**,§3.7), + /// `status_post` 的板号走 `boards[].board`。字段暂保留仅为**兼容历史设备/日志**, + /// 新固件不应再发(原先注释写的"仓控板 ID 1-6"是旧量程,已按协议改为 1–15)。 pub sub_device_id: Option, /// 4G 信号强度 pub rssi: Option, @@ -144,4 +153,32 @@ mod tests { assert!(msg.battery.is_none()); } } + + /// `msg_id` 序列化契约:协议要求 uint32 双向必带,`None` 时**不得输出 `"msg_id":null`** + /// (转发到 Redis 的 JSON 会被 api-server 与固件按"必带"解析) + #[test] + fn test_device_message_msg_id_none_omitted_on_serialize() { + let msg: DeviceMessage = serde_json::from_str(r#"{"act":"status_post"}"#).unwrap(); + assert!(msg.msg_id.is_none()); + let out = serde_json::to_string(&msg).unwrap(); + assert!( + !out.contains("msg_id"), + "msg_id 为 None 时不得出现在序列化结果里: {out}" + ); + + // 有 msg_id 时必须原样保留(协议:应答要回带同一个值) + let msg: DeviceMessage = serde_json::from_str(r#"{"act":"login","msg_id":7}"#).unwrap(); + assert_eq!(msg.msg_id, Some(7)); + let out = serde_json::to_string(&msg).unwrap(); + assert!(out.contains(r#""msg_id":7"#), "msg_id 必须原样序列化: {out}"); + } + + /// 加 `skip_serializing_if` **不能破坏解析**:缺 `msg_id` 的报文仍要能反序列化 + #[test] + fn test_device_message_msg_id_absent_still_deserializes() { + let msg: DeviceMessage = + serde_json::from_str(r#"{"act":"auth_str","dev_id":"864720562557021"}"#).unwrap(); + assert_eq!(msg.msg_id, None); + assert_eq!(msg.dev_id.as_deref(), Some("864720562557021")); + } } diff --git a/software/device-server/src/tcp/server.rs b/software/device-server/src/tcp/server.rs index fc4b9e4..2d19e33 100644 --- a/software/device-server/src/tcp/server.rs +++ b/software/device-server/src/tcp/server.rs @@ -5,7 +5,7 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::sync::mpsc; use tokio_util::codec::{FramedRead, LinesCodec}; -use super::connection::{ConnectionPool, DeviceConnection}; +use super::connection::{ConnectionPool, DeviceConnection, RegisterOutcome, next_conn_id}; use super::handler::handle_message; use super::protocol::DeviceMessage; @@ -65,9 +65,22 @@ async fn handle_connection( // 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积 let (tx, mut rx) = mpsc::channel::(100); + // **主动驱逐信号**:本连接的池条目一旦离开池(被新连接接管),条目里的 + // `DeviceConnection::close`(oneshot 发送端)随之 drop → 下面 `select!` 的接收端 + // 立刻就绪 → 本连接立即关闭 socket 并退出。不依赖设备下次上行, + // 也**不新增任何协议报文**(协议冻结,不能用自造帧通知设备)。 + let (close_tx, mut close_rx) = tokio::sync::oneshot::channel::<()>(); + let mut current_dev_id: Option = None; let mut writer = writer; let connected_at = std::time::Instant::now(); + // 本 TCP 连接的唯一标识:池条目的创建/删除/认证态判定都以它校验归属。 + // `dev_id` 来自报文(不可信),只有 `conn_id` 能证明"池里那条entry是不是我的"。 + let conn_id = next_conn_id(); + + // 连接状态在首次 `auth_str`/`login` 注册时才进池;这里先构造好放在 `Option` 里, + // 注册时 `take()`(`close_tx` 只能移动一次,而注册代码在循环体内)。 + let mut pending_conn = Some(DeviceConnection::new(tx.clone(), conn_id, close_tx)); loop { tokio::select! { @@ -88,31 +101,102 @@ async fn handle_connection( }; // 如果是 login/auth_str,需要 dev_id 来关联连接 - // 注意:注册在签名验证之前,因为 login 响应(auth_str/cell)必须通过 - // 连接池下发。先注册才能回复。纯内网 TCP + 真实设备重连快,攻击面有限。 - // 审查编号:I3(已确认不修,协议约束) + // + // ⚠️ 安全:注册发生在**签名校验之前**(`auth_str`/`login` 的应答 + // 必须经连接池下发,先注册才能回复),所以报文里的 `dev_id` 是 + // **不可信输入**。原注释曾以"纯内网 TCP + 真实设备重连快"为由 + // 认为攻击面有限(审查编号 I3,不修)—— 该前提**不成立**: + // 9001 必须对 4G 设备公网可达,任何能连上端口的人都能伪造 IMEI。 + // 真实威胁(C1): + // `nc host 9001` 发 `{"act":"auth_str","dev_id":"<受害IMEI>","msg_id":1}` + // 1) 覆盖式 register 会把真机的池条目换成攻击者连接 → + // 真机此后所有上行被判"未登录"丢弃; + // 2) 攻击者断开时无条件 remove 又删掉真机条目 → + // 平台永久"设备不在线",每 25 秒重连一次即可维持永久 DoS。 + // 本次修法(不改 TCP 协议,纯服务端逻辑): + // 1) 每条连接分配唯一 `conn_id`,池操作按 conn_id 校验归属; + // 2) `register` **允许接管**同 dev_id 的旧条目(未认证占位 / 已认证连接, + // MQTT 同 clientId 接管语义),接管时显式调用 `evict()` 主动驱逐旧连接 + // —— 既不拒绝真机重连(4G 静默掉线后旧 socket 可能仍是 ESTABLISHED), + // 也不会让攻击者把真机挤下线后就无人能接管回来; + // 3) 断连清理改用 `remove_if(dev_id, conn_id)`,只删自己的条目; + // 4) 认证态与活跃时间都按 `conn_id` 归属判定,被接管的连接不能借用 + // 新连接的已认证身份、也不能替它刷新 `last_seen`。 if (msg.act == "auth_str" || msg.act == "login") && let Some(ref dev_id) = msg.dev_id - && current_dev_id.is_none() { - let conn = DeviceConnection::new(tx.clone()); - pool.register(dev_id.clone(), conn).await; - current_dev_id = Some(dev_id.clone()); - // 首次识别设备时写入 route - update_device_route(&redis, dev_id, &node_id).await; + && current_dev_id.is_none() + && let Some(conn) = pending_conn.take() { + match pool.register(dev_id.clone(), conn).await { + RegisterOutcome::Inserted => { + current_dev_id = Some(dev_id.clone()); + // 首次识别设备时写入 route + update_device_route(&redis, dev_id, &node_id).await; + } + RegisterOutcome::ReplacedUnauthenticated { evicted_conn_id } => { + // 顶替未认证占位(攻击者占位 / 真机重连而旧连接未被清理) + tracing::warn!( + "[安全] dev_id={} 的未认证占位连接(conn_id={}) 已被本连接(conn_id={}, peer={}) 顶替并驱逐", + dev_id, + evicted_conn_id, + conn_id, + peer + ); + current_dev_id = Some(dev_id.clone()); + update_device_route(&redis, dev_id, &node_id).await; + } + RegisterOutcome::ReplacedAuthenticated { evicted_conn_id } => { + // **连接接管**:与 MQTT 同 clientId 语义一致(新连接赢)。 + // 典型场景是真机静默掉线(NAT 超时 / 4G 信号丢失)后重连, + // 而服务端侧旧 socket 仍被视为 ESTABLISHED。若这里拒绝新连接, + // 真机发什么都进不了业务链路(可用性事故),故必须接管。 + // 旧连接由「主动驱逐句柄」立即断开(见 handle_connection 开头的 + // close_rx 分支),不需要设备发任何报文配合。 + // 这条 WARN 是排查"设备反复掉线/被顶替"的关键信号,务必保留。 + tracing::warn!( + "[安全] 同一 IMEI 发生连接接管:dev_id={} 的已认证连接(conn_id={}) 已被新连接(conn_id={}, peer={}) 接管并驱逐(新连接需重新 login 认证)", + dev_id, + evicted_conn_id, + conn_id, + peer + ); + current_dev_id = Some(dev_id.clone()); + update_device_route(&redis, dev_id, &node_id).await; + } + } } - // 更新活跃时间,同时刷新 route(处理重连切换到其他节点的情况) + // 接管兜底(安全网):池条目若已不属于本连接,说明本连接已被新连接接管 + // (等价于"superseded 标记")。主动驱逐信号(close_rx)通常已触发, + // 这里覆盖"信号尚未被 select! 处理完"的竞态:立即断开,不再处理任何上行。 + if let Some(ref dev_id) = current_dev_id + && !pool.is_owner(dev_id, conn_id).await { + use tokio::io::AsyncWriteExt; + tracing::warn!( + "[安全] 连接(conn_id={}, dev_id={}, peer={}) 已被新连接接管,平台主动断开且不再处理其上行", + conn_id, + dev_id, + peer + ); + let _ = writer.shutdown().await; + break; + } + + // 更新活跃时间,同时刷新 route(处理重连切换到其他节点的情况)。 + // `touch` 带 conn_id:只有当前持有者能刷新活跃时间(被接管的旧连接不行)。 if let Some(ref dev_id) = current_dev_id { - pool.touch(dev_id).await; + pool.touch(dev_id, conn_id).await; update_device_route(&redis, dev_id, &node_id).await; } // 安全规则(协议定稿):除 auth_str / login 外,其余指令必须先 login 成功 // 未登录连接发来的业务报文一律丢弃(连接尚未与 dev_id 建立可信绑定, // 平台侧也无法路由应答) + // + // 认证态按 `conn_id` 归属判定:占位连接即使被真机顶替、且真机已认证, + // 也不能"借用"到已认证身份伪造该 IMEI 的上行。 if !UNAUTH_ALLOWED_ACTS.contains(&msg.act.as_str()) { let authed = match current_dev_id.as_deref() { - Some(dev_id) => pool.is_authenticated(dev_id).await, + Some(dev_id) => pool.is_authenticated(dev_id, conn_id).await, None => false, }; if !authed { @@ -140,13 +224,28 @@ async fn handle_connection( } } } + // 主动驱逐:本连接的池条目已被新连接接管(连接池调用 `evict()` 发来信号)。 + // 立即断开,让接管方独占该 dev_id;设备侧无感知(不涉及任何协议报文), + // 真机在自己的重连流程里会重新 auth_str/login。 + _ = &mut close_rx => { + use tokio::io::AsyncWriteExt; + + tracing::warn!( + "[安全] 连接(conn_id={}, dev_id={:?}, peer={}) 的池条目已被新连接接管,平台主动断开旧连接", + conn_id, + current_dev_id, + peer + ); + let _ = writer.shutdown().await; + break; + } // 未登录超时巡检:连接建立后 UNAUTH_TIMEOUT_SECS 秒内未通过 login 即断开 // (协议规定:login 成功这条 TCP 才可以保持) _ = tokio::time::sleep(std::time::Duration::from_secs(UNAUTH_CHECK_INTERVAL_SECS)) => { use tokio::io::AsyncWriteExt; let authed = match current_dev_id.as_deref() { - Some(dev_id) => pool.is_authenticated(dev_id).await, + Some(dev_id) => pool.is_authenticated(dev_id, conn_id).await, None => false, }; if !authed && connected_at.elapsed().as_secs() >= UNAUTH_TIMEOUT_SECS { @@ -187,10 +286,19 @@ async fn handle_connection( } } - // 连接断开,清理 + // 连接断开,清理:**只删 conn_id 属于自己的那条条目**。 + // 无条件 `remove(dev_id)` 会让攻击者用受害 IMEI 占位后、在断开时把真机的 + // 池条目一并删掉 → 平台永久判定该设备离线(C1 的 DoS 主路径)。 if let Some(ref dev_id) = current_dev_id { - pool.remove(dev_id).await; - tracing::info!("[断连] dev_id={}", dev_id); + if pool.remove_if(dev_id, conn_id).await { + tracing::info!("[断连] dev_id={} conn_id={} 池条目已清理", dev_id, conn_id); + } else { + tracing::info!( + "[断连] dev_id={} conn_id={} 池条目已易主,保留当前持有者(不清理)", + dev_id, + conn_id + ); + } } } diff --git a/software/h5-uniapp/src/pages/compartment-detail/index.vue b/software/h5-uniapp/src/pages/compartment-detail/index.vue index 975e2a4..9c31f40 100644 --- a/software/h5-uniapp/src/pages/compartment-detail/index.vue +++ b/software/h5-uniapp/src/pages/compartment-detail/index.vue @@ -41,7 +41,7 @@ 电压{{ fmtVoltage(data.voltage) }} 电流{{ fmtCurrent(data.current) }} - 温度{{ data.temp != null ? ((data.temp / 100) - 273.15).toFixed(1) + ' °C' : '—' }} + 温度{{ formatCelsius(data.temp) }} @@ -83,7 +83,7 @@ import { onLoad } from '@dcloudio/uni-app' import { h5Api, type CompartmentDetail } from '../../api' import AppIcon from '../../components/AppIcon.vue' import { fmtSoc, fmtEnergy, fmtVoltage, fmtCurrent } from '../../utils/format' -import { battStatusOf, powStatusOf } from '../../utils/deviceStatus' +import { battStatusOf, powStatusOf, formatCelsius } from '../../utils/deviceStatus' const statusBarHeight = ref(47) const navBarHeight = ref(44) diff --git a/software/h5-uniapp/src/pages/device-detail/index.vue b/software/h5-uniapp/src/pages/device-detail/index.vue index 7a3fb1c..a5f8ed6 100644 --- a/software/h5-uniapp/src/pages/device-detail/index.vue +++ b/software/h5-uniapp/src/pages/device-detail/index.vue @@ -19,8 +19,9 @@ {{ data.rssi != null ? data.rssi : '—' }} - {{ isPowerFail(data.pow_fail_dc) ? '直流停电' : 'DC' }} - {{ isPowerFail(data.pow_fail_ac) ? '交流停电' : 'AC' }} + + {{ powerFlagOf(data.pow_fail_dc, 'DC').label }} + {{ powerFlagOf(data.pow_fail_ac, 'AC').label }} @@ -73,7 +74,7 @@ import { ref, computed } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { h5Api, type CabinetDetail } from '../../api' import AppIcon from '../../components/AppIcon.vue' -import { battStatusOf, powStatusOf, isPowerFail } from '../../utils/deviceStatus' +import { battStatusOf, powStatusOf, powerFlagOf } from '../../utils/deviceStatus' const statusBarHeight = ref(47) const navBarHeight = ref(44) @@ -152,6 +153,8 @@ function openLocation() { .pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; white-space: nowrap; } .pow-tag.on { background: #f0fdf4; color: #22c55e; } .pow-tag.off { background: #fef2f2; color: #ef4444; } +/* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */ +.pow-tag.unknown { background: #f1f5f9; color: #94a3b8; } .info-rows { border-top: 1px solid #f1f5f9; padding-top: 12px; } .info-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; font-size: 13px; } .info-row .label { color: #94a3b8; } diff --git a/software/h5-uniapp/src/pages/home/index.vue b/software/h5-uniapp/src/pages/home/index.vue index 665a1ad..1b66896 100644 --- a/software/h5-uniapp/src/pages/home/index.vue +++ b/software/h5-uniapp/src/pages/home/index.vue @@ -39,7 +39,7 @@ {{ cab.status === 1 ? '在线' : '离线' }} - {{ cab.temp }}°C + {{ formatCelsius(cab.temp) }} @@ -47,8 +47,9 @@ {{ cab.rssi ?? '—' }} - {{ isPowerFail(cab.pow_fail_dc) ? '直流停电' : 'DC' }} - {{ isPowerFail(cab.pow_fail_ac) ? '交流停电' : 'AC' }} + + {{ powerFlagOf(cab.pow_fail_dc, 'DC').label }} + {{ powerFlagOf(cab.pow_fail_ac, 'AC').label }} @@ -69,6 +70,8 @@ 切换组织✕ + + 暂无可选组织(账号可能缺少「查看组织」权限),当前按「全部」展示 全部 {{ org.name }} @@ -83,7 +86,7 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app' import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api' import { useAuthStore } from '../../store/auth' import AppIcon from '../../components/AppIcon.vue' -import { isPowerFail } from '../../utils/deviceStatus' +import { formatCelsius, powerFlagOf } from '../../utils/deviceStatus' const statusBarHeight = ref(47) const navBarHeight = ref(44) @@ -118,9 +121,31 @@ const showOrgPicker = ref(false) const selectedOrgId = ref(null) const currentOrgName = ref('') +/** + * 加载组织列表并落到「全部」 + * + * ⚠️ `GET /api/organizations` 需要 `org:view` 权限,操作员(normal_user)没有该权限 → 403。 + * 前端兜底:**无论成功失败都切到「全部」**,失败时清空列表并给出可见提示 + * (弹层内有提示行 + toast),绝不静默留一个空弹层。 + * 若需让操作员看到自己组织下的设备,需后端提供免 `org:view` 的可见组织接口(前端无法单独解决)。 + */ async function initOrg() { - try { const res = await h5Api.getOrganizations(); orgs.value = res || []; switchOrgAll() } catch { currentOrgName.value = '' } -}function switchOrgAll() { showOrgPicker.value = false; selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() } + try { + const res = await h5Api.getOrganizations() + orgs.value = res || [] + if (orgs.value.length === 0) { + uni.showToast({ title: '暂无可见组织,已按「全部」展示', icon: 'none' }) + } + } catch { + orgs.value = [] + uni.showToast({ title: '组织列表加载失败,已按「全部」展示', icon: 'none' }) + } + // 统一落到「全部」:只改筛选状态,数据由 onShow 的 fetch 拉取,避免重复请求 + applyOrgAll() +} +/** 切到「全部」(仅状态,不含请求) */ +function applyOrgAll() { selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null } +function switchOrgAll() { showOrgPicker.value = false; applyOrgAll(); fetchDashboard(); fetchCabinets() } function switchOrg(org: OrgItem) { showOrgPicker.value = false; selectedOrgId.value = org.id; currentOrgName.value = org.name; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() } onShow(async () => { @@ -214,6 +239,8 @@ function showScanInput() { .pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; } .pow-tag.on { background: #dcfce7; color: #16a34a; } .pow-tag.off { background: #fee2e2; color: #dc2626; } +/* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */ +.pow-tag.unknown { background: #f1f5f9; color: #94a3b8; } .device-name { font-size: 14px; font-weight: 500; color: #111827; display: block; margin-bottom: 4px; } .device-imei { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; display: block; margin-bottom: 4px; opacity: 0.7; } .device-addr { font-size: 12px; color: #475569; display: block; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -233,6 +260,7 @@ function showScanInput() { .org-title { font-size: 16px; font-weight: 600; } .org-close { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #475569; } .org-list { flex: 1; } +.org-empty { padding: 14px 20px; font-size: 12px; line-height: 18px; color: #94a3b8; background: #f8fafc; } .org-option { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; font-size: 15px; color: #111827; border-bottom: 1px solid #f1f5f9; } .org-option.selected { color: #0d9488; font-weight: 500; } .org-option.selected::after { content: '✓'; font-size: 16px; font-weight: 600; } diff --git a/software/h5-uniapp/src/utils/deviceStatus.ts b/software/h5-uniapp/src/utils/deviceStatus.ts index c75d3af..abcfef2 100644 --- a/software/h5-uniapp/src/utils/deviceStatus.ts +++ b/software/h5-uniapp/src/utils/deviceStatus.ts @@ -81,7 +81,88 @@ export function powStatusOf(hex?: string | null): ChannelStatus { /** * 是否处于停电状态(协议 §5.4) * 后端契约为 `boolean | null`(true=停电 / false=正常 / null=无数据) + * + * ⚠️ 只回答「是否停电」,**不能**用来判断"是否正常":无数据(null)时返回 false, + * 若把它当成"正常"渲染成绿色就会把"无数据"说成"正常"。展示请用下面的 + * [`powerFlagOf`] / [`powerFailState`](三态)。 */ export function isPowerFail(value?: boolean | null): boolean { return value === true } + +/** + * 停电状态三态(协议 §5.4) + * - `'fail'`:处于停电 + * - `'normal'`:正常(后端明确返回 false / 0) + * - `'unknown'`:**无数据**(字段缺失 / null)→ 灰色「未知」 + * + * ⚠️ 语义必须与 PC 管理端一致:见 `web/src/utils/deviceStatus.ts` 的 `normalizeFlag()` + * (PC 端区分 null:灰色 + 「无数据」)。历史 bug:小程序用 + * `isPowerFail(x) ? 'off' : 'on'` 两态判断,null 落到 else 分支被渲染成绿色「DC/AC 正常」, + * 与 PC 端语义相反。 + */ +export type PowerFailState = 'fail' | 'normal' | 'unknown' + +/** 把后端 `pow_fail_dc` / `pow_fail_ac` 归一化为三态(兼容 1/0 与 true/false 两种写法) */ +export function powerFailState(value?: boolean | null | number): PowerFailState { + if (value === null || value === undefined) return 'unknown' + return value === true || value === 1 ? 'fail' : 'normal' +} + +/** 停电标签(文案 + 样式类后缀),供模板直接绑定 */ +export interface PowerFlagTag { + /** 三态 */ + state: PowerFailState + /** 模板样式类后缀:`off`=停电 / `on`=正常 / `unknown`=无数据 */ + tone: 'off' | 'on' | 'unknown' + /** 展示文案 */ + label: string +} + +/** + * 停电标签(直流 / 交流共用) + * - 停电 → `直流停电` / `交流停电`(红) + * - 正常 → `DC` / `AC`(绿) + * - 无数据 → `DC未知` / `AC未知`(灰,与 PC 端灰色「无数据」语义一致) + * + * @param value 后端 `pow_fail_dc` / `pow_fail_ac` + * @param kind 直流(DC)/ 交流(AC),同时用于无数据时的文案,避免两个灰色标签无法区分 + */ +export function powerFlagOf(value: boolean | null | undefined, kind: 'DC' | 'AC'): PowerFlagTag { + const state = powerFailState(value) + if (state === 'fail') { + return { state, tone: 'off', label: kind === 'DC' ? '直流停电' : '交流停电' } + } + if (state === 'unknown') { + return { state, tone: 'unknown', label: `${kind}未知` } + } + return { state, tone: 'on', label: kind } +} + +/** + * 温度换算:协议里温度字段统一是 **K×100**(开尔文 ×100)。 + * + * 依据:协议 §5.3 / §5.4 的示例 `temp = 29800` 即 298.00K; + * 柜级 `cab.temp`(`device:{imei}.temp`)与通道级 `data.temp` 用**同一口径**, + * 因此两个页面必须用同一个换算函数,**禁止把原始值直接拼 `°C`** + * (曾把 29800 显示成 `29800°C`)。 + * + * ⚠️ 与 Web 管理端口径保持一致:`(temp / 100) - 273.15`(见 `web/src/pages/devices/cabinet.tsx`)。 + * + * @param temp K×100 的整数温度;`null` / `undefined` / 非数字 → 返回 `null`(由调用方展示占位符) + */ +export function kelvinX100ToCelsius(temp?: number | null): number | null { + if (temp === null || temp === undefined) return null + const value = Number(temp) + if (!Number.isFinite(value)) return null + return value / 100 - 273.15 +} + +/** + * 温度格式化为展示文本(K×100 → 摄氏度,保留 1 位小数) + * @returns 例如 `"25.0°C"`;无数据时返回 `"—"` + */ +export function formatCelsius(temp?: number | null): string { + const celsius = kelvinX100ToCelsius(temp) + return celsius === null ? '—' : `${celsius.toFixed(1)}°C` +} diff --git a/software/web/src/App.tsx b/software/web/src/App.tsx index 00eb063..e661661 100644 --- a/software/web/src/App.tsx +++ b/software/web/src/App.tsx @@ -12,6 +12,8 @@ import { Navigate, } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; +import { RequirePermission } from '@/components/Permission'; +import NoPermission from '@/components/NoPermission'; import AdminLayout from '@/layouts/AdminLayout'; import Login from '@/pages/Login'; import Dashboard from '@/pages/Dashboard'; @@ -48,6 +50,25 @@ function RedirectIfAuth({ children }: { children: React.ReactNode }) { return <>{children}; } +/** + * 路由级权限守卫:无权限时展示统一的 403 页(`NoPermission`) + * + * 权限码来自后端 `db/migrate.rs` 的 permissions 种子表: + * - `/settings/roles` → `role:view`(种子里存在;仅总管理员被授予) + * - `/settings/operation-logs` → `operation_log:view`(种子里存在;企业管理员/总管理员被授予) + * - `/settings/feedback` → `user:view`(后端 `/api/feedback` 目前只校验登录态、**没有专用权限码**, + * 这里用「查看用户」作为最小可用守卫,详见修复报告) + * + * 总管理员(role_level >= 2)在 `hasPermission` 内直接放行,不会被拦。 + */ +function Guard({ code, children }: { code: string; children: React.ReactNode }) { + return ( + }> + {children} + + ); +} + export default function App() { const restore = useAuthStore((s) => s.restore); @@ -84,9 +105,9 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> diff --git a/software/web/src/api/devices.ts b/software/web/src/api/devices.ts index 80b9799..bc58944 100644 --- a/software/web/src/api/devices.ts +++ b/software/web/src/api/devices.ts @@ -57,17 +57,47 @@ export interface Cabinet { org_disabled?: boolean; /** 所属项目地址(柜子地址为空时使用) */ project_address?: string; + /** + * 柜体温度(协议 §3.8 / 附 A:`status_post` 的 `cabinet.temp`,单位 **K×100**, + * 如 `29800` = 298.00K = 24.85°C) + * + * 数据源为 Redis `device:{imei}.temp`,**后端接口正在补齐该字段**; + * 未返回 / 设备未上报时为 `undefined` / `null`,界面展示 `--°C`。 + */ + temp?: number | null; } +/** + * 仓体(通道) + * + * ⚠️ 字段以后端 `GET /api/cabinets/{id}` 的真实响应为准:该接口把仓体挂在 + * `cabin_boards[].compartments[]` 下,只返回 `id` / `channel_id` / `status` / + * `batt_status` / `pow_status` 五个字段,随后按 Redis 实时数据**按需注入** + * `soc` / `battery_id` / `temp`(`voltage` / `current` 由后端补齐中)。 + * 因此除 id/channel_id/status 外**均为可选**,缺失时界面展示 `-`。 + */ export interface Compartment { id: number; - cabin_board_id: number; + /** + * 所属仓控板 ID + * ⚠️ 柜子详情接口**不返回**该字段(仓体已在 `CabinBoard.compartments` 下, + * 板号可由所在仓板得知);仅 `GET /api/compartments/{id}` 单仓详情返回,故为可选。 + */ + cabin_board_id?: number; channel_id: number; status: number; + /** 电量 **‰**(0-10000,`4500` = 45.0%);展示需 `/10`。后端未注入实时数据时缺失 */ soc?: number; + /** 电池唯一标识;BMS 读不到序列号时可能是空串,字段缺失表示未登记 */ battery_id?: string; + /** 电压 **mV**(`52000` = 52.000V)—— 后端正在补齐该字段,未返回时界面展示 `-` */ voltage?: number; + /** 电流 **mA**(`10000` = 10.000A)—— 后端正在补齐该字段,未返回时界面展示 `-` */ current?: number; + /** + * 温度 **K×100**(`29865` = 298.65K = 25.5°C,展示需 `/100 - 273.15`) + * 后端已从 Redis `device:{imei}:board:{板号}:ch:{通道号}.temp` 注入 + */ temp?: number; /** * 电池状态(协议 §3.8,2 位 hex,如 "82") diff --git a/software/web/src/api/request.ts b/software/web/src/api/request.ts index ed1c959..fa8a304 100644 --- a/software/web/src/api/request.ts +++ b/software/web/src/api/request.ts @@ -30,29 +30,49 @@ function getToken(): string | null { return localStorage.getItem('token'); } +/** + * 拼接请求地址 + * - 相对路径(如 `/downloads/1/file`)→ 前缀 `API_BASE` + * - 绝对地址(后端偶尔返回 `http(s)://...`)→ 原样使用,避免拼出非法 URL + */ +function resolveUrl(url: string): string { + return /^https?:\/\//i.test(url) ? url : `${API_BASE}${url}`; +} + +/** 构造带 token 的请求头(`json=false` 时不设置 Content-Type,避免污染二进制下载) */ +function buildHeaders(json: boolean, extra?: HeadersInit): Record { + const token = getToken(); + const headers: Record = { + ...(json ? { 'Content-Type': 'application/json' } : {}), + ...(extra as Record), + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +/** + * 从失败响应里取出后端 `message` + * 后端约定 `{ code, message }`;非 JSON 响应(如网关 502 页面)回退到状态文案 + */ +async function readErrorMessage(res: Response): Promise { + const body = await res.json().catch(() => null) as { message?: string } | null; + return body?.message || res.statusText || `请求失败(HTTP ${res.status})`; +} + async function request( url: string, options: RequestInit = {}, ): Promise> { - const token = getToken(); - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - try { - const res = await fetch(`${API_BASE}${url}`, { + const res = await fetch(resolveUrl(url), { ...options, - headers, + headers: buildHeaders(true, options.headers), }); if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new ApiError(res.status, body.message || res.statusText); + throw new ApiError(res.status, await readErrorMessage(res)); } return res.json(); @@ -62,6 +82,32 @@ async function request( } } +/** + * 二进制 GET(带 token)——用于 Excel 等文件下载 + * + * 为什么需要它:文件接口(如 `/downloads/{id}/file`)要求 `Authorization: Bearer`, + * 而 `window.open` / `` 都不会带自定义请求头,直接用后端返回的 URL 打开 + * 只会拿到 401(或路径前缀不对拿到 404)。因此统一走带 token 的 `fetch` 取 Blob, + * 再由调用方用 `URL.createObjectURL` 触发浏览器保存。 + * + * @param url 相对 `API_BASE` 的路径(如 `/downloads/1/file`),或绝对 URL + * @throws {ApiError} HTTP 非 2xx —— message 为后端返回的 `message` + */ +async function requestBlob(url: string): Promise { + try { + const res = await fetch(resolveUrl(url), { headers: buildHeaders(false) }); + + if (!res.ok) { + throw new ApiError(res.status, await readErrorMessage(res)); + } + + return await res.blob(); + } catch (err) { + if (err instanceof ApiError) throw err; + throw new ApiError(0, (err as Error).message || '网络错误'); + } +} + export const api = { get: (url: string) => request(url), post: (url: string, data?: unknown) => @@ -69,4 +115,6 @@ export const api = { put: (url: string, data?: unknown) => request(url, { method: 'PUT', body: JSON.stringify(data) }), delete: (url: string) => request(url, { method: 'DELETE' }), + /** 带 token 的文件下载(Blob) */ + getBlob: (url: string) => requestBlob(url), }; diff --git a/software/web/src/components/NoPermission.tsx b/software/web/src/components/NoPermission.tsx new file mode 100644 index 0000000..7df7b8a --- /dev/null +++ b/software/web/src/components/NoPermission.tsx @@ -0,0 +1,35 @@ +/** + * 无权限占位页(403) + * + * 用途:作为路由级权限守卫 `RequirePermission` 的 `fallback`, + * 让无权限用户直接访问受限路由(如直敲 `/settings/roles`)时看到统一的提示页, + * 而不是进到页面里弹「加载失败」。 + * + * 注意:总管理员的放行逻辑在 `@/utils/permission` 的 `hasPermission` 内 + * (`role >= 2` 直接返回 true),因此总管理员永远不会看到本组件。 + */ + +import { Button, Result } from '@arco-design/web-react'; +import { useNavigate } from 'react-router-dom'; + +interface NoPermissionProps { + /** 自定义提示文案(默认通用文案) */ + subTitle?: string; +} + +export default function NoPermission({ subTitle }: NoPermissionProps) { + const navigate = useNavigate(); + + return ( + navigate('/dashboard')}> + 返回首页 + + } + /> + ); +} diff --git a/software/web/src/pages/batteries/index.tsx b/software/web/src/pages/batteries/index.tsx index d3ceb78..c4b17d7 100644 --- a/software/web/src/pages/batteries/index.tsx +++ b/software/web/src/pages/batteries/index.tsx @@ -39,13 +39,18 @@ export default function Batteries() { const pageSize = 20; - const loadData = async () => { + /** + * 加载列表 + * 显式接收分页/筛选参数:避免在 `setState` 尚未生效时用旧值发请求 + * (历史 bug:点「搜索」先 `setPage(1)` 又立刻用旧 `page` 发请求,导致重复请求或停在错的页) + */ + const loadData = async (p: number, status: number | undefined, batteryId: string) => { setLoading(true); try { const res = await batteryApi.list({ - status: statusFilter, - battery_id: batteryIdFilter || undefined, - page, + status, + battery_id: batteryId || undefined, + page: p, page_size: pageSize, }); setData(res.data.list || []); @@ -57,19 +62,29 @@ export default function Batteries() { } }; + // 分页 / 状态筛选变化时自动重查(battery_id 为手动搜索,不作为依赖) useEffect(() => { - loadData(); + loadData(page, statusFilter, batteryIdFilter); }, [page, statusFilter]); + /** 搜索:页码已变化时交给 useEffect 重查,否则直接查第 1 页 */ const handleSearch = () => { - setPage(1); - loadData(); + if (page === 1) { + loadData(1, statusFilter, batteryIdFilter); + } else { + setPage(1); + } }; + /** 重置:清空筛选条件并回到第 1 页(用重置后的条件直接重查,不依赖 setState 时序) */ const handleReset = () => { setStatusFilter(undefined); setBatteryIdFilter(''); - setPage(1); + if (page === 1 && statusFilter === undefined) { + loadData(1, undefined, ''); + } else { + setPage(1); + } }; const columns = [ diff --git a/software/web/src/pages/charge-orders/index.tsx b/software/web/src/pages/charge-orders/index.tsx index d88bdc7..125a08f 100644 --- a/software/web/src/pages/charge-orders/index.tsx +++ b/software/web/src/pages/charge-orders/index.tsx @@ -41,16 +41,24 @@ export default function ChargeOrders() { const pageSize = 20; - const loadData = async () => { + /** + * 加载列表 + * 显式接收分页/筛选参数:避免在 `setState` 尚未生效时用旧值发请求 + * (历史 bug:点「查询」先 `setPage(1)` 又立刻用旧 `page` 发请求) + */ + const loadData = async ( + p: number, + filters: { cabinetId: string; batteryId: string; status: number | undefined; dateRange: string[] }, + ) => { setLoading(true); try { const res = await chargeOrderApi.list({ - cabinet_id: cabinetId ? Number(cabinetId) : undefined, - battery_id: batteryId || undefined, - status: statusFilter, - start_time: dateRange[0] || undefined, - end_time: dateRange[1] || undefined, - page, + cabinet_id: filters.cabinetId ? Number(filters.cabinetId) : undefined, + battery_id: filters.batteryId || undefined, + status: filters.status, + start_time: filters.dateRange[0] || undefined, + end_time: filters.dateRange[1] || undefined, + page: p, page_size: pageSize, }); setData(res.data.list || []); @@ -62,21 +70,34 @@ export default function ChargeOrders() { } }; + /** 当前筛选条件快照 */ + const currentFilters = { cabinetId, batteryId, status: statusFilter, dateRange }; + + // 分页 / 柜子 / 状态变化时自动重查(电池ID、时间范围为手动查询条件) useEffect(() => { - loadData(); + loadData(page, { cabinetId, batteryId, status: statusFilter, dateRange }); }, [page, cabinetId, statusFilter]); + /** 查询:页码已变化时交给 useEffect 重查,否则直接查第 1 页 */ const handleSearch = () => { - setPage(1); - loadData(); + if (page === 1) { + loadData(1, currentFilters); + } else { + setPage(1); + } }; + /** 重置:清空筛选条件并回到第 1 页(用重置后的条件直接重查,不依赖 setState 时序) */ const handleReset = () => { setCabinetId(''); setBatteryId(''); setStatusFilter(undefined); setDateRange([]); - setPage(1); + if (page === 1 && !cabinetId && statusFilter === undefined) { + loadData(1, { cabinetId: '', batteryId: '', status: undefined, dateRange: [] }); + } else { + setPage(1); + } }; const columns = [ diff --git a/software/web/src/pages/charge-records/DownloadCenter.tsx b/software/web/src/pages/charge-records/DownloadCenter.tsx index ee38341..ff27ba1 100644 --- a/software/web/src/pages/charge-records/DownloadCenter.tsx +++ b/software/web/src/pages/charge-records/DownloadCenter.tsx @@ -2,7 +2,7 @@ * 下载中心弹窗组件 * - 展示异步导出任务列表 * - 自动轮询刷新(每5秒) - * - 已完成任务可下载 + * - 已完成任务可下载(带 token 取 Blob 后本地保存) */ import { useState, useEffect, useCallback } from 'react'; @@ -16,6 +16,15 @@ import { } from '@arco-design/web-react'; import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'; import { api } from '@/api/request'; +import { Permission } from '@/components/Permission'; +import { hasPermission } from '@/utils/permission'; + +/** + * 下载中心所有接口(列表 / 下载信息 / 文件 / 清理)在后端都要求 + * `charge_record:export`(见 `api-server/src/routes/downloads.rs`), + * 因此按钮统一用该权限码控制显隐。 + */ +const DOWNLOAD_PERMISSION = 'charge_record:export'; /** 下载任务类型 */ interface DownloadTask { @@ -30,6 +39,53 @@ interface DownloadTask { completed_at: string | null; } +/** 下载信息响应(`GET /downloads/{id}`) */ +interface DownloadInfo { + id: number; + file_name: string | null; + /** 后端返回的服务内部路径,形如 `/api/downloads/{id}/file`(**不含 `/api/pms` 前缀**) */ + download_url: string; +} + +/** + * 后端 `download_url` → api 客户端可用的路径 + * + * 后端返回的是**服务内部路径**(`/api/downloads/{id}/file`),而前端 api 客户端的 + * `API_BASE` 本身已经是 `/api/pms`(nginx 把 `/api/pms/` 反代到后端 `/api/`)。 + * 所以必须剥掉后端路径里的 `/api` 前缀,否则会请求成 + * `/api/pms/api/downloads/...` → 404。 + * + * 历史 bug:旧实现直接 `window.open(download_url)` —— 既少了 `/api/pms` 前缀(404), + * 又不会带 `Authorization: Bearer`(401),导致「导出 → 下载」整条链路不可用。 + */ +function toApiPath(downloadUrl: string): string { + const url = (downloadUrl || '').trim(); + if (!url) return ''; + // 绝对地址:交给 requestBlob 直接请求(仍会带 token) + if (/^https?:\/\//i.test(url)) return url; + if (url.startsWith('/api/pms/')) return url.slice('/api/pms'.length); + if (url.startsWith('/api/')) return url.slice('/api'.length); + return url.startsWith('/') ? url : `/${url}`; +} + +/** + * 触发浏览器保存 Blob + * - `URL.createObjectURL` 生成临时地址 + 隐藏 `` 点击 + * - 延迟释放 objectURL:部分浏览器在 click 同步返回时仍在读取该地址, + * 立即 revoke 可能导致下载被取消 + */ +function saveBlobAsFile(blob: Blob, fileName: string) { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); +} + /** 状态标签颜色映射 */ const STATUS_COLOR: Record = { 0: 'gray', @@ -48,7 +104,11 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); + /** 正在下载的任务 ID(用于按钮 loading,避免重复点击) */ + const [downloadingId, setDownloadingId] = useState(null); const pageSize = 10; + /** 无 `charge_record:export` 时列表接口必然 403,直接跳过请求 */ + const canExport = hasPermission(DOWNLOAD_PERMISSION); /** 获取下载任务列表 */ const fetchTasks = useCallback(async () => { @@ -69,32 +129,42 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps /** 弹窗打开时加载数据 */ useEffect(() => { - if (visible) { + if (visible && canExport) { fetchTasks(); } - }, [visible, fetchTasks]); + }, [visible, canExport, fetchTasks]); /** 自动轮询(有进行中的任务时) */ useEffect(() => { - if (!visible) return; + if (!visible || !canExport) return; const hasActive = tasks.some((t) => t.status === 0 || t.status === 1); if (!hasActive) return; const timer = setInterval(fetchTasks, 5000); return () => clearInterval(timer); - }, [visible, tasks, fetchTasks]); + }, [visible, canExport, tasks, fetchTasks]); - /** 点击下载 */ - const handleDownload = async (taskId: number) => { + /** + * 点击下载 + * + * 1. 带 token 调 `GET /downloads/{id}` 拿文件名与下载地址(失败时用后端 message 提示) + * 2. 用 api 客户端**带 token** 取文件 Blob(`/downloads/{id}/file` 要求 Bearer) + * 3. `URL.createObjectURL` + 临时 `` 触发浏览器保存 + */ + const handleDownload = async (taskId: number, fallbackName?: string | null) => { + setDownloadingId(taskId); try { - const res = await api.get<{ download_url: string }>( - `/downloads/${taskId}` - ); - if (res.data.download_url) { - window.open(res.data.download_url, '_blank'); + const res = await api.get(`/downloads/${taskId}`); + const path = toApiPath(res.data.download_url); + if (!path) { + throw new Error('后端未返回下载地址'); } - } catch { - Message.error('获取下载链接失败'); + const blob = await api.getBlob(path); + saveBlobAsFile(blob, res.data.file_name || fallbackName || `download-${taskId}.xlsx`); + } catch (e) { + Message.error(e instanceof Error && e.message ? e.message : '下载文件失败'); + } finally { + setDownloadingId(null); } }; @@ -125,14 +195,17 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps const renderAction = (_: unknown, record: DownloadTask) => { if (record.status === 2) { return ( - + + + ); } if (record.status === 3) { @@ -157,25 +230,38 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps footer={null} style={{ width: 800 }} > - - - - - + {!canExport ? ( + /* 无权限时不发请求、也不渲染空表格,给出可见提示(而非静默 403) */ +
+ 当前账号没有下载权限({DOWNLOAD_PERMISSION}),请联系管理员开通。 +
+ ) : ( + <> + + + + + + + + +
+ + )} ); } diff --git a/software/web/src/pages/charge-records/index.tsx b/software/web/src/pages/charge-records/index.tsx index 2978aee..d0e5a21 100644 --- a/software/web/src/pages/charge-records/index.tsx +++ b/software/web/src/pages/charge-records/index.tsx @@ -17,6 +17,7 @@ import { } from '@arco-design/web-react'; import { IconDownload, IconSearch } from '@arco-design/web-react/icon'; import { api } from '@/api/request'; +import { Permission } from '@/components/Permission'; import DownloadCenter from './DownloadCenter'; const { RangePicker } = DatePicker; @@ -143,13 +144,20 @@ export default function ChargeRecords() { fetchData(); }, [fetchData]); - /** 查询按钮 */ + /** + * 查询按钮 + * 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求, + * 否则直接重查 —— 避免出现「先 setPage(1) 又用旧 page 发请求」的重复请求/停错页 + */ const handleSearch = () => { - setPage(1); - fetchData(); + if (page === 1) { + fetchData(); + } else { + setPage(1); + } }; - /** 导出 */ + /** 导出(后端要求 charge_record:export) */ const handleExport = async () => { try { const params = new URLSearchParams(); @@ -160,8 +168,9 @@ export default function ChargeRecords() { await api.get(`/charge-records/export?${params.toString()}`); Message.success('导出任务已创建,请到下载中心查看'); setDownloadVisible(true); - } catch { - Message.error('创建导出任务失败'); + } catch (e) { + // 优先展示后端返回的 message(如「缺少权限: charge_record:export」) + Message.error(e instanceof Error && e.message ? e.message : '创建导出任务失败'); } }; @@ -253,9 +262,12 @@ export default function ChargeRecords() { - + {/* 导出需 charge_record:export(后端 /charge-records/export 校验) */} + + + diff --git a/software/web/src/pages/device-logs/index.tsx b/software/web/src/pages/device-logs/index.tsx index 62c6009..d2b6ebf 100644 --- a/software/web/src/pages/device-logs/index.tsx +++ b/software/web/src/pages/device-logs/index.tsx @@ -109,10 +109,17 @@ export default function DeviceLogs() { fetchData(); }, [fetchData]); - /** 查询按钮 */ + /** + * 查询按钮 + * 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求, + * 否则直接重查 —— 避免「先 setPage(1) 又用旧 page 发请求」 + */ const handleSearch = () => { - setPage(1); - fetchData(); + if (page === 1) { + fetchData(); + } else { + setPage(1); + } }; /** 截断内容摘要 */ diff --git a/software/web/src/pages/devices/CabinetGrid.tsx b/software/web/src/pages/devices/CabinetGrid.tsx index b869c43..3546969 100644 --- a/software/web/src/pages/devices/CabinetGrid.tsx +++ b/software/web/src/pages/devices/CabinetGrid.tsx @@ -30,7 +30,7 @@ import QRCode from 'qrcode'; import { cabinetApi } from '@/api/devices'; import type { Cabinet } from '@/api/devices'; import { useAuthStore } from '@/stores/auth'; -import { normalizeFlag } from '@/utils/deviceStatus'; +import { formatTempK100, normalizeFlag } from '@/utils/deviceStatus'; interface CabinetGridProps { cabinets: Cabinet[]; @@ -422,7 +422,8 @@ export default function CabinetGrid({ cabinets, loading, onRefresh }: CabinetGri {cab.rssi || '-'} - --°C + {/* 柜温:后端返回 cabinet.temp(K×100),字段缺失时占位 --°C */} + {formatTempK100(cab.temp, '--°C')} diff --git a/software/web/src/pages/devices/cabinet.tsx b/software/web/src/pages/devices/cabinet.tsx index bcb715e..d9b0a0f 100644 --- a/software/web/src/pages/devices/cabinet.tsx +++ b/software/web/src/pages/devices/cabinet.tsx @@ -22,7 +22,7 @@ import { import { IconLeft, IconDesktop, IconCopy, IconUnlock, IconPlayArrow, IconPoweroff, IconSwap, IconRefresh } from '@arco-design/web-react/icon'; import { cabinetApi, orgApi, projectApi, type CabinetDetail, type Organization, type Project } from '@/api/devices'; import { useAuthStore } from '@/stores/auth'; -import { normalizeFlag, parseBattStatus, parsePowStatus } from '@/utils/deviceStatus'; +import { formatTempK100, normalizeFlag, parseBattStatus, parsePowStatus } from '@/utils/deviceStatus'; /** 状态映射 */ const STATUS_MAP: Record = { @@ -494,7 +494,7 @@ export default function CabinetDetail() { 📶 RSSI: {detail.rssi != null ? detail.rssi : '-'} - 🌡 柜温: --°C + 🌡 柜温: {formatTempK100(detail.temp, '--°C')} {/* 停电状态(协议 §5.4):直流 / 交流两个独立事件,可同时出现 */} @@ -565,7 +565,6 @@ export default function CabinetDetail() {
{board.compartments.map((comp) => { const st = STATUS_MAP[comp.status] || { label: '未知', color: 'gray' }; - const c = comp as any; // 协议 §3.8:电池状态与电源状态是两个相互独立维度,分别解析、分别展示 const batt = parseBattStatus(comp.batt_status); const pow = parsePowStatus(comp.pow_status); @@ -590,11 +589,12 @@ export default function CabinetDetail() {
- {c.voltage ?? '-'}mV - {c.current ?? '-'}mA - {c.temp != null && {((c.temp / 100) - 273.15).toFixed(1)}°C} - {c.soc != null && {c.soc}%} - {c.battery_id && {c.battery_id}} + {comp.voltage ?? '-'}mV + {comp.current ?? '-'}mA + {comp.temp != null && {formatTempK100(comp.temp)}} + {/* 电量单位是 ‰(协议附 A:4500 = 45.0%),需 /10 换算,否则会大 10 倍 */} + {comp.soc != null && {(comp.soc / 10).toFixed(1)}%} + {comp.battery_id && {comp.battery_id}}
@@ -587,20 +625,15 @@ export default function Devices() { -
- - {total > pageSize && ( -
- setPage(p)} - size="small" - hideOnSinglePage - /> -
- )} +
+ {/* 上分页 */} + {renderPagination()} + {/* 卡片网格(独立滚动区域,避免分页器被内容顶走) */} +
+ +
+ {/* 下分页 */} + {renderPagination()}
@@ -672,7 +705,7 @@ export default function Devices() { visible={cabinetModalVisible} orgList={orgList} onCancel={() => setCabinetModalVisible(false)} - onSuccess={async () => { setCabinetModalVisible(false); await loadTree(); }} + onSuccess={async () => { setCabinetModalVisible(false); await handleRefresh(); }} /> {/* 项目管理弹窗 */} diff --git a/software/web/src/pages/energy/index.tsx b/software/web/src/pages/energy/index.tsx index 838a1e8..b3c7176 100644 --- a/software/web/src/pages/energy/index.tsx +++ b/software/web/src/pages/energy/index.tsx @@ -31,6 +31,7 @@ import { ResponsiveContainer, } from 'recharts'; import { api } from '@/api/request'; +import { Permission } from '@/components/Permission'; import DownloadCenter from '../charge-records/DownloadCenter'; const Row = Grid.Row; @@ -145,14 +146,15 @@ export default function Energy() { fetchTrend(); }, [fetchTrend]); - /** 导出 */ + /** 导出(后端要求 energy:export) */ const handleExport = async () => { try { await api.get('/energy-stats/export'); Message.success('导出任务已创建,请到下载中心查看'); setDownloadVisible(true); - } catch { - Message.error('创建导出任务失败'); + } catch (e) { + // 优先展示后端返回的 message(如「缺少权限: energy:export」) + Message.error(e instanceof Error && e.message ? e.message : '创建导出任务失败'); } }; @@ -317,9 +319,12 @@ export default function Energy() {
} onClick={handleExport}> - 导出报表 - + /* 导出报表需 energy:export(后端 /energy-stats/export 校验) */ + + + }>
{ fetchData(); }, [fetchData]); + /** + * 查询按钮 + * 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求, + * 否则直接重查 —— 避免「先 setPage(1) 又用旧 page 发请求」 + */ const handleSearch = () => { - setPage(1); - fetchData(); + if (page === 1) { + fetchData(); + } else { + setPage(1); + } }; const updateStatus = async (id: number, nextStatus: number) => { diff --git a/software/web/src/pages/settings/operation-logs.tsx b/software/web/src/pages/settings/operation-logs.tsx index ed48966..a57b798 100644 --- a/software/web/src/pages/settings/operation-logs.tsx +++ b/software/web/src/pages/settings/operation-logs.tsx @@ -91,10 +91,17 @@ export default function OperationLogsPage() { fetchData(); }, [fetchData]); - /** 查询 */ + /** + * 查询 + * 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求, + * 否则直接重查 —— 避免「先 setPage(1) 又用旧 page 发请求」 + */ const handleSearch = () => { - setPage(1); - fetchData(); + if (page === 1) { + fetchData(); + } else { + setPage(1); + } }; /** 重置 */ @@ -102,7 +109,9 @@ export default function OperationLogsPage() { setUserId(undefined); setAction(''); setTimeRange([]); - setPage(1); + if (page !== 1) { + setPage(1); + } }; /** 操作类型显示映射 */ diff --git a/software/web/src/utils/deviceStatus.ts b/software/web/src/utils/deviceStatus.ts index 7755cff..845786a 100644 --- a/software/web/src/utils/deviceStatus.ts +++ b/software/web/src/utils/deviceStatus.ts @@ -98,3 +98,18 @@ export function normalizeFlag(value?: boolean | null): boolean | null { export function isPowerFail(value?: boolean | null): boolean { return normalizeFlag(value) === true; } + +/** + * 温度格式化:协议统一按 **K×100** 存储(附 A:`29865` = 298.65K = 25.5°C) + * + * 用于柜温 / 仓温等实时温度字段;`null` / `undefined`(字段缺失或设备未上报) + * 返回占位符(默认 `-`),由调用方决定占位文案(如柜温用 `--°C`)。 + * + * @param value 温度原始值(K×100) + * @param placeholder 无数据时的占位文案 + */ +export function formatTempK100(value?: number | null, placeholder = '-'): string { + if (value === null || value === undefined) return placeholder; + const celsius = value / 100 - 273.15; + return `${celsius.toFixed(1)}°C`; +}