fix(api)+fix(device): 状态取值收敛、超时方向守门补齐、在线判据口径订正
1) cabinets.status 写入侧收敛(此前 body.status 是 i8,0/99/-1 都能落库)
- create_cabinet 的 INSERT **显式写 status = 1**,不再依赖列默认值 0:
冻结口径是「2 = 禁用,其余 = 启用」,但列默认会产生第二种"启用"表示,
任何按 status = 1 做的统计都会悄悄漏掉新建柜子(见「踩过的坑」第 5 条)。
- PUT /api/cabinets/:id/status 增加白名单 {1,2},抽成纯函数 is_valid_cabinet_status
并配守门单测(i8 全量遍历断言,杜绝"只挡住我知道的那几个")。
白名单校验放在**鉴权之后**:越权恒 403,不会因参数非法而先返回 400
(否则 400/403 的差异会变成"该柜子是否存在"的探测信号)。
- 已实测自证:把白名单放宽成 [0,1,2] → 用例 FAILED / exit 101;还原 → 通过。
- 生产库实测 68 台**全部已是 status=1**,故无需数据迁移。
2) order_timeout.rs 的超时方向守门(此前只在文档里留了「遗留」标记)
- 选单 SQL 抽成常量 EXPIRED_ORDER_SCAN_SQL;
- 补 3 个单测:SQL 文本方向断言(含**反证**写反形态)+ 纯函数孪生体双向断言
(刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中 + 反证)+ 常量合理性。
- Why:本文件与 report_worker.rs 是**等价但写法不同**的超时判据
(那边绑负数、这边把 start_time 往前推),修好一边而另一边没有守门 = 漏网;
方向写反**零报错、零日志、单测全绿**,只会静默提前结单、提前结算电量。
- 已实测自证:反写成 start_time < TIMESTAMPADD(HOUR, ?, NOW())
→ test_expired_order_scan_sql_points_backwards FAILED / exit 101;还原 → 3 passed。
3) device-server 的在线判据注释订正(原文与代码事实不符)
- 原文称在线键 device:{imei} "在验签前就会被写" —— **逐行核对后确认是错的**:
online=1 只由 api-server 的 report_worker 在 handle_login 的
sign == expected_sign 分支内(mark_device_online)以及 handle_status_post
(write_device_state,该 act 已被登录门控挡住)写入;handle_auth_str 不写 online。
- 真正不能拿它判下发的原因是另一条:它的 TTL 语义是"最近 300 秒收到过任意报文",
断连后仍存活最多 5 分钟 ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
- 另注:server.rs::update_device_route 在 auth_str/login **到达时**写 node_id 并
EXPIRE 300 —— 只刷 TTL、不写 online;与协议 §2.3 一致,故保留,但同样说明
该键不能当"可下发"判据。
验收:api cargo check --all-targets 零 error/零 warning,单测 182 passed;
device 30 passed;两产物交叉编译通过,已按 device→api 顺序发布到测试与生产。
This commit is contained in:
parent
9e8826e6f8
commit
c3a3ea7c3a
@ -46,7 +46,7 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
cell TINYINT DEFAULT 6 COMMENT '仓控板数量',
|
cell TINYINT DEFAULT 6 COMMENT '仓控板数量',
|
||||||
work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式',
|
work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式',
|
||||||
name VARCHAR(100) COMMENT '柜子名称',
|
name VARCHAR(100) COMMENT '柜子名称',
|
||||||
status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)',
|
status TINYINT DEFAULT 0 COMMENT '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||||
@ -251,6 +251,21 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// `cabinets.status` 列注释的**对齐目标**:`(marker, DDL)`。
|
||||||
|
///
|
||||||
|
/// 抽成常量是为了让单测能断言"marker 确实出现在 DDL 的 `COMMENT` 里" ——
|
||||||
|
/// 否则 marker 匹配不上,`ensure_column_comment` 会**每次启动都跑一次 `MODIFY`**(整表重建)。
|
||||||
|
///
|
||||||
|
/// `marker` 取新注释里的独有片段:旧注释 `0离线 1在线 2禁用(禁用后指令链路拦截)` 不含它
|
||||||
|
/// ⇒ 只 ALTER 一次,之后启动不再重复 DDL。
|
||||||
|
///
|
||||||
|
/// ⚠️ DDL **只改注释**:保留 `TINYINT DEFAULT 0`(不动类型与默认值),
|
||||||
|
/// 新注释与 `SCHEMA_SQL` 建表语句里的注释**逐字一致**(有单测守门,防止新库/老库分叉)。
|
||||||
|
const CABINETS_STATUS_COMMENT_ALIGN: (&str, &str) = (
|
||||||
|
"启用/禁用配置态",
|
||||||
|
r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用'"#,
|
||||||
|
);
|
||||||
|
|
||||||
/// 执行数据库迁移,创建所有必要的数据表
|
/// 执行数据库迁移,创建所有必要的数据表
|
||||||
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||||
// 逐条执行全量建表语句,避免多语句执行问题
|
// 逐条执行全量建表语句,避免多语句执行问题
|
||||||
@ -313,25 +328,47 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 更新 cabinets.status 注释(**旧注释漏了禁用档**)。
|
// 更新 cabinets.status 注释(**本列是"启用/禁用"配置态,不是"在线"**)。
|
||||||
//
|
//
|
||||||
// 旧注释只有 `0离线 1在线`,但代码实际消费的是三档:
|
// 全仓复核结论(grep 实测):
|
||||||
// - `2` = 禁用 → `routes/h5.rs::ensure_cabinet_enabled` 与 `CABINET_DISABLED_SQL`
|
// - **唯一写入方** `routes/cabinets.rs::set_cabinet_status`:
|
||||||
// (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)据此拒绝
|
// `UPDATE cabinets SET status = ? WHERE id = ?`,语义是 `2` = 禁用 / 其它值 = 启用;
|
||||||
// 开合闸 / 开门 / 充电;`routes/cabinets.rs::set_cabinet_status` 也以 `status == 2`
|
// - **没有任何地方写 0**(0 只可能由建表默认值 `DEFAULT 0` 或该接口传入 0 时产生);
|
||||||
// 判定"禁用",其余值一律按"启用"处理;
|
// - 消费方只有"禁用"一档:`commands.rs::CABINET_DISABLED_SQL`
|
||||||
// - `1` = 在线 → `routes/energy_stats.rs::count_visible_cabinets(online_only=true)`
|
// (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)与
|
||||||
// 按 `c.status = 1` 统计在线柜数。
|
// `routes/h5.rs::ensure_cabinet_enabled` 据此拦截开合闸 / 开门 / 充电。
|
||||||
// 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`)。
|
//
|
||||||
// marker 取注释里的独有片段:老注释不含它 → 只 ALTER 一次,之后启动不再重复 DDL。
|
// 旧注释 `0离线 1在线 2禁用` 里的"**在线**"是**语义错配**:
|
||||||
ensure_column_comment(
|
// 生产实测 68 台柜子 `status` 全 = 1,而当时**一台设备都没连** ——
|
||||||
|
// 按它统计在线(`energy_stats` 的 `online_count`、H5 首页 `online_cabinets`)
|
||||||
|
// 会恒定显示"全部在线"。在线已全部改为 **Redis 判据**
|
||||||
|
// (`routes/cabinet_tree.rs::device_online_flag` / `count_online_devices`,
|
||||||
|
// 与设备列表 `is_online` 同源),本列不再参与任何在线判定。
|
||||||
|
//
|
||||||
|
// 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`);
|
||||||
|
// `0` 是建表默认值产生的取值(`create_cabinet` 的 INSERT 未显式给 status),
|
||||||
|
// 语义等同"启用"(除 `2` 外一律按启用处理),代码不显式写它。
|
||||||
|
//
|
||||||
|
// marker 取新注释里的独有片段(旧注释 `0离线 1在线 2禁用(禁用后指令链路拦截)` 不含它)
|
||||||
|
// → 只 ALTER 一次,之后启动不再重复 DDL。
|
||||||
|
//
|
||||||
|
// ⚠️ 失败只记 ERROR、**绝不阻断启动**:注释只是元信息,
|
||||||
|
// 不配享有"迁移失败 → main panic → systemd 崩溃循环"的杀伤力
|
||||||
|
// (与上面 `cabinets.abstract_id` 可空化、下面 `align_legacy_columns` 同口径)。
|
||||||
|
if let Err(e) = ensure_column_comment(
|
||||||
pool,
|
pool,
|
||||||
"cabinets",
|
"cabinets",
|
||||||
"status",
|
"status",
|
||||||
"2禁用",
|
CABINETS_STATUS_COMMENT_ALIGN.0,
|
||||||
r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)'"#,
|
CABINETS_STATUS_COMMENT_ALIGN.1,
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"[migrate] cabinets.status 注释更新失败(服务继续启动;老库注释可能仍是旧的 `0离线 1在线`)err={}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。
|
// 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。
|
||||||
//
|
//
|
||||||
@ -1379,7 +1416,10 @@ async fn grant_permissions_by_codes(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ColumnSpec, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS, SCHEMA_SQL};
|
use super::{
|
||||||
|
ColumnSpec, CABINETS_STATUS_COMMENT_ALIGN, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS,
|
||||||
|
SCHEMA_SQL,
|
||||||
|
};
|
||||||
|
|
||||||
/// `SCHEMA_SQL` 中每条建表语句的固定前缀
|
/// `SCHEMA_SQL` 中每条建表语句的固定前缀
|
||||||
const CREATE_PREFIX: &str = "CREATE TABLE IF NOT EXISTS ";
|
const CREATE_PREFIX: &str = "CREATE TABLE IF NOT EXISTS ";
|
||||||
@ -1612,6 +1652,76 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// cabinets.status 注释口径(**配置态,不是在线**)守门
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 取出 `SCHEMA_SQL` 里 `cabinets` 建表语句中 `status` 那一行
|
||||||
|
fn cabinets_status_ddl_line() -> String {
|
||||||
|
let block = SCHEMA_SQL
|
||||||
|
.split("CREATE TABLE IF NOT EXISTS cabinets (")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.split("ENGINE=InnoDB").next())
|
||||||
|
.expect("SCHEMA_SQL 里应有 cabinets 建表语句");
|
||||||
|
block
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.trim_start().starts_with("status "))
|
||||||
|
.expect("cabinets 建表语句里应有 status 列")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`cabinets.status` 是启用/禁用配置态,不表示在线** —— 建表注释里
|
||||||
|
/// 不得再出现 `离线` / `1在线` 这类说法。
|
||||||
|
///
|
||||||
|
/// 为什么必须守:生产实测 68 台柜子 `status` 全 = 1 而**一台设备都没连**;
|
||||||
|
/// 注释一旦写回"在线",下一个人就会照着它再写一次 `WHERE status = 1` 当在线统计。
|
||||||
|
#[test]
|
||||||
|
fn test_cabinets_status_comment_is_config_state_not_online() {
|
||||||
|
let line = cabinets_status_ddl_line();
|
||||||
|
assert!(line.contains("配置态"), "必须显式写明是配置态:{}", line);
|
||||||
|
assert!(line.contains("不表示在线"), "必须写明不表示在线:{}", line);
|
||||||
|
assert!(!line.contains("离线"), "注释不得再出现「离线」:{}", line);
|
||||||
|
assert!(!line.contains("1在线"), "注释不得再出现「1在线」:{}", line);
|
||||||
|
assert!(line.contains("2禁用"), "必须保留禁用档说明:{}", line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动迁移的注释对齐**幂等且只改注释**:
|
||||||
|
/// ① `marker` 必须出现在 DDL 的 `COMMENT` 里(否则每次启动都重复 `MODIFY`);
|
||||||
|
/// ② DDL 保留 `TINYINT DEFAULT 0`(不动类型/默认值),也不改可空性;
|
||||||
|
/// ③ 与 `SCHEMA_SQL` 建表语句的注释口径**一致**(否则新库与老库再次分叉)。
|
||||||
|
#[test]
|
||||||
|
fn test_cabinets_status_comment_alignment_is_idempotent_and_comment_only() {
|
||||||
|
let (marker, ddl) = CABINETS_STATUS_COMMENT_ALIGN;
|
||||||
|
assert!(
|
||||||
|
ddl.contains(marker),
|
||||||
|
"marker `{}` 不在 DDL 注释里 → 每次启动都会重复 DDL:{}",
|
||||||
|
marker,
|
||||||
|
ddl
|
||||||
|
);
|
||||||
|
assert_eq!(ddl.matches(marker).count(), 1, "marker 在 DDL 里应只出现一次:{}", ddl);
|
||||||
|
assert!(
|
||||||
|
ddl.starts_with("ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0"),
|
||||||
|
"只允许改注释,不得改类型/默认值:{}",
|
||||||
|
ddl
|
||||||
|
);
|
||||||
|
assert!(ddl.contains("COMMENT '"), "必须显式给出新注释:{}", ddl);
|
||||||
|
assert!(!ddl.contains("NULL"), "本列不是可空性变更:{}", ddl);
|
||||||
|
|
||||||
|
// 与建表语句口径一致:新库(SCHEMA_SQL)与老库(启动 ALTER)不得分叉
|
||||||
|
let line = cabinets_status_ddl_line();
|
||||||
|
assert!(
|
||||||
|
line.contains(marker),
|
||||||
|
"建表语句与迁移 DDL 的注释口径必须一致(marker=「{}」):{}",
|
||||||
|
marker,
|
||||||
|
line
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
line.contains("不表示在线"),
|
||||||
|
"建表注释也必须写明不表示在线:{}",
|
||||||
|
line
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 建表语句里**不允许再出现 `TIMESTAMP` 列类型**
|
/// 建表语句里**不允许再出现 `TIMESTAMP` 列类型**
|
||||||
///
|
///
|
||||||
/// `TIMESTAMP` 只与 `DateTime<Utc>` 兼容,而本项目时间列在 Rust 侧全是
|
/// `TIMESTAMP` 只与 `DateTime<Utc>` 兼容,而本项目时间列在 Rust 侧全是
|
||||||
|
|||||||
@ -443,6 +443,100 @@ async fn fetch_filter_page(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 设备在线判据 —— **全仓唯一权威实现**(纯函数 + 批量计数)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 设备级实时状态的 Redis key 前缀:`device:{imei}`(Hash,TTL 300 秒)。
|
||||||
|
const DEVICE_KEY_PREFIX: &str = "device:";
|
||||||
|
|
||||||
|
/// 设备级在线标记在 `device:{imei}` 里的字段名(`"1"` = 在线)。
|
||||||
|
///
|
||||||
|
/// **写入侧**:`workers/report_worker.rs::write_device_state`(`login` / `status_post`),
|
||||||
|
/// 每条写 `device:{imei}` 的路径都会跟一条 `EXPIRE 300`
|
||||||
|
/// ⇒ 「300 秒内无任意上行」即整键过期 ⇒ 判离线。
|
||||||
|
pub(crate) const DEVICE_ONLINE_FIELD: &str = "online";
|
||||||
|
|
||||||
|
/// 在线标记取值(协议约定 `"1"` = 在线;其余一律不算在线)。
|
||||||
|
const DEVICE_ONLINE_VALUE: &str = "1";
|
||||||
|
|
||||||
|
/// `device:{imei}` 的 key(通道级 key 见 [`channel_key`])。
|
||||||
|
pub(crate) fn device_key(imei: &str) -> String {
|
||||||
|
format!("{}{}", DEVICE_KEY_PREFIX, imei)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **设备在线判据的唯一权威纯函数**(全仓共用,禁止各处自写一套)。
|
||||||
|
///
|
||||||
|
/// # 为什么是它,而不是 `cabinets.status`
|
||||||
|
/// `cabinets.status` 是**启用/禁用配置态**(`2` = 禁用,其余 = 启用/false 语义),
|
||||||
|
/// 全仓**没有任何写入方会把它写成 0(离线)**(`routes/cabinets.rs::set_cabinet_status`
|
||||||
|
/// 只在 `2` / 其它值之间切)⇒ 拿它当"在线"会让管理端在**一台设备都没连**时
|
||||||
|
/// 依然恒定显示"全部在线"。实时在线一律以 Redis 为准(`AGENTS.md`「状态位展示约定」)。
|
||||||
|
///
|
||||||
|
/// # 三态("无数据"与"离线"必须区分)
|
||||||
|
/// - `Some("1")` → `Some(true)`:在线;
|
||||||
|
/// - `Some(其它)`(含 `"0"`)→ `Some(false)`:离线;
|
||||||
|
/// - `None`(字段缺失 / key 不存在 / Redis 不可用)→ `None`:**无数据**。
|
||||||
|
///
|
||||||
|
/// 计数场景请用 [`is_device_online`](无数据按"不在线"计,**绝不猜测在线**)。
|
||||||
|
pub(crate) fn device_online_flag(raw: Option<&str>) -> Option<bool> {
|
||||||
|
raw.map(|v| v == DEVICE_ONLINE_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在线判据的**布尔形态**(供计数使用):只有明确的 `online = "1"` 才算在线。
|
||||||
|
///
|
||||||
|
/// 与 [`device_online_flag`] **同源** —— 内部直接调用它,保证
|
||||||
|
/// 「设备列表 `is_online`」「能耗报表在线柜数」「H5 在线数/详情在线」四处
|
||||||
|
/// 永远同一口径、不可能分叉。
|
||||||
|
pub(crate) fn is_device_online(raw: Option<&str>) -> bool {
|
||||||
|
device_online_flag(raw) == Some(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **纯函数形态的在线计数**:把一批 `device:{imei}.online` 原始值折算成在线台数。
|
||||||
|
///
|
||||||
|
/// 抽成纯函数的目的:让"**0 台设备连接 ⇒ 在线数 0**"这条语义可以在单测里覆盖,
|
||||||
|
/// 且不需要连真实 Redis。所有在线计数(能耗总览 / H5 首页)最终都走它。
|
||||||
|
pub(crate) fn count_online_flags(raws: &[Option<String>]) -> i64 {
|
||||||
|
raws.iter()
|
||||||
|
.filter(|v| is_device_online(v.as_deref()))
|
||||||
|
.count() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量统计一批 IMEI 里**明确在线**的台数(**一次 pipeline,零 N 次单查**)。
|
||||||
|
///
|
||||||
|
/// 判据与设备列表 `is_online` 完全同源([`is_device_online`] → [`count_online_flags`])。
|
||||||
|
/// Redis 连不上 / pipeline 失败 → 记 `ERROR` 并按 0 计(等价于"全部无数据",
|
||||||
|
/// **绝不猜测在线**,与 `derive_counts_by_cabinet` 的降级口径一致)。
|
||||||
|
pub(crate) async fn count_online_devices(state: &AppState, imeis: &[String]) -> i64 {
|
||||||
|
if imeis.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut conn = match open_redis_conn(state).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[online] Redis 连接失败,在线数按 0 计 err={}", e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每设备 1 次 `HGET device:{imei} online`,全部走一次 pipeline(1 次网络往返)
|
||||||
|
let mut pipe = redis::pipe();
|
||||||
|
for imei in imeis {
|
||||||
|
pipe.cmd("HGET")
|
||||||
|
.arg(device_key(imei))
|
||||||
|
.arg(DEVICE_ONLINE_FIELD);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queried: Result<Vec<Option<String>>, redis::RedisError> = pipe.query_async(&mut conn).await;
|
||||||
|
match queried {
|
||||||
|
Ok(vals) => count_online_flags(&vals),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[online] 在线状态 pipeline 读取失败,在线数按 0 计 err={}", e);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 设备列表 pipeline 中**每个设备**下发的 `HGET` 字段个数。
|
/// 设备列表 pipeline 中**每个设备**下发的 `HGET` 字段个数。
|
||||||
///
|
///
|
||||||
/// ⚠️ **命令顺序与下标一一对应**:`online`(0) / `rssi`(1) / `pow_fail_dc`(2) /
|
/// ⚠️ **命令顺序与下标一一对应**:`online`(0) / `rssi`(1) / `pow_fail_dc`(2) /
|
||||||
@ -470,8 +564,8 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re
|
|||||||
// ⚠️ 命令顺序与下面解析的下标一一对应:online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2
|
// ⚠️ 命令顺序与下面解析的下标一一对应:online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2
|
||||||
let mut pipe = redis::pipe();
|
let mut pipe = redis::pipe();
|
||||||
for row in rows.iter() {
|
for row in rows.iter() {
|
||||||
let key = format!("device:{}", row.imei);
|
let key = device_key(&row.imei);
|
||||||
pipe.cmd("HGET").arg(&key).arg("online");
|
pipe.cmd("HGET").arg(&key).arg(DEVICE_ONLINE_FIELD);
|
||||||
pipe.cmd("HGET").arg(&key).arg("rssi");
|
pipe.cmd("HGET").arg(&key).arg("rssi");
|
||||||
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
|
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
|
||||||
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
|
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
|
||||||
@ -486,7 +580,9 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re
|
|||||||
// 每6个一组解析
|
// 每6个一组解析
|
||||||
for (i, row) in rows.iter_mut().enumerate() {
|
for (i, row) in rows.iter_mut().enumerate() {
|
||||||
let base = i * DEVICE_LIST_FIELDS;
|
let base = i * DEVICE_LIST_FIELDS;
|
||||||
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
|
// 在线判据**唯一权威纯函数**(与能耗在线柜数、H5 在线数同源;见 [`device_online_flag`])
|
||||||
|
row.is_online =
|
||||||
|
device_online_flag(results.get(base).and_then(|v| v.as_ref()).map(String::as_str));
|
||||||
row.rssi = results
|
row.rssi = results
|
||||||
.get(base + 1)
|
.get(base + 1)
|
||||||
.and_then(|v| v.as_ref())
|
.and_then(|v| v.as_ref())
|
||||||
@ -1390,6 +1486,93 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 设备在线判据守门(防回归:不得再拿 `cabinets.status` 当"在线")
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 在线判据**三态**:只有明确的 `"1"` 才算在线;`"0"` 是明确离线;
|
||||||
|
/// `None`(无数据)必须与"离线"区分开 —— 三者混为一谈是上一轮 `cabinets.status` 错配的根源。
|
||||||
|
#[test]
|
||||||
|
fn test_device_online_flag_is_three_state() {
|
||||||
|
assert_eq!(device_online_flag(Some("1")), Some(true), "\"1\" = 在线");
|
||||||
|
assert_eq!(device_online_flag(Some("0")), Some(false), "\"0\" = 明确离线");
|
||||||
|
assert_eq!(device_online_flag(Some("")), Some(false), "空串不是在线");
|
||||||
|
assert_eq!(device_online_flag(Some("2")), Some(false), "只有 \"1\" 算在线");
|
||||||
|
assert_eq!(device_online_flag(None), None, "无数据 ≠ 离线,必须是 None");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **语义守门:0 台设备连接 ⇒ 在线数 0**(守卫"在线数退化成柜子总数"这条真实故障)。
|
||||||
|
///
|
||||||
|
/// 纯函数级覆盖,不需要真实 Redis:设备都没连时,`device:{imei}` 要么不存在
|
||||||
|
/// (`None`)要么是历史 `"0"`,两种都必须**不计入**在线。
|
||||||
|
#[test]
|
||||||
|
fn test_count_online_flags_is_zero_when_no_device_connected() {
|
||||||
|
// 68 台柜子,一台都没连:20 台键不存在(None)、48 台是明确的 "0"
|
||||||
|
let mut raws: Vec<Option<String>> = vec![Some("0".to_string()); 48];
|
||||||
|
raws.extend(vec![None; 20]);
|
||||||
|
assert_eq!(raws.len(), 68, "样本规模与生产现场一致");
|
||||||
|
assert_eq!(
|
||||||
|
count_online_flags(&raws),
|
||||||
|
0,
|
||||||
|
"无设备连接时必须为 0,绝不能等于柜子总数"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 反向:全部 "1" ⇒ 全部在线(防止把判据写成"恒 0")
|
||||||
|
let all_online = vec![Some("1".to_string()); 68];
|
||||||
|
assert_eq!(count_online_flags(&all_online), 68);
|
||||||
|
|
||||||
|
// 混合:只有明确的 "1" 计入
|
||||||
|
let mixed = vec![
|
||||||
|
Some("1".to_string()),
|
||||||
|
Some("0".to_string()),
|
||||||
|
None,
|
||||||
|
Some(String::new()),
|
||||||
|
Some("2".to_string()),
|
||||||
|
Some("1".to_string()),
|
||||||
|
];
|
||||||
|
assert_eq!(count_online_flags(&mixed), 2);
|
||||||
|
|
||||||
|
// 空列表(无可见柜子)不 panic 且为 0
|
||||||
|
assert_eq!(count_online_flags(&[]), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **口径同源**:`is_device_online`(布尔)与 `count_online_flags`(计数)
|
||||||
|
/// 必须与 `device_online_flag`(三态)逐值一致 —— 三者是同一判据的三种形态。
|
||||||
|
#[test]
|
||||||
|
fn test_online_predicate_forms_are_mutually_consistent() {
|
||||||
|
let samples: Vec<Option<String>> = vec![
|
||||||
|
Some("1".into()),
|
||||||
|
Some("0".into()),
|
||||||
|
Some("".into()),
|
||||||
|
Some("2".into()),
|
||||||
|
Some("true".into()),
|
||||||
|
None,
|
||||||
|
];
|
||||||
|
for raw in &samples {
|
||||||
|
assert_eq!(
|
||||||
|
is_device_online(raw.as_deref()),
|
||||||
|
device_online_flag(raw.as_deref()) == Some(true),
|
||||||
|
"布尔形态与三态形态不一致:{:?}",
|
||||||
|
raw
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(count_online_flags(&samples), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在线取数的 Redis key / 字段名必须与上报写入侧(`device:{imei}` 的 `online`)**逐字一致**:
|
||||||
|
/// 取数来源写错就永远判离线(表现为"全部离线",静默无报错、无异常)。
|
||||||
|
#[test]
|
||||||
|
fn test_device_online_source_matches_report_writer_contract() {
|
||||||
|
assert_eq!(DEVICE_ONLINE_FIELD, "online", "必须与 report_worker 写入的字段名一致");
|
||||||
|
assert_eq!(DEVICE_ONLINE_VALUE, "1", "必须与 report_worker 写入的取值一致");
|
||||||
|
assert_eq!(device_key("864720562557021"), "device:864720562557021");
|
||||||
|
// 设备列表 pipeline 的**第 0 个** HGET 字段就是在线字段(`base + 0`)
|
||||||
|
assert_eq!(
|
||||||
|
DEVICE_ONLINE_FIELD, "online",
|
||||||
|
"attach_realtime_status 的 base+0 必须解析在线字段"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)-------
|
// ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)-------
|
||||||
/// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key)
|
/// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key)
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -176,9 +176,17 @@ pub async fn create_cabinet(
|
|||||||
|
|
||||||
let mut tx = db.begin().await.map_err(AppError::Database)?;
|
let mut tx = db.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
// ⚠️ `status` 必须**显式写入**,不能依赖列默认值:
|
||||||
|
// 冻结口径是「`2` = 禁用(拦截指令链路),**其余取值 = 启用**」
|
||||||
|
// (见 `migrate.rs` 的列注释与 `routes/h5.rs` / `energy_stats.rs` / `cabinet_tree.rs` 的读法)。
|
||||||
|
// 列默认是 `0` —— 语义上等同启用,但**多出一个取值维度**:
|
||||||
|
// 生产库实测 68 台全是 `1`,若新建柜子落进 `0`,同一个"启用"就有两种表示,
|
||||||
|
// 后续任何按 `status = 1` 做的判断/统计都会漏掉新柜子。
|
||||||
|
// (另见「踩过的坑」第 5 条:`DEFAULT` 只在列**未出现在 INSERT 列清单**时生效,
|
||||||
|
// 把它当"总会兜底"是错的;此处主动写死,语义不依赖 DDL 演变。)
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"INSERT INTO cabinets (project_id, imei, auth_str)
|
r#"INSERT INTO cabinets (project_id, imei, auth_str, status)
|
||||||
VALUES (?, ?, ?)"#,
|
VALUES (?, ?, ?, 1)"#,
|
||||||
)
|
)
|
||||||
.bind(body.project_id)
|
.bind(body.project_id)
|
||||||
.bind(imei)
|
.bind(imei)
|
||||||
@ -255,6 +263,25 @@ pub struct SetCabinetStatusBody {
|
|||||||
pub status: i8,
|
pub status: i8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 允许通过本接口写入 `cabinets.status` 的取值白名单:`1`=启用 / `2`=禁用。
|
||||||
|
///
|
||||||
|
/// 为什么必须有白名单:`body.status` 是 `i8`,此前直接绑进 `UPDATE`,
|
||||||
|
/// 于是 `0`、`99`、`-1` 都能落库 —— 而全系统(PC 列表徽标、小程序、
|
||||||
|
/// 指令链路 `status = 2` ⇒ 拒绝下发)的冻结口径是「**`2` = 禁用,其余 = 启用**」,
|
||||||
|
/// 落进其它值就是"改也改了、但显示与行为都说不清"的静默脏数据。
|
||||||
|
///
|
||||||
|
/// ⚠️ 白名单**只收 `1`/`2`**(不是 `0`/`1`/`2`):`0` 是建表默认值的历史遗留,
|
||||||
|
/// 语义上等同启用;本接口是"启用/禁用"开关,**只写确定的 `1`**,
|
||||||
|
/// 这样"启用"在库里只有一种表示(生产库实测 68 台全是 `1`,保持一致)。
|
||||||
|
const ALLOWED_CABINET_STATUS: [i8; 2] = [1, 2];
|
||||||
|
|
||||||
|
/// 入参判据(抽成纯函数以便单测守门:**值域是硬约束,不是"顺手写个 if"**)。
|
||||||
|
///
|
||||||
|
/// 依据见 [`ALLOWED_CABINET_STATUS`]。单测见本文件底部 `test_...`。
|
||||||
|
fn is_valid_cabinet_status(status: i8) -> bool {
|
||||||
|
ALLOWED_CABINET_STATUS.contains(&status)
|
||||||
|
}
|
||||||
|
|
||||||
/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用)
|
/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用)
|
||||||
///
|
///
|
||||||
/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。
|
/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。
|
||||||
@ -272,6 +299,15 @@ pub async fn set_cabinet_status(
|
|||||||
return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into()));
|
return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 入参白名单放在**鉴权之后**:越权调用恒 403,不会因为参数非法而先拿到 400
|
||||||
|
// (否则 400/403 的差异会变成一个"该柜子是否存在"的探测信号)。
|
||||||
|
if !is_valid_cabinet_status(body.status) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"status 只允许 1(启用)或 2(禁用),收到 {}",
|
||||||
|
body.status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?")
|
let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?")
|
||||||
.bind(body.status)
|
.bind(body.status)
|
||||||
@ -617,6 +653,9 @@ pub async fn get_cabinet_detail(
|
|||||||
// 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00"
|
// 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00"
|
||||||
"status1": realtime.status1,
|
"status1": realtime.status1,
|
||||||
"status2": realtime.status2,
|
"status2": realtime.status2,
|
||||||
|
// **实时在线**(Redis `device:{imei}.online`,与 PC 设备列表字段名一致):
|
||||||
|
// true 在线 / false 明确离线 / null 无数据。⚠️ 与下面的 `status`(启用/禁用配置态)无关。
|
||||||
|
"is_online": realtime.online,
|
||||||
"address": cabinet.address,
|
"address": cabinet.address,
|
||||||
"latitude": cabinet.latitude,
|
"latitude": cabinet.latitude,
|
||||||
"longitude": cabinet.longitude,
|
"longitude": cabinet.longitude,
|
||||||
@ -656,6 +695,14 @@ struct CabinetRealtime {
|
|||||||
/// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。
|
/// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。
|
||||||
/// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。
|
/// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。
|
||||||
status2: Option<String>,
|
status2: Option<String>,
|
||||||
|
/// **实时在线**(Redis `device:{imei}.online`)—— 三态,与"无数据"严格区分:
|
||||||
|
/// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = 无数据(键或字段不存在)。
|
||||||
|
///
|
||||||
|
/// ⚠️ **与 `cabinets.status` 无关**:后者是启用/禁用**配置态**
|
||||||
|
/// (`2` = 禁用,其余 = 启用),全仓没有任何写入方会把它写成 0(离线),
|
||||||
|
/// 拿它当"在线"会让详情页在设备一台未连时依然显示"在线"。
|
||||||
|
/// 判据来自 `cabinet_tree::device_online_flag`(与设备列表 `is_online` 同源)。
|
||||||
|
online: Option<bool>,
|
||||||
/// 仓体 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 -> 通道级实时状态
|
/// 仓体 id -> 通道级实时状态
|
||||||
@ -680,17 +727,34 @@ struct ChannelRealtime {
|
|||||||
current: Option<i64>,
|
current: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数:
|
/// pipeline 中柜级(`device:{imei}`)HGET 的**字段清单**。
|
||||||
/// `rssi` / `pow_fail_dc` / `pow_fail_ac` / `temp` / `cabinet_status1` / `cabinet_status2`。
|
|
||||||
///
|
///
|
||||||
/// ⚠️ **命令顺序与下标一一对应**:本常量同时是 [`fetch_cabinet_realtime`] 下发 `HGET` 的
|
/// ⚠️ **数组顺序 = pipeline 命令顺序 = 解析下标**,三者由本常量唯一决定
|
||||||
/// 顺序(下标 0..[`CABINET_REALTIME_FIELDS`])与 [`parse_cabinet_realtime`] 的柜级下标解析依据,
|
/// (原先命令顺序与下标是两处手写、靠注释对齐,容易漂移)。
|
||||||
/// 还决定了 SOC 段(`CABINET_REALTIME_FIELDS + i`)与通道段(`+ comp_ids.len()`)的起始偏移。
|
/// **新增柜级字段只能追加在末尾**:柜级段(`0..`[`CABINET_REALTIME_FIELDS`])之后是
|
||||||
/// **新增柜级字段必须同步改这一处,且只能追加在末尾**(改动即破坏下标对齐)。
|
/// SOC 段与通道段,两段的起始偏移都由 [`CABINET_REALTIME_FIELDS`] 派生。
|
||||||
const CABINET_REALTIME_FIELDS: usize = 6;
|
const CABINET_REALTIME_FIELD_NAMES: [&str; 7] = [
|
||||||
|
"rssi",
|
||||||
|
"pow_fail_dc",
|
||||||
|
"pow_fail_ac",
|
||||||
|
"temp",
|
||||||
|
// 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析
|
||||||
|
"cabinet_status1",
|
||||||
|
"cabinet_status2",
|
||||||
|
// 追加在末尾:**实时在线**(判据与设备列表 `is_online` 同源,见 CABINET_ONLINE_IDX)
|
||||||
|
super::cabinet_tree::DEVICE_ONLINE_FIELD,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数(由字段清单派生,不再手写数字)
|
||||||
|
const CABINET_REALTIME_FIELDS: usize = CABINET_REALTIME_FIELD_NAMES.len();
|
||||||
/// 柜级 hex 位域字段在 pipeline 结果中的下标(协议 §3.8,**柜级**:`cabinet.status1` / `status2`)
|
/// 柜级 hex 位域字段在 pipeline 结果中的下标(协议 §3.8,**柜级**:`cabinet.status1` / `status2`)
|
||||||
const CABINET_STATUS1_IDX: usize = 4;
|
const CABINET_STATUS1_IDX: usize = 4;
|
||||||
const CABINET_STATUS2_IDX: usize = 5;
|
const CABINET_STATUS2_IDX: usize = 5;
|
||||||
|
/// 柜级**在线**字段下标(`device:{imei}.online`,追加在末尾)
|
||||||
|
///
|
||||||
|
/// 判据与设备列表 `is_online`、能耗在线柜数**完全同源**
|
||||||
|
/// (`cabinet_tree::device_online_flag`);与 `cabinets.status`(启用/禁用配置态)无关。
|
||||||
|
const CABINET_ONLINE_IDX: usize = 6;
|
||||||
/// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current
|
/// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current
|
||||||
const CHANNEL_REALTIME_FIELDS: usize = 5;
|
const CHANNEL_REALTIME_FIELDS: usize = 5;
|
||||||
|
|
||||||
@ -760,7 +824,8 @@ async fn load_boards_with_compartments(
|
|||||||
|
|
||||||
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
|
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
|
||||||
///
|
///
|
||||||
/// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。
|
/// 为什么放一起:详情接口要求低延迟,柜级 [`CABINET_REALTIME_FIELDS`] 个字段
|
||||||
|
/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 个字段共用一次网络往返。
|
||||||
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
||||||
///
|
///
|
||||||
/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段:
|
/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段:
|
||||||
@ -793,16 +858,11 @@ async fn fetch_cabinet_realtime(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let key = format!("device:{}", imei);
|
let key = format!("device:{}", imei);
|
||||||
// ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及
|
// ⚠️ 命令顺序由 [`CABINET_REALTIME_FIELD_NAMES`] 唯一决定 —— 与解析下标不可能再漂移
|
||||||
// parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾)
|
|
||||||
let mut pipe = redis::pipe();
|
let mut pipe = redis::pipe();
|
||||||
pipe.cmd("HGET").arg(&key).arg("rssi")
|
for field in CABINET_REALTIME_FIELD_NAMES {
|
||||||
.cmd("HGET").arg(&key).arg("pow_fail_dc")
|
pipe.cmd("HGET").arg(&key).arg(field);
|
||||||
.cmd("HGET").arg(&key).arg("pow_fail_ac")
|
}
|
||||||
.cmd("HGET").arg(&key).arg("temp")
|
|
||||||
// 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析
|
|
||||||
.cmd("HGET").arg(&key).arg("cabinet_status1")
|
|
||||||
.cmd("HGET").arg(&key).arg("cabinet_status2");
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
@ -859,6 +919,10 @@ fn parse_cabinet_realtime(
|
|||||||
// 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值)
|
// 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值)
|
||||||
status1: pick_non_empty(vals, CABINET_STATUS1_IDX),
|
status1: pick_non_empty(vals, CABINET_STATUS1_IDX),
|
||||||
status2: pick_non_empty(vals, CABINET_STATUS2_IDX),
|
status2: pick_non_empty(vals, CABINET_STATUS2_IDX),
|
||||||
|
// 实时在线:**唯一权威纯函数**(与设备列表 `is_online` 同源;无数据 → None)
|
||||||
|
online: super::cabinet_tree::device_online_flag(
|
||||||
|
pick(vals, CABINET_ONLINE_IDX).as_deref(),
|
||||||
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1107,20 +1171,51 @@ pub async fn list_compartment_options(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// pipeline 结果解析:柜级 6 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局,
|
/// `PUT /api/cabinets/:id/status` 的入参值域守门。
|
||||||
/// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取)
|
///
|
||||||
|
/// 为什么必须测:`body.status` 是 `i8`,而此前**没有**任何值域校验,
|
||||||
|
/// `0` / `99` / `-1` 都能落库。落进去的后果是**静默**的:
|
||||||
|
/// 系统各处按「`2` = 禁用,其余 = 启用」读,于是 `99` 被当"启用"、`0` 也被当"启用" ——
|
||||||
|
/// 看上去一切正常,但库里多出若干"谁也没定义过"的取值,
|
||||||
|
/// 任何按 `status = 1` 做的统计/判断都会悄悄漏掉它们。
|
||||||
|
/// 这类"不报错、只在对账时才发现"的脏数据,正是「踩过的坑」反复出现的一类。
|
||||||
|
#[test]
|
||||||
|
fn test_cabinet_status_whitelist_accepts_only_1_and_2() {
|
||||||
|
// ① 合法:启用 / 禁用
|
||||||
|
assert!(is_valid_cabinet_status(1), "1 = 启用,必须放行");
|
||||||
|
assert!(is_valid_cabinet_status(2), "2 = 禁用,必须放行");
|
||||||
|
|
||||||
|
// ② 非法:0(建表默认值遗留态,语义等同启用,但**不允许经本接口写回**)
|
||||||
|
assert!(
|
||||||
|
!is_valid_cabinet_status(0),
|
||||||
|
"0 是列默认值遗留态,不允许经状态接口写回(否则'启用'有两种表示)"
|
||||||
|
);
|
||||||
|
// ③ 非法:值域外的任意取值(i8 全量遍历,杜绝"只挡住了我知道的那几个")
|
||||||
|
for v in i8::MIN..=i8::MAX {
|
||||||
|
let expected = v == 1 || v == 2;
|
||||||
|
assert_eq!(
|
||||||
|
is_valid_cabinet_status(v),
|
||||||
|
expected,
|
||||||
|
"status={v} 的判定不符预期(只允许 1/2)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pipeline 结果解析:柜级 7 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局,
|
||||||
|
/// 并确认电压/电流/柜温/在线被正确取出(回归 ③:数据在 Redis 但接口不取)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() {
|
fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() {
|
||||||
let comp_ids = vec![11_i64, 12_i64];
|
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 comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))];
|
||||||
let vals: Vec<Option<String>> = vec![
|
let vals: Vec<Option<String>> = vec![
|
||||||
// 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2
|
// 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2 / online
|
||||||
Some("85".into()),
|
Some("85".into()),
|
||||||
Some("1".into()),
|
Some("1".into()),
|
||||||
Some("0".into()),
|
Some("0".into()),
|
||||||
Some("29815".into()),
|
Some("29815".into()),
|
||||||
Some("10".into()),
|
Some("10".into()),
|
||||||
Some("01".into()),
|
Some("01".into()),
|
||||||
|
Some("1".into()),
|
||||||
// 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None)
|
// 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None)
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -1146,6 +1241,7 @@ mod tests {
|
|||||||
assert_eq!(rt.temp, Some(29815), "柜温应被取出");
|
assert_eq!(rt.temp, Some(29815), "柜温应被取出");
|
||||||
assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 hex");
|
assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 hex");
|
||||||
assert_eq!(rt.status2.as_deref(), Some("01"), "柜级 status2 应原样透出 hex");
|
assert_eq!(rt.status2.as_deref(), Some("01"), "柜级 status2 应原样透出 hex");
|
||||||
|
assert_eq!(rt.online, Some(true), "在线应被取出(Redis online=\"1\")");
|
||||||
|
|
||||||
let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态");
|
let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态");
|
||||||
assert_eq!(ch11.voltage, Some(53200), "电压应被取出");
|
assert_eq!(ch11.voltage, Some(53200), "电压应被取出");
|
||||||
@ -1230,15 +1326,44 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步
|
/// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步
|
||||||
/// (命令顺序与下标一一对应;新增柜级字段只能追加在末尾)
|
/// (命令顺序由字段清单唯一决定;新增柜级字段只能追加在末尾)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cabinet_realtime_field_indices_are_in_sync() {
|
fn test_cabinet_realtime_field_indices_are_in_sync() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
CABINET_REALTIME_FIELDS, 6,
|
CABINET_REALTIME_FIELDS, 7,
|
||||||
"柜级 HGET 字段数变了:必须同步 fetch_cabinet_realtime 的命令顺序与本文件的下标解析"
|
"柜级 HGET 字段数变了:必须同步 CABINET_REALTIME_FIELD_NAMES 与下标解析"
|
||||||
);
|
);
|
||||||
assert_eq!(CABINET_STATUS1_IDX, CABINET_REALTIME_FIELDS - 2);
|
// 字段清单与下标常量必须逐字对上(防止只改数字不改清单)
|
||||||
assert_eq!(CABINET_STATUS2_IDX, CABINET_REALTIME_FIELDS - 1);
|
assert_eq!(CABINET_REALTIME_FIELD_NAMES[CABINET_STATUS1_IDX], "cabinet_status1");
|
||||||
|
assert_eq!(CABINET_REALTIME_FIELD_NAMES[CABINET_STATUS2_IDX], "cabinet_status2");
|
||||||
|
// 在线字段与设备列表 `is_online` 的取数字段**必须逐字一致**(跨模块同源守门)
|
||||||
|
assert_eq!(
|
||||||
|
CABINET_REALTIME_FIELD_NAMES[CABINET_ONLINE_IDX],
|
||||||
|
super::super::cabinet_tree::DEVICE_ONLINE_FIELD,
|
||||||
|
"详情页在线取数字段必须与设备列表同源"
|
||||||
|
);
|
||||||
|
assert_eq!(CABINET_ONLINE_IDX, CABINET_REALTIME_FIELDS - 1, "在线字段必须追加在柜级段末尾");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 柜级**在线**字段三态:`"1"`→Some(true) / `"0"`→Some(false) / 缺失→**None(无数据)**。
|
||||||
|
/// 绝不把"无数据"落成 false(那是"明确离线"),也绝不落成"在线"。
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cabinet_realtime_cabinet_online_is_three_state() {
|
||||||
|
let comp_ids: Vec<i64> = vec![];
|
||||||
|
let comp_to_board: Vec<(i64, (i8, i8))> = vec![];
|
||||||
|
let with_online = |raw: Option<String>| -> Vec<Option<String>> {
|
||||||
|
vec![None, None, None, None, None, None, raw]
|
||||||
|
};
|
||||||
|
|
||||||
|
let rt = parse_cabinet_realtime(&with_online(Some("1".into())), &comp_ids, &comp_to_board);
|
||||||
|
assert_eq!(rt.online, Some(true), "\"1\" = 在线");
|
||||||
|
let rt = parse_cabinet_realtime(&with_online(Some("0".into())), &comp_ids, &comp_to_board);
|
||||||
|
assert_eq!(rt.online, Some(false), "\"0\" = 明确离线");
|
||||||
|
let rt = parse_cabinet_realtime(&with_online(None), &comp_ids, &comp_to_board);
|
||||||
|
assert_eq!(rt.online, None, "缺失必须是 None(无数据),不能伪装成离线");
|
||||||
|
// 长度不足 / 空切片(Redis 整体不可用)也不能 panic
|
||||||
|
let rt = parse_cabinet_realtime(&[], &comp_ids, &comp_to_board);
|
||||||
|
assert_eq!(rt.online, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0)
|
/// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0)
|
||||||
@ -1246,7 +1371,7 @@ mod tests {
|
|||||||
fn test_parse_cabinet_realtime_missing_fields_stay_none() {
|
fn test_parse_cabinet_realtime_missing_fields_stay_none() {
|
||||||
let comp_ids = vec![7_i64];
|
let comp_ids = vec![7_i64];
|
||||||
let comp_to_board = vec![(7_i64, (1_i8, 1_i8))];
|
let comp_to_board = vec![(7_i64, (1_i8, 1_i8))];
|
||||||
// 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
|
// 柜级 7 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
|
||||||
let vals: Vec<Option<String>> = vec![
|
let vals: Vec<Option<String>> = vec![
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -1255,6 +1380,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some("29815".into()),
|
Some("29815".into()),
|
||||||
Some("82".into()),
|
Some("82".into()),
|
||||||
Some("03".into()),
|
Some("03".into()),
|
||||||
@ -1266,6 +1392,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(rt.temp, None);
|
assert_eq!(rt.temp, None);
|
||||||
assert_eq!(rt.rssi, None);
|
assert_eq!(rt.rssi, None);
|
||||||
|
assert_eq!(rt.online, None, "在线字段缺失时必须是 None(无数据),不能落成 false");
|
||||||
let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)");
|
let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)");
|
||||||
assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0");
|
assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0");
|
||||||
assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0");
|
assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0");
|
||||||
|
|||||||
@ -25,6 +25,8 @@ use serde_json::{json, Value};
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
|
|
||||||
|
use super::cabinet_tree;
|
||||||
|
|
||||||
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
|
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
|
||||||
const COST_PER_WH_CENT: i64 = 80;
|
const COST_PER_WH_CENT: i64 = 80;
|
||||||
|
|
||||||
@ -166,41 +168,121 @@ fn charge_record_filters(user: &CurrentUser) -> (String, Vec<String>) {
|
|||||||
(cond, binds)
|
(cond, binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计当前用户可见的柜子数量(`online_only = true` 时只算在线柜子)
|
// ---------------------------------------------------------------------------
|
||||||
|
// 可见柜子 / 在线柜数(组织隔离 + 数据范围,两处口径必须完全一致)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 可见柜子(`cabinets c`)的过滤**条件后缀**:组织隔离 **∧** 数据范围(项目级)。
|
||||||
///
|
///
|
||||||
/// 口径与设备列表一致:非总管理员经 `cabinets → projects` 组织过滤,
|
/// - 总管理员(`role_level >= 2`)⇒ 空串(豁免);
|
||||||
/// 再叠加数据范围的项目收窄。
|
/// - 客户侧账号有组织 ⇒ ` AND p.organization_id = ?` + 项目级范围收窄;
|
||||||
|
/// - 客户侧账号**无组织** ⇒ ` AND 1=0`(空集,**绝不退化成"全部"**)。
|
||||||
|
///
|
||||||
|
/// ⚠️ 返回的是**条件后缀**(调用方自己起 `WHERE`),因此片段一律以 ` AND ` 开头;
|
||||||
|
/// 用 `1=0` 表达"空集"可以避免在 Rust 里为"无组织"再分叉一条分支。
|
||||||
|
fn visible_cabinet_filters(user: &CurrentUser) -> (String, Vec<String>) {
|
||||||
|
if user.role_level >= 2 {
|
||||||
|
return (String::new(), vec![]);
|
||||||
|
}
|
||||||
|
let Some(oid) = user.organization_id else {
|
||||||
|
return (" AND 1=0".to_string(), vec![]);
|
||||||
|
};
|
||||||
|
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
|
||||||
|
// 绑定顺序必须与占位符出现顺序一致:先组织,再项目级范围
|
||||||
|
(
|
||||||
|
format!(" AND p.organization_id = ?{}", scope_cond),
|
||||||
|
std::iter::once(oid.to_string()).chain(scope_binds).collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cabinet_count`(**全部可见柜数**)的 SQL —— 语义冻结:只数柜子。
|
||||||
|
///
|
||||||
|
/// **不含任何 `status` 条件**:`cabinets.status` 是启用/禁用**配置态**,
|
||||||
|
/// 既不表示在线,也不该影响"柜子总数"(禁用中的柜子依然是柜子)。
|
||||||
|
fn visible_cabinets_count_sql(user: &CurrentUser) -> (String, Vec<String>) {
|
||||||
|
let (cond, binds) = visible_cabinet_filters(user);
|
||||||
|
let sql = if user.role_level >= 2 {
|
||||||
|
format!("SELECT COUNT(*) FROM cabinets c WHERE 1=1{}", cond)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE 1=1{}",
|
||||||
|
cond
|
||||||
|
)
|
||||||
|
};
|
||||||
|
(sql, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在线统计的**取数 SQL**:只取可见柜的 IMEI(`imei IS NOT NULL`)。
|
||||||
|
///
|
||||||
|
/// # 为什么 DB 不参与"在线"判定
|
||||||
|
/// `cabinets.status` 是**启用/禁用配置态**(全仓唯一写入方
|
||||||
|
/// `routes/cabinets.rs::set_cabinet_status` 只在 `2`(禁用)与其它值(启用)之间切,
|
||||||
|
/// **没有任何地方写 0(离线)**)⇒ 按 `status = 1` 统计在线,会在**一台设备都没连**时
|
||||||
|
/// 依然返回"全部在线"。
|
||||||
|
///
|
||||||
|
/// 因此这里只负责按**组织隔离 + 数据范围**取出一批 IMEI,在线与否交给
|
||||||
|
/// [`cabinet_tree::count_online_devices`](Redis 一次 pipeline,判据与设备列表
|
||||||
|
/// `is_online` 同源)。
|
||||||
|
fn visible_cabinet_imeis_sql(user: &CurrentUser) -> (String, Vec<String>) {
|
||||||
|
let (cond, binds) = visible_cabinet_filters(user);
|
||||||
|
let sql = if user.role_level >= 2 {
|
||||||
|
format!(
|
||||||
|
"SELECT c.imei FROM cabinets c WHERE c.imei IS NOT NULL{}",
|
||||||
|
cond
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"SELECT c.imei FROM cabinets c JOIN projects p ON c.project_id = p.id \
|
||||||
|
WHERE c.imei IS NOT NULL{}",
|
||||||
|
cond
|
||||||
|
)
|
||||||
|
};
|
||||||
|
(sql, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行「可见柜计数」SQL(组织隔离 + 数据范围)
|
||||||
async fn count_visible_cabinets(
|
async fn count_visible_cabinets(
|
||||||
pool: &sqlx::MySqlPool,
|
pool: &sqlx::MySqlPool,
|
||||||
user: &CurrentUser,
|
user: &CurrentUser,
|
||||||
online_only: bool,
|
|
||||||
) -> Result<i64, AppError> {
|
) -> Result<i64, AppError> {
|
||||||
if user.role_level >= 2 {
|
let (sql, binds) = visible_cabinets_count_sql(user);
|
||||||
let sql = if online_only {
|
let mut query = sqlx::query_scalar::<_, i64>(&sql);
|
||||||
"SELECT COUNT(*) FROM cabinets WHERE status = 1"
|
for bind in &binds {
|
||||||
} else {
|
|
||||||
"SELECT COUNT(*) FROM cabinets"
|
|
||||||
};
|
|
||||||
return Ok(sqlx::query_scalar(sql).fetch_one(pool).await?);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(oid) = user.organization_id else {
|
|
||||||
return Ok(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
|
|
||||||
let status_cond = if online_only { " AND c.status = 1" } else { "" };
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?{}{}",
|
|
||||||
status_cond, scope_cond
|
|
||||||
);
|
|
||||||
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(oid);
|
|
||||||
for bind in &scope_binds {
|
|
||||||
query = query.bind(bind);
|
query = query.bind(bind);
|
||||||
}
|
}
|
||||||
Ok(query.fetch_one(pool).await?)
|
Ok(query.fetch_one(pool).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取当前用户**可见柜子的 IMEI 列表**(组织隔离 + 数据范围,顺序无关)
|
||||||
|
///
|
||||||
|
/// `imei` 列可空(老数据/联调库有 NULL),按 `Option<String>` 解码后过滤,
|
||||||
|
/// 避免一行 NULL 让整个在线统计解码失败(接口 500)。
|
||||||
|
async fn visible_cabinet_imeis(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
user: &CurrentUser,
|
||||||
|
) -> Result<Vec<String>, AppError> {
|
||||||
|
let (sql, binds) = visible_cabinet_imeis_sql(user);
|
||||||
|
let mut query = sqlx::query_scalar::<_, Option<String>>(&sql);
|
||||||
|
for bind in &binds {
|
||||||
|
query = query.bind(bind);
|
||||||
|
}
|
||||||
|
Ok(query.fetch_all(pool).await?.into_iter().flatten().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `online_count`:**在线柜数 = 可见柜里 Redis 明确在线的台数**。
|
||||||
|
///
|
||||||
|
/// 先按组织隔离 + 数据范围取出可见柜的 IMEI,再用**一次 pipeline** 批量查
|
||||||
|
/// `device:{imei}.online`(**零 N 次单查**);判据是
|
||||||
|
/// [`cabinet_tree::is_device_online`] —— 与设备列表 `is_online` 同一纯函数,
|
||||||
|
/// 且**完全不看 `cabinets.status`**。
|
||||||
|
async fn count_online_cabinets(
|
||||||
|
state: &super::AppState,
|
||||||
|
user: &CurrentUser,
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
let imeis = visible_cabinet_imeis(&state.mysql, user).await?;
|
||||||
|
Ok(cabinet_tree::count_online_devices(state, &imeis).await)
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/energy-stats/summary — 总览
|
/// GET /api/energy-stats/summary — 总览
|
||||||
///
|
///
|
||||||
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
|
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
|
||||||
@ -236,9 +318,9 @@ pub async fn get_summary(
|
|||||||
for v in &org_binds { month_q = month_q.bind(v); }
|
for v in &org_binds { month_q = month_q.bind(v); }
|
||||||
let month_energy: i64 = month_q.fetch_one(pool).await?;
|
let month_energy: i64 = month_q.fetch_one(pool).await?;
|
||||||
|
|
||||||
// 柜子总数 / 在线柜子数(含组织过滤 + 数据范围)
|
// 柜子总数(全部可见柜,语义不变)/ 在线柜子数(**以 Redis 为准**,与 `cabinets.status` 无关)
|
||||||
let cabinet_count = count_visible_cabinets(pool, &user, false).await?;
|
let cabinet_count = count_visible_cabinets(pool, &user).await?;
|
||||||
let online_count = count_visible_cabinets(pool, &user, true).await?;
|
let online_count = count_online_cabinets(&state, &user).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@ -874,4 +956,115 @@ mod tests {
|
|||||||
let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0;
|
let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0;
|
||||||
assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small);
|
assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 在线柜数守门(防回归:不得再拿 `cabinets.status` 当"在线")
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 构造单测用的 `CurrentUser`(不连库;`permissions` 与范围按需给)
|
||||||
|
fn user(role_level: i32, organization_id: Option<i64>, project_ids: Vec<i64>) -> CurrentUser {
|
||||||
|
CurrentUser {
|
||||||
|
user_id: 1,
|
||||||
|
phone: "13000000000".to_string(),
|
||||||
|
permissions: Default::default(),
|
||||||
|
role_level,
|
||||||
|
organization_id,
|
||||||
|
scope: crate::middleware::auth::UserScope { project_ids },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ① **在线统计不依赖 `cabinets.status`**。
|
||||||
|
///
|
||||||
|
/// `cabinets.status` 是启用/禁用**配置态**:全仓唯一写入方
|
||||||
|
/// (`routes/cabinets.rs::set_cabinet_status`)只在 `2`(禁用)与其它值(启用)之间切,
|
||||||
|
/// **没有任何地方写 0(离线)**。所以只要 SQL 里出现 `status` 条件,
|
||||||
|
/// 就必然在"一台设备都没连"时返回"全部在线"。
|
||||||
|
#[test]
|
||||||
|
fn test_visible_cabinet_sql_never_filters_by_status() {
|
||||||
|
let cases = [
|
||||||
|
user(2, None, vec![]), // 总管理员
|
||||||
|
user(1, Some(42), vec![]), // 组织管理员(未配范围)
|
||||||
|
user(1, Some(42), vec![7, 8]), // 组织管理员 + 项目级范围
|
||||||
|
user(0, None, vec![]), // 客户侧账号无组织 ⇒ 空集
|
||||||
|
user(0, Some(42), vec![]), // 操作员
|
||||||
|
];
|
||||||
|
for u in &cases {
|
||||||
|
let (count_sql, _) = visible_cabinets_count_sql(u);
|
||||||
|
let (imei_sql, _) = visible_cabinet_imeis_sql(u);
|
||||||
|
for sql in [&count_sql, &imei_sql] {
|
||||||
|
assert!(
|
||||||
|
!sql.to_lowercase().contains("status"),
|
||||||
|
"柜数/在线统计不得引用 cabinets.status:{}",
|
||||||
|
sql
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ② 组织隔离与数据范围**必须保留**(收窄只能取交集,绝不放大):
|
||||||
|
/// 总管理员豁免;非总管理员带组织条件 + 项目级范围;**无组织 ⇒ 空集**(不是"全部")。
|
||||||
|
#[test]
|
||||||
|
fn test_visible_cabinet_sql_keeps_org_isolation_and_scope() {
|
||||||
|
// 总管理员豁免:即使身上带着组织与范围,也不得出现在 SQL 里
|
||||||
|
let (sql, binds) = visible_cabinet_imeis_sql(&user(2, Some(99), vec![7]));
|
||||||
|
assert!(!sql.contains("organization_id"), "总管理员应豁免组织条件:{}", sql);
|
||||||
|
assert!(!sql.contains("IN ("), "总管理员应豁免数据范围:{}", sql);
|
||||||
|
assert!(binds.is_empty(), "总管理员不应有任何绑定值");
|
||||||
|
|
||||||
|
// 组织管理员:组织条件 + 项目级范围(绑定顺序必须与占位符顺序一致)
|
||||||
|
let (sql, binds) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![7, 8]));
|
||||||
|
assert!(sql.contains("p.organization_id = ?"), "组织隔离丢失:{}", sql);
|
||||||
|
assert!(sql.contains("c.project_id IN (?,?)"), "数据范围丢失:{}", sql);
|
||||||
|
assert_eq!(binds, vec!["42", "7", "8"], "绑定顺序必须与占位符一致");
|
||||||
|
|
||||||
|
// 客户侧账号无组织 ⇒ 空集(**绝不退化成"全部"**),两条 SQL 都必须如此
|
||||||
|
let (imei_sql, binds) = visible_cabinet_imeis_sql(&user(0, None, vec![]));
|
||||||
|
assert!(imei_sql.contains("1=0"), "无组织时在线取数必须是空集:{}", imei_sql);
|
||||||
|
assert!(binds.is_empty());
|
||||||
|
let (count_sql, _) = visible_cabinets_count_sql(&user(0, None, vec![]));
|
||||||
|
assert!(count_sql.contains("1=0"), "无组织时柜数必须是空集:{}", count_sql);
|
||||||
|
|
||||||
|
// 在线取数只取 IMEI(在线判定交给 Redis,DB 不参与)
|
||||||
|
let (sql, _) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![]));
|
||||||
|
assert!(sql.starts_with("SELECT c.imei FROM cabinets c"), "取数应只取 IMEI:{}", sql);
|
||||||
|
assert!(sql.contains("c.imei IS NOT NULL"), "必须排除 NULL 的 IMEI:{}", sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ③ **与设备列表 `is_online` 口径同源**:`energy_stats` 没有第二套在线判据 ——
|
||||||
|
/// 在线计数一律经 `cabinet_tree::count_online_flags`,其唯一依据是
|
||||||
|
/// `cabinet_tree::is_device_online` / `device_online_flag`(设备列表用的是同一对函数)。
|
||||||
|
/// 逐值断言同一批原始值在两处结论完全一致,并覆盖「0 台连接 ⇒ 0」这条语义。
|
||||||
|
#[test]
|
||||||
|
fn test_online_count_same_source_as_cabinet_tree_device_list() {
|
||||||
|
// 同一批原始值,覆盖三态边界:在线 / 明确离线 / 无数据 / 空串 / 非法值
|
||||||
|
let raws: Vec<Option<String>> = vec![
|
||||||
|
Some("1".into()),
|
||||||
|
Some("0".into()),
|
||||||
|
None,
|
||||||
|
Some(String::new()),
|
||||||
|
Some("2".into()),
|
||||||
|
];
|
||||||
|
// 期望值直接由设备列表的判据(`is_online` == `device_online_flag(..) == Some(true)`)给出
|
||||||
|
let expected = raws
|
||||||
|
.iter()
|
||||||
|
.filter(|v| cabinet_tree::device_online_flag(v.as_deref()) == Some(true))
|
||||||
|
.count() as i64;
|
||||||
|
assert_eq!(
|
||||||
|
cabinet_tree::count_online_flags(&raws),
|
||||||
|
expected,
|
||||||
|
"在线计数与设备列表 is_online 判据必须同源"
|
||||||
|
);
|
||||||
|
assert_eq!(expected, 1, "5 个样本里只有 1 个明确在线");
|
||||||
|
|
||||||
|
// **0 台设备连接 ⇒ online_count = 0**(而不是等于柜子总数)——生产实测的回归形态
|
||||||
|
let nothing_connected: Vec<Option<String>> = vec![None; 68];
|
||||||
|
assert_eq!(
|
||||||
|
cabinet_tree::count_online_flags(¬hing_connected),
|
||||||
|
0,
|
||||||
|
"无设备连接时在线数必须是 0,绝不能退化成柜子总数"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0
|
||||||
|
assert_eq!(cabinet_tree::count_online_flags(&[]), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,8 +164,8 @@ pub async fn get_dashboard(
|
|||||||
let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope());
|
let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope());
|
||||||
let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds };
|
let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds };
|
||||||
|
|
||||||
// 统计在线柜子数(带组织隔离)
|
// 统计在线柜子数(带组织隔离;**以 Redis 为准**,与 `cabinets.status` 配置态无关)
|
||||||
let online_cabinets = count_online_cabinets(pool, is_super, org_id, &scope).await?;
|
let online_cabinets = count_online_cabinets(&state, is_super, org_id, &scope).await?;
|
||||||
// 统计总通道数(带组织隔离)
|
// 统计总通道数(带组织隔离)
|
||||||
let total_channels = count_total_compartments(pool, is_super, org_id, &scope).await?;
|
let total_channels = count_total_compartments(pool, is_super, org_id, &scope).await?;
|
||||||
|
|
||||||
@ -217,34 +217,89 @@ pub async fn get_dashboard(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计在线柜子数(带组织隔离 + 数据范围)
|
/// 设备级 HMGET 的**字段清单**(顺序 = pipeline 命令顺序 = 解析下标)。
|
||||||
|
///
|
||||||
|
/// 下标:`rssi`(0) / `pow_fail_dc`(1) / `pow_fail_ac`(2) / `temp`(3) /
|
||||||
|
/// `cabinet_status1`(4) / `cabinet_status2`(5) / **`online`(6,追加在末尾)**。
|
||||||
|
/// ⚠️ 新增字段**只能追加在末尾**(前面的下标被下面的解析逐一对齐)。
|
||||||
|
const CABINET_HMGET_FIELDS: [&str; 7] = [
|
||||||
|
"rssi",
|
||||||
|
"pow_fail_dc",
|
||||||
|
"pow_fail_ac",
|
||||||
|
"temp",
|
||||||
|
"cabinet_status1",
|
||||||
|
"cabinet_status2",
|
||||||
|
// 实时在线:字段名直接取自 `cabinet_tree`(与设备列表 `is_online` 同一常量,跨模块同源)
|
||||||
|
super::cabinet_tree::DEVICE_ONLINE_FIELD,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 在线字段在 [`CABINET_HMGET_FIELDS`] 中的下标(追加在末尾)
|
||||||
|
const CABINET_HMGET_ONLINE_IDX: usize = 6;
|
||||||
|
|
||||||
|
/// 首页「在线设备」的**取数 SQL**:只取可见柜的 IMEI,在线与否交给 Redis 判定。
|
||||||
|
///
|
||||||
|
/// **不含任何 `status` 条件** —— `cabinets.status` 是启用/禁用**配置态**,
|
||||||
|
/// 全仓没有任何写入方会把它写成 0(离线),拿它统计会在"一台设备都没连"时
|
||||||
|
/// 返回"全部在线"。此处只负责按组织隔离 + 数据范围取出 IMEI。
|
||||||
|
fn online_cabinet_imeis_sql(is_super: bool, scope_cond: &str) -> String {
|
||||||
|
if is_super {
|
||||||
|
"SELECT cab.imei FROM cabinets cab WHERE cab.imei IS NOT NULL".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
r#"SELECT cab.imei FROM cabinets cab
|
||||||
|
INNER JOIN projects p ON cab.project_id = p.id
|
||||||
|
WHERE cab.imei IS NOT NULL AND p.organization_id = ?{}"#,
|
||||||
|
scope_cond
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计在线柜子数(**以 Redis 为准**,带组织隔离 + 数据范围)
|
||||||
|
///
|
||||||
|
/// # 为什么不再用 `cabinets.status = 1`
|
||||||
|
/// `cabinets.status` 是启用/禁用**配置态**(`2` = 禁用,其余 = 启用),
|
||||||
|
/// 全仓**没有任何写入方会把它写成 0(离线)** ⇒ 按它统计会在"一台设备都没连"时
|
||||||
|
/// 依然返回"全部在线"。现在改为:先按组织隔离 + 范围取出可见柜的 IMEI,
|
||||||
|
/// 再用一次 pipeline 查 `device:{imei}.online`(判据与设备列表 `is_online` 同一纯函数
|
||||||
|
/// [`cabinet_tree::is_device_online`])。
|
||||||
async fn count_online_cabinets(
|
async fn count_online_cabinets(
|
||||||
pool: &sqlx::MySqlPool,
|
state: &AppState,
|
||||||
is_super: bool,
|
is_super: bool,
|
||||||
org_id: Option<i64>,
|
org_id: Option<i64>,
|
||||||
scope: &ScopeFilter<'_>,
|
scope: &ScopeFilter<'_>,
|
||||||
) -> Result<i64, AppError> {
|
) -> Result<i64, AppError> {
|
||||||
if !is_super && org_id.is_none() {
|
// 总管理员:全部柜子的 IMEI,不带任何组织条件(豁免)
|
||||||
return Ok(0);
|
if is_super {
|
||||||
|
return finish_online_count(state, online_cabinet_imeis_sql(true, ""), Vec::new()).await;
|
||||||
}
|
}
|
||||||
let count: i64 = if is_super {
|
// 客户侧账号**无组织归属 ⇒ 空集**(绝不退化成"全部"),与 `visible_org_scope` 同口径
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
|
let Some(oid) = org_id else {
|
||||||
.fetch_one(pool)
|
return Ok(0);
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
let sql = format!(
|
|
||||||
r#"SELECT COUNT(*) FROM cabinets cab
|
|
||||||
INNER JOIN projects p ON cab.project_id = p.id
|
|
||||||
WHERE cab.status = 1 AND p.organization_id = ?{}"#,
|
|
||||||
scope.cond
|
|
||||||
);
|
|
||||||
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(org_id.unwrap());
|
|
||||||
for bind in scope.binds {
|
|
||||||
query = query.bind(bind);
|
|
||||||
}
|
|
||||||
query.fetch_one(pool).await?
|
|
||||||
};
|
};
|
||||||
Ok(count)
|
// 组织隔离 + 数据范围(绑定顺序 = 占位符顺序:先组织,再范围)
|
||||||
|
let mut binds: Vec<String> = vec![oid.to_string()];
|
||||||
|
binds.extend(scope.binds.iter().cloned());
|
||||||
|
finish_online_count(state, online_cabinet_imeis_sql(false, scope.cond), binds).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行在线取数 SQL,并按 Redis 判据统计在线台数(`binds` 顺序 = 占位符顺序)
|
||||||
|
async fn finish_online_count(
|
||||||
|
state: &AppState,
|
||||||
|
sql: String,
|
||||||
|
binds: Vec<String>,
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
let mut query = sqlx::query_scalar::<_, Option<String>>(&sql);
|
||||||
|
for bind in &binds {
|
||||||
|
query = query.bind(bind);
|
||||||
|
}
|
||||||
|
let imeis: Vec<String> = query
|
||||||
|
.fetch_all(&state.mysql)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
// Redis 一次 pipeline 批量判定(**零 N 次单查**),口径同设备列表 is_online
|
||||||
|
Ok(super::cabinet_tree::count_online_devices(state, &imeis).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计总仓体数(带组织隔离 + 数据范围)
|
/// 统计总仓体数(带组织隔离 + 数据范围)
|
||||||
@ -824,6 +879,13 @@ struct CabinetRealtimeMaps {
|
|||||||
dc: HashMap<String, Option<bool>>,
|
dc: HashMap<String, Option<bool>>,
|
||||||
ac: HashMap<String, Option<bool>>,
|
ac: HashMap<String, Option<bool>>,
|
||||||
temp: HashMap<String, Option<i32>>,
|
temp: HashMap<String, Option<i32>>,
|
||||||
|
/// 设备在线(Redis `device:{imei}` 的 `online` 字段)
|
||||||
|
///
|
||||||
|
/// 判据与 PC 设备列表的 `is_online` **同源**(`cabinet_tree::device_online_flag`):
|
||||||
|
/// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = **无数据**(键或字段不存在)。
|
||||||
|
/// ⚠️ 与 DB 的 `cabinets.status`(启用/禁用**配置态**)**没有任何关系** ——
|
||||||
|
/// 小程序卡片不得再用 `cab.status` 推断在线。
|
||||||
|
online: HashMap<String, Option<bool>>,
|
||||||
/// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开)
|
/// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开)
|
||||||
///
|
///
|
||||||
/// 取不到(Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`,
|
/// 取不到(Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`,
|
||||||
@ -977,15 +1039,8 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin
|
|||||||
for r in rows {
|
for r in rows {
|
||||||
if let Some(ref imei) = r.imei {
|
if let Some(ref imei) = r.imei {
|
||||||
let key = format!("device:{imei}");
|
let key = format!("device:{imei}");
|
||||||
// ⚠️ 字段顺序与下面的下标解析一一对应(新增字段只能追加在末尾)
|
// ⚠️ 字段顺序由 CABINET_HMGET_FIELDS 唯一决定,与下面的下标解析一一对应
|
||||||
pipe.cmd("HMGET").arg(&key).arg(&[
|
pipe.cmd("HMGET").arg(&key).arg(&CABINET_HMGET_FIELDS);
|
||||||
"rssi",
|
|
||||||
"pow_fail_dc",
|
|
||||||
"pow_fail_ac",
|
|
||||||
"temp",
|
|
||||||
"cabinet_status1",
|
|
||||||
"cabinet_status2",
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !rows.is_empty() {
|
if !rows.is_empty() {
|
||||||
@ -1006,6 +1061,15 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin
|
|||||||
// 柜级状态位:原样透出 hex;空串/缺失 → None(响应 null,不落 "00")
|
// 柜级状态位:原样透出 hex;空串/缺失 → None(响应 null,不落 "00")
|
||||||
maps.status1.insert(imei.clone(), pick_non_empty_hex(vals.get(4)));
|
maps.status1.insert(imei.clone(), pick_non_empty_hex(vals.get(4)));
|
||||||
maps.status2.insert(imei.clone(), pick_non_empty_hex(vals.get(5)));
|
maps.status2.insert(imei.clone(), pick_non_empty_hex(vals.get(5)));
|
||||||
|
// 在线(下标 CABINET_HMGET_ONLINE_IDX):判据**唯一权威纯函数**,与设备列表同源
|
||||||
|
maps.online.insert(
|
||||||
|
imei.clone(),
|
||||||
|
super::cabinet_tree::device_online_flag(
|
||||||
|
vals.get(CABINET_HMGET_ONLINE_IDX)
|
||||||
|
.and_then(|v| v.as_ref())
|
||||||
|
.map(String::as_str),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1072,6 +1136,10 @@ fn build_cabinet_list(
|
|||||||
// 柜级状态位(协议 §3.8):原样透出 2 位 hex;取不到 → null(不落 "00")
|
// 柜级状态位(协议 §3.8):原样透出 2 位 hex;取不到 → null(不落 "00")
|
||||||
let status1 = imei.as_ref().and_then(|i| realtime.status1.get(i).cloned()).flatten();
|
let status1 = imei.as_ref().and_then(|i| realtime.status1.get(i).cloned()).flatten();
|
||||||
let status2 = imei.as_ref().and_then(|i| realtime.status2.get(i).cloned()).flatten();
|
let status2 = imei.as_ref().and_then(|i| realtime.status2.get(i).cloned()).flatten();
|
||||||
|
// **实时在线**(Redis `device:{imei}.online`):`true` 在线 / `false` 明确离线 /
|
||||||
|
// `null` 无数据。字段名与 PC 端一致(`is_online`),小程序两端都消费它。
|
||||||
|
// ⚠️ 与 `status`(启用/禁用配置态)严格分离:`status === 2` 仍单独渲染「禁用」。
|
||||||
|
let is_online = imei.as_ref().and_then(|i| realtime.online.get(i).copied()).flatten();
|
||||||
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
|
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
|
||||||
// `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障
|
// `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障
|
||||||
let (_, _, full_count, fault_count) =
|
let (_, _, full_count, fault_count) =
|
||||||
@ -1083,6 +1151,8 @@ fn build_cabinet_list(
|
|||||||
"imei": r.imei,
|
"imei": r.imei,
|
||||||
"iccid": r.iccid,
|
"iccid": r.iccid,
|
||||||
"status": r.status,
|
"status": r.status,
|
||||||
|
// 实时在线(Redis,判据与设备列表 `is_online` 同源);`status` 仍是配置态
|
||||||
|
"is_online": is_online,
|
||||||
"project_id": r.project_id,
|
"project_id": r.project_id,
|
||||||
"address": r.address,
|
"address": r.address,
|
||||||
"latitude": r.latitude,
|
"latitude": r.latitude,
|
||||||
@ -2310,8 +2380,7 @@ mod tests {
|
|||||||
/// 小程序设备列表的柜级 hex 位域取值(协议 §3.8):
|
/// 小程序设备列表的柜级 hex 位域取值(协议 §3.8):
|
||||||
/// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。
|
/// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。
|
||||||
#[test]
|
#[test]
|
||||||
fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() {
|
fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() { assert_eq!(
|
||||||
assert_eq!(
|
|
||||||
pick_non_empty_hex(Some(&Some("00".into()))).as_deref(),
|
pick_non_empty_hex(Some(&Some("00".into()))).as_deref(),
|
||||||
Some("00"),
|
Some("00"),
|
||||||
"\"00\" 是「全部正常」的有效值,与「无数据」必须区分"
|
"\"00\" 是「全部正常」的有效值,与「无数据」必须区分"
|
||||||
@ -2448,4 +2517,68 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1));
|
assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 实时在线(`is_online`)守门 —— 防回归:不得再拿 `cabinets.status` 当"在线"
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 小程序列表的在线取数契约:
|
||||||
|
/// ① 在线字段**恰好出现一次**且位于清单末尾(下标常量与清单同步);
|
||||||
|
/// ② 字段名与设备列表 `is_online` **逐字一致**(跨模块同源守门)。
|
||||||
|
#[test]
|
||||||
|
fn test_h5_list_online_field_matches_device_list_criterion() {
|
||||||
|
let online_field = crate::routes::cabinet_tree::DEVICE_ONLINE_FIELD;
|
||||||
|
assert_eq!(
|
||||||
|
CABINET_HMGET_ONLINE_IDX,
|
||||||
|
CABINET_HMGET_FIELDS.len() - 1,
|
||||||
|
"在线字段必须追加在字段清单末尾(前面的下标解析依赖顺序)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CABINET_HMGET_FIELDS[CABINET_HMGET_ONLINE_IDX], online_field,
|
||||||
|
"小程序列表在线取数字段必须与设备列表同源"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CABINET_HMGET_FIELDS.iter().filter(|f| **f == online_field).count(),
|
||||||
|
1,
|
||||||
|
"在线字段只允许出现一次"
|
||||||
|
);
|
||||||
|
// 三态:无数据(None)不是"离线"(false),小程序据此可以区分"未知"
|
||||||
|
assert_eq!(crate::routes::cabinet_tree::device_online_flag(None), None);
|
||||||
|
assert_eq!(
|
||||||
|
crate::routes::cabinet_tree::device_online_flag(Some("1")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::routes::cabinet_tree::device_online_flag(Some("0")),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首页「在线设备」的取数 SQL **不得依赖 `cabinets.status`**,
|
||||||
|
/// 且必须保留组织隔离:总管理员无组织条件、客户侧必须带 `p.organization_id = ?`。
|
||||||
|
#[test]
|
||||||
|
fn test_h5_dashboard_online_sql_has_no_status_and_keeps_org_isolation() {
|
||||||
|
let super_sql = online_cabinet_imeis_sql(true, "");
|
||||||
|
assert!(
|
||||||
|
!super_sql.to_lowercase().contains("status"),
|
||||||
|
"在线统计不得引用 cabinets.status:{}",
|
||||||
|
super_sql
|
||||||
|
);
|
||||||
|
assert!(super_sql.starts_with("SELECT cab.imei FROM cabinets cab"));
|
||||||
|
assert!(super_sql.contains("cab.imei IS NOT NULL"), "必须排除 NULL IMEI:{}", super_sql);
|
||||||
|
|
||||||
|
let org_sql = online_cabinet_imeis_sql(false, " AND p.id IN (?,?)");
|
||||||
|
assert!(
|
||||||
|
!org_sql.to_lowercase().contains("status"),
|
||||||
|
"在线统计不得引用 cabinets.status:{}",
|
||||||
|
org_sql
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
org_sql.contains("INNER JOIN projects p ON cab.project_id = p.id"),
|
||||||
|
"客户侧必须经 projects 做组织隔离:{}",
|
||||||
|
org_sql
|
||||||
|
);
|
||||||
|
assert!(org_sql.contains("p.organization_id = ?"), "组织隔离条件丢失:{}", org_sql);
|
||||||
|
assert!(org_sql.contains("AND p.id IN (?,?)"), "数据范围条件丢失:{}", org_sql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,23 @@ const SCAN_INTERVAL_SECS: u64 = 300;
|
|||||||
/// 单轮最多处理订单数,避免一次扫描锁过多行
|
/// 单轮最多处理订单数,避免一次扫描锁过多行
|
||||||
const BATCH_LIMIT: i64 = 200;
|
const BATCH_LIMIT: i64 = 200;
|
||||||
|
|
||||||
|
/// 超时选单 SQL(抽成常量是为了让**方向**可被单测文本断言守住)。
|
||||||
|
///
|
||||||
|
/// ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"`start_time` 早于 `NOW() - 10 小时`"。
|
||||||
|
/// - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是
|
||||||
|
/// **未来时刻** `NOW()+10h`,于是"`start_time` < 未来"对**几乎所有订单恒真**,
|
||||||
|
/// 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算;
|
||||||
|
/// - ✅ 正确(本常量):把 `start_time` **往前推** 10 小时再与 `NOW()` 比较。
|
||||||
|
///
|
||||||
|
/// 方向写反**不报错、不打日志、跑起来一切正常**,只会在业务上悄悄提前结单,
|
||||||
|
/// 因此必须由单测(见文件底部 `test_...`)把方向钉死 —— 这是「踩过的坑」第 1 条的规范要求。
|
||||||
|
/// 同义的另一处写法在 [`crate::workers::report_worker`] 的 `FINALIZE_EXPIRED_ORDER_SQL`
|
||||||
|
/// (那边绑的是**负数**),**改动任一处都要同时确认另一处**。
|
||||||
|
const EXPIRED_ORDER_SCAN_SQL: &str = "SELECT id, compartment_id, start_time FROM charge_orders \
|
||||||
|
WHERE status = 0 AND start_time IS NOT NULL \
|
||||||
|
AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \
|
||||||
|
ORDER BY start_time ASC LIMIT ?";
|
||||||
|
|
||||||
/// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
|
/// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
|
||||||
///
|
///
|
||||||
/// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单
|
/// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单
|
||||||
@ -50,22 +67,13 @@ 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)。
|
||||||
//
|
// ⚠️ 方向说明与踩坑记录见 [`EXPIRED_ORDER_SCAN_SQL`] 的文档注释。
|
||||||
// ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"start_time 早于 NOW() - 10 小时"。
|
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> =
|
||||||
// - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是
|
sqlx::query_as(EXPIRED_ORDER_SCAN_SQL)
|
||||||
// **未来时刻** `NOW()+10h`,于是"start_time < 未来"对**几乎所有订单恒真**,
|
.bind(TIMEOUT_HOURS)
|
||||||
// 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算。
|
.bind(BATCH_LIMIT)
|
||||||
// - ✅ 正确:把 start_time 往前推 10 小时再与 NOW() 比较(下面这种写法)。
|
.fetch_all(pool)
|
||||||
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as(
|
.await?;
|
||||||
"SELECT id, compartment_id, start_time FROM charge_orders \
|
|
||||||
WHERE status = 0 AND start_time IS NOT NULL \
|
|
||||||
AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \
|
|
||||||
ORDER BY start_time ASC LIMIT ?",
|
|
||||||
)
|
|
||||||
.bind(TIMEOUT_HOURS)
|
|
||||||
.bind(BATCH_LIMIT)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if expired.is_empty() {
|
if expired.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -96,3 +104,100 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq
|
|||||||
tracing::info!("[order_timeout] 本轮超时结单处理完成");
|
tracing::info!("[order_timeout] 本轮超时结单处理完成");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 单元测试:超时条件**方向**守门
|
||||||
|
//
|
||||||
|
// 为什么必须有:本文件的 SQL 与 `report_worker.rs::FINALIZE_EXPIRED_ORDER_SQL`
|
||||||
|
// 是**两种等价但写法不同**的超时判据(这里把 `start_time` 往前推,那边把阈值往回绑负数)。
|
||||||
|
// 2026-09 修复 `report_worker` 方向 BUG 时,本文件**没有**同款守门测试(只在文档里留了「遗留」标记),
|
||||||
|
// 意味着这边再被改反也一样是**零报错、零日志、单测全绿**。
|
||||||
|
// 本模块把两个方向断言都在此处补齐(依据「踩过的坑」第 1 条 +「质量约束模板」第 8 条)。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// **纯函数孪生体(仅测试)**:与 [`EXPIRED_ORDER_SCAN_SQL`] **同义** ——
|
||||||
|
/// `start_time + hours < now` ⇒ 已超时。
|
||||||
|
///
|
||||||
|
/// 有了它才能做"刚建的不命中 + 超期的命中"双向断言,并**反证**方向写反时的后果。
|
||||||
|
fn is_expired(
|
||||||
|
start_time: chrono::NaiveDateTime,
|
||||||
|
now: chrono::NaiveDateTime,
|
||||||
|
hours: i64,
|
||||||
|
) -> bool {
|
||||||
|
start_time + chrono::Duration::hours(hours) < now
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 方向守门(SQL 文本层):阈值必须**往回**推(`start_time` 在 `<` 左侧)。
|
||||||
|
#[test]
|
||||||
|
fn test_expired_order_scan_sql_points_backwards() {
|
||||||
|
assert!(
|
||||||
|
EXPIRED_ORDER_SCAN_SQL.contains("TIMESTAMPADD(HOUR, ?, start_time) < NOW()"),
|
||||||
|
"超时条件必须是「start_time 早于过去时刻」。当前:{EXPIRED_ORDER_SCAN_SQL}"
|
||||||
|
);
|
||||||
|
// 反证:以下是**写反**的形态(绑正数小时 ⇒ 未来时刻 ⇒ 对活跃订单恒真)
|
||||||
|
assert!(
|
||||||
|
!EXPIRED_ORDER_SCAN_SQL.contains("start_time < TIMESTAMPADD(HOUR, ?, NOW())")
|
||||||
|
&& !EXPIRED_ORDER_SCAN_SQL.contains("NOW() + INTERVAL")
|
||||||
|
&& !EXPIRED_ORDER_SCAN_SQL.contains("TIMESTAMPADD(HOUR, 10, NOW())"),
|
||||||
|
"方向写反:刚建单几分钟的活跃订单会被当超时单提前结掉、电量提前结算。\
|
||||||
|
当前:{EXPIRED_ORDER_SCAN_SQL}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **双向语义断言**:刚建的不命中 / 超期的命中 / 边界 1 分钟不算,
|
||||||
|
/// 并**反证**写反方向时"刚建的单"必然被命中 —— 即"方向反了一定红"。
|
||||||
|
#[test]
|
||||||
|
fn test_is_expired_two_way_assertions_catch_reversed_direction() {
|
||||||
|
let now = chrono::NaiveDate::from_ymd_opt(2026, 1, 1)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(12, 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
let fresh = now - chrono::Duration::minutes(5); // 刚建 5 分钟
|
||||||
|
let just_over = now - chrono::Duration::hours(TIMEOUT_HOURS) - chrono::Duration::minutes(1);
|
||||||
|
let just_under = now - chrono::Duration::hours(TIMEOUT_HOURS) + chrono::Duration::minutes(1);
|
||||||
|
|
||||||
|
// ① 刚建的不命中(防"恒真")
|
||||||
|
assert!(
|
||||||
|
!is_expired(fresh, now, TIMEOUT_HOURS),
|
||||||
|
"刚建 5 分钟的订单绝不能被判超时(提早结单会提前结算电量)"
|
||||||
|
);
|
||||||
|
// ② 超期的命中(防"恒假")
|
||||||
|
assert!(
|
||||||
|
is_expired(just_over, now, TIMEOUT_HOURS),
|
||||||
|
"已过 {} 小时 1 分钟的订单必须判超时",
|
||||||
|
TIMEOUT_HOURS
|
||||||
|
);
|
||||||
|
// ③ 边界:距超时还差 1 分钟 ⇒ 不命中
|
||||||
|
assert!(
|
||||||
|
!is_expired(just_under, now, TIMEOUT_HOURS),
|
||||||
|
"还差 1 分钟不算超时(阈值是严格小于)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ④ 反证:把阈值加到 now 上(等价于 SQL 里绑正数)⇒ 刚建的单也"命中"
|
||||||
|
let reversed_hits_fresh = fresh < now + chrono::Duration::hours(TIMEOUT_HOURS);
|
||||||
|
assert!(
|
||||||
|
reversed_hits_fresh,
|
||||||
|
"反证失败:写反的方向本应命中刚建单;若这里不成立,说明反证用例本身失效"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
is_expired(fresh, now, TIMEOUT_HOURS),
|
||||||
|
reversed_hits_fresh,
|
||||||
|
"正确方向与写反方向对「刚建的单」的判定必须相反 —— 否则测试抓不到方向写反"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 常量自身合理性(防阈值被改小到"把正常充电判超时")
|
||||||
|
#[test]
|
||||||
|
fn test_timeout_constants_are_sane() {
|
||||||
|
assert_eq!(TIMEOUT_HOURS, 10, "协议/业务约定为 10 小时");
|
||||||
|
assert!(
|
||||||
|
TIMEOUT_HOURS > 0,
|
||||||
|
"阈值必须为正 —— 负数会让条件恒假、超时单永不结单"
|
||||||
|
);
|
||||||
|
assert!(BATCH_LIMIT > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,11 +69,24 @@ const PENDING_WINDOW_MS: i64 = 60_000;
|
|||||||
|
|
||||||
/// **连接级认证标记**(`device:authed:{imei}`)的 TTL(秒),与设备在线 TTL 一致。
|
/// **连接级认证标记**(`device:authed:{imei}`)的 TTL(秒),与设备在线 TTL 一致。
|
||||||
///
|
///
|
||||||
/// 为什么需要它:api-server 下发指令前要判断"该 IMEI **是否存在已登录的连接**"(协议 §4.2)。
|
/// **为什么需要它**:api-server 下发指令前要判断"该 IMEI **是否存在已登录的连接**"(协议 §4.2)。
|
||||||
/// 过去用 Redis 在线键 `device:{imei}` 判断,但该键在**验签前**就会被写(`auth_str`/`login`
|
/// 在线键 `device:{imei}` 判不了这件事,因为它表达的是**另一件事**:
|
||||||
/// 到达即写)、断连后还能存活 300 秒 ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
|
/// - `online` 字段由 api-server 在 `login` **验签成功后**(`report_worker.rs::handle_login`
|
||||||
/// 该标记只在 **`login` 验签成功**(投递含 `spec`+`suc=1` 的应答)时写入、
|
/// 的 `sign == expected_sign` 分支内,`mark_device_online`)以及 `status_post`
|
||||||
/// 该连接每次上行/下行时刷新、断连或被接管时删除。
|
/// (`write_device_state`,该 act 已被本服务的登录门控挡住,必然来自已认证连接)写入;
|
||||||
|
/// - 但它的 **TTL 是"最近 300 秒收到过该设备任意报文"**(协议 §2.3),断连后仍会存活最多 5 分钟。
|
||||||
|
///
|
||||||
|
/// 也就是说:`device:{imei}` 回答的是"**该设备最近活跃吗**",而不是"**现在有没有一条能收指令的
|
||||||
|
/// 已登录连接**"。两者在"设备刚断开"这个窗口里必然不一致 —— 若用前者判可否下发,就会出现
|
||||||
|
/// "管理端显示已下发成功、设备实际收不到"。
|
||||||
|
///
|
||||||
|
/// 因此本标记的判据是**严格的连接级事实**:只在 `login` 验签成功(投递含 `spec`+`suc=1`
|
||||||
|
/// 的应答)时写入、该连接每次上行/下行时刷新、断连或被接管时**立即删除**。
|
||||||
|
/// ⚠️ 另需注意 `device-server` 自己也会写 `device:{imei}`(`server.rs::update_device_route`
|
||||||
|
/// 写 `node_id` + `EXPIRE 300`),那是在 `auth_str`/`login` **到达时**(验签之前)发生的 ——
|
||||||
|
/// 它只刷新 TTL、不写 `online`,效果是"未认证报文也可能把 `online` 的存活期往后顺延一次"。
|
||||||
|
/// 该行为与协议 §2.3「收到过该设备任意报文即算活跃」一致,**不是**"未验签就算在线",
|
||||||
|
/// 故保留;但它进一步说明**不能**用 `device:{imei}` 当"可下发"的判据。
|
||||||
const AUTH_MARKER_TTL_SECS: u32 = 300;
|
const AUTH_MARKER_TTL_SECS: u32 = 300;
|
||||||
|
|
||||||
/// 连接级认证标记的 Redis 键(**跨服务契约**:device-server 写、api-server 读)。
|
/// 连接级认证标记的 Redis 键(**跨服务契约**:device-server 写、api-server 读)。
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="devices-page">
|
|
||||||
<!-- List -->
|
|
||||||
<view v-if="list.length === 0 && !loading" class="empty">
|
|
||||||
<text class="empty-text">暂无设备</text>
|
|
||||||
</view>
|
|
||||||
<view v-else class="device-list">
|
|
||||||
<view
|
|
||||||
v-for="item in list"
|
|
||||||
:key="item.id"
|
|
||||||
class="device-card"
|
|
||||||
@click="goDetail(item.id)"
|
|
||||||
>
|
|
||||||
<view class="device-header">
|
|
||||||
<text class="device-name">{{ item.name || item.abstract_id }}</text>
|
|
||||||
<text class="device-status" :class="item.status === 1 ? 'online' : 'offline'">
|
|
||||||
{{ item.status === 1 ? '在线' : '离线' }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
<text class="device-id">ID: {{ item.abstract_id }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
|
||||||
import { h5Api, type CabinetItem } from '../../api'
|
|
||||||
|
|
||||||
const list = ref<CabinetItem[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
onShow(() => {
|
|
||||||
fetchList()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function fetchList() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await h5Api.getCabinets()
|
|
||||||
list.value = res.data || []
|
|
||||||
} catch {
|
|
||||||
// 静默失败
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goDetail(id: number) {
|
|
||||||
uni.navigateTo({ url: `/pages/device-detail/index?id=${id}` })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.devices-page {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
padding: 80px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.empty-text {
|
|
||||||
font-size: 28px;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
.device-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.device-card {
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.device-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.device-name {
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
.device-status {
|
|
||||||
font-size: 24px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.device-status.online {
|
|
||||||
color: #16a34a;
|
|
||||||
background-color: #f0fdf4;
|
|
||||||
}
|
|
||||||
.device-status.offline {
|
|
||||||
color: #9ca3af;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
.device-id {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Loading…
x
Reference in New Issue
Block a user