1967 lines
74 KiB
Rust
Raw Normal View History

//! H5 用户端 API
//!
//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
//! - GET /api/dashboard — 首页仪表盘数据
//! - GET /api/cabinets — 设备列表(按项目过滤)
//! - GET /api/cabinets/:id — 设备详情(含仓板+仓体状态)
//! - GET /api/cabinets/abstract/:aid — 通过抽象ID查设备(扫码入口,免认证)
//! - GET /api/compartments/:id — 仓体/通道详情
//! - POST /api/charge/start — 开始充电
//! - POST /api/charge/stop — 停止充电
//! - POST /api/door/open — 开门
//! - GET /api/cabinets/scan/:abstract_id — 扫码验证
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
use axum::extract::{Multipart, Path, Query, State};
use axum::Json;
use redis;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
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
use std::io::Write;
use uuid::Uuid;
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
use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
// ---------------------------------------------------------------------------
// 请求体定义
// ---------------------------------------------------------------------------
/// 充电控制请求体(开始/停止充电、开门共用)
#[derive(Debug, Deserialize)]
pub(crate) struct CompartmentActionInput {
compartment_id: i64,
}
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(Debug, Deserialize)]
pub(crate) struct FeedbackInput {
/// 反馈内容
content: String,
/// 联系方式(可选)
contact: Option<String>,
/// 图片URL列表(可选)
images: Option<Vec<String>>,
}
/// 设备列表查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct CabinetListQuery {
project_id: Option<i64>,
org_id: Option<i64>,
}
/// 仪表盘查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct DashboardQuery {
org_id: Option<i64>,
}
// ---------------------------------------------------------------------------
// 行映射结构体
// ---------------------------------------------------------------------------
/// 项目简要信息行
#[derive(Debug, sqlx::FromRow)]
struct ProjectRow {
id: i64,
name: String,
}
/// 柜子列表行(含通道统计)
#[derive(Debug, sqlx::FromRow)]
struct H5CabinetRow {
id: i64,
abstract_id: String,
name: Option<String>,
status: i8,
project_id: Option<i64>,
imei: 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
iccid: Option<String>,
address: Option<String>,
latitude: Option<String>,
longitude: Option<String>,
channel_count: i64,
idle_count: i64,
charging_count: i64,
full_count: i64,
fault_count: i64,
}
/// 仓控板行
#[derive(Debug, sqlx::FromRow)]
struct H5CabinBoardRow {
id: i64,
board_id: i8,
status: i8,
}
/// 仓体行
#[derive(Debug, sqlx::FromRow)]
struct H5CompartmentRow {
id: i64,
channel_id: i8,
status: i8,
}
/// 充电记录简要行
#[derive(Debug, sqlx::FromRow)]
struct ChargeRecordBriefRow {
id: i64,
start_time: Option<chrono::NaiveDateTime>,
end_time: Option<chrono::NaiveDateTime>,
energy: 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
/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发)
#[derive(Debug, sqlx::FromRow)]
struct CabinetImeiRow {
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
cabinet_id: i64,
imei: 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
board_id: i8,
channel_id: i8,
}
// ---------------------------------------------------------------------------
// 仪表盘
// ---------------------------------------------------------------------------
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离)
pub async fn get_dashboard(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<DashboardQuery>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 根据角色确定组织过滤条件
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_id 参数切换组织
let is_super = user.role_level >= 2 && query.org_id.is_none();
let org_id = query.org_id.or(user.organization_id);
// 统计在线柜子数(带组织隔离)
let online_cabinets = count_online_cabinets(pool, is_super, org_id).await?;
// 统计总通道数(带组织隔离)
let total_channels = count_total_compartments(pool, is_super, org_id).await?;
// 统计充电中仓体数(带组织隔离)
let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?;
// 统计空闲仓体数(带组织隔离)
let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?;
// 统计故障仓体数(带组织隔离)
let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?;
// 统计充满仓体数(status=2,带组织隔离)
let full_count = count_compartments_by_status(pool, is_super, org_id, 2).await?;
// 统计故障数(status=3,带组织隔离)
let fault_count = count_compartments_by_status(pool, is_super, org_id, 3).await?;
// 今日充电次数(带组织隔离)
let today_charge_count = count_today_charges(pool, is_super, org_id).await?;
// 今日充电量(kWh,带组织隔离)
let today_energy = sum_today_energy(pool, is_super, org_id).await?;
// 今日充电时长(小时,带组织隔离)
let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?;
// 故障告警(取最近10条故障仓体,返回项目名+柜名)
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
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 projects = fetch_project_summaries(pool, &user, org_id).await?;
Ok(Json(json!({
"code": 0,
"data": {
"online_cabinets": online_cabinets,
"total_channels": total_channels,
"charging_count": charging_count,
"idle_channels": idle_channels,
"full_count": full_count,
"fault_channels": fault_channels,
"fault_count": fault_count,
"today_charge_count": today_charge_count,
"today_energy_kwh": today_energy,
"today_charge_hours": today_charge_hours,
"alerts": alerts,
"projects": projects,
},
"message": "ok"
})))
}
/// 统计在线柜子数(带组织隔离)
async fn count_online_cabinets(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.status = 1 AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 统计总仓体数(带组织隔离)
async fn count_total_compartments(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets c ON cb.cabinet_id = c.id
WHERE c.status = 1"#,
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets c ON cb.cabinet_id = c.id
JOIN projects p ON c.project_id = p.id
WHERE c.status = 1 AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 按状态统计仓体数(带组织隔离,通过 cabin_boards → cabinets → projects 链路)
async fn count_compartments_by_status(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
status: i8,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar("SELECT COUNT(*) FROM compartments WHERE status = ?")
.bind(status)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
INNER JOIN cabinets cab ON cb.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE comp.status = ? AND p.organization_id = ?"#,
)
.bind(status)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电次数(带组织隔离)
async fn count_today_charges(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar(
"SELECT COUNT(*) FROM charge_records WHERE DATE(start_time) = CURDATE()",
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电量(kWh,带组织隔离)
async fn sum_today_energy(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let energy: Option<i64> = if is_super {
sqlx::query_scalar(
"SELECT CAST(COALESCE(SUM(energy), 0) AS SIGNED) FROM charge_records WHERE DATE(start_time) = CURDATE()",
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(energy.unwrap_or(0))
}
/// 今日充电时长(秒,带组织隔离)
async fn sum_today_charge_hours(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
// 计算总秒数(未结束的记录用 NOW() 代替 end_time)
let total_seconds: Option<i64> = if is_super {
sqlx::query_scalar(
r#"SELECT CAST(COALESCE(SUM(
TIMESTAMPDIFF(SECOND, start_time, COALESCE(end_time, NOW()))
), 0) AS SIGNED) FROM charge_records
WHERE DATE(start_time) = CURDATE()"#,
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT CAST(COALESCE(SUM(
TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW()))
), 0) AS SIGNED) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(total_seconds.unwrap_or(0))
}
/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离)
async fn fetch_fault_alerts(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<Vec<Value>, AppError> {
if !is_super && org_id.is_none() {
return Ok(vec![]);
}
let base_sql = if is_super {
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
comp.channel_id
FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
LEFT JOIN projects p ON cab.project_id = p.id
WHERE comp.status = 2
ORDER BY comp.updated_at DESC
LIMIT 10"#
} else {
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
comp.channel_id
FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE comp.status = 2 AND p.organization_id = ?
ORDER BY comp.updated_at DESC
LIMIT 10"#
};
let rows: Vec<(String, String, i8)> = if is_super {
sqlx::query_as(base_sql).fetch_all(pool).await?
} else {
sqlx::query_as(base_sql)
.bind(org_id.unwrap())
.fetch_all(pool)
.await?
};
Ok(rows
.into_iter()
.map(|(project, cabinet, channel)| {
json!({
"project": project,
"cabinet": cabinet,
"channel": channel,
"alert_type": "fault",
})
})
.collect())
}
// --- 项目摘要 SQL(由函数体原样搬移,WHERE/JOIN/SELECT 未改动) ---
/// 指定组织下的项目
const PROJECTS_BY_ORG_SQL: &str = "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id";
/// 全部项目(总管理员未指定组织时)
const PROJECTS_ALL_SQL: &str = "SELECT id, name FROM projects ORDER BY id";
/// 项目下柜子数量
const PROJECT_CABINET_COUNT_SQL: &str = "SELECT COUNT(*) FROM cabinets WHERE project_id = ?";
/// 项目下充电中仓体数量
const PROJECT_CHARGING_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 1"#;
/// 项目下空闲仓体数量
const PROJECT_IDLE_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 0"#;
/// 项目下故障仓体数量
const PROJECT_FAULT_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 2"#;
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
async fn fetch_project_summaries(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
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_id: Option<i64>,
) -> Result<Vec<Value>, AppError> {
let projects = fetch_visible_projects(pool, user, org_id).await?;
let mut result = Vec::with_capacity(projects.len());
for p in &projects {
result.push(fetch_project_summary(pool, p).await?);
}
Ok(result)
}
/// 获取用户可见的项目(优先使用查询参数 org_id)
async fn fetch_visible_projects(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
org_id: Option<i64>,
) -> Result<Vec<ProjectRow>, 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
let filter_org = org_id.or(user.organization_id);
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
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 let Some(oid) = filter_org {
sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
.bind(oid)
.fetch_all(pool)
.await?
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
} else {
sqlx::query_as::<_, ProjectRow>(PROJECTS_ALL_SQL)
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
.fetch_all(pool)
.await?
}
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
Ok(projects)
}
/// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数
async fn fetch_project_summary(
pool: &sqlx::MySqlPool,
p: &ProjectRow,
) -> Result<Value, AppError> {
let cabinet_count: i64 = sqlx::query_scalar(PROJECT_CABINET_COUNT_SQL)
.bind(p.id)
.fetch_one(pool)
.await?;
let charging: i64 = sqlx::query_scalar(PROJECT_CHARGING_COUNT_SQL)
.bind(p.id)
.fetch_one(pool)
.await?;
let idle: i64 = sqlx::query_scalar(PROJECT_IDLE_COUNT_SQL)
.bind(p.id)
.fetch_one(pool)
.await?;
let fault: i64 = sqlx::query_scalar(PROJECT_FAULT_COUNT_SQL)
.bind(p.id)
.fetch_one(pool)
.await?;
Ok(json!({
"id": p.id,
"name": p.name,
"cabinet_count": cabinet_count,
"charging": charging,
"idle": idle,
"fault": fault,
}))
}
// ---------------------------------------------------------------------------
// 设备列表
// ---------------------------------------------------------------------------
// --- 设备列表 SQL(由原函数体逐字搬移;SELECT 字段列表 / 子查询 / CAST 均未改动) ---
/// 项目过滤 + 总管理员(不带组织条件)
const CABINETS_BY_PROJECT_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c WHERE c.project_id = ?"#;
/// 项目过滤 + 企业管理员(额外校验项目归属组织)
const CABINETS_BY_PROJECT_AND_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
WHERE c.project_id = ? AND p.organization_id = ?"#;
/// 总管理员按组织过滤
const CABINETS_BY_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?"#;
/// 总管理员查看全部
const CABINETS_ALL_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c"#;
/// 企业管理员只看本组织
const CABINETS_BY_USER_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?"#;
/// 柜子所属组织是否禁用(前端徽标用,`{}` 处填入 `?` 占位符列表)
const PROJECT_ORG_DISABLED_SQL: &str = r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p
LEFT JOIN organizations o ON p.organization_id = o.id
WHERE p.id IN ({})"#;
/// 设备列表的 Redis 实时状态(按 IMEI 索引)
#[derive(Default)]
struct CabinetRealtimeMaps {
rssi: HashMap<String, Option<i32>>,
dc: HashMap<String, Option<bool>>,
ac: HashMap<String, Option<bool>>,
temp: HashMap<String, Option<i32>>,
}
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
pub async fn get_cabinets(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
// 权限校验
auth::check_permission(&user, "device:view")?;
let pool = &state.mysql;
// 组织隔离查询:设备基础信息 + 通道统计
let rows = fetch_cabinet_rows(pool, &user, query.project_id, query.org_id).await?;
// 从Redis批量查RSSI + pow_fail + temp
let realtime = load_cabinet_realtime(&state, &rows).await;
// 查询柜子所属组织是否禁用(用于前端徽标)
let org_disabled_map = load_org_disabled_map(pool, &rows).await;
let list = build_cabinet_list(rows, &realtime, &org_disabled_map);
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// 按「项目 / 角色」优先级查询用户可见的柜子行(分支顺序与原实现逐条对应)
async fn fetch_cabinet_rows(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
project_id: Option<i64>,
org_id: Option<i64>,
) -> Result<Vec<H5CabinetRow>, AppError> {
if let Some(pid) = project_id {
// 按项目过滤(同时验证组织权限)
fetch_cabinet_rows_by_project(pool, user, pid).await
} else if user.role_level >= 2 {
// 总管理员:按组织过滤或看全部
fetch_cabinet_rows_for_admin(pool, org_id).await
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_USER_ORG_SQL)
.bind(org_id)
.fetch_all(pool)
.await?;
Ok(rows)
} else {
Ok(vec![])
}
}
/// 项目过滤分支:总管理员不带组织条件,企业管理员额外校验项目归属组织
async fn fetch_cabinet_rows_by_project(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
pid: i64,
) -> Result<Vec<H5CabinetRow>, AppError> {
if user.role_level >= 2 {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_SQL)
.bind(pid)
.fetch_all(pool)
.await?;
Ok(rows)
} else if let Some(org_id) = user.organization_id {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_AND_ORG_SQL)
.bind(pid)
.bind(org_id)
.fetch_all(pool)
.await?;
Ok(rows)
} else {
Ok(vec![])
}
}
/// 总管理员分支:指定组织则按组织过滤,否则查看全部
async fn fetch_cabinet_rows_for_admin(
pool: &sqlx::MySqlPool,
org_id: Option<i64>,
) -> Result<Vec<H5CabinetRow>, AppError> {
if let Some(oid) = org_id {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_ORG_SQL)
.bind(oid)
.fetch_all(pool)
.await?;
Ok(rows)
} else {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_ALL_SQL)
.fetch_all(pool)
.await?;
Ok(rows)
}
}
/// 从 Redis 批量读取柜子实时状态(Pipeline HMGET,1 次网络往返)
///
/// 注意:pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐,
/// 与重构前的行为完全一致(这是既有约定,不做修正)。
async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> CabinetRealtimeMaps {
let mut maps = CabinetRealtimeMaps::default();
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
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
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
let mut pipe = redis::pipe();
for r in rows {
if let Some(ref imei) = r.imei {
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 key = format!("device:{imei}");
pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]);
}
}
if !rows.is_empty() {
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 results: Vec<Vec<Option<String>>> = match pipe.query_async(&mut conn).await {
Ok(r) => r,
Err(e) => {
tracing::error!("[h5] Redis pipeline查询失败: {}", e);
Vec::new()
}
};
for (i, r) in rows.iter().enumerate() {
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 let Some(ref imei) = r.imei {
if let Some(vals) = results.get(i) {
maps.rssi.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
maps.dc.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
maps.ac.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
maps.temp.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().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
}
}
}
}
}
}
maps
}
/// 查询柜子所属组织是否禁用,返回「项目ID -> 是否禁用」映射
async fn load_org_disabled_map(
pool: &sqlx::MySqlPool,
rows: &[H5CabinetRow],
) -> HashMap<i64, bool> {
let mut org_disabled_map: HashMap<i64, bool> = HashMap::new();
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
if !project_ids.is_empty() {
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
// `{}` 处填入占位符列表,等价于原实现的 format! 拼接
let sql = PROJECT_ORG_DISABLED_SQL.replacen("{}", &placeholders.join(","), 1);
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
for pid in &project_ids {
q = q.bind(pid);
}
if let Ok(rows) = q.fetch_all(pool).await {
for (pid, disabled) in rows {
org_disabled_map.insert(pid, disabled == 1);
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_map
}
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
/// 行结构体 + 实时状态 → 响应 JSON 列表(字段名与语义与原实现逐字一致)
fn build_cabinet_list(
rows: Vec<H5CabinetRow>,
realtime: &CabinetRealtimeMaps,
org_disabled_map: &HashMap<i64, bool>,
) -> Vec<Value> {
rows.into_iter()
.map(|r| {
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 imei = r.imei.clone();
let rssi = imei.as_ref().and_then(|i| realtime.rssi.get(i).copied()).flatten();
let pow_fail_dc = imei.as_ref().and_then(|i| realtime.dc.get(i).copied()).flatten();
let pow_fail_ac = imei.as_ref().and_then(|i| realtime.ac.get(i).copied()).flatten();
let temp = imei.as_ref().and_then(|i| realtime.temp.get(i).copied()).flatten();
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 org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
json!({
"id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
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
"imei": r.imei,
"iccid": r.iccid,
"status": r.status,
"project_id": r.project_id,
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
"address": r.address,
"latitude": r.latitude,
"longitude": r.longitude,
"channel_count": r.channel_count,
"idle_count": r.idle_count,
"charging_count": r.charging_count,
"full_count": r.full_count,
"fault_count": r.fault_count,
"rssi": rssi,
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
"pow_fail_dc": pow_fail_dc,
"pow_fail_ac": pow_fail_ac,
"temp": temp,
"org_disabled": org_disabled,
})
})
.collect()
}
// ---------------------------------------------------------------------------
// 设备扫码(免认证)
// ---------------------------------------------------------------------------
pub async fn get_cabinet_by_abstract_id(
State(state): State<AppState>,
Path(abstract_id): Path<String>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
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 cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
)
.bind(&abstract_id)
.fetch_optional(pool)
.await?;
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 (cab_id, abs_id, _imei, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 查询仓控板+仓体
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",
)
.bind(cab_id)
.fetch_all(pool)
.await?;
let mut cabin_boards = Vec::with_capacity(boards.len());
for board in &boards {
let comps: Vec<H5CompartmentRow> = sqlx::query_as::<_, H5CompartmentRow>(
"SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id",
)
.bind(board.id)
.fetch_all(pool)
.await?;
let compartments: Vec<Value> = comps
.into_iter()
.map(|c| json!({ "id": c.id, "channel_id": c.channel_id, "status": c.status }))
.collect();
cabin_boards.push(json!({
"id": board.id,
"board_id": board.board_id,
"status": board.status,
"compartments": compartments,
}));
}
Ok(Json(json!({
"code": 0,
"data": {
"id": cab_id,
"abstract_id": abs_id,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 仓体/通道详情
// ---------------------------------------------------------------------------
/// 仓体基础信息(id, channel_id, status, cabin_board_id)
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 = ?";
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
/// 仓体所属仓控板号 + 柜子 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
WHERE compartment_id = ?
ORDER BY start_time DESC LIMIT 10"#;
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
pub async fn get_compartment_detail(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
// 权限校验
auth::check_permission(&user, "device:view")?;
let pool = &state.mysql;
// 查询仓体基本信息
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(COMPARTMENT_BY_ID_SQL)
.bind(id)
.fetch_optional(pool)
.await?;
let (comp_id, channel_id, status, cabin_board_id) =
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
verify_compartment_access(&user, cabin_board_id, pool).await?;
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 需要(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<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
COMPARTMENT_CHARGE_RECORDS_SQL,
)
.bind(comp_id)
.fetch_all(pool)
.await?;
Ok(Json(json!({
"code": 0,
"data": {
"id": comp_id,
"channel_id": channel_id,
"status": status,
"cabin_board_id": cabin_board_id,
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
// 以下实时字段全部取自设备上报真实写入的 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"
})))
}
/// 组织隔离:仓控板 → 柜子 → 项目 → 组织链路校验访问权限
async fn verify_compartment_access(
user: &CurrentUser,
cabin_board_id: i64,
pool: &sqlx::MySqlPool,
) -> Result<(), AppError> {
let cabinet_id: Option<(i64,)> = sqlx::query_as(CABIN_BOARD_CABINET_ID_SQL)
.bind(cabin_board_id)
.fetch_optional(pool)
.await?;
if let Some((cab_id,)) = cabinet_id {
verify_cabinet_access(user, cab_id, pool).await?;
}
Ok(())
}
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
/// 仓体详情实时状态快照(全部来自**真实写入**的 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<String>,
/// 电源状态(2 位 hex,与电池状态相互独立)
pow_status: Option<String>,
/// SOC,平台单位 ‰
soc: Option<i64>,
/// 充电电压,单位 mV
voltage: Option<i64>,
/// 充电电流,单位 mA
current: Option<i64>,
/// 电芯最高温度,单位 K×100
temp: Option<i64>,
}
/// 通道级 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<Vec<Option<String>>, 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<Option<String>, 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::<i64>().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<String>]) -> CompartmentRealtime {
/// 取第 i 项的非空字符串(空白串视为"无数据",避免前端把空 hex 当有效状态渲染)
fn pick(values: &[Option<String>], i: usize) -> Option<String> {
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<String>], i: usize) -> Option<i64> {
pick(values, i).and_then(|v| v.parse::<i64>().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` 语义不变)
fn map_charge_records(records: Vec<ChargeRecordBriefRow>) -> Vec<Value> {
records
.into_iter()
.map(|r| {
json!({
"id": r.id,
"start_time": r.start_time.map(|t| t.to_string()),
"end_time": r.end_time.map(|t| t.to_string()),
"energy_kwh": r.energy.unwrap_or_default().parse::<i64>().unwrap_or(0),
"cost": 0,
})
})
.collect()
}
// ---------------------------------------------------------------------------
// 扫码验证
// ---------------------------------------------------------------------------
/// GET /api/h5/scan/:abstract_id — 扫码验证设备访问权限
///
/// 根据二维码中的抽象ID查找设备,验证当前用户是否有权访问。
/// 有权限返回设备ID和基本信息,无权限返回403。
pub async fn scan_cabinet(
user: CurrentUser,
State(state): State<AppState>,
Path(abstract_id): Path<String>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 查找设备
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
)
.bind(&abstract_id)
.fetch_optional(pool)
.await?;
let (cab_id, abs_id, imei, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 验证用户是否有权限访问该设备
verify_cabinet_access(&user, cab_id, pool).await?;
Ok(Json(json!({
"code": 0,
"data": {
"id": cab_id,
"abstract_id": abs_id,
"imei": imei,
"name": name,
"status": status,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 充电控制
// ---------------------------------------------------------------------------
/// POST /api/h5/charge/start — 开始充电
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
///
/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`,
/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。
/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。
pub async fn start_charge(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:start")?;
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
// 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式
// (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
let order_row: Result<Option<(i64, String, i8, i8, Option<i8>)>, sqlx::Error> = sqlx::query_as(
r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode
FROM cabinets cab
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#,
)
.bind(body.compartment_id)
.fetch_optional(&state.mysql)
.await;
match order_row {
// 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单;
// **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => {
// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)
match crate::workers::report_worker::load_channel_battery(
&state.redis, &imei, board_id, channel_id,
)
.await
{
Some(battery) => {
if let Err(e) = crate::workers::report_worker::ensure_charge_order(
&state.mysql,
&state.redis,
&imei,
cabinet_id,
board_id,
channel_id,
&battery.battery_id,
Some(battery.soc),
)
.await
{
tracing::error!(
"[h5] 下发 on 前建单失败 compartment_id={} err={}",
body.compartment_id,
e
);
}
}
None => tracing::warn!(
"[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}",
body.compartment_id
),
}
}
Ok(Some(_)) => tracing::info!(
"[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}",
body.compartment_id
),
Ok(None) => tracing::warn!(
"[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}",
body.compartment_id
),
Err(e) => tracing::error!(
"[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}",
body.compartment_id,
e
),
}
send_compartment_command(&state, &user, body.compartment_id, "on").await
}
/// POST /api/h5/charge/stop — 停止充电
pub async fn stop_charge(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:stop")?;
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
send_compartment_command(&state, &user, body.compartment_id, "off").await
}
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
/// POST /api/h5/door/open — 开门(**柜级动作**)
///
/// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓),
/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。
pub async fn open_door(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, 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
auth::check_permission(&user, "charge:open_door")?;
let cabinet_id: Option<i64> = sqlx::query_scalar(
r#"SELECT cb.cabinet_id FROM compartments comp
JOIN cabin_boards cb ON cb.id = comp.cabin_board_id
WHERE comp.id = ?"#,
)
.bind(body.compartment_id)
.fetch_optional(&state.mysql)
.await?;
let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
send_cabinet_command(&state, &user, cabinet_id, None, "open").await
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
// --- 指令下发 SQL / Redis key(由原函数体逐字搬移,契约不变) ---
/// 仓体 → 柜子/IMEI/仓控板/通道 一次查询
const COMPARTMENT_TARGET_SQL: &str = r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
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
FROM cabinets cab
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#;
/// 柜子ID → IMEI
const CABINET_IMEI_SQL: &str = "SELECT imei FROM cabinets WHERE id = ?";
/// 柜子是否被禁用(status = 2)
const CABINET_DISABLED_SQL: &str = "SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2";
/// 柜子所属组织是否被禁用
const CABINET_ORG_DISABLED_SQL: &str = r#"SELECT COUNT(*) > 0 FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
INNER JOIN organizations o ON p.organization_id = o.id
WHERE c.id = ? AND o.disabled = 1"#;
/// 设备指令队列 key(device-server 消费端契约,不可更改)
const DEVICE_COMMANDS_KEY: &str = "device:commands";
/// 校验柜子及其所属组织未被禁用(甲方禁用组织 → 其下柜子全部失效)
async fn ensure_cabinet_enabled(pool: &sqlx::MySqlPool, cabinet_id: i64) -> 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
// 检查设备是否被禁用
let disabled: bool = sqlx::query_scalar(CABINET_DISABLED_SQL)
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
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 disabled {
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
}
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
let org_disabled: bool = sqlx::query_scalar(CABINET_ORG_DISABLED_SQL)
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
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 org_disabled {
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
}
Ok(())
}
/// 设备必须在线:平台对不在线的设备一律不投递
async fn ensure_device_online(state: &AppState, imei: &str) -> Result<(), AppError> {
if !state.nodes.is_device_online(imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
Ok(())
}
/// 序列化指令 → 注入内部路由字段 → LPUSH 进 `device:commands` 队列
async fn push_device_command(
state: &AppState,
cmd: &DeviceCommand,
imei: &str,
) -> Result<(), AppError> {
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
let mut json_val =
serde_json::to_value(cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
let json_str = json_val.to_string();
let mut conn = state.redis.clone();
redis::cmd("LPUSH")
.arg(DEVICE_COMMANDS_KEY)
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
Ok(())
}
/// 指令下发成功响应(返回码与文案与原实现一致)
fn command_dispatched_json() -> Json<Value> {
Json(json!({
"code": 0,
"data": { "success": true },
"message": "指令已下发"
}))
}
/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。
///
/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。
/// 柜级指令(`open` / `pow_on` / `pow_off` / `reset`)请用 [`send_cabinet_command`]。
async fn send_compartment_command(
state: &AppState,
user: &CurrentUser,
compartment_id: i64,
action: &str,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 一次查询拿到 cabinet_id + IMEI + board_id + channel_id
let cab_row = load_compartment_target(pool, compartment_id).await?;
// 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
ensure_cabinet_enabled(pool, cab_row.cabinet_id).await?;
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
ensure_device_online(state, &cab_row.imei).await?;
let imei = &cab_row.imei;
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
// 构造协议 id:
// 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零)
// 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定
let id = format!("{:02}-{}", cab_row.board_id, cab_row.channel_id);
let cmd = DeviceCommand {
act: action.to_string(),
msg_id: 0,
id: Some(id.clone()),
params: Some(json!({})),
};
push_device_command(state, &cmd, imei).await?;
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
tracing::info!(
"[h5] 指令已推入队列 id={} action={} compartment_id={}",
id, action, compartment_id
);
Ok(command_dispatched_json())
}
/// 按仓体ID查询目标设备与通道信息
async fn load_compartment_target(
pool: &sqlx::MySqlPool,
compartment_id: i64,
) -> Result<CabinetImeiRow, AppError> {
let row: Option<CabinetImeiRow> = sqlx::query_as(COMPARTMENT_TARGET_SQL)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))
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
}
// ---------------------------------------------------------------------------
// 柜级 / 单仓控板级指令(pow_on / pow_off / reset)
// ---------------------------------------------------------------------------
/// 柜级 / 单仓控板级指令下发。
///
/// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段)
/// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`)
///
/// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。
async fn send_cabinet_command(
state: &AppState,
user: &CurrentUser,
cabinet_id: i64,
board_id: Option<i8>,
action: &str,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let imei = load_cabinet_imei(pool, cabinet_id).await?;
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_cabinet_enabled(pool, cabinet_id).await?;
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
verify_cabinet_access(user, cabinet_id, pool).await?;
ensure_device_online(state, &imei).await?;
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
// 构造协议 id:
// - `board_id = None` → **柜级**:**不带 id**(省略该字段)
// - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01")
let id = board_id.map(|b| format!("{:02}", b));
let cmd = DeviceCommand {
act: action.to_string(),
msg_id: 0,
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
id: id.clone(),
params: Some(json!({})),
};
push_device_command(state, &cmd, &imei).await?;
tracing::info!(
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
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
id,
action,
cabinet_id,
board_id
);
Ok(command_dispatched_json())
}
/// 按柜子ID查询 IMEI
async fn load_cabinet_imei(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result<String, AppError> {
let imei: Option<String> = sqlx::query_scalar(CABINET_IMEI_SQL)
.bind(cabinet_id)
.fetch_optional(pool)
.await?;
imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))
}
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
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级)
///
/// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。
/// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。
pub async fn cabinet_open(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:open_door")?;
send_cabinet_command(&state, &user, id, None, "open").await
}
/// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级)
pub async fn cabinet_pow_on(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
send_cabinet_command(&state, &user, id, None, "pow_on").await
}
/// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级)
///
/// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`;
/// 恢复供电须再下发 `pow-on`。
pub async fn cabinet_pow_off(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
send_cabinet_command(&state, &user, id, None, "pow_off").await
}
/// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`)
pub async fn cabinet_reset(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
send_cabinet_command(&state, &user, id, None, "reset").await
}
/// POST /api/cabin-boards/:id/reset — **重启单块仓控板**
/// (协议 `id = IMEI-仓控板号`,两段)
pub async fn board_reset(
user: CurrentUser,
State(state): State<AppState>,
Path(board_pk): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
let row: Option<(i64, i8)> =
sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?")
.bind(board_pk)
.fetch_optional(&state.mysql)
.await?;
let (cabinet_id, board_id) =
row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?;
send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await
}
/// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交)
pub async fn submit_feedback(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<FeedbackInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let content = body.content.trim();
if content.is_empty() {
return Err(AppError::BadRequest("反馈内容不能为空".into()));
}
if content.chars().count() > 2000 {
return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into()));
}
let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
if let Some(c) = &contact {
if c.chars().count() > 200 {
return Err(AppError::BadRequest("联系方式过长".into()));
}
}
// 图片校验:最多 6 张
let images = body.images.clone().unwrap_or_default();
let images: Vec<String> = images
.iter()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect();
if images.len() > 6 {
return Err(AppError::BadRequest("最多上传6张图片".into()));
}
let images_json = if images.is_empty() {
String::from("[]")
} else {
serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string())
};
sqlx::query(
"INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)"
)
.bind(user.user_id)
.bind(content)
.bind(contact)
.bind(&images_json)
.execute(pool)
.await
.map_err(AppError::Database)?;
tracing::info!(
"[h5] 问题反馈已提交 user_id={} content_len={}",
user.user_id, content.chars().count()
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "反馈已提交,感谢您的反馈"
})))
}
/// GET /api/feedback — 查询反馈列表(分页,管理员使用)
#[derive(Debug, Deserialize)]
pub(crate) struct FeedbackListQuery {
page: Option<i64>,
page_size: Option<i64>,
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
/// 处理状态筛选:0=待处理 1=已处理;缺省/不传 = 全部。
/// 必须显式声明,否则 serde 会**静默丢弃**前端传来的 `status`(前端筛选点了没反应)。
status: Option<i8>,
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(Debug, sqlx::FromRow)]
struct FeedbackRow {
id: i64,
user_id: i64,
content: String,
contact: Option<String>,
images: Option<String>,
status: i8,
created_at: chrono::NaiveDateTime,
}
/// GET /api/h5/feedback — 反馈列表(管理端)
///
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
/// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据,
/// 因此不能只校验登录态 —— 复用权限种子里真实存在的 `operation_log:view`
/// (`db/migrate.rs::seed_roles`:总管理员全量、企业管理员含该码、操作员**没有**该码),
/// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近;
/// 操作员即使登录也会被 403 拦住。
///
/// `status` 为可选筛选条件,落到 SQL(不影响未传参时的原有查询语义)。
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
pub async fn list_feedback(
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
user: CurrentUser,
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
State(state): State<AppState>,
Query(query): Query<FeedbackListQuery>,
) -> Result<Json<Value>, AppError> {
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
auth::check_permission(&user, "operation_log: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
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;
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 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()));
}
}
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
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
// `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<FeedbackRow> = list_query
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?;
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 list: Vec<Value> = rows.into_iter().map(|r| {
let images: Vec<String> = r.images
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
json!({
"id": r.id,
"user_id": r.user_id,
"content": r.content,
"contact": r.contact,
"images": images,
"status": r.status,
"status_text": if r.status == 0 { "待处理" } else { "已处理" },
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": { "list": list, "total": total, "page": page, "page_size": page_size },
"message": "ok"
})))
}
/// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理)
#[derive(Debug, Deserialize)]
pub(crate) struct FeedbackStatusInput {
status: i8,
}
const FEEDBACK_IMG_DIR: &str = "uploads/feedback";
pub async fn upload_feedback_image(
user: CurrentUser,
mut multipart: Multipart,
) -> Result<Json<Value>, AppError> {
let mut file_data: Option<Vec<u8>> = None;
let mut ext = "".to_string();
while let Some(field) = multipart.next_field().await
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
let filename = field.file_name().unwrap_or("").to_string();
let data = field.bytes().await
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
if let Some(pos) = filename.rfind('.') {
ext = filename[pos..].to_string();
}
file_data = Some(data.to_vec());
}
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
if data.is_empty() {
return Err(AppError::BadRequest("上传内容为空".into()));
}
if data.len() > 5 * 1024 * 1024 {
return Err(AppError::BadRequest("图片文件不能超过 5MB".into()));
}
if ext.is_empty() {
ext = ".jpg".to_string();
}
let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
if !allowed.contains(&ext.as_str()) {
return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into()));
}
let dir = std::path::Path::new(FEEDBACK_IMG_DIR);
std::fs::create_dir_all(dir)
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
let filepath = dir.join(&filename);
let mut f = std::fs::File::create(&filepath)
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
f.write_all(&data)
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
let url = format!("/uploads/feedback/{}", filename);
tracing::info!(
user_id = user.user_id, path = %url,
"反馈图片上传成功"
);
Ok(Json(json!({
"code": 0,
"data": { "url": url },
"message": "ok"
})))
}
pub async fn update_feedback_status(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<FeedbackStatusInput>,
) -> Result<Json<Value>, AppError> {
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
// 与 list_feedback 同一权限码:能改处理状态的人必须能看反馈列表
// (`operation_log:view` 在 `db/migrate.rs` 的权限种子里真实存在)
auth::check_permission(&user, "operation_log: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
let pool = &state.mysql;
if body.status != 0 && body.status != 1 {
return Err(AppError::BadRequest("状态值不合法".into()));
}
let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?")
.bind(body.status)
.bind(id)
.execute(pool)
.await
.map_err(AppError::Database)?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound("反馈记录不存在".into()));
}
tracing::info!(
"[h5] 反馈状态已更新 operator={} feedback_id={} status={}",
user.user_id, id, body.status
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "ok"
})))
}
/// 验证用户是否有权访问指定柜子(组织数据隔离)
///
/// 总管理员(role_level >= 2)可访问所有柜子;
/// 企业管理员只能访问本组织下项目关联的柜子;
/// 无组织关联的用户无权访问任何柜子。
async fn verify_cabinet_access(
user: &CurrentUser,
cabinet_id: i64,
pool: &sqlx::MySqlPool,
) -> Result<(), AppError> {
// 总管理员无需校验
if user.role_level >= 2 {
return Ok(());
}
let org_id = user
.organization_id
.ok_or_else(|| AppError::Forbidden("无组织关联,无权访问设备".into()))?;
// 通过 cabinet → project → organization 链路验证归属
let access: Option<(i64,)> = sqlx::query_as(
r#"SELECT cab.id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.id = ? AND p.organization_id = ?"#,
)
.bind(cabinet_id)
.bind(org_id)
.fetch_optional(pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
Ok(())
}
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
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[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<Option<String>> = 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<Option<String>> = 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<Option<String>> = 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<Option<String>> = 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");
}
}