885 lines
34 KiB
Rust
Raw Normal View History

//! 组织树 API
//!
//! 两个独立接口:
//! 1. /api/organization-tree — 返回多级组织->项目树
//! 2. /api/cabinets/filter — 根据组织/项目筛选设备列表
use axum::extract::{State, Query};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
use std::collections::{BTreeMap, HashMap};
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
/// SQL查询专用结构体(只包含数据库字段)
#[derive(Debug, Clone, sqlx::FromRow)]
struct CabinetSqlRow {
id: i64,
project_id: Option<i64>,
abstract_id: Option<String>,
imei: String,
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
project_address: Option<String>,
status: i8,
board_count: Option<i64>,
channel_count: Option<i64>,
idle_count: Option<i64>,
charging_count: Option<i64>,
full_count: Option<i64>,
fault_count: Option<i64>,
}
impl From<CabinetSqlRow> for CabinetRow {
fn from(row: CabinetSqlRow) -> Self {
CabinetRow {
id: row.id,
project_id: row.project_id,
abstract_id: row.abstract_id,
imei: row.imei,
iccid: row.iccid,
name: row.name,
address: row.address,
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
project_address: row.project_address,
status: row.status,
board_count: row.board_count,
channel_count: row.channel_count,
idle_count: row.idle_count,
charging_count: row.charging_count,
full_count: row.full_count,
fault_count: row.fault_count,
rssi: None,
pow_fail_dc: None,
pow_fail_ac: None,
is_online: None,
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
org_disabled: None,
}
}
}
// ---------------------------------------------------------------------------
// SQL 常量
//
// 说明:以下 SQL 均由原函数体**原样搬移**,未改动任何 SELECT 字段列表、
// 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。
// ---------------------------------------------------------------------------
/// 全部组织(组织树用)
const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id";
/// 全部项目(经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题)
const PROJECTS_ALL_SQL: &str = "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id";
/// 各项目下的设备数量
const PROJECT_CABINET_COUNTS_SQL: &str =
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id";
/// 设备列表基础 SQL:联表查询获取通道状态统计(子查询 idle_count/charging_count/... 原样保留)
const CABINET_FILTER_BASE_SQL: &str = "
SELECT
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
c.status,
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
(SELECT COUNT(*) FROM cabin_boards cb2
JOIN compartments comp ON comp.cabin_board_id = cb2.id
WHERE cb2.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM cabin_boards cb3
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
(SELECT COUNT(*) FROM cabin_boards cb4
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
(SELECT COUNT(*) FROM cabin_boards cb5
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
(SELECT COUNT(*) FROM cabin_boards cb6
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
FROM cabinets c
";
/// 设备归属校验(非总管理员):设备必须属于当前用户组织
const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \
JOIN projects p ON c.project_id = p.id \
WHERE c.imei = ? AND p.organization_id = ?)";
// ---------------------------------------------------------------------------
// 组织树
// ---------------------------------------------------------------------------
/// 组织树接口 — 返回多级组织->项目树(含设备数量)
pub async fn organization_tree(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
// 获取所有组织
let orgs: Vec<OrganizationRow> = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?;
// 获取所有项目
let projects: Vec<ProjectRow> = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?;
// 获取每个项目的设备数量
let project_count_map = fetch_project_counts(db).await?;
// 递归构建树
let tree = build_tree(&orgs, &projects, &project_count_map, None);
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
}
/// 查询「项目ID -> 设备数量」映射
async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result<HashMap<i64, i64>, AppError> {
let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL)
.fetch_all(db)
.await?;
Ok(project_counts.into_iter().collect())
}
/// 递归构建「组织 -> 项目」树节点,项目名称携带设备数量
fn build_tree(
orgs: &[OrganizationRow],
projects: &[ProjectRow],
project_count_map: &HashMap<i64, i64>,
parent_id: Option<i64>,
) -> Vec<TreeNode> {
orgs.iter()
.filter(|o| o.parent_id == parent_id)
.map(|org| {
// 递归获取子组织
let children = build_tree(orgs, projects, project_count_map, Some(org.id));
// 获取该组织下的项目
let org_projects: Vec<TreeNode> = projects
.iter()
.filter(|p| p.organization_id == Some(org.id))
.map(|p| {
let count = project_count_map.get(&p.id).copied().unwrap_or(0);
TreeNode {
id: p.id,
name: format!("{} ({})", p.name, count),
children: None,
abstract_id: None,
imei: None,
status: None,
disabled: None,
address: p.address.clone(),
latitude: p.latitude.clone(),
longitude: p.longitude.clone(),
}
})
.collect();
// 合并子组织和项目
let mut all_children = children;
all_children.extend(org_projects);
// 计算本组织下的总设备数量
let total_count: i64 = all_children
.iter()
.map(|c| {
// 从项目名称中提取数量
if let Some(start) = c.name.rfind('(') {
if let Some(end) = c.name.rfind(')') {
c.name[start + 1..end].parse().unwrap_or(0)
} else {
0
}
} else {
0
}
})
.sum();
TreeNode {
id: org.id,
name: format!("{} ({})", org.name, total_count),
children: if all_children.is_empty() {
None
} else {
Some(all_children)
},
abstract_id: None,
imei: None,
status: None,
disabled: org.disabled,
address: None,
latitude: None,
longitude: None,
}
})
.collect()
}
// ---------------------------------------------------------------------------
// 设备列表筛选
// ---------------------------------------------------------------------------
/// 设备列表筛选参数
#[derive(Deserialize)]
pub struct CabinetFilterParams {
pub organization_id: Option<i64>,
pub project_id: Option<i64>,
pub page: Option<i64>,
pub page_size: Option<i64>,
pub bound: Option<String>,
}
/// 设备列表接口 — 根据组织/项目筛选
pub async fn filter_cabinets(
user: CurrentUser,
State(state): State<AppState>,
Query(params): Query<CabinetFilterParams>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
let offset = (page - 1) * page_size;
// 组织/项目过滤 + 已绑定/未绑定过滤
let (where_clause, binds) = build_filter_where(&params, &user);
// 总数量
let total = fetch_filter_total(db, &where_clause, &binds).await;
if total == 0 || offset >= total {
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
}
// 分页数据
let sql_rows = fetch_filter_page(db, &where_clause, &binds, page_size, offset).await?;
// 转换为CabinetRow
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
attach_realtime_status(&state, &mut rows).await?;
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
attach_org_disabled(db, &mut rows).await;
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
}
/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致)
fn build_filter_where(
params: &CabinetFilterParams,
user: &CurrentUser,
) -> (String, Vec<String>) {
let (where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
} else if let Some(org_id) = params.organization_id {
("JOIN projects p ON c.project_id = p.id
WHERE p.organization_id IN (
SELECT id FROM organizations WHERE id = ? OR parent_id = ?
)".into(), vec![org_id.to_string(), org_id.to_string()])
} else if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(org_id) = user.organization_id {
("JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?".into(), vec![org_id.to_string()])
} else {
("WHERE 1=0".into(), vec![])
};
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
// 已绑定/未绑定过滤(绑定 = 分配到项目)
let where_clause = match params.bound.as_deref() {
Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"),
Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"),
_ => where_clause,
};
(where_clause, binds)
}
/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接
fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String {
if where_clause.is_empty() {
where_new.to_string()
} else {
let mut clause = where_clause;
clause.push_str(and_tail);
clause
}
}
/// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致)
async fn fetch_filter_total(db: &sqlx::MySqlPool, where_clause: &str, binds: &[String]) -> i64 {
let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for b in binds {
count_query = count_query.bind(b);
}
count_query.fetch_one(db).await.unwrap_or(0)
}
/// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定)
async fn fetch_filter_page(
db: &sqlx::MySqlPool,
where_clause: &str,
binds: &[String],
page_size: i64,
offset: i64,
) -> Result<Vec<CabinetSqlRow>, AppError> {
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", CABINET_FILTER_BASE_SQL, where_clause);
let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql);
for b in binds {
data_query = data_query.bind(b);
}
let rows: Vec<CabinetSqlRow> = data_query
.bind(page_size)
.bind(offset)
.fetch_all(db)
.await?;
Ok(rows)
}
/// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返)
async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Result<(), AppError> {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = open_redis_conn(state).await?;
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
let mut pipe = redis::pipe();
for row in rows.iter() {
let key = format!("device:{}", row.imei);
pipe.cmd("HGET").arg(&key).arg("online");
pipe.cmd("HGET").arg(&key).arg("rssi");
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
}
// 一次发送,获取所有结果
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
// 每4个一组解析
for (i, row) in rows.iter_mut().enumerate() {
let base = i * 4;
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
row.rssi = results
.get(base + 1)
.and_then(|v| v.as_ref())
.and_then(|v| v.parse::<i32>().ok());
row.pow_fail_dc = results
.get(base + 2)
.and_then(|v| v.as_ref())
.map(|v| v == "1");
row.pow_fail_ac = results
.get(base + 3)
.and_then(|v| v.as_ref())
.map(|v| v == "1");
}
Ok(())
}
/// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标)
async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) {
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
if project_ids.is_empty() {
return;
}
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
r#"SELECT p.id, o.disabled FROM projects p
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
LEFT JOIN organizations o ON p.organization_id = o.id
WHERE p.id IN ({})"#,
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
for pid in &project_ids {
q = q.bind(pid);
}
if let Ok(results) = q.fetch_all(db).await {
let disabled_map: HashMap<i64, bool> =
results.into_iter().map(|(pid, d)| (pid, d == 1)).collect();
for row in rows.iter_mut() {
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
}
}
}
// ---------------------------------------------------------------------------
// 设备实时状态
// ---------------------------------------------------------------------------
/// 设备实时状态参数
#[derive(Deserialize)]
pub struct DeviceStatusParams {
pub imei: String,
}
/// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误)
async fn open_redis_conn(state: &AppState) -> Result<redis::aio::MultiplexedConnection, AppError> {
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
AppError::Internal(format!("Redis客户端创建失败: {}", e))
})?;
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
client.get_multiplexed_async_connection().await.map_err(|e| {
AppError::Internal(format!("Redis连接失败: {}", e))
})
}
/// 设备实时状态接口 — 返回板级+通道级状态
pub async fn device_realtime_status(
user: CurrentUser,
State(state): State<AppState>,
Query(params): Query<DeviceStatusParams>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
// 校验设备归属
ensure_device_access(&state, &user, &params.imei).await?;
let mut conn = open_redis_conn(&state).await?;
let device_status = load_device_status(&mut conn, &params.imei).await;
let boards = load_boards_status(&mut conn, &params.imei).await;
Ok(Json(json!({
"code": 0,
"data": {
"device": device_status,
"boards": boards,
},
"message": "ok"
})))
}
/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织
async fn ensure_device_access(
state: &AppState,
user: &CurrentUser,
imei: &str,
) -> Result<(), AppError> {
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
if user.role_level < 2 {
if let Some(org_id) = user.organization_id {
let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL)
.bind(imei)
.bind(org_id)
.fetch_one(&state.mysql)
.await
.unwrap_or(false);
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
if !has_access {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
} else {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
}
Ok(())
}
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
/// 读取设备级字段(online / rssi / pow_fail_dc / pow_fail_ac)
async fn load_device_status(
conn: &mut redis::aio::MultiplexedConnection,
imei: &str,
) -> Value {
let device_key = format!("device:{}", imei);
// 获取设备级字段
let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"];
let values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(&device_key)
.arg(&fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
json!({
"online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
"rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()),
"pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
"pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
})
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
/// 通道级 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 {
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
let ch_pattern = format!("device:{}:board:*:ch:*", imei);
let ch_keys: Vec<String> = redis::cmd("KEYS")
.arg(&ch_pattern)
.query_async(&mut *conn)
.await
.unwrap_or_default();
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
// 板号 → [(通道号, key)]:BTreeMap 保证板号升序,输出顺序稳定
let mut grouped: BTreeMap<i64, Vec<(i64, String)>> = 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));
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
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
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
/// 由**已排序**的通道级 key 组装单块仓控板的响应(板级 + 通道级)。
async fn load_board_status(
conn: &mut redis::aio::MultiplexedConnection,
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
entries: &[(i64, String)],
) -> Value {
let mut channels = json!({});
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
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);
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
channels[channel_no.to_string().as_str()] = channel;
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
json!({
"status_hex": status_hex,
"channels": channels,
})
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
/// 电池状态位(协议 §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<u8> {
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<i64>,
voltage: Option<i64>,
current: Option<i64>,
temp: Option<i64>,
) -> Value {
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
let batt = parse_hex_byte(batt_raw);
let pow = parse_hex_byte(pow_raw);
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
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"
} else if full {
"full"
} else if on {
"charging"
} else {
"idle"
};
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
// 统一输出小写 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,
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
"hex": hex,
"temp": temp,
"batt_status": batt_status,
"pow_status": pow_status,
"soc": soc,
"voltage": voltage,
"current": current,
})
}
fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底) ## 严重问题 - 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00
/// 读取单个通道状态。
///
/// ⚠️ 旧实现读的是 `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<Option<String>> = 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<i64> { text(idx).and_then(|v| v.parse::<i64>().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
);
}
}