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、消费链路正常、迁移已建索引
This commit is contained in:
12451 2026-09-20 00:36:37 +08:00
parent 8bc7812733
commit f80b3b634c
35 changed files with 3668 additions and 468 deletions

File diff suppressed because one or more lines are too long

View File

@ -273,20 +273,103 @@ pub async fn send_device_response(
Ok(()) Ok(())
} }
/// 本接口放行的**柜级/仓体级指令白名单**。
///
/// 为什么必须有白名单:`act` 是请求体里的自由字符串。原先不加限制 + 不做归属校验,
/// 任何持有 `device:operate` 的低权限用户都能对**全平台任意柜机**下发协议里的
/// **任何**动作(开门 / 断电 / 重启),属于越权高危操作(C2)。
///
/// 只放行管理端确有需要的动作:
/// - `open` / `pow_on` / `pow_off`:**柜级**(协议不带 `id`)
/// - `reset`:柜级;单仓控板时由调用方显式传 `id = "01"`
/// - `on` / `off`:**仓体级**,调用方必须显式传 `id = "01-3"`(协议要求)
const CABINET_COMMAND_ACTS: &[&str] = &["on", "off", "open", "pow_on", "pow_off", "reset"];
/// 协议 `id` 的合法形态校验:`id` 只表示**动作目标** ——
/// 一段 = 单仓控板(`"01"`)/ 两段 = 仓体级(`"01-3"`),每段 1~2 位数字。
///
/// 协议定稿后 `id` **不含 IMEI**(设备身份由 TCP 连接确定),因此 15 位 IMEI 一律非法。
fn is_valid_command_target(id: &str) -> bool {
/// 单段:1~2 位数字(仓控板 `"1"`/`"01"`,通道 `"3"`)
fn segment_ok(seg: &str) -> bool {
!seg.is_empty() && seg.len() <= 2 && seg.bytes().all(|b| b.is_ascii_digit())
}
let mut parts = id.split('-');
match (parts.next(), parts.next(), parts.next()) {
(Some(a), None, _) => segment_ok(a),
(Some(a), Some(b), None) => segment_ok(a) && segment_ok(b),
// 空串、三段及以上(含 IMEI 这类无分隔的长数字)一律拒绝
_ => false,
}
}
/// 从请求体参数里取出协议 `id`(动作目标),并从 `params` 中**摘除**。
///
/// - 取不到(柜级指令本就不带 `id`)→ `None`
/// - 由调用方显式给出:单板 `reset` → `"01"`;仓体级 `on`/`off` → `"01-3"`
/// - **绝不允许再用 IMEI 当 `id`**:下行路由靠内部字段
/// [`ROUTE_DEV_ID_FIELD`](`_dev_id`),协议 `id` 与设备身份无关
///
/// 为什么必须摘除:`DeviceCommand::params` 是 `#[serde(flatten)]`,
/// 若 `params` 里仍带 `id`,序列化时会与结构体显式 `id` 字段互相覆盖,
/// 可能把未校验的值(甚至非字符串)写进下行报文。
fn take_command_target(params: &mut Option<Value>) -> Result<Option<String>, AppError> {
let raw = params
.as_mut()
.and_then(|p| p.as_object_mut())
.and_then(|obj| obj.remove("id"));
match raw {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => {
if !is_valid_command_target(&s) {
return Err(AppError::BadRequest(format!(
"指令目标 id 非法: {}(应形如 \"01\" 或 \"01-3\",且不含 IMEI)",
s
)));
}
Ok(Some(s))
}
Some(_) => Err(AppError::BadRequest("指令目标 id 必须是字符串".into())),
}
}
/// POST /api/devices/:dev_id/command — 向指定设备下发指令 /// POST /api/devices/:dev_id/command — 向指定设备下发指令
///
/// 安全链路(C2 加固,缺一不可):
/// 1. `dev_id`(IMEI) 反查 `cabinets.id`,查不到 → `NotFound`
/// 2. **组织归属校验** `verify_cabinet_ownership`(与 `routes/cabinets.rs` 同一套隔离规则;
/// 总管理员 role_level=2 豁免)
/// 3. 设备禁用校验
/// 4. `act` 白名单(见 [`CABINET_COMMAND_ACTS`])
/// 5. 设备必须在线(与 `/api/h5/charge/start` 一致,复用 `NodeRegistry::is_device_online`)
/// 6. 协议 `id` 只表示动作目标、由调用方显式给出,**绝不再填 IMEI**
pub async fn send_command( pub async fn send_command(
user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(dev_id): Path<String>, Path(dev_id): Path<String>,
Json(req): Json<CommandRequest>, Json(mut req): Json<CommandRequest>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?; auth::check_permission(&user, "device:operate")?;
// 检查设备是否被禁用 // 1. IMEI → 柜子ID(归属校验必须落在 cabinets.id 上,故先反查)
let disabled: bool = sqlx::query_scalar( let cabinet_id: Option<i64> = sqlx::query_scalar("SELECT id FROM cabinets WHERE imei = ?")
r#"SELECT COUNT(*) > 0 FROM cabinets WHERE imei = ? AND status = 2"#,
)
.bind(&dev_id) .bind(&dev_id)
.fetch_optional(&state.mysql)
.await
.map_err(AppError::Database)?;
let cabinet_id =
cabinet_id.ok_or_else(|| AppError::NotFound(format!("设备 {} 不存在", dev_id)))?;
// 2. 组织数据隔离:非总管理员只能操作本组织项目下的柜子
crate::routes::scope_guard::verify_cabinet_ownership(&state.mysql, &user, cabinet_id).await?;
// 3. 设备禁用校验
let disabled: bool = sqlx::query_scalar(
r#"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2"#,
)
.bind(cabinet_id)
.fetch_one(&state.mysql) .fetch_one(&state.mysql)
.await .await
.map_err(AppError::Database)?; .map_err(AppError::Database)?;
@ -294,18 +377,44 @@ pub async fn send_command(
return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into())); return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into()));
} }
let cmd = DeviceCommand { // 4. `act` 白名单
act: req.act, if !CABINET_COMMAND_ACTS.contains(&req.act.as_str()) {
msg_id: req.msg_id, return Err(AppError::BadRequest(format!(
id: Some(dev_id.clone()), "不支持的指令 act={},本接口仅允许: {}",
params: req.params, req.act,
}; CABINET_COMMAND_ACTS.join("/")
)));
}
// 5. 设备必须在线(平台对不在线的设备一律不投递)
if !state.nodes.is_device_online(&dev_id).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 6. 协议 `id`(动作目标):只从请求体显式取,**不再把 IMEI 塞进去**
let id = take_command_target(&mut req.params)?;
// 仓体级动作(on/off)必须带目标,否则设备侧无法定位仓体(协议要求 `id` = 仓控板-通道)
if matches!(req.act.as_str(), "on" | "off") && id.is_none() {
return Err(AppError::BadRequest(format!(
"指令 act={} 必须显式给出目标 id(仓控板-通道,如 \"01-3\")",
req.act
)));
}
let cmd = DeviceCommand { act: req.act, msg_id: req.msg_id, id, params: req.params };
send_device_command(&state.redis, &dev_id, &cmd) send_device_command(&state.redis, &dev_id, &cmd)
.await .await
.map_err(AppError::Internal)?; .map_err(AppError::Internal)?;
tracing::info!("[command] 已推入队列 dev_id={} act={}", dev_id, cmd.act); tracing::info!(
"[command] 已推入队列 dev_id={} cabinet_id={} act={} id={:?}",
dev_id,
cabinet_id,
cmd.act,
cmd.id
);
Ok(Json(json!({ "suc": 1 }))) Ok(Json(json!({ "suc": 1 })))
} }
@ -320,3 +429,59 @@ pub async fn device_stats(
"node_count": nodes.len(), "node_count": nodes.len(),
}))) })))
} }
// ---------------------------------------------------------------------------
// 单元测试:C2 协议 `id`(动作目标)的校验与摘除
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 合法形态:一段 = 单仓控板(`"01"`)、两段 = 仓体级(`"01-3"`),每段 1~2 位数字
#[test]
fn test_is_valid_command_target_accepts_board_and_compartment() {
for ok in ["01", "1", "6", "01-3", "1-3", "12-34"] {
assert!(is_valid_command_target(ok), "{} 应为合法目标", ok);
}
}
/// IMEI(15 位纯数字)与其它畸形值一律非法 —— 协议 `id` **不含 IMEI**
#[test]
fn test_is_valid_command_target_rejects_imei_and_malformed() {
for bad in ["864720562557021", "", "-", "01-", "-3", "01-3-2", "0a", "123", "01 -3"] {
assert!(!is_valid_command_target(bad), "{} 应为非法目标", bad);
}
}
/// 显式给出 `id` 时取出,并把它从 `params` 中摘除
/// (否则 `#[serde(flatten)]` 会在序列化时覆盖结构体的显式 `id` 字段)
#[test]
fn test_take_command_target_extracts_and_removes_id() {
let mut params = Some(json!({ "id": "01-3", "duration": 60 }));
let id = take_command_target(&mut params).unwrap();
assert_eq!(id.as_deref(), Some("01-3"));
let obj = params.as_ref().and_then(|p| p.as_object()).unwrap();
assert!(!obj.contains_key("id"), "id 必须从 params 摘除");
assert_eq!(obj["duration"], 60, "其它参数必须原样保留");
}
/// 柜级指令不带 `id` → `None`(**绝不回填 IMEI**)
#[test]
fn test_take_command_target_absent_returns_none() {
assert_eq!(take_command_target(&mut None).unwrap(), None);
assert_eq!(take_command_target(&mut Some(json!({ "id": null }))).unwrap(), None);
assert_eq!(take_command_target(&mut Some(json!({ "board": 1 }))).unwrap(), None);
}
/// 非法目标(IMEI / 非字符串)必须报错,不得下发
#[test]
fn test_take_command_target_rejects_invalid_id() {
let mut imei = Some(json!({ "id": "864720562557021" }));
assert!(matches!(take_command_target(&mut imei), Err(AppError::BadRequest(_))));
let mut numeric = Some(json!({ "id": 3 }));
assert!(matches!(take_command_target(&mut numeric), Err(AppError::BadRequest(_))));
}
}

View File

@ -277,6 +277,18 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 兼容已有数据库:新增字段(幂等,列已存在则跳过) // 兼容已有数据库:新增字段(幂等,列已存在则跳过)
add_columns_if_not_exists(pool).await?; add_columns_if_not_exists(pool).await?;
// P0-2 的 DB 层兜底:**同一仓体最多一笔活跃订单**(`status = 0`)的唯一约束。
//
// ⚠️ 这一步的失败**绝不能中断启动**:`run()` 返回 Err 会让 main panic 退出(生产事故),
// 而业务侧已经有「按仓体的进程内锁 + 条件插入」两层防护,DB 约束只是第三层兜底。
// 因此这里只记 ERROR 后继续(与上方 `ALTER TABLE cabinets ...` 的容错口径一致)。
if let Err(e) = ensure_active_order_unique(pool).await {
tracing::error!(
"[migrate] 活跃订单唯一约束迁移失败(服务继续启动;业务侧仍有进程内锁+条件插入兜底)err={}",
e
);
}
// 初始化默认权限码(INSERT IGNORE 避免重复插入) // 初始化默认权限码(INSERT IGNORE 避免重复插入)
seed_permissions(pool).await?; seed_permissions(pool).await?;
// 初始化默认角色 // 初始化默认角色
@ -325,6 +337,109 @@ async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> {
Ok(()) Ok(())
} }
/// DB 层兜底:**同一仓体最多只能有一笔活跃订单**(`status = 0`)的唯一约束。
///
/// # 为什么需要(P0-2)
/// `workers/report_worker.rs` 的 `ensure_charge_order` 原本是"先查活跃单、再插入":
/// 判定与插入之间没有事务、没有锁、也没有唯一键,并发两次建单
/// (H5 双击/重试、两人同时点、设备上报 `on` 与平台下发 `on` 撞在一起)
/// 会各插一笔 `status = 0`。后果:结单只收最新一笔、老单**永久卡在 `status = 0`**,
/// 还会被 `order_timeout` 扫到后**把正在充电的新单当超时单结束**,
/// 一次充电会话产生两条 `charge_records`("今日充电次数"虚增)。
///
/// 代码侧已加「按仓体的进程内锁 + 条件插入」,本迁移是**第三层(DB 层)兜底**:
/// 即使将来有人绕过封装直接 `INSERT`,也写不出第二笔活跃订单。
///
/// # 方案:生成列 + 唯一索引(MySQL 不支持条件唯一索引)
/// ```sql
/// ALTER TABLE charge_orders ADD COLUMN active_flag BIGINT
/// GENERATED ALWAYS AS (CASE WHEN status = 0 THEN compartment_id ELSE NULL END) STORED;
/// CREATE UNIQUE INDEX uk_charge_orders_active ON charge_orders(active_flag);
/// ```
/// `status <> 0`(已完成/异常)的行 `active_flag = NULL`,而 **MySQL 唯一索引不约束 NULL**
/// (多行 NULL 可以共存)—— 正是需要的语义:历史订单可以有任意多笔,
/// **活跃订单同一仓体只能有一笔**。
///
/// # 幂等与安全(三步各自独立可重入)
/// 1. 索引已存在 ⇒ 直接返回(OLD 库/第二次启动都不会重复 DDL,避免每次启动重建整表);
/// 2. 加约束前**先预检重复活跃单**:存在重复就**只记 ERROR、放弃本次 DDL**
/// (强行加约束会报 1062;脏数据必须先人工清理,不能让迁移失败);
/// 3. 生成列已存在但索引缺失(上次"加列成功、建索引失败")⇒ 只补建索引,不重复 `ALTER`。
///
/// 失败一律上抛给 [`run`],由 `run` 记 ERROR 后**继续启动**(绝不让服务起不来)。
async fn ensure_active_order_unique(pool: &MySqlPool) -> Result<(), sqlx::Error> {
/// 目标表名(仅用于 information_schema 查询与日志)
const TABLE: &str = "charge_orders";
/// 生成列名(活跃订单哨兵)
const COLUMN: &str = "active_flag";
/// 唯一索引名(幂等判定依据)
const INDEX: &str = "uk_charge_orders_active";
// 1) 索引已存在 ⇒ 幂等跳过
let index_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM information_schema.statistics \
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
)
.bind(TABLE)
.bind(INDEX)
.fetch_one(pool)
.await?;
if index_exists.0 > 0 {
return Ok(());
}
// 2) 预检:已存在重复活跃单时**不能**加唯一约束,只能记 ERROR 交人工处理
let duplicates: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM ( \
SELECT compartment_id FROM charge_orders WHERE status = 0 \
GROUP BY compartment_id HAVING COUNT(*) > 1 \
) AS dup",
)
.fetch_one(pool)
.await?;
if duplicates.0 > 0 {
tracing::error!(
"[migrate] 检测到 {} 个仓体存在多笔活跃订单(charge_orders.status=0),\
已跳过 {} 唯一约束;请先人工清理重复活跃单(保留最新一笔、其余置为结束态)再重启服务",
duplicates.0,
INDEX
);
return Ok(());
}
// 3) 生成列(幂等:列已存在则只补建索引)
let column_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
)
.bind(TABLE)
.bind(COLUMN)
.fetch_one(pool)
.await?;
if column_exists.0 == 0 {
sqlx::query(
"ALTER TABLE charge_orders ADD COLUMN active_flag BIGINT \
GENERATED ALWAYS AS (CASE WHEN status = 0 THEN compartment_id ELSE NULL END) STORED \
COMMENT '活跃订单唯一性哨兵:status=0 时=compartment_id,否则 NULL(NULL 不参与唯一性)'",
)
.execute(pool)
.await?;
tracing::info!("[migrate] 新增生成列 {}.{}", TABLE, COLUMN);
}
sqlx::query("CREATE UNIQUE INDEX uk_charge_orders_active ON charge_orders(active_flag)")
.execute(pool)
.await?;
tracing::info!(
"[migrate] 已创建活跃订单唯一约束 {}(同一仓体最多一笔 status=0)",
INDEX
);
Ok(())
}
/// 列级幂等迁移:列不存在时才 ALTER TABLE 补列 /// 列级幂等迁移:列不存在时才 ALTER TABLE 补列
/// ///
/// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」, /// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」,

View File

@ -160,7 +160,48 @@ pub fn check_permission(user: &CurrentUser, code: &str) -> Result<(), AppError>
} }
} }
/// 总管理员角色等级(`users.role`,与 `roles` 表的 `super_admin` 对应)
pub const SUPER_ADMIN_ROLE_LEVEL: i32 = 2;
/// 内置角色名常量(与 `db/migrate.rs::insert_default_roles` 的种子角色名严格一致)
const ROLE_NAME_NORMAL_USER: &str = "normal_user";
const ROLE_NAME_ORG_ADMIN: &str = "org_admin";
const ROLE_NAME_SUPER_ADMIN: &str = "super_admin";
/// `users.role` → 内置角色名(纯函数,便于单测)
///
/// 映射关系与 `db/migrate.rs` 的种子角色一一对应:
/// - `0` 操作员 → `normal_user`
/// - `1` 企业管理员 → `org_admin`
/// - `2` 总管理员 → `super_admin`
/// - 其它值 → `None`(不产生映射,由调用方决定兜底策略)
///
/// 为什么需要它:`users.role` 是**权威的角色等级列**(登录、越权校验都用它),
/// 而 `user_roles` 只是"多角色扩展"的关联表;两者必须能互相推导,
/// 否则一旦关联表为空,用户就会掉权限(见 [`load_user_permissions`] 的兜底说明)。
pub fn builtin_role_name(role_level: i32) -> Option<&'static str> {
match role_level {
0 => Some(ROLE_NAME_NORMAL_USER),
1 => Some(ROLE_NAME_ORG_ADMIN),
2 => Some(ROLE_NAME_SUPER_ADMIN),
_ => None,
}
}
/// 加载用户权限码集合(公开接口,供路由处理器调用) /// 加载用户权限码集合(公开接口,供路由处理器调用)
///
/// **两条来源,互为兜底:**
/// 1. 首选 `user_roles` → `role_permissions` → `permissions`(精确的角色分配,
/// 即 `routes/users_perm.rs::set_user_roles` 写入的结果,**优先级最高**);
/// 2. 若 (1) 查不到任何权限码(`user_roles` 中该用户没有任何行),
/// 则按 `users.role` 回退到对应内置角色的权限集。
///
/// **为什么必须有 (2) 这个兜底(不是"顺手加的功能"):**
/// 历史数据里 `user_roles` 大量为空 —— 早先的 `POST/PUT /api/users` 只写 `users.role`,
/// 从不写 `user_roles`;于是新建的「操作员(role=0)/企业管理员(role=1)」登录后权限集为空,
/// 所有接口都回 403「缺少权限: xxx」,前端按钮全被隐藏(小程序微信登录同理)。
/// 回退到 `users.role` 对应的内置角色集后,历史数据无需刷库即可恢复可用;
/// 而总管理员(`role >= 2`)直接返回全部权限码,与 `check_permission` 的短路语义一致。
pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result<HashSet<String>, sqlx::Error> { pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result<HashSet<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as( let rows: Vec<(String,)> = sqlx::query_as(
r#" r#"
@ -175,6 +216,59 @@ pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result<Has
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
// 角色分配优先:只要用户有明确的角色映射(且该角色确实配了权限),就按映射返回
if !rows.is_empty() {
return Ok(rows.into_iter().map(|(code,)| code).collect());
}
// 兜底:按 users.role 回退到内置角色权限集
load_builtin_role_permissions(pool, user_id).await
}
/// 按 `users.role` 回退加载内置角色权限集
///
/// - `role >= 2`(总管理员)→ 全部权限码(与 `check_permission` 的短路一致,
/// 也让 `/api/users/:id/permissions` 能如实展示超管的完整权限);
/// - `role = 0/1` → 查同名内置角色在 `role_permissions` 里配置的权限码;
/// - 用户不存在 / 未知 role 值 → 空集(失败安全:不给任何权限)。
async fn load_builtin_role_permissions(
pool: &MySqlPool,
user_id: i64,
) -> Result<HashSet<String>, sqlx::Error> {
let role_level: Option<(i32,)> = sqlx::query_as("SELECT role FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(pool)
.await?;
let Some((role_level,)) = role_level else {
return Ok(HashSet::new());
};
if role_level >= SUPER_ADMIN_ROLE_LEVEL {
let rows: Vec<(String,)> = sqlx::query_as("SELECT code FROM permissions")
.fetch_all(pool)
.await?;
return Ok(rows.into_iter().map(|(code,)| code).collect());
}
let Some(role_name) = builtin_role_name(role_level) else {
tracing::warn!(user_id, role_level, "未知角色等级,按空权限集处理");
return Ok(HashSet::new());
};
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT DISTINCT p.code
FROM permissions p
INNER JOIN role_permissions rp ON rp.permission_id = p.id
INNER JOIN roles r ON r.id = rp.role_id
WHERE r.name = ?
"#,
)
.bind(role_name)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(code,)| code).collect()) Ok(rows.into_iter().map(|(code,)| code).collect())
} }
@ -354,4 +448,74 @@ mod tests {
let result = verify_password("test123", "not-a-valid-hash"); let result = verify_password("test123", "not-a-valid-hash");
assert!(result.is_err(), "无效哈希应返回错误"); assert!(result.is_err(), "无效哈希应返回错误");
} }
// -----------------------------------------------------------------------
// users.role → 内置角色名(权限兜底映射)
// -----------------------------------------------------------------------
/// role=0(操作员)应映射到内置角色 normal_user
#[test]
fn test_builtin_role_name_operator_maps_to_normal_user() {
assert_eq!(builtin_role_name(0), Some("normal_user"));
}
/// role=1(企业管理员)应映射到内置角色 org_admin
#[test]
fn test_builtin_role_name_org_admin_maps_to_org_admin() {
assert_eq!(builtin_role_name(1), Some("org_admin"));
}
/// role=2(总管理员)应映射到内置角色 super_admin
#[test]
fn test_builtin_role_name_super_admin_maps_to_super_admin() {
assert_eq!(builtin_role_name(2), Some("super_admin"));
}
/// 非法 role 值(负数/越界)应返回 None,不得误映射到某个内置角色
#[test]
fn test_builtin_role_name_invalid_level_returns_none() {
assert_eq!(builtin_role_name(-1), None);
assert_eq!(builtin_role_name(3), None);
assert_eq!(builtin_role_name(i32::MAX), None);
}
/// 映射表覆盖 0..=2 全部合法角色等级(防止将来漏改分支)
#[test]
fn test_builtin_role_name_covers_all_valid_levels() {
for level in 0..=SUPER_ADMIN_ROLE_LEVEL {
assert!(
builtin_role_name(level).is_some(),
"合法角色等级 {} 必须有内置角色映射",
level
);
}
}
/// 角色等级 >=2 的权限集兜底分支:check_permission 必须先短路放行,
/// 且超管等级常量与内置角色名映射保持一致
#[test]
fn test_super_admin_role_level_constant_matches_mapping() {
assert_eq!(builtin_role_name(SUPER_ADMIN_ROLE_LEVEL), Some("super_admin"));
let user = CurrentUser {
user_id: 1,
phone: "13800138000".into(),
role_level: SUPER_ADMIN_ROLE_LEVEL,
organization_id: None,
permissions: HashSet::new(),
};
assert!(check_permission(&user, "device:view").is_ok(), "超管应短路放行");
}
/// 非超管的空权限集仍必须被拒绝(兜底不能变成"人人放行")
#[test]
fn test_check_permission_operator_empty_permissions_forbidden() {
let user = CurrentUser {
user_id: 4,
phone: "13800138003".into(),
role_level: 0,
organization_id: Some(1),
permissions: HashSet::new(),
};
assert!(check_permission(&user, "device:view").is_err(), "空权限集应拒绝");
}
} }

View File

@ -8,7 +8,7 @@ use axum::extract::{State, Query};
use axum::Json; use axum::Json;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode}; use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
use crate::error::AppError; use crate::error::AppError;
@ -507,102 +507,146 @@ async fn load_device_status(
}) })
} }
/// 扫描并组装全部仓控板状态(板级 + 通道级) /// 通道级 key `device:{imei}:board:{板号}:ch:{通道号}` 的段数(板号/通道号均十进制不补零)
const CHANNEL_KEY_SEGMENTS: usize = 6;
/// 仓控板号量程:协议 §3.7「仓控板 1–15」
const BOARD_ID_MIN: i64 = 1;
const BOARD_ID_MAX: i64 = 15;
/// 通道号量程:协议 §3.7「通道 1–8」(不是 0–7)
const CHANNEL_ID_MIN: i64 = 1;
const CHANNEL_ID_MAX: i64 = 8;
/// 从通道级 key 解析 `(板号, 通道号)`:段数为 6 / 第 3 段是 `board` / 第 5 段是 `ch` /
/// 板号在 1–15、通道号在 1–8 才算合法,否则 `None`。
///
/// 为什么必须按段解析:`KEYS device:{imei}:board:*:ch:*` 会把内部键
/// `...:ch:{通道号}:bat`(电池台账)与 `...:ch:{通道号}:order`(订单电量累加器)也扫出来,
/// 按段校验能把它们挡掉,**不会生成幽灵板/幽灵通道**。
fn parse_channel_key(key: &str) -> Option<(i64, i64)> {
let parts: Vec<&str> = key.split(':').collect();
if parts.len() != CHANNEL_KEY_SEGMENTS
|| parts.get(2) != Some(&"board")
|| parts.get(4) != Some(&"ch")
{
return None;
}
let board_no: i64 = parts.get(3)?.parse().ok()?;
let channel_no: i64 = parts.get(5)?.parse().ok()?;
if !(BOARD_ID_MIN..=BOARD_ID_MAX).contains(&board_no)
|| !(CHANNEL_ID_MIN..=CHANNEL_ID_MAX).contains(&channel_no)
{
return None;
}
Some((board_no, channel_no))
}
/// 扫描并组装全部仓控板状态(板级 + 通道级)。
///
/// ⚠️ 设备上报链路**只写通道级 key**(`device:{imei}:board:{板号}:ch:{通道号}`,
/// 见 `workers/report_worker.rs::write_channel_state`),**新协议里根本没有板级 hash**
/// (老实现的 `device:{imei}:board:{板号}.status_hex` 已不复存在)。
/// 旧代码按 `KEYS device:{imei}:board:*` 取"板级 key",实际扫到的全是 `...:ch:{通道号}`
/// 这类 key,把**通道号当成了板号** → 返回一堆幽灵板(每个还都没有 channels),
/// 这也是本接口"永远返回空"的另一半原因。
/// 现在改为扫描通道级 key、**按解析出的板号分组**,再由通道数据组装板级视图。
async fn load_boards_status( async fn load_boards_status(
conn: &mut redis::aio::MultiplexedConnection, conn: &mut redis::aio::MultiplexedConnection,
imei: &str, imei: &str,
) -> Value { ) -> Value {
// 扫描板级key: device:{imei}:board:* let ch_pattern = format!("device:{}:board:*:ch:*", imei);
let board_pattern = format!("device:{}:board:*", imei);
let board_keys: Vec<String> = redis::cmd("KEYS")
.arg(&board_pattern)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let mut boards = json!({});
for board_key in &board_keys {
// 提取board_idx
let board_idx = board_key.split(":").last().unwrap_or("");
if board_idx.is_empty() {
continue;
}
boards[board_idx] = load_single_board(conn, board_key).await;
}
boards
}
/// 读取单块仓控板的板级字段,并扫描其下的通道级 key
async fn load_single_board(
conn: &mut redis::aio::MultiplexedConnection,
board_key: &str,
) -> Value {
// 获取板级字段
let board_fields = vec!["status_hex"];
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(board_key)
.arg(&board_fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let mut board_data = json!({
"status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
});
// 扫描通道级key: device:{imei}:board:{idx}:ch:*
let ch_pattern = format!("{}:ch:*", board_key);
let ch_keys: Vec<String> = redis::cmd("KEYS") let ch_keys: Vec<String> = redis::cmd("KEYS")
.arg(&ch_pattern) .arg(&ch_pattern)
.query_async(&mut *conn) .query_async(&mut *conn)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let mut channels = json!({}); // 板号 → [(通道号, key)]:BTreeMap 保证板号升序,输出顺序稳定
for ch_key in &ch_keys { let mut grouped: BTreeMap<i64, Vec<(i64, String)>> = BTreeMap::new();
let ch_idx = ch_key.split(":").last().unwrap_or(""); for key in ch_keys {
if ch_idx.is_empty() { if let Some((board_no, channel_no)) = parse_channel_key(&key) {
continue; grouped.entry(board_no).or_default().push((channel_no, key));
}
} }
channels[ch_idx] = load_channel_status(conn, ch_key).await; let mut boards = json!({});
for (board_no, mut entries) in grouped {
entries.sort_by_key(|(channel_no, _)| *channel_no);
let board = load_board_status(conn, &entries).await;
boards[board_no.to_string().as_str()] = board;
} }
board_data["channels"] = channels; boards
board_data
} }
/// 读取单个通道状态(状态映射优先级:fault > full > on > idle) /// 由**已排序**的通道级 key 组装单块仓控板的响应(板级 + 通道级)。
async fn load_channel_status( async fn load_board_status(
conn: &mut redis::aio::MultiplexedConnection, conn: &mut redis::aio::MultiplexedConnection,
ch_key: &str, entries: &[(i64, String)],
) -> Value { ) -> Value {
let ch_fields = vec!["on", "full", "fault", "hex", "temp"]; let mut channels = json!({});
let ch_values: Vec<Option<String>> = redis::cmd("HMGET") let mut status_hex = String::new();
.arg(ch_key) for (channel_no, ch_key) in entries {
.arg(&ch_fields) let channel = load_channel_status(conn, ch_key).await;
.query_async(&mut *conn) // 板级 `status_hex` 已废弃(新协议板级没有状态字节):
.await // 用各通道的 `batt_status + pow_status`(各 2 位 hex)按**通道号升序**拼回,
.unwrap_or_default(); // 与旧格式 `0001000200...` 同构,语义完全由真实字段解释 —— **字段名保持不变**。
if let Some(hex) = channel.get("hex").and_then(|v| v.as_str()) {
status_hex.push_str(hex);
}
channels[channel_no.to_string().as_str()] = channel;
}
// 状态映射:fault > full > on > idle json!({
let fault = ch_values "status_hex": status_hex,
.get(0) "channels": channels,
.and_then(|v| v.as_ref()) })
.map(|v| v == "1") }
.unwrap_or(false);
let full = ch_values /// 电池状态位(协议 §3.8 `batt_status`):bit0 充满 / bit1 正在充电 /
.get(1) /// bit4 过欠压 / bit5 过流 / bit6 热失控 / bit7 有电池
.and_then(|v| v.as_ref()) const BATT_FULL_MASK: u8 = 0x01;
.map(|v| v == "1") const BATT_CHARGING_MASK: u8 = 0x02;
.unwrap_or(false); const BATT_FAULT_MASK: u8 = 0x10 | 0x20 | 0x40;
let on = ch_values /// 电源状态位(协议 §3.8 `pow_status`):bit0 电源故障
.get(2) const POW_FAULT_MASK: u8 = 0x01;
.and_then(|v| v.as_ref())
.map(|v| v == "1") /// 2 位(兼容 1 位)hex → u8;字段缺失 / 长度超 2 / 非 hex → `None`
.unwrap_or(false); /// (与 `web/src/utils/deviceStatus.ts::parseHexByte` 口径一致)
fn parse_hex_byte(raw: Option<&str>) -> Option<u8> {
let text = raw?.trim();
if text.is_empty() || text.len() > 2 {
return None;
}
u8::from_str_radix(text, 16).ok()
}
/// 纯函数:把通道的**真实字段**组装成响应 JSON。
///
/// **响应字段名与旧实现完全一致**(前端在消费),但取值改为由真实字段推导:
/// - `on` = `batt_status` bit1(0x02 正在充电)
/// - `full` = `batt_status` bit0(0x01 充满)
/// - `fault` = `batt_status` bit4/5/6(过欠压 / 过流 / 热失控)**或** `pow_status` bit0(电源故障)
/// - `hex` = `batt_status` + `pow_status`(即通道 18 字符 hex 的前 4 个字符;缺任一字节则为空串)
/// - `status` = `fault` > `full` > `charging` > `idle`(与旧实现的优先级顺序一致)
/// - `temp` = 原样透传(单位 **K×100**,由前端换算,见 `web/.../cabinet.tsx`)
/// 另外**新增** `batt_status` / `pow_status` / `soc`(‰)/ `voltage`(mV)/ `current`(mA)
/// 便于管理端展示真实语义(新增字段不影响既有字段名)。
/// 位定义与前端 `web/src/utils/deviceStatus.ts` 完全一致(两端口径必须一致)。
fn derive_channel_view(
batt_raw: Option<&str>,
pow_raw: Option<&str>,
soc: Option<i64>,
voltage: Option<i64>,
current: Option<i64>,
temp: Option<i64>,
) -> Value {
let batt = parse_hex_byte(batt_raw);
let pow = parse_hex_byte(pow_raw);
let full = batt.is_some_and(|b| b & BATT_FULL_MASK != 0);
let on = batt.is_some_and(|b| b & BATT_CHARGING_MASK != 0);
let fault = batt.is_some_and(|b| b & BATT_FAULT_MASK != 0)
|| pow.is_some_and(|p| p & POW_FAULT_MASK != 0);
let status = if fault { let status = if fault {
"fault" "fault"
@ -614,12 +658,227 @@ async fn load_channel_status(
"idle" "idle"
}; };
// 统一输出小写 2 位 hex(与 `report_worker` 的写入格式一致)
let batt_status = batt.map(|b| format!("{:02x}", b)).unwrap_or_default();
let pow_status = pow.map(|p| format!("{:02x}", p)).unwrap_or_default();
let hex = if batt_status.is_empty() || pow_status.is_empty() {
String::new()
} else {
format!("{}{}", batt_status, pow_status)
};
json!({ json!({
"status": status, "status": status,
"on": on, "on": on,
"full": full, "full": full,
"fault": fault, "fault": fault,
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), "hex": hex,
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()), "temp": temp,
"batt_status": batt_status,
"pow_status": pow_status,
"soc": soc,
"voltage": voltage,
"current": current,
}) })
} }
/// 读取单个通道状态。
///
/// ⚠️ 旧实现读的是 `on` / `full` / `fault` / `hex`,但设备上报链路
/// (`workers/report_worker.rs::write_channel_state`)**只写**:
/// `batt_status` / `pow_status`(各 2 位 hex)/ `soc`(‰)/ `voltage`(mV)/
/// `current`(mA)/ `temp`(K×100)—— 旧字段根本不存在,于是本接口恒返回
/// `status:"idle"` + 三个 `false` + `hex:""`,管理端"设备实时详情"一直是坏的。
/// 现在读**真实存在**的字段,再由它们推导旧字段(字段名保持不变,推导规则见 [`derive_channel_view`])。
async fn load_channel_status(
conn: &mut redis::aio::MultiplexedConnection,
ch_key: &str,
) -> Value {
let ch_fields = vec!["batt_status", "pow_status", "soc", "voltage", "current", "temp"];
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(ch_key)
.arg(&ch_fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let text = |idx: usize| -> Option<&str> {
ch_values.get(idx).and_then(|v| v.as_ref()).map(|v| v.as_str())
};
let number = |idx: usize| -> Option<i64> { text(idx).and_then(|v| v.parse::<i64>().ok()) };
derive_channel_view(
text(0),
text(1),
number(2),
number(3),
number(4),
number(5),
)
}
// ---------------------------------------------------------------------------
// 单元测试:通道状态推导(协议 §3.8,口径必须与 web 端 deviceStatus.ts 一致)
//
// 只测纯函数(不连 Redis / DB):这一层是"设备实时详情"接口正确性的关键,
// 因为它负责把 `batt_status` / `pow_status` 翻译成前端消费的旧字段名。
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// 交付文档 §5.3 示例 `828255152c01f40143`:有电池 + 正在充电、电源已打开
#[test]
fn test_derive_channel_view_charging_channel_sets_on_true() {
let view = derive_channel_view(Some("82"), Some("82"), Some(850), Some(54200), Some(5000), Some(32300));
assert_eq!(view["status"], "charging");
assert_eq!(view["on"], true);
assert_eq!(view["full"], false);
assert_eq!(view["fault"], false);
assert_eq!(view["hex"], "8282");
assert_eq!(view["batt_status"], "82");
assert_eq!(view["pow_status"], "82");
assert_eq!(view["soc"], 850);
assert_eq!(view["voltage"], 54200);
assert_eq!(view["current"], 5000);
assert_eq!(view["temp"], 32300);
}
/// 充满(`batt_status` bit0)+ 仍在报充电位 → `status` 取「充满」(优先级 full > charging)
#[test]
fn test_derive_channel_view_full_battery_prefers_full_over_charging() {
let view = derive_channel_view(Some("83"), Some("82"), None, None, None, None);
assert_eq!(view["full"], true);
assert_eq!(view["on"], true);
assert_eq!(view["status"], "full");
}
/// `batt_status` 的过欠压 / 过流 / 热失控位(bit4/5/6)任一置位 → 故障
#[test]
fn test_derive_channel_view_batt_fault_bits_map_to_fault() {
for (batt, other) in [("10", "00"), ("20", "00"), ("40", "00"), ("10", "82")] {
let view = derive_channel_view(Some(batt), Some(other), None, None, None, None);
assert_eq!(view["fault"], true, "batt={batt} pow={other}");
assert_eq!(view["status"], "fault", "batt={batt} pow={other}");
}
// 故障优先级最高:即使同时"充满 + 正在充电",仍是 fault
let view = derive_channel_view(Some("43"), Some("00"), None, None, None, None);
assert_eq!(view["fault"], true);
assert_eq!(view["status"], "fault");
}
/// `pow_status` bit0 = 电源故障 → 通道判故障(与电池状态相互独立)
#[test]
fn test_derive_channel_view_pow_fault_maps_to_fault() {
let view = derive_channel_view(Some("82"), Some("01"), None, None, None, None);
assert_eq!(view["fault"], true);
assert_eq!(view["status"], "fault");
assert_eq!(view["hex"], "8201");
}
/// **空仓**:无电池(batt `00`)+ 电源已打开(pow `82`)→ 语义仍是 idle,
/// 但电源状态照实返回(两维度独立,协议 §3.8)
#[test]
fn test_derive_channel_view_empty_channel_keeps_power_status() {
let view = derive_channel_view(Some("00"), Some("82"), Some(0), Some(0), Some(0), Some(0));
assert_eq!(view["status"], "idle");
assert_eq!(view["on"], false);
assert_eq!(view["full"], false);
assert_eq!(view["fault"], false);
assert_eq!(view["hex"], "0082");
assert_eq!(view["pow_status"], "82");
}
/// 字段整体缺失(老数据 / 通道刚建立)→ 全部落空,**不得 panic、不得拼出半个字节**
#[test]
fn test_derive_channel_view_missing_fields_yields_idle_and_empty_hex() {
let view = derive_channel_view(None, None, None, None, None, None);
assert_eq!(view["status"], "idle");
assert_eq!(view["on"], false);
assert_eq!(view["full"], false);
assert_eq!(view["fault"], false);
assert_eq!(view["hex"], "");
assert_eq!(view["batt_status"], "");
assert_eq!(view["pow_status"], "");
assert_eq!(view["soc"], Value::Null);
assert_eq!(view["temp"], Value::Null);
}
/// 只有一个字节(例如电源状态缺失)→ `hex` 必须为空串,而不是只拼一半
#[test]
fn test_derive_channel_view_single_byte_yields_empty_hex() {
let view = derive_channel_view(Some("82"), None, None, None, None, None);
assert_eq!(view["hex"], "");
assert_eq!(view["batt_status"], "82");
assert_eq!(view["status"], "charging");
}
/// hex 大小写不敏感 + 兼容 1 位写法,输出统一补零小写(与 web 端 `parseHexByte` 一致)
#[test]
fn test_derive_channel_view_normalizes_hex_case_and_padding() {
let view = derive_channel_view(Some("8A"), Some("A"), None, None, None, None);
assert_eq!(view["batt_status"], "8a");
assert_eq!(view["pow_status"], "0a");
assert_eq!(view["hex"], "8a0a");
}
/// hex 解析:长度超 2 / 非 hex / 空白 → `None`
#[test]
fn test_parse_hex_byte_rejects_malformed_values() {
assert_eq!(parse_hex_byte(None), None);
assert_eq!(parse_hex_byte(Some("")), None);
assert_eq!(parse_hex_byte(Some(" ")), None);
assert_eq!(parse_hex_byte(Some("123")), None);
assert_eq!(parse_hex_byte(Some("zz")), None);
assert_eq!(parse_hex_byte(Some("82")), Some(0x82));
assert_eq!(parse_hex_byte(Some(" 82 ")), Some(0x82));
}
// ---- parse_channel_key:通道级 key 分组(板号 1–15 / 通道号 1–8)-------
/// 正常通道 key → (板号, 通道号)
#[test]
fn test_parse_channel_key_accepts_real_channel_keys() {
assert_eq!(
parse_channel_key("device:864720562557021:board:1:ch:3"),
Some((1, 3))
);
assert_eq!(
parse_channel_key("device:864720562557021:board:15:ch:8"),
Some((15, 8))
);
}
/// **内部键必须被挡掉**:`...:ch:{通道号}:bat`(电池台账)/ `...:ch:{通道号}:order`(电量累加器)
/// 若被当成通道,会生成幽灵板(旧实现就是按 KEYS 末段取"板号"的)
#[test]
fn test_parse_channel_key_rejects_internal_keys() {
assert_eq!(
parse_channel_key("device:864720562557021:board:1:ch:3:bat"),
None
);
assert_eq!(
parse_channel_key("device:864720562557021:board:1:ch:3:order"),
None
);
// 旧实现会扫到这种"板级 key"(其实不存在),现在也必须拒绝
assert_eq!(parse_channel_key("device:864720562557021:board:1"), None);
assert_eq!(parse_channel_key("device:864720562557021"), None);
assert_eq!(parse_channel_key(""), None);
}
/// 量程与结构校验:板号 0/16、通道号 0/9、非数字、段内关键字写错都拒绝
#[test]
fn test_parse_channel_key_rejects_out_of_range_and_malformed() {
assert_eq!(parse_channel_key("device:864720562557021:board:0:ch:3"), None);
assert_eq!(parse_channel_key("device:864720562557021:board:16:ch:3"), None);
assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:0"), None);
assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:9"), None);
assert_eq!(parse_channel_key("device:864720562557021:board:a:ch:3"), None);
assert_eq!(parse_channel_key("device:864720562557021:board:1:xx:3"), None);
assert_eq!(
parse_channel_key("device:864720562557021:brd:1:ch:3"),
None
);
}
}

View File

@ -589,6 +589,9 @@ pub async fn get_cabinet_detail(
"rssi": realtime.rssi, "rssi": realtime.rssi,
"pow_fail_dc": realtime.pow_fail_dc, "pow_fail_dc": realtime.pow_fail_dc,
"pow_fail_ac": realtime.pow_fail_ac, "pow_fail_ac": realtime.pow_fail_ac,
// 柜温:Redis `device:{imei}.temp`,语义 K×100(与柜级 temp 口径一致);
// 取不到为 null(前端显示 `--°C`),不落 0
"temp": realtime.temp,
"address": cabinet.address, "address": cabinet.address,
"latitude": cabinet.latitude, "latitude": cabinet.latitude,
"longitude": cabinet.longitude, "longitude": cabinet.longitude,
@ -603,17 +606,47 @@ pub async fn get_cabinet_detail(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// 详情页实时状态快照;Redis 不可用时整体为默认空值 /// 详情页实时状态快照;Redis 不可用时整体为默认空值
///
/// 所有字段一律 `Option`:Redis 里取不到就保持 `None`(响应 JSON 为 `null`),
/// **绝不落成 0/空串** —— 0 是"真实读数为 0",与"无数据"必须区分。
#[derive(Default)] #[derive(Default)]
struct CabinetRealtime { struct CabinetRealtime {
rssi: Option<i32>, rssi: Option<i32>,
pow_fail_dc: Option<bool>, pow_fail_dc: Option<bool>,
pow_fail_ac: Option<bool>, pow_fail_ac: Option<bool>,
/// 柜温,平台单位 K×100(Redis `device:{imei}` 的 `temp`)
temp: Option<i64>,
/// 仓体 id -> (SOC, 电池ID) /// 仓体 id -> (SOC, 电池ID)
soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>, soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>,
/// 仓体 id -> (温度, batt_status, pow_status) /// 仓体 id -> 通道级实时状态
ch_map: std::collections::HashMap<i64, (Option<i64>, Option<String>, Option<String>)>, ch_map: std::collections::HashMap<i64, ChannelRealtime>,
} }
/// 单通道实时状态(与 `report_worker::write_channel_state` 写入的 Hash 字段一一对应)
///
/// 单位口径与 `docs/API.md` 一致:`soc` ‰ / `voltage` mV / `current` mA / `temp` K×100,
/// `batt_status` / `pow_status` 为 2 位 hex 字符串。
#[derive(Default, Clone, Debug)]
struct ChannelRealtime {
/// 电芯最高温度,单位 K×100
temp: Option<i64>,
/// 电池状态(2 位 hex)
batt_status: Option<String>,
/// 电源状态(2 位 hex)
pow_status: Option<String>,
/// 充电电压,单位 mV
voltage: Option<i64>,
/// 充电电流,单位 mA
current: Option<i64>,
}
/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数:rssi / pow_fail_dc / pow_fail_ac / temp。
/// 与 [`fetch_cabinet_realtime`] 下发的命令顺序、[`parse_cabinet_realtime`] 的下标解析
/// **三者必须同步修改**,故抽成常量。
const CABINET_REALTIME_FIELDS: usize = 4;
/// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current
const CHANNEL_REALTIME_FIELDS: usize = 5;
/// 查询仓控板与仓体,构建带实时字段占位的 JSON /// 查询仓控板与仓体,构建带实时字段占位的 JSON
/// ///
/// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`, /// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`,
@ -658,6 +691,11 @@ async fn load_boards_with_compartments(
"status": c.status, "status": c.status,
"batt_status": Value::Null, "batt_status": Value::Null,
"pow_status": Value::Null, "pow_status": Value::Null,
// 通道级实时字段占位:Redis 缺失时保持 null(**不落 0**,
// 前端 `c.voltage ?? '-'` 据此显示"-"而不是"-mV")
"voltage": Value::Null,
"current": Value::Null,
"temp": Value::Null,
}) })
}) })
.collect(); .collect();
@ -675,8 +713,12 @@ async fn load_boards_with_compartments(
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态 /// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
/// ///
/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。 /// 为什么放一起:详情接口要求低延迟,柜级 4 个字段 + 每通道 5 个字段共用一次网络往返。
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
///
/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段:
/// 设备上报(`report_worker::write_device_state` / `write_channel_state`)早就写进了 Redis,
/// 只是接口没取,导致 PC 端柜温恒为 `--°C`、电压电流恒为 `-mV -mA`。
async fn fetch_cabinet_realtime( async fn fetch_cabinet_realtime(
redis_url: &str, redis_url: &str,
imei: &str, imei: &str,
@ -703,7 +745,8 @@ async fn fetch_cabinet_realtime(
let mut pipe = redis::pipe(); let mut pipe = redis::pipe();
pipe.cmd("HGET").arg(&key).arg("rssi") pipe.cmd("HGET").arg(&key).arg("rssi")
.cmd("HGET").arg(&key).arg("pow_fail_dc") .cmd("HGET").arg(&key).arg("pow_fail_dc")
.cmd("HGET").arg(&key).arg("pow_fail_ac"); .cmd("HGET").arg(&key).arg("pow_fail_ac")
.cmd("HGET").arg(&key).arg("temp");
for cid in comp_ids { for cid in comp_ids {
pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); pipe.cmd("GET").arg(format!("compartment:status:{}", cid));
} }
@ -712,7 +755,9 @@ async fn fetch_cabinet_realtime(
let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id); let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id);
pipe.cmd("HGET").arg(&rkey).arg("temp") pipe.cmd("HGET").arg(&rkey).arg("temp")
.cmd("HGET").arg(&rkey).arg("batt_status") .cmd("HGET").arg(&rkey).arg("batt_status")
.cmd("HGET").arg(&rkey).arg("pow_status"); .cmd("HGET").arg(&rkey).arg("pow_status")
.cmd("HGET").arg(&rkey).arg("voltage")
.cmd("HGET").arg(&rkey).arg("current");
} }
// 用类型标注而非 turbofish:query_async 的泛型首参是连接类型 // 用类型标注而非 turbofish:query_async 的泛型首参是连接类型
@ -727,7 +772,10 @@ async fn fetch_cabinet_realtime(
} }
} }
/// 解析 pipeline 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status) /// 解析 pipeline 结果:柜级 [`CABINET_REALTIME_FIELDS`] 项 + 每仓体 1 项(SOC JSON)
/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 项(temp/batt_status/pow_status/voltage/current)
///
/// 任何一项缺失或解析失败 → 对应字段保持 `None`(**不落 0**)。
fn parse_cabinet_realtime( fn parse_cabinet_realtime(
vals: &[Option<String>], vals: &[Option<String>],
comp_ids: &[i64], comp_ids: &[i64],
@ -737,16 +785,21 @@ fn parse_cabinet_realtime(
fn pick(vals: &[Option<String>], i: usize) -> Option<String> { fn pick(vals: &[Option<String>], i: usize) -> Option<String> {
vals.get(i).and_then(|v| v.as_ref()).cloned() vals.get(i).and_then(|v| v.as_ref()).cloned()
} }
/// 取出第 i 项并解析为 i64(空值/非数字 → None)
fn pick_i64(vals: &[Option<String>], i: usize) -> Option<i64> {
pick(vals, i).and_then(|v| v.trim().parse::<i64>().ok())
}
let mut rt = CabinetRealtime { let mut rt = CabinetRealtime {
rssi: pick(vals, 0).and_then(|v| v.parse().ok()), rssi: pick(vals, 0).and_then(|v| v.parse().ok()),
pow_fail_dc: pick(vals, 1).map(|v| v == "1"), pow_fail_dc: pick(vals, 1).map(|v| v == "1"),
pow_fail_ac: pick(vals, 2).map(|v| v == "1"), pow_fail_ac: pick(vals, 2).map(|v| v == "1"),
temp: pick_i64(vals, 3),
..Default::default() ..Default::default()
}; };
for (i, cid) in comp_ids.iter().enumerate() { for (i, cid) in comp_ids.iter().enumerate() {
if let Some(data) = pick(vals, 3 + i) { if let Some(data) = pick(vals, CABINET_REALTIME_FIELDS + i) {
if let Ok(v) = serde_json::from_str::<Value>(&data) { if let Ok(v) = serde_json::from_str::<Value>(&data) {
let soc = v.get("soc").and_then(|s| s.as_i64()); let soc = v.get("soc").and_then(|s| s.as_i64());
let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string); let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string);
@ -755,20 +808,26 @@ fn parse_cabinet_realtime(
} }
} }
// 通道级字段紧随 SOC 段之后,每通道 3 项 // 通道级字段紧随 SOC 段之后,每通道 CHANNEL_REALTIME_FIELDS 项
let base = 3 + comp_ids.len(); let base = CABINET_REALTIME_FIELDS + comp_ids.len();
for (i, (cid, _)) in comp_to_board.iter().enumerate() { for (i, (cid, _)) in comp_to_board.iter().enumerate() {
let off = base + i * 3; let off = base + i * CHANNEL_REALTIME_FIELDS;
let temp = pick(vals, off).and_then(|v| v.parse::<i64>().ok()); rt.ch_map.insert(
let batt_status = pick(vals, off + 1); *cid,
let pow_status = pick(vals, off + 2); ChannelRealtime {
rt.ch_map.insert(*cid, (temp, batt_status, pow_status)); temp: pick_i64(vals, off),
batt_status: pick(vals, off + 1),
pow_status: pick(vals, off + 2),
voltage: pick_i64(vals, off + 3),
current: pick_i64(vals, off + 4),
},
);
} }
rt rt
} }
/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串 /// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串,也绝不落 0
fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) {
for board in cabin_boards.iter_mut() { for board in cabin_boards.iter_mut() {
let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else { let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else {
@ -786,16 +845,22 @@ fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) {
comp["battery_id"] = json!(b); comp["battery_id"] = json!(b);
} }
} }
if let Some((temp, batt_status, pow_status)) = rt.ch_map.get(&cid) { if let Some(ch) = rt.ch_map.get(&cid) {
if let Some(t) = temp { if let Some(t) = ch.temp {
comp["temp"] = json!(t); comp["temp"] = json!(t);
} }
if let Some(bs) = batt_status { if let Some(bs) = &ch.batt_status {
comp["batt_status"] = json!(bs); comp["batt_status"] = json!(bs);
} }
if let Some(ps) = pow_status { if let Some(ps) = &ch.pow_status {
comp["pow_status"] = json!(ps); comp["pow_status"] = json!(ps);
} }
if let Some(v) = ch.voltage {
comp["voltage"] = json!(v);
}
if let Some(c) = ch.current {
comp["current"] = json!(c);
}
} }
} }
} }
@ -911,3 +976,129 @@ pub async fn list_compartment_options(
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
} }
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// pipeline 结果解析:柜级 4 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局,
/// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取)
#[test]
fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() {
let comp_ids = vec![11_i64, 12_i64];
let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))];
let vals: Vec<Option<String>> = vec![
// 柜级:rssi / pow_fail_dc / pow_fail_ac / temp
Some("85".into()),
Some("1".into()),
Some("0".into()),
Some("29815".into()),
// 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None)
None,
None,
// 通道 11:temp / batt_status / pow_status / voltage / current
Some("29815".into()),
Some("82".into()),
Some("03".into()),
Some("53200".into()),
Some("1500".into()),
// 通道 12
Some("30015".into()),
Some("02".into()),
Some("01".into()),
Some("48000".into()),
Some("0".into()),
];
let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board);
assert_eq!(rt.rssi, Some(85));
assert_eq!(rt.pow_fail_dc, Some(true));
assert_eq!(rt.pow_fail_ac, Some(false));
assert_eq!(rt.temp, Some(29815), "柜温应被取出");
let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态");
assert_eq!(ch11.voltage, Some(53200), "电压应被取出");
assert_eq!(ch11.current, Some(1500), "电流应被取出");
assert_eq!(ch11.temp, Some(29815));
assert_eq!(ch11.batt_status.as_deref(), Some("82"));
assert_eq!(ch11.pow_status.as_deref(), Some("03"));
let ch12 = rt.ch_map.get(&12).expect("通道 12 应有实时状态");
// 0 是**真实读数**,必须原样保留(不能与"无数据"混为一谈)
assert_eq!(ch12.current, Some(0));
assert_eq!(ch12.voltage, Some(48000));
}
/// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0)
#[test]
fn test_parse_cabinet_realtime_missing_fields_stay_none() {
let comp_ids = vec![7_i64];
let comp_to_board = vec![(7_i64, (1_i8, 1_i8))];
// 柜级 4 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
let vals: Vec<Option<String>> = vec![
None,
None,
None,
None,
None,
Some("29815".into()),
Some("82".into()),
Some("03".into()),
Some("abc".into()),
None,
];
let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board);
assert_eq!(rt.temp, None);
assert_eq!(rt.rssi, None);
let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)");
assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0");
assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0");
assert_eq!(ch.temp, Some(29815));
}
/// apply_realtime:只覆盖取到的值,取不到的字段保持占位 null
#[test]
fn test_apply_realtime_keeps_null_when_missing() {
let mut wrap = vec![json!({
"compartments": [{
"id": 11,
"channel_id": 1,
"status": 0,
"batt_status": Value::Null,
"pow_status": Value::Null,
"voltage": Value::Null,
"current": Value::Null,
"temp": Value::Null,
}]
})];
let mut ch_map = std::collections::HashMap::new();
ch_map.insert(
11_i64,
ChannelRealtime {
temp: None,
batt_status: Some("82".into()),
pow_status: None,
voltage: Some(53200),
current: None,
},
);
let rt = CabinetRealtime { ch_map, ..Default::default() };
apply_realtime(&mut wrap, &rt);
let comp = &wrap[0]["compartments"][0];
assert_eq!(comp["batt_status"], json!("82"));
assert_eq!(comp["voltage"], json!(53200));
assert_eq!(comp["current"], Value::Null, "缺失电流必须保持 null");
assert_eq!(comp["temp"], Value::Null, "缺失温度必须保持 null");
assert_eq!(comp["pow_status"], Value::Null);
}
}

View File

@ -879,6 +879,11 @@ const COMPARTMENT_BY_ID_SQL: &str =
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?"; "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 = ?"; const CABIN_BOARD_CABINET_ID_SQL: &str = "SELECT cabinet_id FROM cabin_boards WHERE id = ?";
/// 仓体所属仓控板号 + 柜子 IMEI(拼通道级 Redis key 用)
const CABIN_BOARD_REDIS_TARGET_SQL: &str = r#"SELECT cb.board_id, c.imei
FROM cabin_boards cb
INNER JOIN cabinets c ON c.id = cb.cabinet_id
WHERE cb.id = ?"#;
/// 仓体最近 10 条充电记录 /// 仓体最近 10 条充电记录
const COMPARTMENT_CHARGE_RECORDS_SQL: &str = r#"SELECT id, start_time, end_time, energy const COMPARTMENT_CHARGE_RECORDS_SQL: &str = r#"SELECT id, start_time, end_time, energy
FROM charge_records FROM charge_records
@ -908,8 +913,26 @@ pub async fn get_compartment_detail(
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限 // 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
verify_compartment_access(&user, cabin_board_id, pool).await?; verify_compartment_access(&user, cabin_board_id, pool).await?;
// 从 Redis 获取实时数据 // 通道级实时数据 key 需要(IMEI, 仓控板号, 通道号);
let realtime = load_compartment_realtime(&state, comp_id).await; // 关联缺失时记录并整体降级为「无数据」(字段 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>( let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
@ -926,13 +949,23 @@ pub async fn get_compartment_detail(
"channel_id": channel_id, "channel_id": channel_id,
"status": status, "status": status,
"cabin_board_id": cabin_board_id, "cabin_board_id": cabin_board_id,
"voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0), // 以下实时字段全部取自设备上报真实写入的 Redis key;
"current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0), // 取不到时返回 null(**不落 0**),避免把"无数据"伪装成 0
"energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0), "voltage": realtime.voltage,
"soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0), "current": realtime.current,
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])), // energy 没有实时数据源(通道级 Hash 不含电量累计),如实返回 null
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])), "energy": Value::Null,
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])), "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), "charge_records": map_charge_records(records),
}, },
"message": "ok" "message": "ok"
@ -957,16 +990,137 @@ async fn verify_compartment_access(
Ok(()) Ok(())
} }
/// 从 Redis 读取仓体实时数据(key: `compartment:status:{comp_id}`) /// 仓体详情实时状态快照(全部来自**真实写入**的 Redis key)
async fn load_compartment_realtime(state: &AppState, comp_id: i64) -> Value { ///
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { /// 所有字段一律 `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 // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
if let Ok(mut conn) = client.get_multiplexed_async_connection().await { let mut conn = match client.get_multiplexed_async_connection().await {
let key = format!("compartment:status:{}", comp_id); Ok(c) => c,
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None); Err(e) => {
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})) tracing::warn!("[h5] Redis 连接失败,仓体实时数据降级 imei={} err={}", imei, e);
} else { json!({}) } return CompartmentRealtime::default();
} else { json!({}) } }
};
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` 语义不变) /// 充电记录行 → 响应 JSON(`energy_kwh` / `cost` 语义不变)
@ -1482,6 +1636,9 @@ pub async fn submit_feedback(
pub(crate) struct FeedbackListQuery { pub(crate) struct FeedbackListQuery {
page: Option<i64>, page: Option<i64>,
page_size: Option<i64>, page_size: Option<i64>,
/// 处理状态筛选:0=待处理 1=已处理;缺省/不传 = 全部。
/// 必须显式声明,否则 serde 会**静默丢弃**前端传来的 `status`(前端筛选点了没反应)。
status: Option<i8>,
} }
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
@ -1497,27 +1654,54 @@ struct FeedbackRow {
/// GET /api/h5/feedback — 反馈列表(管理端) /// GET /api/h5/feedback — 反馈列表(管理端)
/// ///
/// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。 /// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据,
/// 因此不能只校验登录态 —— 复用权限种子里真实存在的 `operation_log:view`
/// (`db/migrate.rs::seed_roles`:总管理员全量、企业管理员含该码、操作员**没有**该码),
/// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近;
/// 操作员即使登录也会被 403 拦住。
///
/// `status` 为可选筛选条件,落到 SQL(不影响未传参时的原有查询语义)。
pub async fn list_feedback( pub async fn list_feedback(
_user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<FeedbackListQuery>, Query(query): Query<FeedbackListQuery>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql; let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1); let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).clamp(1, 100); let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * page_size; let offset = (page - 1) * page_size;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback") let status = query.status;
.fetch_one(pool) // 状态值口径与 update_feedback_status 保持一致(0待处理 / 1已处理)
.await?; if let Some(s) = status {
if s != 0 && s != 1 {
return Err(AppError::BadRequest("状态值必须为0(待处理)/1(已处理)".into()));
}
}
let rows: Vec<FeedbackRow> = sqlx::query_as::<_, FeedbackRow>( // `filter` 只可能是下面这两个常量字面量之一(不含任何用户输入),
r#"SELECT id, user_id, content, contact, images, status, created_at // 参数依旧全部走 bind,不拼接外部数据
FROM user_feedback let filter = if status.is_some() { " WHERE status = ?" } else { "" };
ORDER BY created_at DESC
LIMIT ? OFFSET ?"#, 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(page_size)
.bind(offset) .bind(offset)
.fetch_all(pool) .fetch_all(pool)
@ -1620,6 +1804,10 @@ pub async fn update_feedback_status(
Path(id): Path<i64>, Path(id): Path<i64>,
Json(body): Json<FeedbackStatusInput>, Json(body): Json<FeedbackStatusInput>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
// 与 list_feedback 同一权限码:能改处理状态的人必须能看反馈列表
// (`operation_log:view` 在 `db/migrate.rs` 的权限种子里真实存在)
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql; let pool = &state.mysql;
if body.status != 0 && body.status != 1 { if body.status != 0 && body.status != 1 {
@ -1686,3 +1874,93 @@ async fn verify_cabinet_access(
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// 通道级 HMGET 六字段(batt_status/pow_status/soc/voltage/current/temp)解析
#[test]
fn test_parse_compartment_realtime_reads_all_fields() {
let values: Vec<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");
}
}

View File

@ -29,7 +29,7 @@ mod organizations;
mod projects; mod projects;
mod roles; mod roles;
mod users; mod users;
mod scope_guard; pub(crate) mod scope_guard;
mod users_perm; mod users_perm;
mod wechat; mod wechat;
mod maps; mod maps;

View File

@ -113,6 +113,78 @@ async fn verify_org_admin_scope(
Ok(target_org) Ok(target_org)
} }
// ---------------------------------------------------------------------------
// 辅助函数: users.role ↔ user_roles 同步
// ---------------------------------------------------------------------------
/// 把 `users.role` 同步写入 `user_roles`(幂等)
///
/// **为什么必须写**:`middleware/auth.rs::load_user_permissions` 的权限唯一来源是
/// `user_roles → role_permissions`;早先的创建/编辑用户只写 `users.role`,
/// 导致新建的操作员/企业管理员登录后权限集为空、全站 403。
/// 认证侧虽已加「按 `users.role` 回退内置角色」的兜底(历史数据无需刷库),
/// 但写入侧也必须补齐,否则:
/// - `/api/users/:id/permissions` 查不到角色、前端角色展示为空;
/// - `set_user_roles` 的显式分配与 `users.role` 长期不一致,回退反而会掩盖真实角色。
///
/// **幂等策略**:先 `DELETE` 该用户既有映射,再插一条新映射
/// (`user_roles` 主键为 `(user_id, role_id)`,可用 `INSERT IGNORE` 兜底重复;
/// 同一 `users.role` 只对应一个内置角色,故不会丢掉"多角色"语义之外的信息 ——
/// 需要多角色的场景走 `PUT /api/users/:id/roles`)。
///
/// 失败时记 `tracing::error!` 并返回错误,**绝不静默**:角色映射写不进去,
/// 该用户的权限就只能依赖认证侧回退,属于必须暴露的异常。
async fn sync_user_role_mapping(
pool: &sqlx::MySqlPool,
user_id: i64,
role_level: i32,
) -> Result<(), AppError> {
let role_name = auth::builtin_role_name(role_level).ok_or_else(|| {
tracing::error!(user_id, role_level, "同步用户角色失败: 未知角色等级,无内置角色可映射");
AppError::BadRequest(format!("未知角色等级: {},无法同步角色权限", role_level))
})?;
let role: Option<(i64,)> = sqlx::query_as("SELECT id FROM roles WHERE name = ?")
.bind(role_name)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!(user_id, role_level, role_name, err = %e, "同步用户角色失败: 查询内置角色出错");
AppError::Database(e)
})?;
let Some((role_id,)) = role else {
tracing::error!(user_id, role_level, role_name, "同步用户角色失败: 内置角色不存在");
return Err(AppError::Internal(format!(
"内置角色 {} 不存在,无法同步用户角色",
role_name
)));
};
// 先清空该用户既有映射(幂等的关键:重复调用/改角色都不会留下旧映射)
sqlx::query("DELETE FROM user_roles WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await
.map_err(|e| {
tracing::error!(user_id, role_name, err = %e, "同步用户角色失败: 清理 user_roles 出错");
AppError::Database(e)
})?;
sqlx::query("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)")
.bind(user_id)
.bind(role_id)
.execute(pool)
.await
.map_err(|e| {
tracing::error!(user_id, role_name, role_id, err = %e, "同步用户角色失败: 写入 user_roles 出错");
AppError::Database(e)
})?;
tracing::info!(user_id, role_level, role_name, role_id, "已同步 users.role 到 user_roles");
Ok(())
}
/// 记录一条操作日志到 operation_logs 表 /// 记录一条操作日志到 operation_logs 表
async fn log_operation( async fn log_operation(
pool: &sqlx::MySqlPool, pool: &sqlx::MySqlPool,
@ -442,6 +514,9 @@ pub async fn create_user(
let user_id = result.last_insert_id() as i64; let user_id = result.last_insert_id() as i64;
// 同步写入 user_roles(权限唯一来源),失败即返回错误 —— 不静默吞掉
sync_user_role_mapping(pool, user_id, role).await?;
// 记录操作日志 // 记录操作日志
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-")); let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-"));
log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await; log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await;
@ -556,6 +631,21 @@ pub async fn update_user(
return Err(AppError::NotFound("用户不存在".into())); return Err(AppError::NotFound("用户不存在".into()));
} }
// 角色**发生变化**时**才**把 users.role 同步到 user_roles(幂等全量替换)。
//
// 为什么加 `new_role != old_role` 这个条件(而不是无条件同步):
// `PUT /api/users/:id` 同时承担"改姓名/改组织"的职责,而无条件 `DELETE + INSERT`
// 会把管理员通过 `PUT /api/users/:id/roles`(users_perm.rs)显式分配的**多角色**
// 一并抹掉 —— 那才是"破坏 set_user_roles 语义"。只在角色真的变了时以
// `users.role` 为准重建映射;角色没变时保持 user_roles 原样,
// 历史空映射由认证侧「按 users.role 回退内置角色」兜底,权限一样正确。
//
// 若此处失败:users.role 已更新、user_roles 已被清空 → 认证侧回退会接管,
// 权限仍然正确;但仍要把错误暴露出来以便排查。
if new_role != old_role {
sync_user_role_mapping(pool, user_id, new_role).await?;
}
// 记录操作日志 // 记录操作日志
let detail = format!( let detail = format!(
"编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}", "编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}",

View File

@ -50,10 +50,16 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq
// 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的, // 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的,
// 交给数据库算差值可避免本地时区换算偏差; // 交给数据库算差值可避免本地时区换算偏差;
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。 // 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
//
// ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"start_time 早于 NOW() - 10 小时"。
// - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是
// **未来时刻** `NOW()+10h`,于是"start_time < 未来"对**几乎所有订单恒真**,
// 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算。
// - ✅ 正确:把 start_time 往前推 10 小时再与 NOW() 比较(下面这种写法)。
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as( let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as(
"SELECT id, compartment_id, start_time FROM charge_orders \ "SELECT id, compartment_id, start_time FROM charge_orders \
WHERE status = 0 AND start_time IS NOT NULL \ WHERE status = 0 AND start_time IS NOT NULL \
AND start_time < TIMESTAMPADD(HOUR, ?, NOW()) \ AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \
ORDER BY start_time ASC LIMIT ?", ORDER BY start_time ASC LIMIT ?",
) )
.bind(TIMEOUT_HOURS) .bind(TIMEOUT_HOURS)

File diff suppressed because it is too large Load Diff

View File

@ -42,8 +42,8 @@ async fn main() {
.expect("Redis 连接失败"); .expect("Redis 连接失败");
tracing::info!("Redis 连接成功"); tracing::info!("Redis 连接成功");
// 初始化设备连接池(注入 Redis:下发指令时写挂起登记 `pending:{dev_id}`) // 初始化设备连接池(纯内存结构:`dev_id` → 连接,增删按 `conn_id` 校验归属)
let device_pool = tcp::connection::ConnectionPool::new(redis_conn.clone()); let device_pool = tcp::connection::ConnectionPool::new();
// 通过 Redis 注册节点信息 // 通过 Redis 注册节点信息
register_node(&redis_conn, &cfg).await; register_node(&redis_conn, &cfg).await;
@ -227,7 +227,8 @@ async fn consume_commands(
} }
_ => { _ => {
// 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景) // 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景)
match pool.send_with_msgid(&dev_id, &cmd_json).await { // Redis 由调用方传入(仅用于挂起登记 `pending:{dev_id}`,失败不影响下发)
match pool.send_with_msgid(&redis, &dev_id, &cmd_json).await {
Ok(()) => { Ok(()) => {
tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id); tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id);
} }

View File

@ -2,12 +2,40 @@
//! //!
//! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。 //! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。
//! `ConnectionPool` 以 `dev_id`(IMEI)为键,支持并发读写。 //! `ConnectionPool` 以 `dev_id`(IMEI)为键,支持并发读写。
//!
//! # 安全模型(C1 加固,2026-09)
//!
//! `dev_id` 来自**报文**,且在签名校验之前就已到达(应答必须经连接池下发,
//! 先注册才能回复),因此它**不可作为"池条目归属"的依据**。原实现的两个缺陷:
//! 1. `register` 是覆盖式 `insert`:任意未认证连接用受害 IMEI 发一条
//! `auth_str` 就能顶掉真机的池条目(真机后续上行被判"未登录"全部丢弃);
//! 2. 断连时无条件 `remove(dev_id)`:攻击者断开即可删掉**真机**的池条目 →
//! 平台永久判定设备离线(指令全进 `device:commands:dead`),
//! 每 25 秒重连一次即可维持永久 DoS。
//!
//! 修法:每条 TCP 连接分配一个进程内唯一的 `conn_id`,池的关键操作都带 conn_id 校验:
//! - [`ConnectionPool::register`]:**允许接管**同 `dev_id` 的旧条目(未认证占位 / 已认证连接),
//! 接管即**主动驱逐**旧连接(MQTT 同 clientId 接管语义);**不允许**拒绝新连接
//! - [`ConnectionPool::remove_if`]:只删除 conn_id 与本连接一致的那条条目
//! - [`ConnectionPool::is_authenticated`] / [`ConnectionPool::is_owner`]:归属同样按 `conn_id` 判定
//! - [`ConnectionPool::touch`]:只有当前持有者能刷新 `last_seen`
//!
//! 为什么"接管"而不是"拒绝覆盖已认证连接":4G 设备**静默掉线**(NAT 超时 / 信号丢失)时,
//! 旧 socket 在服务端可能仍是 ESTABLISHED、池中条目仍是 `authenticated`。若拒绝新连接,
//! 真机重连后将**发什么都进不了业务链路**,只能等旧 socket 因写失败或内核 TCP 重传超时被清理
//! (分钟级、最坏十几分钟)—— 这是正常网络抖动下的可用性事故。接管 + 主动驱逐则让真机
//! 立即恢复,且**不需要新增任何协议报文**(协议冻结)。
//!
//! 连接池本身是**纯内存**结构(不含 Redis);下发指令时的挂起登记
//! (`pending:{dev_id}`)由调用方把 Redis 连接传进来,便于单元测试构造无外部依赖的池。
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant; use std::time::Instant;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::oneshot;
/// 敏感指令列表(未认证设备不得执行) /// 敏感指令列表(未认证设备不得执行)
const SENSITIVE_ACTS: &[&str] = &["open", "pow_on", "pow_off", "reset", "auth_str"]; const SENSITIVE_ACTS: &[&str] = &["open", "pow_on", "pow_off", "reset", "auth_str"];
@ -20,8 +48,22 @@ const PENDING_TTL_SECS: u32 = 120;
/// 单台设备的连接状态。 /// 单台设备的连接状态。
pub struct DeviceConnection { pub struct DeviceConnection {
/// 本 TCP 连接的进程内唯一标识(见 [`next_conn_id`])。
///
/// 池条目的增删都以此校验归属:`dev_id` 可伪造,`conn_id` 不可 ——
/// 它由服务端在连接建立时分配,报文里没有对应字段(协议不变)。
pub conn_id: u64,
/// 向设备发送指令的通道。 /// 向设备发送指令的通道。
pub tx: mpsc::Sender<String>, pub tx: mpsc::Sender<String>,
/// **主动驱逐句柄**:本条目被新连接接管时,连接池调用 [`DeviceConnection::evict`]
/// 发出信号 → 对应连接任务 `select!` 里的接收端立刻就绪,旧连接立即关闭 socket
/// 并走断连清理。
///
/// 为什么是"服务端内部信号"而不是发报文:协议冻结,不允许用 `ping` 之类自造帧
/// 去通知设备;而且要驱逐的是**服务端侧的旧 socket**,与设备无关。
/// 为什么不是"关掉 `tx`":`tx` 只是**写通道的克隆**,连接任务自己还持有一份,
/// 丢掉池里这份并不会让通道关闭,旧任务不会因此退出。
close: oneshot::Sender<()>,
/// 最后活跃时间(收到任意消息时更新)。 /// 最后活跃时间(收到任意消息时更新)。
pub last_seen: Instant, pub last_seen: Instant,
/// 是否已完成登录验证。 /// 是否已完成登录验证。
@ -31,13 +73,27 @@ pub struct DeviceConnection {
next_msg_id: u32, next_msg_id: u32,
} }
/// `conn_id` 自增源。`0` 保留为"无效/未知",因此从 `1` 起发号。
static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1);
/// 分配一个进程内唯一的连接标识。
///
/// 用 `AtomicU64` 自增(进程重启后从 1 重新开始):只要**同一进程内**不会重复即可 ——
/// 池是进程内结构,重启后池也一并清空,无需全局唯一。
pub fn next_conn_id() -> u64 {
NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed)
}
impl DeviceConnection { impl DeviceConnection {
/// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。 /// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。
pub fn new(tx: mpsc::Sender<String>) -> Self { ///
/// `conn_id` 由 [`next_conn_id`] 在 TCP 连接建立时分配(每条连接一个,不重复使用);
/// `close` 是**主动驱逐句柄**,连接任务持有配对的接收端(见结构体字段注释)。
pub fn new(tx: mpsc::Sender<String>, conn_id: u64, close: oneshot::Sender<()>) -> Self {
let now = Instant::now(); let now = Instant::now();
// 初值 1:协议定稿「两个方向各自独立计数,都每连接从 1 起自增,0 不使用」; // 初值 1:协议定稿「两个方向各自独立计数,都每连接从 1 起自增,0 不使用」;
// 且 api-server 多处用 `unwrap_or(0)` 兜底,用 0 起会与"缺号"混淆 // 且 api-server 多处用 `unwrap_or(0)` 兜底,用 0 起会与"缺号"混淆
Self { tx, last_seen: now, authenticated: false, next_msg_id: 1 } Self { conn_id, tx, close, last_seen: now, authenticated: false, next_msg_id: 1 }
} }
/// 标记活跃时间。 /// 标记活跃时间。
@ -51,39 +107,129 @@ impl DeviceConnection {
self.next_msg_id += 1; self.next_msg_id += 1;
id id
} }
/// **主动驱逐**本连接:向持有本条目驱逐句柄的连接任务发信号,让它立即退出。
///
/// 由连接池在"条目被新连接接管(未认证占位 / 已认证连接)"时调用 ——
/// 对端任务在 `select!` 里收到信号后关闭 socket 并走断连清理。
/// **不新增任何协议报文**(协议冻结,不能自造帧去通知设备)。
/// 发送失败表示对端已结束或已不再监听,属正常情况,忽略即可。
fn evict(self) {
let _ = self.close.send(());
}
} }
/// 线程安全的设备连接池。 /// [`ConnectionPool::register`] 的结果。
///
/// 三种结果**都表示本连接已接管该 `dev_id`**(`server.rs` 据此绑定 `current_dev_id`);
/// 区别只在"顶掉了谁",用于日志区分(接管已认证连接是排查"设备反复掉线/被顶替"的关键信号)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegisterOutcome {
/// 池中原本没有该设备 → 新登记
Inserted,
/// 池中原本是**未认证**占位(攻击者占位,或真机重连而旧连接尚未被清理)
/// → 已顶替并驱逐该占位,本连接接管该 `dev_id`
ReplacedUnauthenticated {
/// 被顶替的连接标识(仅用于日志)
evicted_conn_id: u64,
},
/// 池中原本是**已认证**连接 → 已接管并驱逐(MQTT 同 clientId 接管语义)。
///
/// 典型场景:设备**静默掉线**(NAT 超时 / 4G 信号丢失)后重连,而服务端侧旧 socket
/// 仍被视为 ESTABLISHED、旧条目仍是 `authenticated`。此时必须让真机立即接管,
/// 否则真机发什么都进不了业务链路(可用性事故)。
/// 代价:攻击者也能用受害 IMEI 顶掉真机连接 —— 但**顶不掉数据可信性**:
/// 新条目 `authenticated` 从 `false` 重新开始,而 `login` 签名由 api-server 校验,
/// 攻击者过不了这一关;真机随即重连即可再接管回来(协议「IMEI 即身份」的固有代价)。
ReplacedAuthenticated {
/// 被接管的连接标识(仅用于日志;其连接已被主动驱逐)
evicted_conn_id: u64,
},
}
/// 线程安全的设备连接池(纯内存,无外部依赖)。
#[derive(Clone)] #[derive(Clone)]
pub struct ConnectionPool { pub struct ConnectionPool {
inner: Arc<RwLock<HashMap<String, DeviceConnection>>>, inner: Arc<RwLock<HashMap<String, DeviceConnection>>>,
/// 挂起登记用的 Redis 连接(`pending:{dev_id}`)。
/// 仅用于辅助登记,**任何 Redis 失败都不得影响指令下发**。
redis: redis::aio::ConnectionManager,
} }
impl ConnectionPool { impl ConnectionPool {
pub fn new(redis: redis::aio::ConnectionManager) -> Self { pub fn new() -> Self {
Self { inner: Arc::new(RwLock::new(HashMap::new())), redis } Self { inner: Arc::new(RwLock::new(HashMap::new())) }
} }
/// 注册设备连接。如果已存在则替换旧连接。 /// 注册设备连接(`dev_id` = IMEI,来自报文,**不可信**)。
pub async fn register(&self, dev_id: String, conn: DeviceConnection) { ///
/// 语义(C1 加固,替代原覆盖式 `insert`):
/// - 池中尚无该 `dev_id` → [`RegisterOutcome::Inserted`]
/// - 池中已有**未认证**占位 → [`RegisterOutcome::ReplacedUnauthenticated`]
/// - 池中已有**已认证**连接 → [`RegisterOutcome::ReplacedAuthenticated`](接管,不拒绝)
///
/// **三条路径都不拒绝新连接**,旧条目一律被换出池并被**主动驱逐**
/// (调用 [`DeviceConnection::evict`] → 旧连接任务立即关闭 socket,
/// 不依赖设备下次上行,也不新增协议报文)。
///
/// 注意:注册发生在**签名校验之前**(`login`/`auth_str` 的应答必须经本池下发),
/// 所以归属只能靠 `conn_id`;身份真实性由 api-server 的签名校验保证,
/// 本层只保证"条目归属唯一 + 旧连接被驱逐"。
pub async fn register(&self, dev_id: String, conn: DeviceConnection) -> RegisterOutcome {
let mut map = self.inner.write().await; let mut map = self.inner.write().await;
match map.get(&dev_id) {
None => {
map.insert(dev_id, conn); map.insert(dev_id, conn);
RegisterOutcome::Inserted
}
Some(existing) if existing.authenticated => {
let evicted_conn_id = existing.conn_id;
// 接管即驱逐:旧条目被换出 → 立即通知旧连接退出
if let Some(evicted) = map.insert(dev_id, conn) {
evicted.evict();
}
RegisterOutcome::ReplacedAuthenticated { evicted_conn_id }
}
Some(existing) => {
let evicted_conn_id = existing.conn_id;
if let Some(evicted) = map.insert(dev_id, conn) {
evicted.evict();
}
RegisterOutcome::ReplacedUnauthenticated { evicted_conn_id }
}
}
} }
/// 移除设备连接(断连时调用)。 /// 按 `conn_id` 归属移除连接(断连时调用)。
pub async fn remove(&self, dev_id: &str) { ///
/// **只有**池中该 `dev_id` 的条目 `conn_id` 与本连接一致时才删除,返回是否删除。
/// 这是防 DoS 的关键:若无条件 `remove(dev_id)`,攻击者用受害 IMEI 占位后断开
/// 就会把**真机**的池条目一并删掉 → 平台永久判定该设备离线。
/// 已认证条目被接管后,旧连接调用本方法返回 `false`(条目已归新连接)。
pub async fn remove_if(&self, dev_id: &str, conn_id: u64) -> bool {
let mut map = self.inner.write().await; let mut map = self.inner.write().await;
let owned_by_this_conn = matches!(map.get(dev_id), Some(c) if c.conn_id == conn_id);
if owned_by_this_conn {
map.remove(dev_id); map.remove(dev_id);
} }
owned_by_this_conn
}
/// 查询该设备连接是否已通过 `login` 认证。 /// 池中该 `dev_id` 的条目是否仍属于 `conn_id` 这条连接(不看认证态)。
/// 连接不存在或尚未认证时返回 `false`。 ///
pub async fn is_authenticated(&self, dev_id: &str) -> bool { /// 用途:连接任务在处理每条上行前确认"我还是这个 `dev_id` 的持有者吗"——
/// 已被接管(superseded)的连接据此立刻断开,不再处理任何上行。
pub async fn is_owner(&self, dev_id: &str, conn_id: u64) -> bool {
let map = self.inner.read().await; let map = self.inner.read().await;
map.get(dev_id).map(|c| c.authenticated).unwrap_or(false) map.get(dev_id).map(|c| c.conn_id == conn_id).unwrap_or(false)
}
/// 查询该设备连接是否已通过 `login` 认证,且池条目仍属于 `conn_id` 这条连接。
///
/// 连接不存在、尚未认证、或池条目已被别的连接接管时返回 `false`
/// —— 最后一个条件很重要:被接管的连接不能"借用"新连接的已认证身份伪造该 IMEI 的上行。
pub async fn is_authenticated(&self, dev_id: &str, conn_id: u64) -> bool {
let map = self.inner.read().await;
map.get(dev_id)
.map(|c| c.conn_id == conn_id && c.authenticated)
.unwrap_or(false)
} }
/// 向指定设备发送指令并注入自增 msg_id。 /// 向指定设备发送指令并注入自增 msg_id。
@ -97,13 +243,21 @@ impl ConnectionPool {
/// ///
/// 指令在注入 `msg_id` 后写入 Redis 挂起表 `pending:{dev_id}`, /// 指令在注入 `msg_id` 后写入 Redis 挂起表 `pending:{dev_id}`,
/// 供 api-server 把设备回执配对回具体指令(见 `register_pending`)。 /// 供 api-server 把设备回执配对回具体指令(见 `register_pending`)。
pub async fn send_with_msgid(&self, dev_id: &str, json: &str) -> Result<(), &'static str> { ///
/// `redis` 由调用方(`main.rs` 的指令消费循环)传入:连接池本身是纯内存结构,
/// 挂起登记只是**辅助功能** —— 任何 Redis 失败都不得影响指令下发。
pub async fn send_with_msgid(
&self,
redis: &redis::aio::ConnectionManager,
dev_id: &str,
json: &str,
) -> Result<(), &'static str> {
let pending = self.prepare_outbound(dev_id, json).await?; let pending = self.prepare_outbound(dev_id, json).await?;
// 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。 // 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。
// 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。 // 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。
if let Some((msg_id, act)) = pending { if let Some((msg_id, act)) = pending {
register_pending(&self.redis, dev_id, msg_id, &act).await; register_pending(redis, dev_id, msg_id, &act).await;
} }
Ok(()) Ok(())
} }
@ -162,14 +316,19 @@ impl ConnectionPool {
Ok(pending) Ok(pending)
} }
/// 更新设备活跃时间。 /// 更新设备活跃时间(**仅当前持有者**可刷新)。
pub async fn touch(&self, dev_id: &str) { ///
/// 必须带 `conn_id` 校验:否则被接管的旧连接能替持有人刷新 `last_seen`,
/// 让"最后活跃时间"失去意义(例如将来基于 `last_seen` 做陈旧接管判断时会被绕过)。
pub async fn touch(&self, dev_id: &str, conn_id: u64) {
let mut map = self.inner.write().await; let mut map = self.inner.write().await;
if let Some(conn) = map.get_mut(dev_id) { if let Some(conn) = map.get_mut(dev_id) {
if conn.conn_id == conn_id {
conn.touch(); conn.touch();
} }
} }
} }
}
/// 写入挂起登记:`HSET pending:{dev_id} {msg_id} {act}` + `EXPIRE pending:{dev_id} 120`。 /// 写入挂起登记:`HSET pending:{dev_id} {msg_id} {act}` + `EXPIRE pending:{dev_id} 120`。
/// ///
@ -225,22 +384,42 @@ async fn register_pending(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 单元测试:下行编号与内部字段(协议 §3.4 / §3.5) // 单元测试:下行编号与内部字段(协议 §3.4 / §3.5)+ 连接池归属(C1)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tokio::sync::mpsc; use tokio::sync::{mpsc, oneshot};
fn conn() -> DeviceConnection { /// 构造一条连接状态(丢弃驱逐信号接收端:用例不关心旧连接是否被驱逐)
fn conn(conn_id: u64) -> DeviceConnection {
conn_with_close(conn_id).0
}
/// 构造一条连接状态,并返回其**驱逐信号接收端** ——
/// 条目离开池(被接管)时该接收端会立刻以 `Err(RecvError)` 就绪
fn conn_with_close(conn_id: u64) -> (DeviceConnection, oneshot::Receiver<()>) {
let (tx, _rx) = mpsc::channel(4); let (tx, _rx) = mpsc::channel(4);
DeviceConnection::new(tx) let (close_tx, close_rx) = oneshot::channel::<()>();
(DeviceConnection::new(tx, conn_id, close_tx), close_rx)
}
/// 构造一条**已认证**连接(模拟池中已有的真机连接)
fn authed_conn(conn_id: u64) -> DeviceConnection {
authed_conn_with_close(conn_id).0
}
/// 已认证连接 + 驱逐信号接收端
fn authed_conn_with_close(conn_id: u64) -> (DeviceConnection, oneshot::Receiver<()>) {
let (mut c, rx) = conn_with_close(conn_id);
c.authenticated = true;
(c, rx)
} }
/// 协议定稿:两个方向各自独立计数,**都每连接从 `1` 起自增**(`0` 不使用) /// 协议定稿:两个方向各自独立计数,**都每连接从 `1` 起自增**(`0` 不使用)
#[test] #[test]
fn test_alloc_msg_id_starts_at_one_and_increments() { fn test_alloc_msg_id_starts_at_one_and_increments() {
let mut c = conn(); let mut c = conn(1);
assert_eq!(c.alloc_msg_id(), 1); assert_eq!(c.alloc_msg_id(), 1);
assert_eq!(c.alloc_msg_id(), 2); assert_eq!(c.alloc_msg_id(), 2);
assert_eq!(c.alloc_msg_id(), 3); assert_eq!(c.alloc_msg_id(), 3);
@ -249,6 +428,155 @@ mod tests {
/// 新建连接默认未认证 —— 未认证时敏感指令会被拦、业务报文会被丢弃 /// 新建连接默认未认证 —— 未认证时敏感指令会被拦、业务报文会被丢弃
#[test] #[test]
fn test_new_connection_is_unauthenticated() { fn test_new_connection_is_unauthenticated() {
assert!(!conn().authenticated); assert!(!conn(1).authenticated);
}
/// `conn_id` 由进程内自增分配:严格不重复(池条目归属的唯一依据)
#[test]
fn test_next_conn_id_is_unique_and_increasing() {
let a = next_conn_id();
let b = next_conn_id();
assert_ne!(a, b);
assert!(b > a);
}
/// C1-①:已认证条目**可以**被新连接接管(MQTT 同 clientId 语义),
/// 接管后归属与认证态都转给新连接,旧连接被**主动驱逐**。
///
/// 为什么必须允许接管:4G 设备静默掉线(NAT 超时 / 信号丢失)时旧 socket 在服务端
/// 可能仍是 ESTABLISHED、条目仍是 `authenticated`;若拒绝新连接,真机重连后
/// 发什么都进不了业务链路(可用性事故)。
#[tokio::test]
async fn test_register_takes_over_authenticated_connection() {
let pool = ConnectionPool::new();
let (old, old_close_rx) = authed_conn_with_close(1);
assert_eq!(pool.register("imei-owner".to_string(), old).await, RegisterOutcome::Inserted);
// 真机重连(conn_id=2)→ 必须接管成功,而不是被拒绝
let outcome = pool.register("imei-owner".to_string(), authed_conn(2)).await;
assert_eq!(
outcome,
RegisterOutcome::ReplacedAuthenticated { evicted_conn_id: 1 },
"已认证条目必须允许被新连接接管"
);
// 旧连接收到**主动驱逐信号**(其条目离开池 → 驱逐句柄 drop):
// 旧任务会因此立即关闭 socket,无需设备配合、也无新增协议报文
assert!(old_close_rx.await.is_ok(), "旧连接必须收到主动驱逐信号");
// 归属与认证态都归属于新连接
assert!(!pool.is_owner("imei-owner", 1).await, "旧连接不再持有条目");
assert!(pool.is_owner("imei-owner", 2).await, "新连接成为持有者");
assert!(!pool.is_authenticated("imei-owner", 1).await, "旧连接不再算已认证");
assert!(pool.is_authenticated("imei-owner", 2).await, "认证态归新连接");
// 旧连接再也不能删条目(否则会把真机条目删掉 → 平台永久判离线)
assert!(!pool.remove_if("imei-owner", 1).await, "被接管的连接无权删除条目");
assert!(pool.is_owner("imei-owner", 2).await, "条目仍在(属于新连接)");
}
/// C1-②:未认证占位**会**被新连接替换(真机重连必须能顶掉攻击者占的位),
/// 且占位连接同样被主动驱逐。
#[tokio::test]
async fn test_register_replaces_unauthenticated_placeholder() {
let pool = ConnectionPool::new();
let (placeholder, placeholder_close_rx) = conn_with_close(11);
assert_eq!(
pool.register("imei-victim".to_string(), placeholder).await,
RegisterOutcome::Inserted
);
// 真机(新连接)连上来 → 顶替未认证占位
let outcome = pool.register("imei-victim".to_string(), conn(12)).await;
assert_eq!(
outcome,
RegisterOutcome::ReplacedUnauthenticated { evicted_conn_id: 11 },
"未认证占位必须允许被顶替"
);
assert!(placeholder_close_rx.await.is_ok(), "占位连接必须被主动驱逐");
// 归属已转移:旧占位连接既不能删条目,也不能借用新连接的认证态
assert!(!pool.remove_if("imei-victim", 11).await, "被顶替的连接已失去条目归属");
assert!(!pool.is_authenticated("imei-victim", 12).await, "新连接尚未认证");
assert!(pool.remove_if("imei-victim", 12).await, "新连接已接管该 dev_id");
}
/// C1-③:接管后旧连接**被拒绝继续上行**(等价于 superseded 标记):
/// 它既不持有条目、也不被算作已认证,`server.rs` 的上行门控会直接丢弃其报文;
/// 同时驱逐信号已发出(连接任务会退出)。
#[tokio::test]
async fn test_evicted_connection_is_rejected_for_further_uplink() {
let pool = ConnectionPool::new();
let (old, old_close_rx) = authed_conn_with_close(41);
assert_eq!(pool.register("imei-super".to_string(), old).await, RegisterOutcome::Inserted);
// 新连接接管
assert_eq!(
pool.register("imei-super".to_string(), authed_conn(42)).await,
RegisterOutcome::ReplacedAuthenticated { evicted_conn_id: 41 }
);
assert!(old_close_rx.await.is_ok(), "驱逐信号必须已发出");
assert!(!pool.is_owner("imei-super", 41).await, "旧连接不再是持有者 → 上行前检查即断开");
assert!(
!pool.is_authenticated("imei-super", 41).await,
"旧连接的上行必须被拒(认证态已不归它)"
);
assert!(pool.is_authenticated("imei-super", 42).await, "新连接的上行正常放行");
}
/// C1-④:`touch` 必须按 conn_id 校验归属 ——
/// 被接管的旧连接不得替持有人刷新 `last_seen`(否则"最后活跃时间"失去意义)。
#[tokio::test]
async fn test_touch_requires_conn_ownership() {
let pool = ConnectionPool::new();
assert_eq!(
pool.register("imei-touch".to_string(), authed_conn(51)).await,
RegisterOutcome::Inserted
);
let snapshot = pool.inner.read().await.get("imei-touch").map(|c| c.last_seen);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
// 非持有者(旧连接 50 / 无关连接 52)刷新:无效
pool.touch("imei-touch", 50).await;
pool.touch("imei-touch", 52).await;
let after_wrong = pool.inner.read().await.get("imei-touch").map(|c| c.last_seen);
assert_eq!(after_wrong, snapshot, "非持有者不得刷新 last_seen");
// 持有者刷新:生效
pool.touch("imei-touch", 51).await;
let after_right = pool.inner.read().await.get("imei-touch").map(|c| c.last_seen);
assert!(after_right > after_wrong, "持有者应刷新 last_seen");
}
/// C1-⑤:`remove_if` 用**错误**的 conn_id 时**不删除**条目
/// (否则攻击者断开就能删掉真机条目 → 平台永久判定设备离线)
#[tokio::test]
async fn test_remove_if_wrong_conn_id_keeps_entry() {
let pool = ConnectionPool::new();
assert_eq!(
pool.register("imei-live".to_string(), authed_conn(21)).await,
RegisterOutcome::Inserted
);
// 攻击者连接(conn_id=999,从未注册)断连
assert!(!pool.remove_if("imei-live", 999).await);
// 真机条目必须原封不动
assert!(pool.is_authenticated("imei-live", 21).await, "错误 conn_id 不得删除条目");
}
/// C1-⑥:`remove_if` 用**正确**的 conn_id 时删除条目(正常断连清理)
#[tokio::test]
async fn test_remove_if_matching_conn_id_removes_entry() {
let pool = ConnectionPool::new();
assert_eq!(
pool.register("imei-bye".to_string(), authed_conn(31)).await,
RegisterOutcome::Inserted
);
assert!(pool.remove_if("imei-bye", 31).await);
assert!(!pool.is_authenticated("imei-bye", 31).await, "条目已删除");
// 删除后同一连接再删一次不应成功(幂等,不误伤后续新连接)
assert!(!pool.remove_if("imei-bye", 31).await);
} }
} }

View File

@ -21,12 +21,21 @@ pub struct DeviceMessage {
/// **其余上行一律不带** —— 连接建立后设备身份即由该 TCP 连接确定。 /// **其余上行一律不带** —— 连接建立后设备身份即由该 TCP 连接确定。
/// 内部转发到 Redis `device:reports` 时由 handler 补写该字段(api-server 依赖它识别设备)。 /// 内部转发到 Redis `device:reports` 时由 handler 补写该字段(api-server 依赖它识别设备)。
pub dev_id: Option<String>, pub dev_id: Option<String>,
/// 报文序号:协议要求 **uint32 双向必带**(应答与请求的 `msg_id` 必须一致)。
///
/// `None` 时**不序列化该字段**:否则会向下游输出 `"msg_id":null`,
/// 与"必带 uint32"的契约不符(解析侧不受影响,缺字段即 `None`)。
#[serde(skip_serializing_if = "Option::is_none")]
pub msg_id: Option<u32>, pub msg_id: Option<u32>,
/// 登录签名时的时间戳(秒级) /// 登录签名时的时间戳(秒级)
pub timestamp: Option<u64>, pub timestamp: Option<u64>,
/// SHA256 签名尾巴 7 位 /// SHA256 签名尾巴 **8 位**(v1.1 定稿:取 `hex[56..64]`)
pub sign: Option<String>, pub sign: Option<String>,
/// 485 子设备 ID(仓控板 ID 1-6) /// ⚠️ **已废弃**:485 子设备 ID(历史字段)。
///
/// 新协议已不用它寻址 —— 动作目标统一走 `id`(`仓控板-通道`,仓控板 **1–15**、通道 **1–8**,§3.7),
/// `status_post` 的板号走 `boards[].board`。字段暂保留仅为**兼容历史设备/日志**,
/// 新固件不应再发(原先注释写的"仓控板 ID 1-6"是旧量程,已按协议改为 1–15)。
pub sub_device_id: Option<u8>, pub sub_device_id: Option<u8>,
/// 4G 信号强度 /// 4G 信号强度
pub rssi: Option<i32>, pub rssi: Option<i32>,
@ -144,4 +153,32 @@ mod tests {
assert!(msg.battery.is_none()); assert!(msg.battery.is_none());
} }
} }
/// `msg_id` 序列化契约:协议要求 uint32 双向必带,`None` 时**不得输出 `"msg_id":null`**
/// (转发到 Redis 的 JSON 会被 api-server 与固件按"必带"解析)
#[test]
fn test_device_message_msg_id_none_omitted_on_serialize() {
let msg: DeviceMessage = serde_json::from_str(r#"{"act":"status_post"}"#).unwrap();
assert!(msg.msg_id.is_none());
let out = serde_json::to_string(&msg).unwrap();
assert!(
!out.contains("msg_id"),
"msg_id 为 None 时不得出现在序列化结果里: {out}"
);
// 有 msg_id 时必须原样保留(协议:应答要回带同一个值)
let msg: DeviceMessage = serde_json::from_str(r#"{"act":"login","msg_id":7}"#).unwrap();
assert_eq!(msg.msg_id, Some(7));
let out = serde_json::to_string(&msg).unwrap();
assert!(out.contains(r#""msg_id":7"#), "msg_id 必须原样序列化: {out}");
}
/// 加 `skip_serializing_if` **不能破坏解析**:缺 `msg_id` 的报文仍要能反序列化
#[test]
fn test_device_message_msg_id_absent_still_deserializes() {
let msg: DeviceMessage =
serde_json::from_str(r#"{"act":"auth_str","dev_id":"864720562557021"}"#).unwrap();
assert_eq!(msg.msg_id, None);
assert_eq!(msg.dev_id.as_deref(), Some("864720562557021"));
}
} }

View File

@ -5,7 +5,7 @@ use tokio::net::{TcpListener, TcpStream};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::codec::{FramedRead, LinesCodec};
use super::connection::{ConnectionPool, DeviceConnection}; use super::connection::{ConnectionPool, DeviceConnection, RegisterOutcome, next_conn_id};
use super::handler::handle_message; use super::handler::handle_message;
use super::protocol::DeviceMessage; use super::protocol::DeviceMessage;
@ -65,9 +65,22 @@ async fn handle_connection(
// 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积 // 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积
let (tx, mut rx) = mpsc::channel::<String>(100); let (tx, mut rx) = mpsc::channel::<String>(100);
// **主动驱逐信号**:本连接的池条目一旦离开池(被新连接接管),条目里的
// `DeviceConnection::close`(oneshot 发送端)随之 drop → 下面 `select!` 的接收端
// 立刻就绪 → 本连接立即关闭 socket 并退出。不依赖设备下次上行,
// 也**不新增任何协议报文**(协议冻结,不能用自造帧通知设备)。
let (close_tx, mut close_rx) = tokio::sync::oneshot::channel::<()>();
let mut current_dev_id: Option<String> = None; let mut current_dev_id: Option<String> = None;
let mut writer = writer; let mut writer = writer;
let connected_at = std::time::Instant::now(); let connected_at = std::time::Instant::now();
// 本 TCP 连接的唯一标识:池条目的创建/删除/认证态判定都以它校验归属。
// `dev_id` 来自报文(不可信),只有 `conn_id` 能证明"池里那条entry是不是我的"。
let conn_id = next_conn_id();
// 连接状态在首次 `auth_str`/`login` 注册时才进池;这里先构造好放在 `Option` 里,
// 注册时 `take()`(`close_tx` 只能移动一次,而注册代码在循环体内)。
let mut pending_conn = Some(DeviceConnection::new(tx.clone(), conn_id, close_tx));
loop { loop {
tokio::select! { tokio::select! {
@ -88,31 +101,102 @@ async fn handle_connection(
}; };
// 如果是 login/auth_str,需要 dev_id 来关联连接 // 如果是 login/auth_str,需要 dev_id 来关联连接
// 注意:注册在签名验证之前,因为 login 响应(auth_str/cell)必须通过 //
// 连接池下发。先注册才能回复。纯内网 TCP + 真实设备重连快,攻击面有限。 // ⚠️ 安全:注册发生在**签名校验之前**(`auth_str`/`login` 的应答
// 审查编号:I3(已确认不修,协议约束) // 必须经连接池下发,先注册才能回复),所以报文里的 `dev_id` 是
// **不可信输入**。原注释曾以"纯内网 TCP + 真实设备重连快"为由
// 认为攻击面有限(审查编号 I3,不修)—— 该前提**不成立**:
// 9001 必须对 4G 设备公网可达,任何能连上端口的人都能伪造 IMEI。
// 真实威胁(C1):
// `nc host 9001` 发 `{"act":"auth_str","dev_id":"<受害IMEI>","msg_id":1}`
// 1) 覆盖式 register 会把真机的池条目换成攻击者连接 →
// 真机此后所有上行被判"未登录"丢弃;
// 2) 攻击者断开时无条件 remove 又删掉真机条目 →
// 平台永久"设备不在线",每 25 秒重连一次即可维持永久 DoS。
// 本次修法(不改 TCP 协议,纯服务端逻辑):
// 1) 每条连接分配唯一 `conn_id`,池操作按 conn_id 校验归属;
// 2) `register` **允许接管**同 dev_id 的旧条目(未认证占位 / 已认证连接,
// MQTT 同 clientId 接管语义),接管时显式调用 `evict()` 主动驱逐旧连接
// —— 既不拒绝真机重连(4G 静默掉线后旧 socket 可能仍是 ESTABLISHED),
// 也不会让攻击者把真机挤下线后就无人能接管回来;
// 3) 断连清理改用 `remove_if(dev_id, conn_id)`,只删自己的条目;
// 4) 认证态与活跃时间都按 `conn_id` 归属判定,被接管的连接不能借用
// 新连接的已认证身份、也不能替它刷新 `last_seen`。
if (msg.act == "auth_str" || msg.act == "login") if (msg.act == "auth_str" || msg.act == "login")
&& let Some(ref dev_id) = msg.dev_id && let Some(ref dev_id) = msg.dev_id
&& current_dev_id.is_none() { && current_dev_id.is_none()
let conn = DeviceConnection::new(tx.clone()); && let Some(conn) = pending_conn.take() {
pool.register(dev_id.clone(), conn).await; match pool.register(dev_id.clone(), conn).await {
RegisterOutcome::Inserted => {
current_dev_id = Some(dev_id.clone()); current_dev_id = Some(dev_id.clone());
// 首次识别设备时写入 route // 首次识别设备时写入 route
update_device_route(&redis, dev_id, &node_id).await; update_device_route(&redis, dev_id, &node_id).await;
} }
RegisterOutcome::ReplacedUnauthenticated { evicted_conn_id } => {
// 顶替未认证占位(攻击者占位 / 真机重连而旧连接未被清理)
tracing::warn!(
"[安全] dev_id={} 的未认证占位连接(conn_id={}) 已被本连接(conn_id={}, peer={}) 顶替并驱逐",
dev_id,
evicted_conn_id,
conn_id,
peer
);
current_dev_id = Some(dev_id.clone());
update_device_route(&redis, dev_id, &node_id).await;
}
RegisterOutcome::ReplacedAuthenticated { evicted_conn_id } => {
// **连接接管**:与 MQTT 同 clientId 语义一致(新连接赢)。
// 典型场景是真机静默掉线(NAT 超时 / 4G 信号丢失)后重连,
// 而服务端侧旧 socket 仍被视为 ESTABLISHED。若这里拒绝新连接,
// 真机发什么都进不了业务链路(可用性事故),故必须接管。
// 旧连接由「主动驱逐句柄」立即断开(见 handle_connection 开头的
// close_rx 分支),不需要设备发任何报文配合。
// 这条 WARN 是排查"设备反复掉线/被顶替"的关键信号,务必保留。
tracing::warn!(
"[安全] 同一 IMEI 发生连接接管:dev_id={} 的已认证连接(conn_id={}) 已被新连接(conn_id={}, peer={}) 接管并驱逐(新连接需重新 login 认证)",
dev_id,
evicted_conn_id,
conn_id,
peer
);
current_dev_id = Some(dev_id.clone());
update_device_route(&redis, dev_id, &node_id).await;
}
}
}
// 更新活跃时间,同时刷新 route(处理重连切换到其他节点的情况) // 接管兜底(安全网):池条目若已不属于本连接,说明本连接已被新连接接管
// (等价于"superseded 标记")。主动驱逐信号(close_rx)通常已触发,
// 这里覆盖"信号尚未被 select! 处理完"的竞态:立即断开,不再处理任何上行。
if let Some(ref dev_id) = current_dev_id
&& !pool.is_owner(dev_id, conn_id).await {
use tokio::io::AsyncWriteExt;
tracing::warn!(
"[安全] 连接(conn_id={}, dev_id={}, peer={}) 已被新连接接管,平台主动断开且不再处理其上行",
conn_id,
dev_id,
peer
);
let _ = writer.shutdown().await;
break;
}
// 更新活跃时间,同时刷新 route(处理重连切换到其他节点的情况)。
// `touch` 带 conn_id:只有当前持有者能刷新活跃时间(被接管的旧连接不行)。
if let Some(ref dev_id) = current_dev_id { if let Some(ref dev_id) = current_dev_id {
pool.touch(dev_id).await; pool.touch(dev_id, conn_id).await;
update_device_route(&redis, dev_id, &node_id).await; update_device_route(&redis, dev_id, &node_id).await;
} }
// 安全规则(协议定稿):除 auth_str / login 外,其余指令必须先 login 成功 // 安全规则(协议定稿):除 auth_str / login 外,其余指令必须先 login 成功
// 未登录连接发来的业务报文一律丢弃(连接尚未与 dev_id 建立可信绑定, // 未登录连接发来的业务报文一律丢弃(连接尚未与 dev_id 建立可信绑定,
// 平台侧也无法路由应答) // 平台侧也无法路由应答)
//
// 认证态按 `conn_id` 归属判定:占位连接即使被真机顶替、且真机已认证,
// 也不能"借用"到已认证身份伪造该 IMEI 的上行。
if !UNAUTH_ALLOWED_ACTS.contains(&msg.act.as_str()) { if !UNAUTH_ALLOWED_ACTS.contains(&msg.act.as_str()) {
let authed = match current_dev_id.as_deref() { let authed = match current_dev_id.as_deref() {
Some(dev_id) => pool.is_authenticated(dev_id).await, Some(dev_id) => pool.is_authenticated(dev_id, conn_id).await,
None => false, None => false,
}; };
if !authed { if !authed {
@ -140,13 +224,28 @@ async fn handle_connection(
} }
} }
} }
// 主动驱逐:本连接的池条目已被新连接接管(连接池调用 `evict()` 发来信号)。
// 立即断开,让接管方独占该 dev_id;设备侧无感知(不涉及任何协议报文),
// 真机在自己的重连流程里会重新 auth_str/login。
_ = &mut close_rx => {
use tokio::io::AsyncWriteExt;
tracing::warn!(
"[安全] 连接(conn_id={}, dev_id={:?}, peer={}) 的池条目已被新连接接管,平台主动断开旧连接",
conn_id,
current_dev_id,
peer
);
let _ = writer.shutdown().await;
break;
}
// 未登录超时巡检:连接建立后 UNAUTH_TIMEOUT_SECS 秒内未通过 login 即断开 // 未登录超时巡检:连接建立后 UNAUTH_TIMEOUT_SECS 秒内未通过 login 即断开
// (协议规定:login 成功这条 TCP 才可以保持) // (协议规定:login 成功这条 TCP 才可以保持)
_ = tokio::time::sleep(std::time::Duration::from_secs(UNAUTH_CHECK_INTERVAL_SECS)) => { _ = tokio::time::sleep(std::time::Duration::from_secs(UNAUTH_CHECK_INTERVAL_SECS)) => {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
let authed = match current_dev_id.as_deref() { let authed = match current_dev_id.as_deref() {
Some(dev_id) => pool.is_authenticated(dev_id).await, Some(dev_id) => pool.is_authenticated(dev_id, conn_id).await,
None => false, None => false,
}; };
if !authed && connected_at.elapsed().as_secs() >= UNAUTH_TIMEOUT_SECS { if !authed && connected_at.elapsed().as_secs() >= UNAUTH_TIMEOUT_SECS {
@ -187,10 +286,19 @@ async fn handle_connection(
} }
} }
// 连接断开,清理 // 连接断开,清理:**只删 conn_id 属于自己的那条条目**。
// 无条件 `remove(dev_id)` 会让攻击者用受害 IMEI 占位后、在断开时把真机的
// 池条目一并删掉 → 平台永久判定该设备离线(C1 的 DoS 主路径)。
if let Some(ref dev_id) = current_dev_id { if let Some(ref dev_id) = current_dev_id {
pool.remove(dev_id).await; if pool.remove_if(dev_id, conn_id).await {
tracing::info!("[断连] dev_id={}", dev_id); tracing::info!("[断连] dev_id={} conn_id={} 池条目已清理", dev_id, conn_id);
} else {
tracing::info!(
"[断连] dev_id={} conn_id={} 池条目已易主,保留当前持有者(不清理)",
dev_id,
conn_id
);
}
} }
} }

View File

@ -41,7 +41,7 @@
<view class="data-grid"> <view class="data-grid">
<view class="data-item"><text class="data-label">电压</text><text class="data-value">{{ fmtVoltage(data.voltage) }}</text></view> <view class="data-item"><text class="data-label">电压</text><text class="data-value">{{ fmtVoltage(data.voltage) }}</text></view>
<view class="data-item"><text class="data-label">电流</text><text class="data-value">{{ fmtCurrent(data.current) }}</text></view> <view class="data-item"><text class="data-label">电流</text><text class="data-value">{{ fmtCurrent(data.current) }}</text></view>
<view class="data-item"><text class="data-label">温度</text><text class="data-value">{{ data.temp != null ? ((data.temp / 100) - 273.15).toFixed(1) + ' °C' : '—' }}</text></view> <view class="data-item"><text class="data-label">温度</text><text class="data-value">{{ formatCelsius(data.temp) }}</text></view>
</view> </view>
</view> </view>
@ -83,7 +83,7 @@ import { onLoad } from '@dcloudio/uni-app'
import { h5Api, type CompartmentDetail } from '../../api' import { h5Api, type CompartmentDetail } from '../../api'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { fmtSoc, fmtEnergy, fmtVoltage, fmtCurrent } from '../../utils/format' import { fmtSoc, fmtEnergy, fmtVoltage, fmtCurrent } from '../../utils/format'
import { battStatusOf, powStatusOf } from '../../utils/deviceStatus' import { battStatusOf, powStatusOf, formatCelsius } from '../../utils/deviceStatus'
const statusBarHeight = ref(47) const statusBarHeight = ref(47)
const navBarHeight = ref(44) const navBarHeight = ref(44)

View File

@ -19,8 +19,9 @@
<view class="signal" :class="signalClass(data.rssi)"><view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view></view> <view class="signal" :class="signalClass(data.rssi)"><view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view></view>
<text class="rssi-val">{{ data.rssi != null ? data.rssi : '—' }}</text> <text class="rssi-val">{{ data.rssi != null ? data.rssi : '—' }}</text>
<view class="pow-tags"> <view class="pow-tags">
<text class="pow-tag" :class="isPowerFail(data.pow_fail_dc) ? 'off' : 'on'">{{ isPowerFail(data.pow_fail_dc) ? '直流停电' : 'DC' }}</text> <!-- 三态:停电(红)/ 正常(绿)/ 未知(灰)—— 无数据绝不能显示成"正常" -->
<text class="pow-tag" :class="isPowerFail(data.pow_fail_ac) ? 'off' : 'on'">{{ isPowerFail(data.pow_fail_ac) ? '交流停电' : 'AC' }}</text> <text class="pow-tag" :class="powerFlagOf(data.pow_fail_dc, 'DC').tone">{{ powerFlagOf(data.pow_fail_dc, 'DC').label }}</text>
<text class="pow-tag" :class="powerFlagOf(data.pow_fail_ac, 'AC').tone">{{ powerFlagOf(data.pow_fail_ac, 'AC').label }}</text>
</view> </view>
</view> </view>
</view> </view>
@ -73,7 +74,7 @@ import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { h5Api, type CabinetDetail } from '../../api' import { h5Api, type CabinetDetail } from '../../api'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { battStatusOf, powStatusOf, isPowerFail } from '../../utils/deviceStatus' import { battStatusOf, powStatusOf, powerFlagOf } from '../../utils/deviceStatus'
const statusBarHeight = ref(47) const statusBarHeight = ref(47)
const navBarHeight = ref(44) const navBarHeight = ref(44)
@ -152,6 +153,8 @@ function openLocation() {
.pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; white-space: nowrap; } .pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; white-space: nowrap; }
.pow-tag.on { background: #f0fdf4; color: #22c55e; } .pow-tag.on { background: #f0fdf4; color: #22c55e; }
.pow-tag.off { background: #fef2f2; color: #ef4444; } .pow-tag.off { background: #fef2f2; color: #ef4444; }
/* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */
.pow-tag.unknown { background: #f1f5f9; color: #94a3b8; }
.info-rows { border-top: 1px solid #f1f5f9; padding-top: 12px; } .info-rows { border-top: 1px solid #f1f5f9; padding-top: 12px; }
.info-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; font-size: 13px; } .info-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; font-size: 13px; }
.info-row .label { color: #94a3b8; } .info-row .label { color: #94a3b8; }

View File

@ -39,7 +39,7 @@
<view class="status-badge" :class="cab.status === 1 ? 'online' : 'offline'"><text>{{ cab.status === 1 ? '在线' : '离线' }}</text></view> <view class="status-badge" :class="cab.status === 1 ? 'online' : 'offline'"><text>{{ cab.status === 1 ? '在线' : '离线' }}</text></view>
</view> </view>
<view class="header-right"> <view class="header-right">
<text v-if="cab.temp != null" class="temp-badge">{{ cab.temp }}°C</text> <text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text>
<view class="signal-inline"> <view class="signal-inline">
<view class="signal" :class="signalClass(cab.rssi)"> <view class="signal" :class="signalClass(cab.rssi)">
<view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view> <view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view>
@ -47,8 +47,9 @@
<text class="rssi-value">{{ cab.rssi ?? '—' }}</text> <text class="rssi-value">{{ cab.rssi ?? '—' }}</text>
</view> </view>
<view class="pow-tags"> <view class="pow-tags">
<view class="pow-tag" :class="isPowerFail(cab.pow_fail_dc) ? 'off' : 'on'"><text>{{ isPowerFail(cab.pow_fail_dc) ? '直流停电' : 'DC' }}</text></view> <!-- 三态:停电(红)/ 正常(绿)/ 未知(灰)—— 无数据绝不能显示成"正常" -->
<view class="pow-tag" :class="isPowerFail(cab.pow_fail_ac) ? 'off' : 'on'"><text>{{ isPowerFail(cab.pow_fail_ac) ? '交流停电' : 'AC' }}</text></view> <view class="pow-tag" :class="powerFlagOf(cab.pow_fail_dc, 'DC').tone"><text>{{ powerFlagOf(cab.pow_fail_dc, 'DC').label }}</text></view>
<view class="pow-tag" :class="powerFlagOf(cab.pow_fail_ac, 'AC').tone"><text>{{ powerFlagOf(cab.pow_fail_ac, 'AC').label }}</text></view>
</view> </view>
</view> </view>
</view> </view>
@ -69,6 +70,8 @@
<view class="org-sheet" @click.stop> <view class="org-sheet" @click.stop>
<view class="org-header"><text class="org-title">切换组织</text><view class="org-close" @click="showOrgPicker = false"><text>✕</text></view></view> <view class="org-header"><text class="org-title">切换组织</text><view class="org-close" @click="showOrgPicker = false"><text>✕</text></view></view>
<scroll-view class="org-list" scroll-y> <scroll-view class="org-list" scroll-y>
<!-- 组织接口失败(如操作员缺少 org:view → 403)时给出可见提示,避免弹层静默为空 -->
<view v-if="orgs.length === 0" class="org-empty"><text>暂无可选组织(账号可能缺少「查看组织」权限),当前按「全部」展示</text></view>
<view class="org-option" :class="{ selected: selectedOrgId === null }" @click="switchOrgAll"><text>全部</text></view> <view class="org-option" :class="{ selected: selectedOrgId === null }" @click="switchOrgAll"><text>全部</text></view>
<view v-for="org in orgs" :key="org.id" class="org-option" :class="{ selected: selectedOrgId === org.id }" @click="switchOrg(org)"><text>{{ org.name }}</text></view> <view v-for="org in orgs" :key="org.id" class="org-option" :class="{ selected: selectedOrgId === org.id }" @click="switchOrg(org)"><text>{{ org.name }}</text></view>
</scroll-view> </scroll-view>
@ -83,7 +86,7 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api' import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api'
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { isPowerFail } from '../../utils/deviceStatus' import { formatCelsius, powerFlagOf } from '../../utils/deviceStatus'
const statusBarHeight = ref(47) const statusBarHeight = ref(47)
const navBarHeight = ref(44) const navBarHeight = ref(44)
@ -118,9 +121,31 @@ const showOrgPicker = ref(false)
const selectedOrgId = ref<number | null>(null) const selectedOrgId = ref<number | null>(null)
const currentOrgName = ref('') const currentOrgName = ref('')
/**
* 加载组织列表并落到「全部」
*
* ⚠️ `GET /api/organizations` 需要 `org:view` 权限,操作员(normal_user)没有该权限 → 403。
* 前端兜底:**无论成功失败都切到「全部」**,失败时清空列表并给出可见提示
* (弹层内有提示行 + toast),绝不静默留一个空弹层。
* 若需让操作员看到自己组织下的设备,需后端提供免 `org:view` 的可见组织接口(前端无法单独解决)。
*/
async function initOrg() { async function initOrg() {
try { const res = await h5Api.getOrganizations(); orgs.value = res || []; switchOrgAll() } catch { currentOrgName.value = '' } try {
}function switchOrgAll() { showOrgPicker.value = false; selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() } const res = await h5Api.getOrganizations()
orgs.value = res || []
if (orgs.value.length === 0) {
uni.showToast({ title: '暂无可见组织,已按「全部」展示', icon: 'none' })
}
} catch {
orgs.value = []
uni.showToast({ title: '组织列表加载失败,已按「全部」展示', icon: 'none' })
}
// 统一落到「全部」:只改筛选状态,数据由 onShow 的 fetch 拉取,避免重复请求
applyOrgAll()
}
/** 切到「全部」(仅状态,不含请求) */
function applyOrgAll() { selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null }
function switchOrgAll() { showOrgPicker.value = false; applyOrgAll(); fetchDashboard(); fetchCabinets() }
function switchOrg(org: OrgItem) { showOrgPicker.value = false; selectedOrgId.value = org.id; currentOrgName.value = org.name; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() } function switchOrg(org: OrgItem) { showOrgPicker.value = false; selectedOrgId.value = org.id; currentOrgName.value = org.name; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() }
onShow(async () => { onShow(async () => {
@ -214,6 +239,8 @@ function showScanInput() {
.pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; } .pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; }
.pow-tag.on { background: #dcfce7; color: #16a34a; } .pow-tag.on { background: #dcfce7; color: #16a34a; }
.pow-tag.off { background: #fee2e2; color: #dc2626; } .pow-tag.off { background: #fee2e2; color: #dc2626; }
/* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */
.pow-tag.unknown { background: #f1f5f9; color: #94a3b8; }
.device-name { font-size: 14px; font-weight: 500; color: #111827; display: block; margin-bottom: 4px; } .device-name { font-size: 14px; font-weight: 500; color: #111827; display: block; margin-bottom: 4px; }
.device-imei { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; display: block; margin-bottom: 4px; opacity: 0.7; } .device-imei { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; display: block; margin-bottom: 4px; opacity: 0.7; }
.device-addr { font-size: 12px; color: #475569; display: block; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .device-addr { font-size: 12px; color: #475569; display: block; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@ -233,6 +260,7 @@ function showScanInput() {
.org-title { font-size: 16px; font-weight: 600; } .org-title { font-size: 16px; font-weight: 600; }
.org-close { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #475569; } .org-close { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #475569; }
.org-list { flex: 1; } .org-list { flex: 1; }
.org-empty { padding: 14px 20px; font-size: 12px; line-height: 18px; color: #94a3b8; background: #f8fafc; }
.org-option { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; font-size: 15px; color: #111827; border-bottom: 1px solid #f1f5f9; } .org-option { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; font-size: 15px; color: #111827; border-bottom: 1px solid #f1f5f9; }
.org-option.selected { color: #0d9488; font-weight: 500; } .org-option.selected { color: #0d9488; font-weight: 500; }
.org-option.selected::after { content: '✓'; font-size: 16px; font-weight: 600; } .org-option.selected::after { content: '✓'; font-size: 16px; font-weight: 600; }

View File

@ -81,7 +81,88 @@ export function powStatusOf(hex?: string | null): ChannelStatus {
/** /**
* 是否处于停电状态(协议 §5.4) * 是否处于停电状态(协议 §5.4)
* 后端契约为 `boolean | null`(true=停电 / false=正常 / null=无数据) * 后端契约为 `boolean | null`(true=停电 / false=正常 / null=无数据)
*
* ⚠️ 只回答「是否停电」,**不能**用来判断"是否正常":无数据(null)时返回 false,
* 若把它当成"正常"渲染成绿色就会把"无数据"说成"正常"。展示请用下面的
* [`powerFlagOf`] / [`powerFailState`](三态)。
*/ */
export function isPowerFail(value?: boolean | null): boolean { export function isPowerFail(value?: boolean | null): boolean {
return value === true return value === true
} }
/**
* 停电状态三态(协议 §5.4)
* - `'fail'`:处于停电
* - `'normal'`:正常(后端明确返回 false / 0)
* - `'unknown'`:**无数据**(字段缺失 / null)→ 灰色「未知」
*
* ⚠️ 语义必须与 PC 管理端一致:见 `web/src/utils/deviceStatus.ts` 的 `normalizeFlag()`
* (PC 端区分 null:灰色 + 「无数据」)。历史 bug:小程序用
* `isPowerFail(x) ? 'off' : 'on'` 两态判断,null 落到 else 分支被渲染成绿色「DC/AC 正常」,
* 与 PC 端语义相反。
*/
export type PowerFailState = 'fail' | 'normal' | 'unknown'
/** 把后端 `pow_fail_dc` / `pow_fail_ac` 归一化为三态(兼容 1/0 与 true/false 两种写法) */
export function powerFailState(value?: boolean | null | number): PowerFailState {
if (value === null || value === undefined) return 'unknown'
return value === true || value === 1 ? 'fail' : 'normal'
}
/** 停电标签(文案 + 样式类后缀),供模板直接绑定 */
export interface PowerFlagTag {
/** 三态 */
state: PowerFailState
/** 模板样式类后缀:`off`=停电 / `on`=正常 / `unknown`=无数据 */
tone: 'off' | 'on' | 'unknown'
/** 展示文案 */
label: string
}
/**
* 停电标签(直流 / 交流共用)
* - 停电 → `直流停电` / `交流停电`(红)
* - 正常 → `DC` / `AC`(绿)
* - 无数据 → `DC未知` / `AC未知`(灰,与 PC 端灰色「无数据」语义一致)
*
* @param value 后端 `pow_fail_dc` / `pow_fail_ac`
* @param kind 直流(DC)/ 交流(AC),同时用于无数据时的文案,避免两个灰色标签无法区分
*/
export function powerFlagOf(value: boolean | null | undefined, kind: 'DC' | 'AC'): PowerFlagTag {
const state = powerFailState(value)
if (state === 'fail') {
return { state, tone: 'off', label: kind === 'DC' ? '直流停电' : '交流停电' }
}
if (state === 'unknown') {
return { state, tone: 'unknown', label: `${kind}未知` }
}
return { state, tone: 'on', label: kind }
}
/**
* 温度换算:协议里温度字段统一是 **K×100**(开尔文 ×100)。
*
* 依据:协议 §5.3 / §5.4 的示例 `temp = 29800` 即 298.00K;
* 柜级 `cab.temp`(`device:{imei}.temp`)与通道级 `data.temp` 用**同一口径**,
* 因此两个页面必须用同一个换算函数,**禁止把原始值直接拼 `°C`**
* (曾把 29800 显示成 `29800°C`)。
*
* ⚠️ 与 Web 管理端口径保持一致:`(temp / 100) - 273.15`(见 `web/src/pages/devices/cabinet.tsx`)。
*
* @param temp K×100 的整数温度;`null` / `undefined` / 非数字 → 返回 `null`(由调用方展示占位符)
*/
export function kelvinX100ToCelsius(temp?: number | null): number | null {
if (temp === null || temp === undefined) return null
const value = Number(temp)
if (!Number.isFinite(value)) return null
return value / 100 - 273.15
}
/**
* 温度格式化为展示文本(K×100 → 摄氏度,保留 1 位小数)
* @returns 例如 `"25.0°C"`;无数据时返回 `"—"`
*/
export function formatCelsius(temp?: number | null): string {
const celsius = kelvinX100ToCelsius(temp)
return celsius === null ? '—' : `${celsius.toFixed(1)}°C`
}

View File

@ -12,6 +12,8 @@ import {
Navigate, Navigate,
} from 'react-router-dom'; } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { RequirePermission } from '@/components/Permission';
import NoPermission from '@/components/NoPermission';
import AdminLayout from '@/layouts/AdminLayout'; import AdminLayout from '@/layouts/AdminLayout';
import Login from '@/pages/Login'; import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard'; import Dashboard from '@/pages/Dashboard';
@ -48,6 +50,25 @@ function RedirectIfAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
/**
* 路由级权限守卫:无权限时展示统一的 403 页(`NoPermission`)
*
* 权限码来自后端 `db/migrate.rs` 的 permissions 种子表:
* - `/settings/roles` → `role:view`(种子里存在;仅总管理员被授予)
* - `/settings/operation-logs` → `operation_log:view`(种子里存在;企业管理员/总管理员被授予)
* - `/settings/feedback` → `user:view`(后端 `/api/feedback` 目前只校验登录态、**没有专用权限码**,
* 这里用「查看用户」作为最小可用守卫,详见修复报告)
*
* 总管理员(role_level >= 2)在 `hasPermission` 内直接放行,不会被拦。
*/
function Guard({ code, children }: { code: string; children: React.ReactNode }) {
return (
<RequirePermission code={code} fallback={<NoPermission />}>
{children}
</RequirePermission>
);
}
export default function App() { export default function App() {
const restore = useAuthStore((s) => s.restore); const restore = useAuthStore((s) => s.restore);
@ -84,9 +105,9 @@ export default function App() {
<Route path="/device-logs" element={<DeviceLogs />} /> <Route path="/device-logs" element={<DeviceLogs />} />
<Route path="/energy" element={<Energy />} /> <Route path="/energy" element={<Energy />} />
<Route path="/settings/users" element={<Users />} /> <Route path="/settings/users" element={<Users />} />
<Route path="/settings/roles" element={<Roles />} /> <Route path="/settings/roles" element={<Guard code="role:view"><Roles /></Guard>} />
<Route path="/settings/operation-logs" element={<OperationLogs />} /> <Route path="/settings/operation-logs" element={<Guard code="operation_log:view"><OperationLogs /></Guard>} />
<Route path="/settings/feedback" element={<Feedback />} /> <Route path="/settings/feedback" element={<Guard code="user:view"><Feedback /></Guard>} />
<Route path="/batteries" element={<Batteries />} /> <Route path="/batteries" element={<Batteries />} />
<Route path="/charge-orders" element={<ChargeOrders />} /> <Route path="/charge-orders" element={<ChargeOrders />} />
</Route> </Route>

View File

@ -57,17 +57,47 @@ export interface Cabinet {
org_disabled?: boolean; org_disabled?: boolean;
/** 所属项目地址(柜子地址为空时使用) */ /** 所属项目地址(柜子地址为空时使用) */
project_address?: string; project_address?: string;
/**
* 柜体温度(协议 §3.8 / 附 A:`status_post` 的 `cabinet.temp`,单位 **K×100**,
* 如 `29800` = 298.00K = 24.85°C)
*
* 数据源为 Redis `device:{imei}.temp`,**后端接口正在补齐该字段**;
* 未返回 / 设备未上报时为 `undefined` / `null`,界面展示 `--°C`。
*/
temp?: number | null;
} }
/**
* 仓体(通道)
*
* ⚠️ 字段以后端 `GET /api/cabinets/{id}` 的真实响应为准:该接口把仓体挂在
* `cabin_boards[].compartments[]` 下,只返回 `id` / `channel_id` / `status` /
* `batt_status` / `pow_status` 五个字段,随后按 Redis 实时数据**按需注入**
* `soc` / `battery_id` / `temp`(`voltage` / `current` 由后端补齐中)。
* 因此除 id/channel_id/status 外**均为可选**,缺失时界面展示 `-`。
*/
export interface Compartment { export interface Compartment {
id: number; id: number;
cabin_board_id: number; /**
* 所属仓控板 ID
* ⚠️ 柜子详情接口**不返回**该字段(仓体已在 `CabinBoard.compartments` 下,
* 板号可由所在仓板得知);仅 `GET /api/compartments/{id}` 单仓详情返回,故为可选。
*/
cabin_board_id?: number;
channel_id: number; channel_id: number;
status: number; status: number;
/** 电量 **‰**(0-10000,`4500` = 45.0%);展示需 `/10`。后端未注入实时数据时缺失 */
soc?: number; soc?: number;
/** 电池唯一标识;BMS 读不到序列号时可能是空串,字段缺失表示未登记 */
battery_id?: string; battery_id?: string;
/** 电压 **mV**(`52000` = 52.000V)—— 后端正在补齐该字段,未返回时界面展示 `-` */
voltage?: number; voltage?: number;
/** 电流 **mA**(`10000` = 10.000A)—— 后端正在补齐该字段,未返回时界面展示 `-` */
current?: number; current?: number;
/**
* 温度 **K×100**(`29865` = 298.65K = 25.5°C,展示需 `/100 - 273.15`)
* 后端已从 Redis `device:{imei}:board:{板号}:ch:{通道号}.temp` 注入
*/
temp?: number; temp?: number;
/** /**
* 电池状态(协议 §3.8,2 位 hex,如 "82") * 电池状态(协议 §3.8,2 位 hex,如 "82")

View File

@ -30,29 +30,49 @@ function getToken(): string | null {
return localStorage.getItem('token'); return localStorage.getItem('token');
} }
/**
* 拼接请求地址
* - 相对路径(如 `/downloads/1/file`)→ 前缀 `API_BASE`
* - 绝对地址(后端偶尔返回 `http(s)://...`)→ 原样使用,避免拼出非法 URL
*/
function resolveUrl(url: string): string {
return /^https?:\/\//i.test(url) ? url : `${API_BASE}${url}`;
}
/** 构造带 token 的请求头(`json=false` 时不设置 Content-Type,避免污染二进制下载) */
function buildHeaders(json: boolean, extra?: HeadersInit): Record<string, string> {
const token = getToken();
const headers: Record<string, string> = {
...(json ? { 'Content-Type': 'application/json' } : {}),
...(extra as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* 从失败响应里取出后端 `message`
* 后端约定 `{ code, message }`;非 JSON 响应(如网关 502 页面)回退到状态文案
*/
async function readErrorMessage(res: Response): Promise<string> {
const body = await res.json().catch(() => null) as { message?: string } | null;
return body?.message || res.statusText || `请求失败(HTTP ${res.status})`;
}
async function request<T>( async function request<T>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try { try {
const res = await fetch(`${API_BASE}${url}`, { const res = await fetch(resolveUrl(url), {
...options, ...options,
headers, headers: buildHeaders(true, options.headers),
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); throw new ApiError(res.status, await readErrorMessage(res));
throw new ApiError(res.status, body.message || res.statusText);
} }
return res.json(); return res.json();
@ -62,6 +82,32 @@ async function request<T>(
} }
} }
/**
* 二进制 GET(带 token)——用于 Excel 等文件下载
*
* 为什么需要它:文件接口(如 `/downloads/{id}/file`)要求 `Authorization: Bearer`,
* 而 `window.open` / `<a href>` 都不会带自定义请求头,直接用后端返回的 URL 打开
* 只会拿到 401(或路径前缀不对拿到 404)。因此统一走带 token 的 `fetch` 取 Blob,
* 再由调用方用 `URL.createObjectURL` 触发浏览器保存。
*
* @param url 相对 `API_BASE` 的路径(如 `/downloads/1/file`),或绝对 URL
* @throws {ApiError} HTTP 非 2xx —— message 为后端返回的 `message`
*/
async function requestBlob(url: string): Promise<Blob> {
try {
const res = await fetch(resolveUrl(url), { headers: buildHeaders(false) });
if (!res.ok) {
throw new ApiError(res.status, await readErrorMessage(res));
}
return await res.blob();
} catch (err) {
if (err instanceof ApiError) throw err;
throw new ApiError(0, (err as Error).message || '网络错误');
}
}
export const api = { export const api = {
get: <T>(url: string) => request<T>(url), get: <T>(url: string) => request<T>(url),
post: <T>(url: string, data?: unknown) => post: <T>(url: string, data?: unknown) =>
@ -69,4 +115,6 @@ export const api = {
put: <T>(url: string, data?: unknown) => put: <T>(url: string, data?: unknown) =>
request<T>(url, { method: 'PUT', body: JSON.stringify(data) }), request<T>(url, { method: 'PUT', body: JSON.stringify(data) }),
delete: <T>(url: string) => request<T>(url, { method: 'DELETE' }), delete: <T>(url: string) => request<T>(url, { method: 'DELETE' }),
/** 带 token 的文件下载(Blob) */
getBlob: (url: string) => requestBlob(url),
}; };

View File

@ -0,0 +1,35 @@
/**
* 无权限占位页(403)
*
* 用途:作为路由级权限守卫 `RequirePermission` 的 `fallback`,
* 让无权限用户直接访问受限路由(如直敲 `/settings/roles`)时看到统一的提示页,
* 而不是进到页面里弹「加载失败」。
*
* 注意:总管理员的放行逻辑在 `@/utils/permission` 的 `hasPermission` 内
* (`role >= 2` 直接返回 true),因此总管理员永远不会看到本组件。
*/
import { Button, Result } from '@arco-design/web-react';
import { useNavigate } from 'react-router-dom';
interface NoPermissionProps {
/** 自定义提示文案(默认通用文案) */
subTitle?: string;
}
export default function NoPermission({ subTitle }: NoPermissionProps) {
const navigate = useNavigate();
return (
<Result
status="403"
title="403"
subTitle={subTitle || '抱歉,你没有访问该页面的权限,请联系管理员开通。'}
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
返回首页
</Button>
}
/>
);
}

View File

@ -39,13 +39,18 @@ export default function Batteries() {
const pageSize = 20; const pageSize = 20;
const loadData = async () => { /**
* 加载列表
* 显式接收分页/筛选参数:避免在 `setState` 尚未生效时用旧值发请求
* (历史 bug:点「搜索」先 `setPage(1)` 又立刻用旧 `page` 发请求,导致重复请求或停在错的页)
*/
const loadData = async (p: number, status: number | undefined, batteryId: string) => {
setLoading(true); setLoading(true);
try { try {
const res = await batteryApi.list({ const res = await batteryApi.list({
status: statusFilter, status,
battery_id: batteryIdFilter || undefined, battery_id: batteryId || undefined,
page, page: p,
page_size: pageSize, page_size: pageSize,
}); });
setData(res.data.list || []); setData(res.data.list || []);
@ -57,19 +62,29 @@ export default function Batteries() {
} }
}; };
// 分页 / 状态筛选变化时自动重查(battery_id 为手动搜索,不作为依赖)
useEffect(() => { useEffect(() => {
loadData(); loadData(page, statusFilter, batteryIdFilter);
}, [page, statusFilter]); }, [page, statusFilter]);
/** 搜索:页码已变化时交给 useEffect 重查,否则直接查第 1 页 */
const handleSearch = () => { const handleSearch = () => {
if (page === 1) {
loadData(1, statusFilter, batteryIdFilter);
} else {
setPage(1); setPage(1);
loadData(); }
}; };
/** 重置:清空筛选条件并回到第 1 页(用重置后的条件直接重查,不依赖 setState 时序) */
const handleReset = () => { const handleReset = () => {
setStatusFilter(undefined); setStatusFilter(undefined);
setBatteryIdFilter(''); setBatteryIdFilter('');
if (page === 1 && statusFilter === undefined) {
loadData(1, undefined, '');
} else {
setPage(1); setPage(1);
}
}; };
const columns = [ const columns = [

View File

@ -41,16 +41,24 @@ export default function ChargeOrders() {
const pageSize = 20; const pageSize = 20;
const loadData = async () => { /**
* 加载列表
* 显式接收分页/筛选参数:避免在 `setState` 尚未生效时用旧值发请求
* (历史 bug:点「查询」先 `setPage(1)` 又立刻用旧 `page` 发请求)
*/
const loadData = async (
p: number,
filters: { cabinetId: string; batteryId: string; status: number | undefined; dateRange: string[] },
) => {
setLoading(true); setLoading(true);
try { try {
const res = await chargeOrderApi.list({ const res = await chargeOrderApi.list({
cabinet_id: cabinetId ? Number(cabinetId) : undefined, cabinet_id: filters.cabinetId ? Number(filters.cabinetId) : undefined,
battery_id: batteryId || undefined, battery_id: filters.batteryId || undefined,
status: statusFilter, status: filters.status,
start_time: dateRange[0] || undefined, start_time: filters.dateRange[0] || undefined,
end_time: dateRange[1] || undefined, end_time: filters.dateRange[1] || undefined,
page, page: p,
page_size: pageSize, page_size: pageSize,
}); });
setData(res.data.list || []); setData(res.data.list || []);
@ -62,21 +70,34 @@ export default function ChargeOrders() {
} }
}; };
/** 当前筛选条件快照 */
const currentFilters = { cabinetId, batteryId, status: statusFilter, dateRange };
// 分页 / 柜子 / 状态变化时自动重查(电池ID、时间范围为手动查询条件)
useEffect(() => { useEffect(() => {
loadData(); loadData(page, { cabinetId, batteryId, status: statusFilter, dateRange });
}, [page, cabinetId, statusFilter]); }, [page, cabinetId, statusFilter]);
/** 查询:页码已变化时交给 useEffect 重查,否则直接查第 1 页 */
const handleSearch = () => { const handleSearch = () => {
if (page === 1) {
loadData(1, currentFilters);
} else {
setPage(1); setPage(1);
loadData(); }
}; };
/** 重置:清空筛选条件并回到第 1 页(用重置后的条件直接重查,不依赖 setState 时序) */
const handleReset = () => { const handleReset = () => {
setCabinetId(''); setCabinetId('');
setBatteryId(''); setBatteryId('');
setStatusFilter(undefined); setStatusFilter(undefined);
setDateRange([]); setDateRange([]);
if (page === 1 && !cabinetId && statusFilter === undefined) {
loadData(1, { cabinetId: '', batteryId: '', status: undefined, dateRange: [] });
} else {
setPage(1); setPage(1);
}
}; };
const columns = [ const columns = [

View File

@ -2,7 +2,7 @@
* 下载中心弹窗组件 * 下载中心弹窗组件
* - 展示异步导出任务列表 * - 展示异步导出任务列表
* - 自动轮询刷新(每5秒) * - 自动轮询刷新(每5秒)
* - 已完成任务可下载 * - 已完成任务可下载(带 token 取 Blob 后本地保存)
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
@ -16,6 +16,15 @@ import {
} from '@arco-design/web-react'; } from '@arco-design/web-react';
import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'; import { IconDownload, IconRefresh } from '@arco-design/web-react/icon';
import { api } from '@/api/request'; import { api } from '@/api/request';
import { Permission } from '@/components/Permission';
import { hasPermission } from '@/utils/permission';
/**
* 下载中心所有接口(列表 / 下载信息 / 文件 / 清理)在后端都要求
* `charge_record:export`(见 `api-server/src/routes/downloads.rs`),
* 因此按钮统一用该权限码控制显隐。
*/
const DOWNLOAD_PERMISSION = 'charge_record:export';
/** 下载任务类型 */ /** 下载任务类型 */
interface DownloadTask { interface DownloadTask {
@ -30,6 +39,53 @@ interface DownloadTask {
completed_at: string | null; completed_at: string | null;
} }
/** 下载信息响应(`GET /downloads/{id}`) */
interface DownloadInfo {
id: number;
file_name: string | null;
/** 后端返回的服务内部路径,形如 `/api/downloads/{id}/file`(**不含 `/api/pms` 前缀**) */
download_url: string;
}
/**
* 后端 `download_url` → api 客户端可用的路径
*
* 后端返回的是**服务内部路径**(`/api/downloads/{id}/file`),而前端 api 客户端的
* `API_BASE` 本身已经是 `/api/pms`(nginx 把 `/api/pms/` 反代到后端 `/api/`)。
* 所以必须剥掉后端路径里的 `/api` 前缀,否则会请求成
* `/api/pms/api/downloads/...` → 404。
*
* 历史 bug:旧实现直接 `window.open(download_url)` —— 既少了 `/api/pms` 前缀(404),
* 又不会带 `Authorization: Bearer`(401),导致「导出 → 下载」整条链路不可用。
*/
function toApiPath(downloadUrl: string): string {
const url = (downloadUrl || '').trim();
if (!url) return '';
// 绝对地址:交给 requestBlob 直接请求(仍会带 token)
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith('/api/pms/')) return url.slice('/api/pms'.length);
if (url.startsWith('/api/')) return url.slice('/api'.length);
return url.startsWith('/') ? url : `/${url}`;
}
/**
* 触发浏览器保存 Blob
* - `URL.createObjectURL` 生成临时地址 + 隐藏 `<a download>` 点击
* - 延迟释放 objectURL:部分浏览器在 click 同步返回时仍在读取该地址,
* 立即 revoke 可能导致下载被取消
*/
function saveBlobAsFile(blob: Blob, fileName: string) {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
/** 状态标签颜色映射 */ /** 状态标签颜色映射 */
const STATUS_COLOR: Record<number, string> = { const STATUS_COLOR: Record<number, string> = {
0: 'gray', 0: 'gray',
@ -48,7 +104,11 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
/** 正在下载的任务 ID(用于按钮 loading,避免重复点击) */
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const pageSize = 10; const pageSize = 10;
/** 无 `charge_record:export` 时列表接口必然 403,直接跳过请求 */
const canExport = hasPermission(DOWNLOAD_PERMISSION);
/** 获取下载任务列表 */ /** 获取下载任务列表 */
const fetchTasks = useCallback(async () => { const fetchTasks = useCallback(async () => {
@ -69,32 +129,42 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps
/** 弹窗打开时加载数据 */ /** 弹窗打开时加载数据 */
useEffect(() => { useEffect(() => {
if (visible) { if (visible && canExport) {
fetchTasks(); fetchTasks();
} }
}, [visible, fetchTasks]); }, [visible, canExport, fetchTasks]);
/** 自动轮询(有进行中的任务时) */ /** 自动轮询(有进行中的任务时) */
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible || !canExport) return;
const hasActive = tasks.some((t) => t.status === 0 || t.status === 1); const hasActive = tasks.some((t) => t.status === 0 || t.status === 1);
if (!hasActive) return; if (!hasActive) return;
const timer = setInterval(fetchTasks, 5000); const timer = setInterval(fetchTasks, 5000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [visible, tasks, fetchTasks]); }, [visible, canExport, tasks, fetchTasks]);
/** 点击下载 */ /**
const handleDownload = async (taskId: number) => { * 点击下载
*
* 1. 带 token 调 `GET /downloads/{id}` 拿文件名与下载地址(失败时用后端 message 提示)
* 2. 用 api 客户端**带 token** 取文件 Blob(`/downloads/{id}/file` 要求 Bearer)
* 3. `URL.createObjectURL` + 临时 `<a download>` 触发浏览器保存
*/
const handleDownload = async (taskId: number, fallbackName?: string | null) => {
setDownloadingId(taskId);
try { try {
const res = await api.get<{ download_url: string }>( const res = await api.get<DownloadInfo>(`/downloads/${taskId}`);
`/downloads/${taskId}` const path = toApiPath(res.data.download_url);
); if (!path) {
if (res.data.download_url) { throw new Error('后端未返回下载地址');
window.open(res.data.download_url, '_blank');
} }
} catch { const blob = await api.getBlob(path);
Message.error('获取下载链接失败'); saveBlobAsFile(blob, res.data.file_name || fallbackName || `download-${taskId}.xlsx`);
} catch (e) {
Message.error(e instanceof Error && e.message ? e.message : '下载文件失败');
} finally {
setDownloadingId(null);
} }
}; };
@ -125,14 +195,17 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps
const renderAction = (_: unknown, record: DownloadTask) => { const renderAction = (_: unknown, record: DownloadTask) => {
if (record.status === 2) { if (record.status === 2) {
return ( return (
<Permission code={DOWNLOAD_PERMISSION}>
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<IconDownload />} icon={<IconDownload />}
onClick={() => handleDownload(record.id)} loading={downloadingId === record.id}
onClick={() => handleDownload(record.id, record.file_name)}
> >
下载 下载
</Button> </Button>
</Permission>
); );
} }
if (record.status === 3) { if (record.status === 3) {
@ -157,11 +230,22 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps
footer={null} footer={null}
style={{ width: 800 }} style={{ width: 800 }}
> >
{!canExport ? (
/* 无权限时不发请求、也不渲染空表格,给出可见提示(而非静默 403) */
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--color-text-3)' }}>
当前账号没有下载权限({DOWNLOAD_PERMISSION}),请联系管理员开通。
</div>
) : (
<>
<Space style={{ marginBottom: 12 }}> <Space style={{ marginBottom: 12 }}>
<Permission code={DOWNLOAD_PERMISSION}>
<Button icon={<IconRefresh />} onClick={fetchTasks} loading={loading}> <Button icon={<IconRefresh />} onClick={fetchTasks} loading={loading}>
刷新 刷新
</Button> </Button>
</Permission>
<Permission code={DOWNLOAD_PERMISSION}>
<Button onClick={handleCleanup}>清理过期文件</Button> <Button onClick={handleCleanup}>清理过期文件</Button>
</Permission>
</Space> </Space>
<Table <Table
rowKey="id" rowKey="id"
@ -176,6 +260,8 @@ export default function DownloadCenter({ visible, onClose }: DownloadCenterProps
}} }}
size="small" size="small"
/> />
</>
)}
</Modal> </Modal>
); );
} }

View File

@ -17,6 +17,7 @@ import {
} from '@arco-design/web-react'; } from '@arco-design/web-react';
import { IconDownload, IconSearch } from '@arco-design/web-react/icon'; import { IconDownload, IconSearch } from '@arco-design/web-react/icon';
import { api } from '@/api/request'; import { api } from '@/api/request';
import { Permission } from '@/components/Permission';
import DownloadCenter from './DownloadCenter'; import DownloadCenter from './DownloadCenter';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@ -143,13 +144,20 @@ export default function ChargeRecords() {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
/** 查询按钮 */ /**
* 查询按钮
* 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求,
* 否则直接重查 —— 避免出现「先 setPage(1) 又用旧 page 发请求」的重复请求/停错页
*/
const handleSearch = () => { const handleSearch = () => {
setPage(1); if (page === 1) {
fetchData(); fetchData();
} else {
setPage(1);
}
}; };
/** 导出 */ /** 导出(后端要求 charge_record:export) */
const handleExport = async () => { const handleExport = async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -160,8 +168,9 @@ export default function ChargeRecords() {
await api.get(`/charge-records/export?${params.toString()}`); await api.get(`/charge-records/export?${params.toString()}`);
Message.success('导出任务已创建,请到下载中心查看'); Message.success('导出任务已创建,请到下载中心查看');
setDownloadVisible(true); setDownloadVisible(true);
} catch { } catch (e) {
Message.error('创建导出任务失败'); // 优先展示后端返回的 message(如「缺少权限: charge_record:export」)
Message.error(e instanceof Error && e.message ? e.message : '创建导出任务失败');
} }
}; };
@ -253,9 +262,12 @@ export default function ChargeRecords() {
<Button type="primary" icon={<IconSearch />} onClick={handleSearch}> <Button type="primary" icon={<IconSearch />} onClick={handleSearch}>
查询 查询
</Button> </Button>
{/* 导出需 charge_record:export(后端 /charge-records/export 校验) */}
<Permission code="charge_record:export">
<Button icon={<IconDownload />} onClick={handleExport}> <Button icon={<IconDownload />} onClick={handleExport}>
导出 导出
</Button> </Button>
</Permission>
<Button onClick={() => setDownloadVisible(true)}> <Button onClick={() => setDownloadVisible(true)}>
下载中心 下载中心
</Button> </Button>

View File

@ -109,10 +109,17 @@ export default function DeviceLogs() {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
/** 查询按钮 */ /**
* 查询按钮
* 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求,
* 否则直接重查 —— 避免「先 setPage(1) 又用旧 page 发请求」
*/
const handleSearch = () => { const handleSearch = () => {
setPage(1); if (page === 1) {
fetchData(); fetchData();
} else {
setPage(1);
}
}; };
/** 截断内容摘要 */ /** 截断内容摘要 */

View File

@ -30,7 +30,7 @@ import QRCode from 'qrcode';
import { cabinetApi } from '@/api/devices'; import { cabinetApi } from '@/api/devices';
import type { Cabinet } from '@/api/devices'; import type { Cabinet } from '@/api/devices';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { normalizeFlag } from '@/utils/deviceStatus'; import { formatTempK100, normalizeFlag } from '@/utils/deviceStatus';
interface CabinetGridProps { interface CabinetGridProps {
cabinets: Cabinet[]; cabinets: Cabinet[];
@ -422,7 +422,8 @@ export default function CabinetGrid({ cabinets, loading, onRefresh }: CabinetGri
<span style={{ fontSize: 11, fontWeight: 500 }}>{cab.rssi || '-'}</span> <span style={{ fontSize: 11, fontWeight: 500 }}>{cab.rssi || '-'}</span>
</span> </span>
</Tooltip> </Tooltip>
<span style={{ fontSize: 11, color: '#6b7280' }}>--°C</span> {/* 柜温:后端返回 cabinet.temp(K×100),字段缺失时占位 --°C */}
<span style={{ fontSize: 11, color: '#6b7280' }}>{formatTempK100(cab.temp, '--°C')}</span>
</div> </div>
</div> </div>

View File

@ -22,7 +22,7 @@ import {
import { IconLeft, IconDesktop, IconCopy, IconUnlock, IconPlayArrow, IconPoweroff, IconSwap, IconRefresh } from '@arco-design/web-react/icon'; import { IconLeft, IconDesktop, IconCopy, IconUnlock, IconPlayArrow, IconPoweroff, IconSwap, IconRefresh } from '@arco-design/web-react/icon';
import { cabinetApi, orgApi, projectApi, type CabinetDetail, type Organization, type Project } from '@/api/devices'; import { cabinetApi, orgApi, projectApi, type CabinetDetail, type Organization, type Project } from '@/api/devices';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { normalizeFlag, parseBattStatus, parsePowStatus } from '@/utils/deviceStatus'; import { formatTempK100, normalizeFlag, parseBattStatus, parsePowStatus } from '@/utils/deviceStatus';
/** 状态映射 */ /** 状态映射 */
const STATUS_MAP: Record<number, { label: string; color: string }> = { const STATUS_MAP: Record<number, { label: string; color: string }> = {
@ -494,7 +494,7 @@ export default function CabinetDetail() {
📶 RSSI: <b style={{ color: 'var(--color-text-1)' }}>{detail.rssi != null ? detail.rssi : '-'}</b> 📶 RSSI: <b style={{ color: 'var(--color-text-1)' }}>{detail.rssi != null ? detail.rssi : '-'}</b>
</span> </span>
<span style={{ fontSize: 11, color: 'var(--color-text-2)', background: 'var(--color-fill-2)', borderRadius: 4, padding: '2px 8px', lineHeight: '18px' }}> <span style={{ fontSize: 11, color: 'var(--color-text-2)', background: 'var(--color-fill-2)', borderRadius: 4, padding: '2px 8px', lineHeight: '18px' }}>
🌡 柜温: <b style={{ color: 'var(--color-text-1)' }}>--°C</b> 🌡 柜温: <b style={{ color: 'var(--color-text-1)' }}>{formatTempK100(detail.temp, '--°C')}</b>
</span> </span>
{/* 停电状态(协议 §5.4):直流 / 交流两个独立事件,可同时出现 */} {/* 停电状态(协议 §5.4):直流 / 交流两个独立事件,可同时出现 */}
<PowerFlag label="DC" fail={detail.pow_fail_dc} /> <PowerFlag label="DC" fail={detail.pow_fail_dc} />
@ -565,7 +565,6 @@ export default function CabinetDetail() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
{board.compartments.map((comp) => { {board.compartments.map((comp) => {
const st = STATUS_MAP[comp.status] || { label: '未知', color: 'gray' }; const st = STATUS_MAP[comp.status] || { label: '未知', color: 'gray' };
const c = comp as any;
// 协议 §3.8:电池状态与电源状态是两个相互独立维度,分别解析、分别展示 // 协议 §3.8:电池状态与电源状态是两个相互独立维度,分别解析、分别展示
const batt = parseBattStatus(comp.batt_status); const batt = parseBattStatus(comp.batt_status);
const pow = parsePowStatus(comp.pow_status); const pow = parsePowStatus(comp.pow_status);
@ -590,11 +589,12 @@ export default function CabinetDetail() {
</div> </div>
</div> </div>
<div style={{ color: 'var(--color-text-2)', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'nowrap', overflow: 'hidden' }}> <div style={{ color: 'var(--color-text-2)', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'nowrap', overflow: 'hidden' }}>
<span>{c.voltage ?? '-'}mV</span> <span>{comp.voltage ?? '-'}mV</span>
<span>{c.current ?? '-'}mA</span> <span>{comp.current ?? '-'}mA</span>
{c.temp != null && <span>{((c.temp / 100) - 273.15).toFixed(1)}°C</span>} {comp.temp != null && <span>{formatTempK100(comp.temp)}</span>}
{c.soc != null && <span style={{ color: '#2563eb', fontWeight: 600 }}>{c.soc}%</span>} {/* 电量单位是 ‰(协议附 A:4500 = 45.0%),需 /10 换算,否则会大 10 倍 */}
{c.battery_id && <span style={{ color: 'var(--color-text-3)', flexShrink: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.battery_id}</span>} {comp.soc != null && <span style={{ color: '#2563eb', fontWeight: 600 }}>{(comp.soc / 10).toFixed(1)}%</span>}
{comp.battery_id && <span style={{ color: 'var(--color-text-3)', flexShrink: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{comp.battery_id}</span>}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4, flexShrink: 0 }}> <div style={{ marginLeft: 'auto', display: 'flex', gap: 4, flexShrink: 0 }}>
<Button <Button
size="mini" size="mini"

View File

@ -51,6 +51,9 @@ import CabinetGrid from './CabinetGrid';
import AddCabinetModal from './AddCabinetModal'; import AddCabinetModal from './AddCabinetModal';
import LocationPicker from '@/components/LocationPicker'; import LocationPicker from '@/components/LocationPicker';
/** 分页每页条数可选项(前端规范:page-sizes = [10,20,30,50]) */
const PAGE_SIZE_OPTIONS = [10, 20, 30, 50];
export default function Devices() { export default function Devices() {
const navigate = useNavigate(); const navigate = useNavigate();
const [treeData, setTreeData] = useState<TreeInternalNode[]>([]); const [treeData, setTreeData] = useState<TreeInternalNode[]>([]);
@ -61,7 +64,7 @@ export default function Devices() {
const [selectedType, setSelectedType] = useState<NodeType>('all'); const [selectedType, setSelectedType] = useState<NodeType>('all');
const [selectedId, setSelectedId] = useState(0); const [selectedId, setSelectedId] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize] = useState(16); const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [filterTab, setFilterTab] = useState<string>('all'); const [filterTab, setFilterTab] = useState<string>('all');
@ -150,6 +153,22 @@ export default function Devices() {
} }
}, [selectedType, selectedId, page, filterTab, loadCabinets]); }, [selectedType, selectedId, page, filterTab, loadCabinets]);
/**
* 刷新:**同时**重拉组织树与设备列表
*
* `CabinetGrid` 在改名 / 绑定 / 换绑 IMEI 成功后会回调 `onRefresh`;此前只重拉了
* 组织树(`/organization-tree`),没有重拉 `/cabinets/filter`,于是卡片仍显示旧名称、
* 「绑定」按钮也不消失。这里保持当前树节点 / 分页 / 绑定筛选状态重拉设备列表,行为与
* `useEffect` 自动刷新一致。
*/
const handleRefresh = useCallback(async () => {
await loadTree();
// 选中「设备」节点时 loadCabinets 会跳转详情页,刷新场景下跳过
if (selectedType !== 'cabinet') {
await loadCabinets(selectedType, selectedId, page, filterTab);
}
}, [loadTree, loadCabinets, selectedType, selectedId, page, filterTab]);
const handleTreeSelect = useCallback((keys: string[]) => { const handleTreeSelect = useCallback((keys: string[]) => {
const key = keys[0]; const key = keys[0];
setPage(1); setPage(1);
@ -505,6 +524,25 @@ export default function Devices() {
[renderNodeTitle], [renderNodeTitle],
); );
/** 分页器(上下各一份,共用同一套分页状态;每页条数可选 10/20/30/50) */
const renderPagination = () => (
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', flexShrink: 0 }}>
<Pagination
total={total}
current={page}
pageSize={pageSize}
sizeCanChange
sizeOptions={PAGE_SIZE_OPTIONS}
showTotal
size="small"
onChange={(p, size) => {
setPage(p);
if (size !== pageSize) setPageSize(size);
}}
/>
</div>
);
return ( return (
<div style={{ display: 'flex', gap: 16, height: '100%', width: '100%', overflow: 'hidden' }}> <div style={{ display: 'flex', gap: 16, height: '100%', width: '100%', overflow: 'hidden' }}>
{/* 左列:组织架构树 */} {/* 左列:组织架构树 */}
@ -521,7 +559,7 @@ export default function Devices() {
/> />
</Tooltip> </Tooltip>
<Tooltip content="刷新"> <Tooltip content="刷新">
<Button size="mini" type="text" icon={<IconRefresh />} onClick={loadTree} /> <Button size="mini" type="text" icon={<IconRefresh />} onClick={handleRefresh} />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@ -587,20 +625,15 @@ export default function Devices() {
</Button> </Button>
</div> </div>
</div> </div>
<div style={{ flex: 1, overflow: 'hidden', padding: '0 8px' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: '0 8px' }}>
<CabinetGrid cabinets={cabinets} loading={loading} onRefresh={loadTree} /> {/* 上分页 */}
{total > pageSize && ( {renderPagination()}
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}> {/* 卡片网格(独立滚动区域,避免分页器被内容顶走) */}
<Pagination <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
total={total} <CabinetGrid cabinets={cabinets} loading={loading} onRefresh={handleRefresh} />
current={page}
pageSize={pageSize}
onChange={(p) => setPage(p)}
size="small"
hideOnSinglePage
/>
</div> </div>
)} {/* 下分页 */}
{renderPagination()}
</div> </div>
</div> </div>
@ -672,7 +705,7 @@ export default function Devices() {
visible={cabinetModalVisible} visible={cabinetModalVisible}
orgList={orgList} orgList={orgList}
onCancel={() => setCabinetModalVisible(false)} onCancel={() => setCabinetModalVisible(false)}
onSuccess={async () => { setCabinetModalVisible(false); await loadTree(); }} onSuccess={async () => { setCabinetModalVisible(false); await handleRefresh(); }}
/> />
{/* 项目管理弹窗 */} {/* 项目管理弹窗 */}

View File

@ -31,6 +31,7 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import { api } from '@/api/request'; import { api } from '@/api/request';
import { Permission } from '@/components/Permission';
import DownloadCenter from '../charge-records/DownloadCenter'; import DownloadCenter from '../charge-records/DownloadCenter';
const Row = Grid.Row; const Row = Grid.Row;
@ -145,14 +146,15 @@ export default function Energy() {
fetchTrend(); fetchTrend();
}, [fetchTrend]); }, [fetchTrend]);
/** 导出 */ /** 导出(后端要求 energy:export) */
const handleExport = async () => { const handleExport = async () => {
try { try {
await api.get('/energy-stats/export'); await api.get('/energy-stats/export');
Message.success('导出任务已创建,请到下载中心查看'); Message.success('导出任务已创建,请到下载中心查看');
setDownloadVisible(true); setDownloadVisible(true);
} catch { } catch (e) {
Message.error('创建导出任务失败'); // 优先展示后端返回的 message(如「缺少权限: energy:export」)
Message.error(e instanceof Error && e.message ? e.message : '创建导出任务失败');
} }
}; };
@ -317,9 +319,12 @@ export default function Energy() {
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}> <Col span={12}>
<Card title="柜子能耗排行" extra={ <Card title="柜子能耗排行" extra={
/* 导出报表需 energy:export(后端 /energy-stats/export 校验) */
<Permission code="energy:export">
<Button icon={<IconDownload />} onClick={handleExport}> <Button icon={<IconDownload />} onClick={handleExport}>
导出报表 导出报表
</Button> </Button>
</Permission>
}> }>
<Table <Table
rowKey="cabinet_id" rowKey="cabinet_id"

View File

@ -70,9 +70,17 @@ const buildQuery = useCallback(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
/**
* 查询按钮
* 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求,
* 否则直接重查 —— 避免「先 setPage(1) 又用旧 page 发请求」
*/
const handleSearch = () => { const handleSearch = () => {
setPage(1); if (page === 1) {
fetchData(); fetchData();
} else {
setPage(1);
}
}; };
const updateStatus = async (id: number, nextStatus: number) => { const updateStatus = async (id: number, nextStatus: number) => {

View File

@ -91,10 +91,17 @@ export default function OperationLogsPage() {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
/** 查询 */ /**
* 查询
* 过滤条件变化后需回到第 1 页;`page` 已变更时交给 `useEffect` 统一发请求,
* 否则直接重查 —— 避免「先 setPage(1) 又用旧 page 发请求」
*/
const handleSearch = () => { const handleSearch = () => {
setPage(1); if (page === 1) {
fetchData(); fetchData();
} else {
setPage(1);
}
}; };
/** 重置 */ /** 重置 */
@ -102,7 +109,9 @@ export default function OperationLogsPage() {
setUserId(undefined); setUserId(undefined);
setAction(''); setAction('');
setTimeRange([]); setTimeRange([]);
if (page !== 1) {
setPage(1); setPage(1);
}
}; };
/** 操作类型显示映射 */ /** 操作类型显示映射 */

View File

@ -98,3 +98,18 @@ export function normalizeFlag(value?: boolean | null): boolean | null {
export function isPowerFail(value?: boolean | null): boolean { export function isPowerFail(value?: boolean | null): boolean {
return normalizeFlag(value) === true; return normalizeFlag(value) === true;
} }
/**
* 温度格式化:协议统一按 **K×100** 存储(附 A:`29865` = 298.65K = 25.5°C)
*
* 用于柜温 / 仓温等实时温度字段;`null` / `undefined`(字段缺失或设备未上报)
* 返回占位符(默认 `-`),由调用方决定占位文案(如柜温用 `--°C`)。
*
* @param value 温度原始值(K×100)
* @param placeholder 无数据时的占位文案
*/
export function formatTempK100(value?: number | null, placeholder = '-'): string {
if (value === null || value === undefined) return placeholder;
const celsius = value / 100 - 273.15;
return `${celsius.toFixed(1)}°C`;
}