From c3a3ea7c3a8614e357657ca5a08346bec72fc9a3 Mon Sep 17 00:00:00 2001 From: 12451 <12451@example.com> Date: Sun, 20 Sep 2026 04:31:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(api)+fix(device):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=8F=96=E5=80=BC=E6=94=B6=E6=95=9B=E3=80=81=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=96=B9=E5=90=91=E5=AE=88=E9=97=A8=E8=A1=A5=E9=BD=90=E3=80=81?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E5=88=A4=E6=8D=AE=E5=8F=A3=E5=BE=84=E8=AE=A2?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 顺序发布到测试与生产。 --- software/api-server/src/db/migrate.rs | 142 ++++++++-- .../api-server/src/routes/cabinet_tree.rs | 189 ++++++++++++- software/api-server/src/routes/cabinets.rs | 183 +++++++++++-- .../api-server/src/routes/energy_stats.rs | 249 ++++++++++++++++-- software/api-server/src/routes/h5.rs | 201 +++++++++++--- .../api-server/src/workers/order_timeout.rs | 137 ++++++++-- software/device-server/src/tcp/connection.rs | 23 +- .../h5-uniapp/src/pages/devices/index.vue | 105 -------- 8 files changed, 994 insertions(+), 235 deletions(-) delete mode 100644 software/h5-uniapp/src/pages/devices/index.vue diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index 2f454fc..b02ce77 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -46,7 +46,7 @@ const SCHEMA_SQL: &str = r#" cell TINYINT DEFAULT 6 COMMENT '仓控板数量', work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式', 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, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES projects(id) @@ -251,6 +251,21 @@ const SCHEMA_SQL: &str = r#" ) 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> { // 逐条执行全量建表语句,避免多语句执行问题 @@ -313,25 +328,47 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { ) .await?; - // 更新 cabinets.status 注释(**旧注释漏了禁用档**)。 + // 更新 cabinets.status 注释(**本列是"启用/禁用"配置态,不是"在线"**)。 // - // 旧注释只有 `0离线 1在线`,但代码实际消费的是三档: - // - `2` = 禁用 → `routes/h5.rs::ensure_cabinet_enabled` 与 `CABINET_DISABLED_SQL` - // (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)据此拒绝 - // 开合闸 / 开门 / 充电;`routes/cabinets.rs::set_cabinet_status` 也以 `status == 2` - // 判定"禁用",其余值一律按"启用"处理; - // - `1` = 在线 → `routes/energy_stats.rs::count_visible_cabinets(online_only=true)` - // 按 `c.status = 1` 统计在线柜数。 - // 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`)。 - // marker 取注释里的独有片段:老注释不含它 → 只 ALTER 一次,之后启动不再重复 DDL。 - ensure_column_comment( + // 全仓复核结论(grep 实测): + // - **唯一写入方** `routes/cabinets.rs::set_cabinet_status`: + // `UPDATE cabinets SET status = ? WHERE id = ?`,语义是 `2` = 禁用 / 其它值 = 启用; + // - **没有任何地方写 0**(0 只可能由建表默认值 `DEFAULT 0` 或该接口传入 0 时产生); + // - 消费方只有"禁用"一档:`commands.rs::CABINET_DISABLED_SQL` + // (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)与 + // `routes/h5.rs::ensure_cabinet_enabled` 据此拦截开合闸 / 开门 / 充电。 + // + // 旧注释 `0离线 1在线 2禁用` 里的"**在线**"是**语义错配**: + // 生产实测 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, "cabinets", "status", - "2禁用", - r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)'"#, + CABINETS_STATUS_COMMENT_ALIGN.0, + CABINETS_STATUS_COMMENT_ALIGN.1, ) - .await?; + .await + { + tracing::error!( + "[migrate] cabinets.status 注释更新失败(服务继续启动;老库注释可能仍是旧的 `0离线 1在线`)err={}", + e + ); + } // 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。 // @@ -1379,7 +1416,10 @@ async fn grant_permissions_by_codes( #[cfg(test)] 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` 中每条建表语句的固定前缀 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` 只与 `DateTime` 兼容,而本项目时间列在 Rust 侧全是 diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index 5dea63e..328468a 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -443,6 +443,100 @@ async fn fetch_filter_page( 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 { + 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]) -> 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>, 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` 字段个数。 /// /// ⚠️ **命令顺序与下标一一对应**:`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 let mut pipe = redis::pipe(); for row in rows.iter() { - let key = format!("device:{}", row.imei); - pipe.cmd("HGET").arg(&key).arg("online"); + let key = device_key(&row.imei); + pipe.cmd("HGET").arg(&key).arg(DEVICE_ONLINE_FIELD); pipe.cmd("HGET").arg(&key).arg("rssi"); pipe.cmd("HGET").arg(&key).arg("pow_fail_dc"); pipe.cmd("HGET").arg(&key).arg("pow_fail_ac"); @@ -486,7 +580,9 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re // 每6个一组解析 for (i, row) in rows.iter_mut().enumerate() { 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 .get(base + 1) .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> = 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> = 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)------- /// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key) #[test] diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index eff2369..cfb1beb 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -176,9 +176,17 @@ pub async fn create_cabinet( 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( - r#"INSERT INTO cabinets (project_id, imei, auth_str) - VALUES (?, ?, ?)"#, + r#"INSERT INTO cabinets (project_id, imei, auth_str, status) + VALUES (?, ?, ?, 1)"#, ) .bind(body.project_id) .bind(imei) @@ -255,6 +263,25 @@ pub struct SetCabinetStatusBody { 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 — 设置柜子状态(启用/禁用) /// /// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。 @@ -272,6 +299,15 @@ pub async fn set_cabinet_status( 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 rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?") .bind(body.status) @@ -617,6 +653,9 @@ pub async fn get_cabinet_detail( // 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00" "status1": realtime.status1, "status2": realtime.status2, + // **实时在线**(Redis `device:{imei}.online`,与 PC 设备列表字段名一致): + // true 在线 / false 明确离线 / null 无数据。⚠️ 与下面的 `status`(启用/禁用配置态)无关。 + "is_online": realtime.online, "address": cabinet.address, "latitude": cabinet.latitude, "longitude": cabinet.longitude, @@ -656,6 +695,14 @@ struct CabinetRealtime { /// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。 /// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。 status2: Option, + /// **实时在线**(Redis `device:{imei}.online`)—— 三态,与"无数据"严格区分: + /// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = 无数据(键或字段不存在)。 + /// + /// ⚠️ **与 `cabinets.status` 无关**:后者是启用/禁用**配置态** + /// (`2` = 禁用,其余 = 启用),全仓没有任何写入方会把它写成 0(离线), + /// 拿它当"在线"会让详情页在设备一台未连时依然显示"在线"。 + /// 判据来自 `cabinet_tree::device_online_flag`(与设备列表 `is_online` 同源)。 + online: Option, /// 仓体 id -> (SOC, 电池ID) soc_map: std::collections::HashMap, Option)>, /// 仓体 id -> 通道级实时状态 @@ -680,17 +727,34 @@ struct ChannelRealtime { current: Option, } -/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数: -/// `rssi` / `pow_fail_dc` / `pow_fail_ac` / `temp` / `cabinet_status1` / `cabinet_status2`。 +/// pipeline 中柜级(`device:{imei}`)HGET 的**字段清单**。 /// -/// ⚠️ **命令顺序与下标一一对应**:本常量同时是 [`fetch_cabinet_realtime`] 下发 `HGET` 的 -/// 顺序(下标 0..[`CABINET_REALTIME_FIELDS`])与 [`parse_cabinet_realtime`] 的柜级下标解析依据, -/// 还决定了 SOC 段(`CABINET_REALTIME_FIELDS + i`)与通道段(`+ comp_ids.len()`)的起始偏移。 -/// **新增柜级字段必须同步改这一处,且只能追加在末尾**(改动即破坏下标对齐)。 -const CABINET_REALTIME_FIELDS: usize = 6; +/// ⚠️ **数组顺序 = pipeline 命令顺序 = 解析下标**,三者由本常量唯一决定 +/// (原先命令顺序与下标是两处手写、靠注释对齐,容易漂移)。 +/// **新增柜级字段只能追加在末尾**:柜级段(`0..`[`CABINET_REALTIME_FIELDS`])之后是 +/// SOC 段与通道段,两段的起始偏移都由 [`CABINET_REALTIME_FIELDS`] 派生。 +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`) const CABINET_STATUS1_IDX: usize = 4; 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 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}) 实时状态 /// -/// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。 +/// 为什么放一起:详情接口要求低延迟,柜级 [`CABINET_REALTIME_FIELDS`] 个字段 +/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 个字段共用一次网络往返。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 /// /// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段: @@ -793,16 +858,11 @@ async fn fetch_cabinet_realtime( }; let key = format!("device:{}", imei); - // ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及 - // parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾) + // ⚠️ 命令顺序由 [`CABINET_REALTIME_FIELD_NAMES`] 唯一决定 —— 与解析下标不可能再漂移 let mut pipe = redis::pipe(); - pipe.cmd("HGET").arg(&key).arg("rssi") - .cmd("HGET").arg(&key).arg("pow_fail_dc") - .cmd("HGET").arg(&key).arg("pow_fail_ac") - .cmd("HGET").arg(&key).arg("temp") - // 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析 - .cmd("HGET").arg(&key).arg("cabinet_status1") - .cmd("HGET").arg(&key).arg("cabinet_status2"); + for field in CABINET_REALTIME_FIELD_NAMES { + pipe.cmd("HGET").arg(&key).arg(field); + } for cid in comp_ids { pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); } @@ -859,6 +919,10 @@ fn parse_cabinet_realtime( // 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值) status1: pick_non_empty(vals, CABINET_STATUS1_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() }; @@ -1107,20 +1171,51 @@ pub async fn list_compartment_options( mod tests { use super::*; - /// pipeline 结果解析:柜级 6 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局, - /// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取) + /// `PUT /api/cabinets/:id/status` 的入参值域守门。 + /// + /// 为什么必须测:`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] fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() { let comp_ids = vec![11_i64, 12_i64]; let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))]; let vals: Vec> = vec![ - // 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2 + // 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2 / online Some("85".into()), Some("1".into()), Some("0".into()), Some("29815".into()), Some("10".into()), Some("01".into()), + Some("1".into()), // 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None) None, None, @@ -1146,6 +1241,7 @@ mod tests { assert_eq!(rt.temp, Some(29815), "柜温应被取出"); assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 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 应有实时状态"); assert_eq!(ch11.voltage, Some(53200), "电压应被取出"); @@ -1230,15 +1326,44 @@ mod tests { } /// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步 - /// (命令顺序与下标一一对应;新增柜级字段只能追加在末尾) + /// (命令顺序由字段清单唯一决定;新增柜级字段只能追加在末尾) #[test] fn test_cabinet_realtime_field_indices_are_in_sync() { assert_eq!( - CABINET_REALTIME_FIELDS, 6, - "柜级 HGET 字段数变了:必须同步 fetch_cabinet_realtime 的命令顺序与本文件的下标解析" + CABINET_REALTIME_FIELDS, 7, + "柜级 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 = vec![]; + let comp_to_board: Vec<(i64, (i8, i8))> = vec![]; + let with_online = |raw: Option| -> Vec> { + 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) @@ -1246,7 +1371,7 @@ mod tests { 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))]; - // 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 + // 柜级 7 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 let vals: Vec> = vec![ None, None, @@ -1255,6 +1380,7 @@ mod tests { None, None, None, + None, Some("29815".into()), Some("82".into()), Some("03".into()), @@ -1266,6 +1392,7 @@ mod tests { assert_eq!(rt.temp, None); assert_eq!(rt.rssi, None); + assert_eq!(rt.online, None, "在线字段缺失时必须是 None(无数据),不能落成 false"); let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)"); assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0"); assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0"); diff --git a/software/api-server/src/routes/energy_stats.rs b/software/api-server/src/routes/energy_stats.rs index 16adae6..f43dc1a 100644 --- a/software/api-server/src/routes/energy_stats.rs +++ b/software/api-server/src/routes/energy_stats.rs @@ -25,6 +25,8 @@ use serde_json::{json, Value}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; +use super::cabinet_tree; + /// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh const COST_PER_WH_CENT: i64 = 80; @@ -166,41 +168,121 @@ fn charge_record_filters(user: &CurrentUser) -> (String, Vec) { (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) { + 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) { + 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) { + 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( pool: &sqlx::MySqlPool, user: &CurrentUser, - online_only: bool, ) -> Result { - if user.role_level >= 2 { - let sql = if online_only { - "SELECT COUNT(*) FROM cabinets WHERE status = 1" - } 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 { + let (sql, binds) = visible_cabinets_count_sql(user); + let mut query = sqlx::query_scalar::<_, i64>(&sql); + for bind in &binds { query = query.bind(bind); } Ok(query.fetch_one(pool).await?) } +/// 取当前用户**可见柜子的 IMEI 列表**(组织隔离 + 数据范围,顺序无关) +/// +/// `imei` 列可空(老数据/联调库有 NULL),按 `Option` 解码后过滤, +/// 避免一行 NULL 让整个在线统计解码失败(接口 500)。 +async fn visible_cabinet_imeis( + pool: &sqlx::MySqlPool, + user: &CurrentUser, +) -> Result, AppError> { + let (sql, binds) = visible_cabinet_imeis_sql(user); + let mut query = sqlx::query_scalar::<_, Option>(&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 { + let imeis = visible_cabinet_imeis(&state.mysql, user).await?; + Ok(cabinet_tree::count_online_devices(state, &imeis).await) +} + /// 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); } let month_energy: i64 = month_q.fetch_one(pool).await?; - // 柜子总数 / 在线柜子数(含组织过滤 + 数据范围) - let cabinet_count = count_visible_cabinets(pool, &user, false).await?; - let online_count = count_visible_cabinets(pool, &user, true).await?; + // 柜子总数(全部可见柜,语义不变)/ 在线柜子数(**以 Redis 为准**,与 `cabinets.status` 无关) + let cabinet_count = count_visible_cabinets(pool, &user).await?; + let online_count = count_online_cabinets(&state, &user).await?; Ok(Json(json!({ "code": 0, @@ -874,4 +956,115 @@ mod tests { let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0; assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small); } + + // ----------------------------------------------------------------------- + // 在线柜数守门(防回归:不得再拿 `cabinets.status` 当"在线") + // ----------------------------------------------------------------------- + + /// 构造单测用的 `CurrentUser`(不连库;`permissions` 与范围按需给) + fn user(role_level: i32, organization_id: Option, project_ids: Vec) -> 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> = 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> = vec![None; 68]; + assert_eq!( + cabinet_tree::count_online_flags(¬hing_connected), + 0, + "无设备连接时在线数必须是 0,绝不能退化成柜子总数" + ); + + // 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0 + assert_eq!(cabinet_tree::count_online_flags(&[]), 0); + } } diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index 170c79f..4dafad8 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -164,8 +164,8 @@ pub async fn get_dashboard( let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope()); let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds }; - // 统计在线柜子数(带组织隔离) - let online_cabinets = count_online_cabinets(pool, is_super, org_id, &scope).await?; + // 统计在线柜子数(带组织隔离;**以 Redis 为准**,与 `cabinets.status` 配置态无关) + 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?; @@ -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( - pool: &sqlx::MySqlPool, + state: &AppState, is_super: bool, org_id: Option, scope: &ScopeFilter<'_>, ) -> Result { - if !is_super && org_id.is_none() { - return Ok(0); + // 总管理员:全部柜子的 IMEI,不带任何组织条件(豁免) + if is_super { + return finish_online_count(state, online_cabinet_imeis_sql(true, ""), Vec::new()).await; } - let count: i64 = if is_super { - sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1") - .fetch_one(pool) - .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? + // 客户侧账号**无组织归属 ⇒ 空集**(绝不退化成"全部"),与 `visible_org_scope` 同口径 + let Some(oid) = org_id else { + return Ok(0); }; - Ok(count) + // 组织隔离 + 数据范围(绑定顺序 = 占位符顺序:先组织,再范围) + let mut binds: Vec = 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, +) -> Result { + let mut query = sqlx::query_scalar::<_, Option>(&sql); + for bind in &binds { + query = query.bind(bind); + } + let imeis: Vec = 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>, ac: HashMap>, temp: HashMap>, + /// 设备在线(Redis `device:{imei}` 的 `online` 字段) + /// + /// 判据与 PC 设备列表的 `is_online` **同源**(`cabinet_tree::device_online_flag`): + /// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = **无数据**(键或字段不存在)。 + /// ⚠️ 与 DB 的 `cabinets.status`(启用/禁用**配置态**)**没有任何关系** —— + /// 小程序卡片不得再用 `cab.status` 推断在线。 + online: HashMap>, /// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开) /// /// 取不到(Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`, @@ -977,15 +1039,8 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin for r in rows { if let Some(ref imei) = r.imei { let key = format!("device:{imei}"); - // ⚠️ 字段顺序与下面的下标解析一一对应(新增字段只能追加在末尾) - pipe.cmd("HMGET").arg(&key).arg(&[ - "rssi", - "pow_fail_dc", - "pow_fail_ac", - "temp", - "cabinet_status1", - "cabinet_status2", - ]); + // ⚠️ 字段顺序由 CABINET_HMGET_FIELDS 唯一决定,与下面的下标解析一一对应 + pipe.cmd("HMGET").arg(&key).arg(&CABINET_HMGET_FIELDS); } } if !rows.is_empty() { @@ -1006,6 +1061,15 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin // 柜级状态位:原样透出 hex;空串/缺失 → None(响应 null,不落 "00") maps.status1.insert(imei.clone(), pick_non_empty_hex(vals.get(4))); 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") 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(); + // **实时在线**(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); // `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障 let (_, _, full_count, fault_count) = @@ -1083,6 +1151,8 @@ fn build_cabinet_list( "imei": r.imei, "iccid": r.iccid, "status": r.status, + // 实时在线(Redis,判据与设备列表 `is_online` 同源);`status` 仍是配置态 + "is_online": is_online, "project_id": r.project_id, "address": r.address, "latitude": r.latitude, @@ -2310,8 +2380,7 @@ mod tests { /// 小程序设备列表的柜级 hex 位域取值(协议 §3.8): /// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。 #[test] - fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() { - assert_eq!( + fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() { assert_eq!( pick_non_empty_hex(Some(&Some("00".into()))).as_deref(), Some("00"), "\"00\" 是「全部正常」的有效值,与「无数据」必须区分" @@ -2448,4 +2517,68 @@ mod tests { ); 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); + } } diff --git a/software/api-server/src/workers/order_timeout.rs b/software/api-server/src/workers/order_timeout.rs index d44bb2d..4cea773 100644 --- a/software/api-server/src/workers/order_timeout.rs +++ b/software/api-server/src/workers/order_timeout.rs @@ -23,6 +23,23 @@ const SCAN_INTERVAL_SECS: u64 = 300; /// 单轮最多处理订单数,避免一次扫描锁过多行 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 启动,常驻运行) /// /// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单 @@ -50,22 +67,13 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq // 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的, // 交给数据库算差值可避免本地时区换算偏差; // 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。 - // - // ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"start_time 早于 NOW() - 10 小时"。 - // - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是 - // **未来时刻** `NOW()+10h`,于是"start_time < 未来"对**几乎所有订单恒真**, - // 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算。 - // - ✅ 正确:把 start_time 往前推 10 小时再与 NOW() 比较(下面这种写法)。 - let expired: Vec<(i64, i64, Option)> = sqlx::query_as( - "SELECT id, compartment_id, start_time FROM charge_orders \ - WHERE status = 0 AND start_time IS NOT NULL \ - AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \ - ORDER BY start_time ASC LIMIT ?", - ) - .bind(TIMEOUT_HOURS) - .bind(BATCH_LIMIT) - .fetch_all(pool) - .await?; + // ⚠️ 方向说明与踩坑记录见 [`EXPIRED_ORDER_SCAN_SQL`] 的文档注释。 + let expired: Vec<(i64, i64, Option)> = + sqlx::query_as(EXPIRED_ORDER_SCAN_SQL) + .bind(TIMEOUT_HOURS) + .bind(BATCH_LIMIT) + .fetch_all(pool) + .await?; if expired.is_empty() { return Ok(()); @@ -96,3 +104,100 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq tracing::info!("[order_timeout] 本轮超时结单处理完成"); 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); + } +} + diff --git a/software/device-server/src/tcp/connection.rs b/software/device-server/src/tcp/connection.rs index 8d74149..5e8ee8d 100644 --- a/software/device-server/src/tcp/connection.rs +++ b/software/device-server/src/tcp/connection.rs @@ -69,11 +69,24 @@ const PENDING_WINDOW_MS: i64 = 60_000; /// **连接级认证标记**(`device:authed:{imei}`)的 TTL(秒),与设备在线 TTL 一致。 /// -/// 为什么需要它:api-server 下发指令前要判断"该 IMEI **是否存在已登录的连接**"(协议 §4.2)。 -/// 过去用 Redis 在线键 `device:{imei}` 判断,但该键在**验签前**就会被写(`auth_str`/`login` -/// 到达即写)、断连后还能存活 300 秒 ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。 -/// 该标记只在 **`login` 验签成功**(投递含 `spec`+`suc=1` 的应答)时写入、 -/// 该连接每次上行/下行时刷新、断连或被接管时删除。 +/// **为什么需要它**:api-server 下发指令前要判断"该 IMEI **是否存在已登录的连接**"(协议 §4.2)。 +/// 在线键 `device:{imei}` 判不了这件事,因为它表达的是**另一件事**: +/// - `online` 字段由 api-server 在 `login` **验签成功后**(`report_worker.rs::handle_login` +/// 的 `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; /// 连接级认证标记的 Redis 键(**跨服务契约**:device-server 写、api-server 读)。 diff --git a/software/h5-uniapp/src/pages/devices/index.vue b/software/h5-uniapp/src/pages/devices/index.vue deleted file mode 100644 index 47437df..0000000 --- a/software/h5-uniapp/src/pages/devices/index.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - -