fix(security)+fix(api)+docs: 越权面修复、登录门控与状态语义对齐、文档全面校对

- 越权面(全部 before/after 实测可复现,10 条):cabinets/filter 的 project_id/organization_id
  参数分支越权(客户可拿对方向柜全字段)、organization-tree 返回全量组织树、
  cabin-boards/compartments/cabinets 三个 options 无归属校验、batteries 无归属(改为经
  charge_orders→cabinets→projects 间接归属)、feedback 跨组织读且能改他人状态、
  GET /downloads/{id} 漏 user_id 过滤(跨用户元数据泄露)、
  POST /downloads/cleanup 全局删除所有组织导出(改客户侧只清自己)、
  /api/nodes/* 仅需登录(客户可读内网 IP 并注销节点)→ 收紧 role_level>=2;
  /api/devices/stats 同样收紧;新增共享闸门 auth::require_super_admin
- TCP 登录门控:下行投递改为"登录前白名单"(PRE_LOGIN_ALLOWED_ACTS),与上行同一规则
- 60 秒超时对齐协议:挂起项 TTL 120→60 + 值内嵌 deadline 精确判窗 + 超时 WARN(兼容老格式)
- 下发前在线判据改连接级认证标记 device:authed:{imei}(消除"管理端显示成功、设备收不到")
  ⚠️ device-server 与 api-server 必须同批发布
- 协议对齐:auth_str 登记校验提到限流之前、build_spec Topology 分支 clamp(15/8)、
  行号引用改小节引用、修 3 处错引小节
- 状态语义:compartments.status 只表占用,充满/故障由通道状态派生;柜级 status1/status2
  与开门按钮(柜级独立动作不联动);列注释对齐(cabinets.status 补 2=禁用、
  charge_orders.status 标注 2 保留未使用)
- 假绿单测修复:超时 SQL 抽常量 + 纯函数孪生体双向断言(方向写反必红)
- RSSI 两端统一为同源纯函数 5 档(小程序显示随之变化)+ test/rssi_signal.test.mjs
- nginx:扫码入口 abstract 限流(zone=scan_abstract_zone 30r/m burst=20)+ 主配置仓库镜像
- 文档:AGENTS.md 新增「踩过的坑」7 条教训 + 部署注意事项 11 条 + 文档口径优先级;
  docs/API.md 接口总表 80 条与 mod.rs 逐条一致、Redis 契约按代码重写、9 处事实错误修正;
  权威协议文档 v1.1.1(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
This commit is contained in:
12451 2026-09-20 03:23:00 +08:00
parent 4e752953b0
commit 61adcdeaf9
25 changed files with 3388 additions and 678 deletions

538
AGENTS.md
View File

@ -21,7 +21,7 @@
- 数据库:MySQL(阿里云RDS),开发/生产共用 - 数据库:MySQL(阿里云RDS),开发/生产共用
- 缓存:Redis(阿里云Redis),开发/生产共用 - 缓存:Redis(阿里云Redis),开发/生产共用
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1) - 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
- 权限:RBAC,精确到按钮级别(权限码如 device:door:unlock) - 权限:RBAC,精确到按钮级别(权限码如 `charge:open_door`、`device:view`;**权威清单见 `software/api-server/src/db/migrate.rs::seed_permissions`,共 31 个**)
## 项目结构 ## 项目结构
@ -29,19 +29,21 @@
D:\charging-cabinet\ D:\charging-cabinet\
├── tasks/ # 任务文件(opencode执行) ├── tasks/ # 任务文件(opencode执行)
├── docs/ # 开发文档、协议文档 ├── docs/ # 开发文档、协议文档
├── deploy/ # 部署配置文件 ├── deploy/ # 部署配置与脚本
│ ├── nginx/ # Nginx配置 │ ├── nginx/ # Nginx 配置镜像(**三份**:nginx.conf 主配置 + app/test 两份 conf.d,均与线上逐字节一致)
│ ├── *.service # systemd服务单元 │ ├── *.service # systemd 单元(pms-api / pms-device × prod·test 共 4 个)
│ └── deploy-*.sh # 部署脚本 │ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口)
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」)
├── tools/ # 开发工具 ├── tools/ # 开发工具
│ └── db.py # 数据库工具 │ ├── db.py # 数据库工具(默认连测试库 pms_dev)
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
├── software/ ├── software/
│ ├── api-server/ # Rust API服务 │ ├── api-server/ # Rust API服务(产物 pms-api)
│ ├── device-server/ # Rust TCP设备服务 │ ├── device-server/ # Rust TCP设备服务(产物 pms-device-server)
│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin) │ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
│ └── web/ # React前端(WEB管理端) │ └── web/ # React前端(WEB管理端)
├── hardware/ # 硬件资料(原理图/PCB等) ├── hardware/ # 硬件资料(原理图/PCB等)
└── test/ # 测试用例/报告 └── test/ # 测试/验收脚本(TCP 端到端、结构对齐、状态位与 RSSI 两端一致性)
``` ```
> **技能库不入库** — 仓库内不存放任何 skills(`.opencode/skills`、`software/h5-uniapp/.agents/skills`、`software/h5-uniapp/agent/skills` 已于提交 `9829e9d` 移除)。开发期技能统一从全局技能目录加载:`~/.dsh/skills`(如 wechatide-skill)、`~/.agents/skills`(如 anysearch)。请勿在仓库内重新 vendor 技能副本。 > **技能库不入库** — 仓库内不存放任何 skills(`.opencode/skills`、`software/h5-uniapp/.agents/skills`、`software/h5-uniapp/agent/skills` 已于提交 `9829e9d` 移除)。开发期技能统一从全局技能目录加载:`~/.dsh/skills`(如 wechatide-skill)、`~/.agents/skills`(如 anysearch)。请勿在仓库内重新 vendor 技能副本。
@ -163,8 +165,11 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-p
``` ```
> ⚠️ **2026-09-20 生产结构变更(行为变化,非 bug)**:`organizations`、`projects`、`cabinets`、 > ⚠️ **2026-09-20 生产结构变更(行为变化,非 bug)**:`organizations`、`projects`、`cabinets`、
> `cabin_boards`、`compartments`、`users`、`roles` 这 **7 张表的 `updated_at` 恢复为 > `cabin_boards`、`compartments`、`batteries`、`users`、`roles` 这 **8 张表的 `updated_at` 恢复为
> `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`** > `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`**
> (实测依据:`information_schema.COLUMNS` 里这 8 张表的 `updated_at` 均为
> `EXTRA = DEFAULT_GENERATED on update CURRENT_TIMESTAMP`;代码侧对应
> `migrate.rs:844-851` 的 `LEGACY_COLUMN_TARGETS`,同样是 8 条)
> —— 此前它们是"人工从 `TIMESTAMP` 改成 `DATETIME` 时漏掉了 `ON UPDATE`"的偏离状态, > —— 此前它们是"人工从 `TIMESTAMP` 改成 `DATETIME` 时漏掉了 `ON UPDATE`"的偏离状态,
> 所以**这些表的 `updated_at` 从"不会自动刷新"变成"随 `UPDATE` 自动刷新"**, > 所以**这些表的 `updated_at` 从"不会自动刷新"变成"随 `UPDATE` 自动刷新"**,
> 看到 `updated_at` 变动属预期行为。同批变更还把两库全部时间列统一成 `DATETIME` > 看到 `updated_at` 变动属预期行为。同批变更还把两库全部时间列统一成 `DATETIME`
@ -196,7 +201,7 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-p
- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md) - 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md)
- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json) - 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json)
- 完成后由 PM(本 agent)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件 - 完成后由 PM(本 agent)验收:按「验收流程」的 7 项(cargo check + test → 前端真实类型检查 → 结构漂移巡检 → 端到端)逐条跑 → 并行子代理审查代码 → 不通过则写返工任务文件
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落 - 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议 - 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
- 已完成的任务文件及时清理 - 已完成的任务文件及时清理
@ -205,21 +210,142 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-p
所有编码任务必须追加以下约束: 所有编码任务必须追加以下约束:
1. 完成代码后执行对应栈静态校验(Rust→cargo check,TS→tsc --noEmit),零报错零警告 1. 完成代码后执行对应栈静态校验(Rust→`cargo check`;TS→**`npm run typecheck`(即 `tsc -b`)**),零报错零警告
- ⛔ **不要写 `tsc --noEmit`**:`software/web/tsconfig.json` 是 **solution 型**(`files: []` + `references`),
不带 `-b` 时 tsc **一个文件都不编译、直接 exit 0** ⇒ 假绿。详见「踩过的坑」第 2 条
2. 分层拆分,单函数≤80行,命名语义化,完整注释 2. 分层拆分,单函数≤80行,命名语义化,完整注释
3. 所有外部IO/网络请求异常捕获,禁止裸panic 3. 所有外部IO/网络请求异常捕获,禁止裸panic
4. 分支逻辑全覆盖,不遗漏兜底分支 4. 分支逻辑全覆盖,不遗漏兜底分支
5. 常量抽离,不使用废弃API 5. 常量抽离,不使用废弃API
6. Rust:内存安全,合理管理所有权,禁用unsafe无合理理由 6. Rust:内存安全,合理管理所有权,禁用unsafe无合理理由
7. React:函数式组件+Hooks,不写Class组件 7. React:函数式组件+Hooks,不写Class组件
8. **时间阈值类 SQL 必须配"刚建的不命中 + 超期的命中"双向断言**(见「踩过的坑」第 1 条)
### 验收流程 ### 验收流程
1. `cargo check` 两个服务(api-server + device-server)零错误 **实际执行的验收项(逐条都要跑,缺一条不算通过):**
```bash
# 1) 两个 Rust 服务静态校验 + 单测(零 error;警告也不放过)
cd software/api-server && cargo check --all-targets && cargo test --bin pms-api
cd software/device-server && cargo check --all-targets && cargo test --bin pms-device-server
# 2) 前端真实类型检查(⛔ 不是 tsc --noEmit:solution 型 tsconfig 下它空跑假绿)
cd software/web && npm run typecheck # = tsc -b
# 3) 结构漂移巡检(改了表结构/迁移必跑;顺手看 TIMESTAMP 巡检)
python tools/db_schema_diff.py
python tools/db_schema_diff.py --vs-ddl
python tools/db_schema_diff.py --db pms --vs-ddl
# 4) 结构对齐端到端(造临时行并自动清理,需该环境 JWT_SECRET)
python test/schema_alignment_e2e.py --env dev --jwt-secret <JWT_SECRET>
python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-prod
# 5) TCP 协议端到端(模拟柜控板,打测试环境 device-server :3001)
python test/tcp_e2e_smoke.py
# 6) 状态位解析两端一致性(逐位遍历,断言 PC 与小程序口径一致)
node test/device_status_parse.test.mjs
# 7) RSSI 分档两端一致性(遍历 0–99 逐值断言 level/bars/color 相同)
node test/rssi_signal.test.mjs
```
1. 上面 7 项全绿
2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束) 2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号) 3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
4. 审查通过 → 清理任务文件,更新文档 4. 审查通过 → 清理任务文件,更新文档
> ⚠️ **验收命令必须先自证能失败**:写完一条"通过"的验收命令,**故意造一个该命令应当抓到的缺陷,确认它真的报错**,
> 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(`tsc --noEmit` 就是这么假绿的,见「踩过的坑」第 2 条)。
---
## 踩过的坑(历史教训,改动前必读)
> 每条都是**真实踩过**且"当时没有任何报错"的类型。新写代码/新写验收时逐条对号入座。
### 1. `TIMESTAMPADD` / `INTERVAL` 做时间阈值,方向极易写反,写反了不报错、单测也能过
- 目标语义示例:"找出 `start_time` 早于 `NOW() - 10 小时` 的订单"。
- ❌ 错误写法:`start_time < TIMESTAMPADD(HOUR, ?, NOW())`,绑 `+10` → 得到**未来时刻**,
"`start_time` < 未来"对**几乎所有订单恒真** ⇒ 刚建单几分钟的活跃订单被当超时单提前结掉、电量提前结算。
- ✅ 正确写法:把 `start_time` **往前推**再与 `NOW()` 比 ——
`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`(`order_timeout.rs:62`),
或 `start_time < TIMESTAMPADD(HOUR, ?, NOW())` 且**绑负数**(`report_worker.rs:2445-2449` 绑 `-ORDER_TIMEOUT_HOURS`)。
- **规范(已写入「质量约束模板」第 8 条)**:这类改动必须配**双向断言**——
① **刚建的记录不命中**(防止"恒真");② **超期的记录命中**(防止"恒假")。
只断言"超期的命中"是无效测试:方向写反时它照样通过。
- ✅ **现状(2026-09 已修,勿再退回)**:`report_worker.rs` 的超时选单 SQL 已抽成常量
`FINALIZE_EXPIRED_ORDER_SQL`,并新增两个守门单测:
- `test_expired_order_condition_points_backwards` —— 断言 SQL 里是
`start_time < TIMESTAMPADD(`(阈值形态固定),且**显式排除**写反的形态;
- `test_is_expired_two_way_assertions_catch_reversed_direction` —— 用纯函数孪生体 `is_expired()`
做**双向断言**(刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中),
并**反证**"写反的方向必然命中刚建单",保证方向一反**一定红**。
已实测:把 SQL 改成 `TIMESTAMPADD(HOUR, 10, NOW())` 后该用例**立即 FAILED**。
⚠️ 遗留:`workers/order_timeout.rs` 的扫描条件是**另一种等价写法**
(`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`),该文件不在本次改动范围内,
**改动任一处都要同时确认另一处**。
### 2. 验收命令必须先自证能失败:`tsc --noEmit` 在 solution 型 tsconfig 下是空跑假绿
- `software/web/tsconfig.json` 是 **solution 文件**(`files: []` + `references`)。不带 `-b` 时
`tsc --noEmit` **一个文件都不编译、直接 exit 0**。
- **实测(2026-09)**:故意植入 `export const probe: number = "..."` 后,
`npx tsc --noEmit` → **exit 0、零输出**;`npm run typecheck`(= `tsc -b`)→
`src/probe.ts(2,14): error TS2322: Type 'string' is not assignable to type 'number'.`、**exit 2**。
- **正确命令**:`cd software/web && npm run typecheck`(`package.json` 里就是 `tsc -b`)。
`tsconfig.app.json` 已设 `noEmit: true`,`tsc -b` 不会吐 JS 产物。
- `software/h5-uniapp/tsconfig.json` 是**普通** tsconfig(有 `include`),`tsc --noEmit` 在那里
是有效的;但该工程**没有 typecheck 脚本**,且 `tsc` **不检查 `.vue` 文件**,小程序端的真实
类型安全目前靠构建 + `node test/device_status_parse.test.mjs`。
### 3. MySQL 的 `TIMESTAMP` 列与 sqlx `NaiveDateTime` **不兼容**(只与 `DateTime<Utc>` 兼容)
- 本仓 Rust 侧时间列全是 `NaiveDateTime` / `Option<NaiveDateTime>`;`TIMESTAMP` 列一碰就 500:
`mismatched types; Rust type Option<NaiveDateTime> (as SQL type DATETIME) is not compatible with SQL type TIMESTAMP`。
- **已实测的两处真实后果**:测试环境充电记录导出必败(`pms_dev.charge_records.start_time` 曾是 `TIMESTAMP`)、
生产 `/api/charge-orders` 一有订单就 500(`pms.charge_orders.start_time` 曾是 `TIMESTAMP`)。
- **规范**:**全库时间列必须是 `DATETIME`**;用 `python tools/db_schema_diff.py` 巡检
(它自带 TIMESTAMP 专项检查),`--vs-ddl` 再与 `migrate.rs::LEGACY_COLUMN_TARGETS` 对齐。
### 4. `CREATE TABLE IF NOT EXISTS` **不改已存在表的列定义** ⇒ 结构漂移会长期潜伏
- 它只保证"表存在"。所以 `SCHEMA_SQL` **只对全新库生效**,老库要靠启动时
`migrate.rs::align_legacy_columns` 拉齐;两边口径不一致 = 新建库与老库再次分叉。
- **改 `SCHEMA_SQL` 的列定义时,必须同步 `LEGACY_COLUMN_TARGETS`**(有单测守门)。
- 这条已经让"测试库与生产结构不一致"栽了 **5 次**(BRPOP 卡死、`iccid` NULL、`SCHEMA_SQL` 前向引用、
测试环境导出 500、生产 `/api/charge-orders` 500),每次都是人肉发现。
### 5. **不能相信"列有 DEFAULT 就不会插 NULL"** —— `INSERT ... SELECT NULL` 是**显式提供**,不退回默认值
- `column DEFAULT x` 只在**该列未出现在 INSERT 列清单里**时生效。
写成 `INSERT INTO t (a) SELECT NULL FROM ...` 时 `a` 是**显式 NULL**,MySQL 不会用默认值兜底
(`NOT NULL` 列则直接报 1364/1048)。
- 典型踩法:从别的表 `SELECT` 搬运时把某个"本该有默认值"的列显式选中(且它可为 NULL),
于是库里出现一批 NULL,接口再解码进非 `Option` 字段就 500。
- **规范**:`INSERT ... SELECT` 里的每一列都要**显式确认取值**;要么别写该列(让 DEFAULT 生效),
要么用 `COALESCE(源列, 期望默认值)`。写入后校验一次 `IS NULL` 计数。
### 6. Redis 实时键结构变更后,**必须清理旧键**(否则旧键会被当成实时值)
- **根因**:协议重构改掉了 key 格式与字段名,但**旧版本的键是"无 TTL 的永久键",没人清理也没人覆盖**,
于是它们与新版键**长期共存**,读侧只认 key 前缀就必然把旧值当实时值。
- **实测(2026-09,生产 db5)**:465 个 key 里 **462 个是旧格式残留**——
6 个 `device:{imei}`(`online=1` 且 **TTL=-1**)+ 60 个板级 `device:*:board:N`(只有 `status_hex`)
+ 360 个 0 基通道 `...:ch:M`(字段是旧版 `hex/on/full/fault`)+ 36 个死键 `compartment:status:*`。
- **后果**:管理端把 **6 台其实早已离线的柜子显示成"在线"并带 RSSI**
(`GET /api/cabinets/filter` 的 `online=true`/`rssi=85`),运维据此误判。
- **规范**:
1. **改 Redis key 结构或字段名时,同一次变更里必须清理/迁移旧键**(并在报告里给出清理前后 keyspace 统计);
2. **实时状态键一律带 TTL**,"无 TTL 的 `device:{imei}`"可直接判定为异常/历史残留;
3. 排查手法:用 `SCAN`(**不要用 `KEYS`**)按前缀分类统计 + 抽查 `TTL` 与字段名,
判据是"**字段名属于哪一版**"而不是"key 存不存在"。
- 记录:本次已清理 db5 的 462 个旧键,保留 `auth_str*` / `pending:*` / `device:reports` /
`device:commands*` / `node:*`(db6 无残留)。清理后 `/api/cabinets/filter` 的在线台数 **6 → 0**。
--- ---
## Rust 编码规范 ## Rust 编码规范
@ -386,7 +512,7 @@ src/
|------|------|------| |------|------|------|
| 单元测试 | Rust `#[test]` / Vitest | 核心业务逻辑必须覆盖 | | 单元测试 | Rust `#[test]` / Vitest | 核心业务逻辑必须覆盖 |
| 集成测试 | API 请求验证 | 关键接口(登录/充电/开门)必须验证 | | 集成测试 | API 请求验证 | 关键接口(登录/充电/开门)必须验证 |
| 静态校验 | `cargo check` / `tsc --noEmit` | 零报错零警告 | | 静态校验 | `cargo check` / **`npm run typecheck`(`tsc -b`)** | 零报错零警告;⛔ TS 侧不得用 `tsc --noEmit`(solution 型 tsconfig 下空跑假绿) |
### 测试覆盖率 ### 测试覆盖率
@ -414,7 +540,7 @@ test_check_permission_user_returns_forbidden
- JWT Token 有效期:24小时,过期需重新登录 - JWT Token 有效期:24小时,过期需重新登录
- JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret - JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret
- 密码存储:argon2 哈希,禁止明文或 MD5/SHA1 - 密码存储:argon2 哈希,禁止明文或 MD5/SHA1
- RBAC 权限码:精确到按钮级别(如 `device:door:unlock`) - RBAC 权限码:精确到按钮级别(如 `charge:open_door`、`device:operate`;权威清单=`db/migrate.rs::seed_permissions` 的 31 条)
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免 - 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
- 敏感操作(删除/禁用/开门)需二次确认 - 敏感操作(删除/禁用/开门)需二次确认
- **TCP 连接登录门控(上行 + 下行,同一规则)**:同一条 TCP 连接在 `login` 成功前**只允许 `auth_str` / `login`**; - **TCP 连接登录门控(上行 + 下行,同一规则)**:同一条 TCP 连接在 `login` 成功前**只允许 `auth_str` / `login`**;
@ -569,6 +695,18 @@ python tools/db.py exec "UPDATE users SET ..." # 写入
### Nginx配置 ### Nginx配置
> ⚠️ **下面是"骨架节选",不是线上全文** —— 线上配置还包含(2026-09 实测):
> 安全响应头(`Strict-Transport-Security` / `X-Content-Type-Options` / `X-Frame-Options` /
> `Referrer-Policy` / `Content-Security-Policy-Report-Only`)、
> **限流 location**(`= /api/pms/auth/login`、`= /api/pms/auth/challenge`,配合
> `/etc/nginx/nginx.conf` 里的 `limit_req_zone auth_login_zone / auth_challenge_zone / uploads_zone`)、
> `location /uploads/`(免认证同源直出 + 限流 + `expires 30d`)、生产侧 `location = /pms { return 301 /pms/; }`、
> 测试侧 `location /h5/`。
> **改动前先拉线上真实文件为准**:
> `plink -ssh root@118.31.119.206 "cat /etc/nginx/conf.d/app.anzhizhichong.com.conf"`。
> 仓库内 `deploy/nginx/` 是**三份配置的镜像**(`nginx.conf` 主配置 + `app`/`test` 两份 conf.d,
> 均与线上**逐字节一致**)—— 改线上前先比对,见「部署注意事项」第 10 条。
**正式环境** (`/etc/nginx/conf.d/app.anzhizhichong.com.conf`): **正式环境** (`/etc/nginx/conf.d/app.anzhizhichong.com.conf`):
```nginx ```nginx
server { server {
@ -708,27 +846,97 @@ npm run build:mp-weixin
``` ```
**2. 传输到服务器** **2. 传输到服务器**
> 产物名以 `Cargo.toml` 的 `[package] name` 为准:**api-server → `pms-api`**
> (`software/api-server/Cargo.toml:2`)、**device-server → `pms-device-server`**
> (`software/device-server/Cargo.toml:2`)。⛔ 旧文档写的 `device-server` 是**产物名写错**,
> 在 `target/.../release/` 下根本没有这个文件。
```bash ```bash
# 传输后端二进制(测试环境) # 传输后端二进制(测试环境)
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/ scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms_dev/device-server/ # ⚠️ 测试环境的落地文件名目前是 device-server(见 deploy/pms-device-test.service:11
# 的 ExecStart=/data/pms_dev/device-server/device-server),与 Cargo.toml 的
# pms-device-server 不一致 —— 这是既存事实,照它传,别改名(改名要同步改单元)
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms_dev/device-server/device-server
# 传输后端二进制(正式环境) # 传输后端二进制(正式环境)—— 生产落地名就是 Cargo.toml 产物名
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/ scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/
scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms/device-server/ scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms/device-server/
# 传输前端静态文件 # 传输前端静态文件
scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境 scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境
scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境 scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境
``` ```
> ⚠️ 后端二进制必须用 `cargo zigbuild --release --target x86_64-unknown-linux-gnu` 交叉编译,服务器 2核/3.5G 会 OOM,禁止在服务器上编译 Rust。 > 服务器实测(2026-09,`ls -la` + `ss -lntp`):
> > `/data/pms/api-server/pms-api`(:9000)、`/data/pms/device-server/pms-device-server`(:9001)、
> ⚠️ **`pscp`/`scp` 上传后必须 `chmod +x`**(否则 systemd 报 `203/EXEC`); > `/data/pms_dev/api-server/pms-api`(:3000)、`/data/pms_dev/device-server/device-server`(:3001)。
> 且**运行中的二进制不可直接覆盖**(Linux 报 `ETXTBSY`)—— 正确姿势:
> 传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。 #### 部署注意事项(最容易踩的坑,逐条都栽过)
>
> ⚠️ **`.env` 不支持行内注释**:`PORT=3000 # 注释` 会让配置解析失败、服务起不来,注释必须独占一行。 1. **上传后必须 `chmod +x`** —— 否则 systemd 报 `203/EXEC`,服务起不来。
2. **运行中的二进制不可直接覆盖**(Linux `ETXTBSY`)—— 正确姿势:
传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。
(`deploy-prod.ps1` 已内置这套流程。)
3. **`.env` 不支持行内注释** —— `PORT=9000 # 注释` 会让配置解析失败、服务起不来;
注释必须**独占一行**。(历史上用带行内注释的本地 `.env` 覆盖线上,导致生产起不来。)
4. **`exports` 目录必须预置、且属主为 `pms`** —— api-server 以 `User=pms` **降权运行**
(`deploy/pms-api-*.service` 的 `User=pms`/`Group=pms`),它**无法在 `/data/pms`(root 属主)
下自建目录**;`exports`(异步导出落盘)缺失或属主不对 → 导出任务直接失败。
实测正确属主:`drwxr-xr-x pms pms /data/pms/api-server/exports`(`uploads` 同理)。
5. **`worker_shutdown_timeout 30s`**(`/etc/nginx/nginx.conf` 第 2 行)—— 调短会让正在传输的
响应被中断;**改 nginx 配置前先 `nginx -t`**,通过后再 `systemctl reload nginx`。
6. **`stream {}` 块只能有一个** —— MySQL(3306)/Redis(6379) 的 stream 反代写在**主配置
`/etc/nginx/nginx.conf`** 里。⛔ **不要在 `/etc/nginx/conf.d/*.conf` 里再写一个 `stream {}`**:
nginx 会以 `"stream" directive is duplicate` 直接启动失败(连带整站不可用)。
conf.d 下只放 `http` 上下文的 `server {}`。
7. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来:
`EnvironmentFile=/data/pms/api-server/.env` / `/data/pms/device-server/.env`(测试环境同理)。
8. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。
部署后务必核对 `ss -lntp` 与域名返回码(见「部署架构」的判断方法)。
9. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条(无 TTL 的旧键会被当成实时值)。
10. **nginx 主配置也有仓库镜像,改线上前必须先比对** —— `deploy/nginx/nginx.conf`
(**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、116 行、3629 字节)。
`deploy/nginx/` 下三份分别是:
- `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `stream{}` / `worker_shutdown_timeout`)
- `app.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/app.anzhizhichong.com.conf`
- `test.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/test.anzhizhichong.com.conf`
**为什么必须有主配置镜像**:`limit_req_zone` 定义在**主配置**的 `http{}` 里,而 `conf.d/*.conf` 的
`location` 引用了这些 zone。**只照仓库的 conf.d 重建 nginx,zone 就没了 ⇒ `nginx -t` 直接失败、整站起不来。**
(历史已踩过 `stream{}` 重复、行内注释等同类问题。)
```bash
# 改线上前:先确认线上与仓库镜像一致(不一致就先搞清是谁改的)
plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "md5sum /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf"
md5sum deploy/nginx/nginx.conf deploy/nginx/*.conf # 纯 LF,可直接比对
# 逐行比对(推荐,能看出差在哪)
diff <(plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "cat /etc/nginx/nginx.conf") deploy/nginx/nginx.conf
```
改完线上后**把仓库镜像同步更新**(双向一致),否则下次比对全是噪声。
11. **新增域名/新增路径要限流,按这个顺序落(三步缺一不可)**:
1. **主配置 `http{}` 加 zone**(`deploy/nginx/nginx.conf` + 线上 `/etc/nginx/nginx.conf`):
```nginx
limit_req_zone $binary_remote_addr zone=<name>_zone:10m rate=<N>r/m;
```
⛔ zone 名要唯一;`limit_req_status 429;` 只需在主配置写一次(已在)。
> 现有 zone(2026-09 实测 4 个):`auth_login_zone` 10r/m、`auth_challenge_zone` 20r/m、
> `uploads_zone` 120r/m、**`scan_abstract_zone` 30r/m**(扫码查设备 `/api/cabinets/scan/{abstract_id}`
> 与 `/api/cabinets/abstract/{abstract_id}` 这类**免认证**入口,是枚举攻击面)。
2. **对应的 `conf.d/*.conf` 里给那个 location 挂 `limit_req`**:
```nginx
location = /api/pms/<path> {
limit_req zone=<name>_zone burst=N nodelay;
proxy_pass http://127.0.0.1:<port>/api/<path>;
...
}
```
⛔ **`limit_req` 引用的 zone 必须在主配置里存在**,否则 `nginx -t` 报
`unknown limit_req_zone "<name>_zone"` 而拒绝启动。
3. **先 `nginx -t`,通过后才 reload**:`nginx -t && systemctl reload nginx`。
⛔ 不要跳过 `-t` 直接 `restart` —— 配置错会让**整站**(含生产域)起不来。
4. 把线上改动同步回 `deploy/nginx/` 的对应文件(见第 10 条),并在报告里注明。
**2.1 微信小程序上传** **2.1 微信小程序上传**
@ -863,78 +1071,172 @@ tail -f /var/log/nginx/error.log
organization(组织/企业) organization(组织/企业)
├── project(项目/放置地点) ├── project(项目/放置地点)
│ └── cabinet(充电柜) │ └── cabinet(充电柜)
│ ├── cabin_board(仓控板×N) │ ├── cabin_board(仓控板×N) board_id 1–15
│ │ └── compartment(仓体×6) │ │ └── compartment(仓体×6) channel_id 1–8
│ ├── charge_record(充电记录) │ ├── charge_order(充电订单,进行中/已结算;active_flag 唯一键防并发)
│ └── energy_stat(能耗统计) │ ├── charge_record(充电记录,结算后留痕)
├── user(用户) │ ├── energy_stat(能耗统计,按 柜×日 唯一)
└── device_log(设备日志) │ └── device_log(设备日志)
├── user(用户) ──┬── user_roles ── role ── role_permissions ── permission
│ ├── user_scopes(数据范围,项目级生效)
│ └── operation_log(操作日志) / user_feedback(问题反馈) / download_tasks(下载中心)
└── abstract_binding(平台抽象ID 10-xxxxxxxx ↔ cabinet 一对一)
operation_log(操作日志) battery(电池台账,按 battery_id 唯一) ── sim_change_log(换 SIM 记录,按 imei)
``` ```
**外键与唯一键要点(改动前必读)**
- `compartments` 的父是 `cabin_boards`,`cabin_boards` 的父是 `cabinets`,`cabinets` 的父是 `projects`;
建表语句**顺序即依赖顺序**(`migrate.rs::SCHEMA_SQL`,被引用表必须先建,否则 MySQL 报 1824)。
- `charge_orders` / `charge_records` 同时外键到 `compartments` 与 `cabinets`。
- `energy_stats` 有 `UNIQUE(cabinet_id, stat_date)`;`abstract_bindings` / `batteries` / `cabinets.imei`(唯一) 同理。
### 数据库表 ### 数据库表
**实测:测试库 `pms_dev` 与生产库 `pms` 各 21 张表**(依据:
`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=...`,两库表名完全一致)。
| 表名 | 说明 | | 表名 | 说明 |
|------|------| |------|------|
| organizations | 组织表 | | `organizations` | 组织/企业(`parent_id` 支持父组织,NULL=顶级) |
| projects | 项目表 | | `projects` | 项目/放置地点(隶属组织) |
| cabinets | 充电柜表(IMEI、abstract_id、auth_str) | | `cabinets` | 充电柜(`imei` 唯一、`abstract_id`、`auth_str`+`auth_str_issued`、`cell`、`work_mode`、`status`、经纬度、`address`) |
| cabin_boards | 仓控板表 | | `cabin_boards` | 仓控板(`board_id` 1–15) |
| compartments | 仓体表 | | `compartments` | 仓体/通道(`channel_id` 1–8;`status` **只表示占用** 0 空闲/1 充电中,充满与故障不落库) |
| users | 用户表(手机号登录) | | `batteries` | 电池台账(`battery_id` 唯一、SOC‰、厂家/型号/材料代码原值、额定电压容量、状态) |
| charge_records | 充电记录表 | | `charge_orders` | 充电订单(进行中/已结算;`reason` normal/user_stop/forced/timeout;`active_flag` 唯一键防并发建单) |
| device_logs | 设备日志表 | | `charge_records` | 充电记录(结算后留痕;`energy` 单位 **Wh**、`max_temperature` K×100) |
| energy_stats | 能耗统计表 | | `device_logs` | 设备日志(`log_type` 1 状态上报 / 2 故障 / 3 告警 / 4 停电) |
| operation_logs | 操作日志表 | | `energy_stats` | 能耗统计(按 `cabinet_id` + `stat_date` 唯一,`total_energy` 单位 **Wh**) |
| `operation_logs` | 操作日志(按 `user_id` 归属组织;**不叠加项目级数据范围**) |
| `users` | 用户(`phone` 唯一、argon2 密码、`role` 0/1/2、`organization_id`) |
| `roles` | 角色(内置 `super_admin` / `org_admin` / `normal_user`) |
| `permissions` | 权限码字典(31 条,见 `migrate.rs::seed_permissions`) |
| `role_permissions` | 角色↔权限 关联(复合主键) |
| `user_roles` | 用户↔角色 关联(复合主键;**为空时按 `users.role` 回退内置角色权限**,见 `auth.rs::load_user_permissions`) |
| `user_scopes` | 数据范围(`scope_type` 1 组织 / 2 项目 / 3 设备;**只有 1、2 参与鉴权**) |
| `download_tasks` | 下载中心/异步导出任务(`task_type`、`status` 0排队~3失败、`file_path`) |
| `abstract_bindings` | 平台抽象ID ↔ 柜子 绑定(`abstract_id` / `cabinet_id` 均唯一) |
| `sim_change_logs` | SIM 卡(ICCID)变更记录(`login` 时检测到变更即写) |
| `user_feedback` | 用户问题反馈(内容/联系方式/处理状态) |
> 表清单的**权威来源**是 `software/api-server/src/db/migrate.rs::SCHEMA_SQL`(代码建表 DDL)
> 与库实测两处,两者必须一致;不一致用 `python tools/db_schema_diff.py --vs-ddl` 查。
### Redis 实时状态存储 ### Redis 实时状态存储
设备实时状态通过 Redis Hash 层级存储,按 `status_post` 上报内容解析写入。 > **权威依据:`software/api-server/src/workers/report_worker.rs` 的模块头注释 + `write_device_state` /
> `write_channel_state` / `handle_pow_fail` / `save_channel_battery`(写入侧),
> `workers/energy.rs::order_energy_key`(累加器),`device-server/src/tcp/server.rs::update_device_route`(路由)。**
> 下表是**逐字段核对代码后的实际写入契约**,不是设计稿。
**层级结构:** **层级结构(实测:测试库 db6 的真实 key/hash 与下表完全一致):**
``` ```
device:{imei} # 设备级 Hash device:{imei} # 设备级 Hash(TTL 300 秒,写它的每一条路径都会 EXPIRE 300)
├─ online = "1" # 在线标记(TTL 300秒) ├─ online = "1" # 在线标记(login 成功 / status_post 均会置)
├─ rssi = "85" # 信号强度 ├─ rssi = "85" # 信号强度(协议口径 0–99 等级,非 dBm)
├─ pow_fail_dc = "0/1" # 直流停电(12V掉电) ├─ temp = "29800" # 柜温 K×100(status_post 的 cabinet.temp)
├─ pow_fail_ac = "0/1" # 交流停电(380V掉电) ├─ meter = "123456" # 电表读数(status_post 的 cabinet.meter)
└─ device:{imei}:board:{board_idx} # 板级 Hash(仓控板1~N) ├─ cabinet_status1 = "01" # 柜级状态位 byte0(2 位 hex,**取不到不写**)
├─ status_hex = "000100020003000400050006" # 原始hex状态 ├─ cabinet_status2 = "00" # 柜级状态位 byte1(2 位 hex,**取不到不写**)
└─ device:{imei}:board:{board_idx}:ch:{0-5} # 通道级 Hash(6仓) ├─ pow_fail_dc = "0/1" # 直流停电(接触器分闸)
├─ on = "0/1" # 充电中 ├─ pow_fail_ac = "0/1" # 交流停电(柜控板自身断电)
├─ full = "0/1" # 充满 └─ node_id = "node-1" # 该设备 TCP 连接所在节点(device-server 写,用于多节点路由)
├─ fault = "0/1" # 故障
└─ hex = "00010002" # 原始hex device:{imei}:board:{板号}:ch:{通道号} # 通道级 Hash(**唯一的状态载体,无 TTL**)
├─ batt_status = "82" # byte0 电池状态(2 位小写 hex,位定义见协议 §3.8)
├─ pow_status = "82" # byte1 电源状态(2 位小写 hex,与电池状态独立)
├─ soc = "850" # SOC ‰(硬件 1% → ×10)
├─ voltage = "54000" # 电压 mV(硬件 0.01V → ×10)
├─ current = "8000" # 电流 mA(硬件 0.01A → ×10)
└─ temp = "29800" # 电芯最高温 K×100(硬件 1K → ×100)
device:{imei}:board:{板号}:ch:{通道号}:bat # 通道电池台账(内部键,TTL 48h,bat_in 登记)
├─ battery_id / soc / info / brand_code / model_code / material_code / cell_count
└─ (rated_voltage / rated_capacity)
device:{imei}:board:{板号}:ch:{通道号}:order # 通道订单电量累加器(内部键,Lua 原子累加)
├─ mwh / maxtemp / ts / nobat
└─ (由 workers/energy.rs 维护,供结单取电量)
``` ```
> ⚠️ **关键事实(旧文档写错了,逐条纠正):**
> 1. **板级 hash `device:{imei}:board:{板号}` 根本不存在** —— 现协议板级没有状态字节,
> 写入侧**只写通道级 key**。`/api/cabinets/realtime-status` 响应里那个 `boards[].status_hex`
> 是**接口现拼**的(各通道 `batt_status + pow_status` 按通道号升序拼接),**不是一个 Redis 键**。
> 生产 Redis 里若还能搜到 `device:*:board:N` 这种 hash(内容只有 `status_hex`),那是**旧版本残留**。
> 2. **通道号是 1 基(1–8),不是 0 基**;老数据里出现过 `...:ch:0`,也是旧版本残留。
> 板号同样从 1 开始,量程 1–15。
> 3. **通道级字段是 `batt_status` / `pow_status` / `soc` / `voltage` / `current` / `temp`**,
> **没有** `on` / `full` / `fault` / `hex` 这四个字段(那是旧格式;`on/full/fault/hex` 是接口
> 由 `batt_status`/`pow_status` **派生出来的响应字段**,不是存储字段)。
> 4. **板号和通道号十进制不补零**(`board:1:ch:3`,不是 `board:01:ch:03`)。
> 5. 设备级 key 的**字段可能是残缺的**(如只有 `online`+`rssi`):`cabinet_status1/2` 只在
> `status_post` 带合法 `cabinet.status1/2` 时才写;**读不到就是"无数据",绝不落 `"00"`**。
> 6. `device:{imei}` 的 **TTL 就是在线判据**(300 秒内无任意上行即判离线),
> 所以**任何写 `device:{imei}` 的代码都必须跟一条 `EXPIRE 300`**。
> 7. hex 编码格式**已定稿**(协议 §3.8 的位定义表),**不再有"待硬件团队确认"的悬置项**。
**写入时机:** **写入时机:**
| 事件 | 写入位置 | 说明 |
|------|----------|------|
| `login` | `device:{imei}.online` | 标记在线,设置TTL 300秒 |
| `status_post` | `device:{imei}` + board + ch | 解析status字典,按层级写入 |
| `pow_fail` | `device:{imei}.pow_fail_dc/ac` | 更新停电状态 |
**通道状态映射(hex → 语义):** | 事件 | 写入内容 |
- 空闲(idle) |------|----------|
- 插入(inserted) | `login`(验签通过) | `device:{imei}`:`online=1` + `EXPIRE 300`;另写 `node_id`(device-server 侧) |
- 充电中(charging) | `status_post` | `device:{imei}`:`online/rssi/temp/meter`(+`cabinet_status1/2`) + `EXPIRE 300`;<br>逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;<br>`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);<br>并累加 `...:order` 的电量 |
- 充满(full) | `pow_fail_dc` | `device:{imei}.pow_fail_dc = 1` |
- 故障(fault) | `pow_fail_ac` | `device:{imei}.pow_fail_ac = 1` |
| `bat_in` | `device:{imei}:board:{b}:ch:{c}:bat`(电池台账,TTL 48h) |
| 任意上行报文 | device-server 刷新 `device:{imei}` 的 TTL/`node_id`(路由用) |
> ⚠️ hex编码格式待硬件团队最终确认,当前实现预留状态字段,按 bit 位解析。 > ⛔ **没有 `pow_fail` 这条 act**:停电是**两条独立指令** `pow_fail_dc`(直流,接触器分闸→仓控板下电)
> 与 `pow_fail_ac`(交流,柜控板自身断电),各写**同名字段**(协议 §5.4 / §10 第 11 条)。
> `report_worker.rs:237` 的 `"pow_fail_dc" | "pow_fail_ac" => handle_pow_fail(...)` 是唯一入口。
**API 接口:** **通道状态位(`batt_status` / `pow_status`)—— 按协议 §3.8 位定义,不按"五态枚举":**
| 接口 | 说明 | 查询方式 |
|------|------|----------| `batt_status`(byte0):bit0 充满|bit1 正在充电|bit2–3 保留(不展示)|bit4 过欠压|bit5 过流|
| `GET /api/cabinets/filter` | 设备列表(设备级状态) | Pipeline 批量 HGET,1次往返 | bit6 热失控|**bit7 有电池**(决定"空仓/在位")。
| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | KEYS 扫描 + HMGET | `pow_status`(byte1):bit0 电源故障|bit1–2 电源状态位|bit3–6 保留(不展示)|bit7 电源有效。
**全仓唯一权威推导规则**(`routes/cabinet_tree.rs::derive_channel_flags`,前端
`web/src/utils/deviceStatus.ts` 与 `h5-uniapp/src/utils/deviceStatus.ts` 同源):
```
展示状态 = fault > full > charging > idle
fault = batt_status bit4|bit5|bit6 或 pow_status bit0
full = batt_status bit0
charging = batt_status bit1
无数据(batt_status / pow_status 都取不到)= has_data:false
→ 列表计数按 0 计、详情保留 DB 占用值,**绝不猜测**
```
> 旧文档那张"空闲/插入/充电中/充满/故障"五态表**已废弃**:它既不是协议口径,
> 也无法表达"电池在位但未充电"(`batt_status` bit7=1、bit0/bit1=0)这类真实状态。
**API 接口(接口总表以 [docs/API.md](docs/API.md) 为准,这里只列与 Redis 存储强相关的两个):**
| 接口 | 说明 | Redis 查询方式 |
|------|------|----------------|
| `GET /api/cabinets/filter` | 设备列表(设备级状态 + 充满/故障计数) | ① Pipeline `HGET` 每柜 6 个设备级字段(`online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2`,1 次往返);② 通道计数:**按 DB 的板号/通道号拼 key**(`fetch_channel_keys`,SQL 取拓扑)+ pipeline `HMGET(batt_status, pow_status)` —— **零 KEYS 扫描** |
| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | `HMGET` 取设备级 4 字段;板/通道:**按 DB 拓扑(`cabinets→cabin_boards→compartments`)拼候选 key + 一次 pipeline `HGETALL`**,空 map = 键不存在(等价于只返回存在的键)。<br>✅ **2026-09 已修**:旧实现用 `KEYS device:{imei}:board:*:ch:*`,在**与既有充电站平台共用**的 Redis 实例上会阻塞单线程(实测每次请求 `cmdstat_keys` +1),已彻底移除 |
> ⛔ **本项目禁止在任何请求路径上用 `KEYS`** —— Redis 实例是共用的,`KEYS` 会阻塞单线程遍历整个
> keyspace。要枚举就用「按 DB 拓扑拼 key」或 `SCAN` 游标。查是否有人用了:
> `grep -rn '"KEYS"' software/*/src/`。
## TCP通讯协议 ## TCP通讯协议
详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。 详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
> 📌 **文档口径优先级(避免"两份文档谁对"的扯皮)**:
> 1. **`docs/柜控板固件对接文档.md`** —— 设备协议(帧格式/`act`/字段/签名)的**唯一权威**;
> 2. **本文件(AGENTS.md)** —— **平台侧实现口径**的权威:Redis 键/字段、接口行为、库结构、
> 验收流程、部署注意事项。凡与 `docs/API.md` 的**平台存储/行为描述**不一致时,**以本文件为准**
> (本文件的每一条都按代码契约与库实测核对过);
> 3. **`docs/API.md`** —— **HTTP 接口清单与响应结构**的权威(含数据单位规范);
> 4. 代码与文档冲突时:**先确认是不是代码错**;确认代码对就改文档并在报告里点出。
## 数据单位规范 ## 数据单位规范
详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。 详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
@ -989,53 +1291,62 @@ const BASE_URL = '/api/pms'
### 信号强度显示(RSSI) ### 信号强度显示(RSSI)
设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和小程序共用同一套逻辑。 设备卡片右上角显示 RSSI 信号强度。
**RSSI 映射:** > ✅ **2026-09 已收敛为「两端同源同名、统一 5 档」**(此前两端不一致,见下方"行为变化")。
> **唯一权威实现**:`web/src/utils/deviceStatus.ts` 与 `h5-uniapp/src/utils/deviceStatus.ts`
> 的 `rssiSignal()` / `rssiBarColors()` / `RSSI_TIERS`(**两端逐字一致,改一边必须同步另一边**)。
> 一致性用例:`node test/rssi_signal.test.mjs`(遍历 0–99 逐值断言两端 `level`/`bars`/`color` 相同)。
> ⛔ **页面里不得再自己写一套阈值**(历史上两端各写一套,就是不一致的根因)。
| RSSI 范围 | 图标 (Lucide) | 信号格 | 颜色 | **统一分档表(协议口径:`rssi` 是 0–99 的信号等级,不是 dBm)**
|-----------|--------------|--------|------|
| ≥ 70 | signal | 5 格(含 M22) | #16a34a 绿色 |
| ≥ 55 | signal-high | 4 格(含 M17) | #2563eb 蓝色 |
| ≥ 40 | signal-medium | 3 格(含 M12) | #f59e0b 黄色 |
| ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 |
| < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) |
**实现方式(小程序):** | 阈值 | `level` | 颜色 | 5 格制格数 | PC 图标亮的 `<path>` |
|------|---------|------|-----------|---------------------|
| ≥ 70 | 4 `strong` | `#16a34a` 绿 | 5 | 4 条全亮 |
| ≥ 55 | 3 `good` | `#2563eb` 蓝 | 4 | 3 条 |
| ≥ 40 | 2 `fair` | `#f59e0b` 黄 | 3 | 2 条 |
| ≥ 30 | 1 `weak` | `#f97316` 橙 | 2 | 1 条 |
| < 30 或 null | 0 `none` | `#9ca3af` 灰 | 1(底格) | 0 条(只剩底部圆点 `M2 20h.01`) |
```vue **两端如何消费同一份纯函数:**
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" ...> - **PC**(`web/src/pages/devices/CabinetGrid.tsx::SignalIconComponent`):`rssiSignal(rssi)` 取 `color` 与 `level`,
<path v-if="(rssi ?? -1) >= 30" d="M7 20v-4"/> 4 条 `<path>` 按 `level > i` 决定亮几条;卡片头部数值颜色用同一个 `color`。
<path v-if="(rssi ?? -1) >= 40" d="M12 20v-8"/> - **小程序**(`home/index.vue`、`device-detail/index.vue`):
<path v-if="(rssi ?? -1) >= 55" d="M17 20V8"/> `v-for="(color, i) in rssiBarColors(cab.rssi)"` 渲染 `bars` 根 `<view class="signal-bar">`,
<path v-if="(rssi ?? -1) >= 70" d="M22 4v16"/> 颜色**内联** `:style="{ background: color }"`;**格数真的随档位变化**。
<path d="M2 20h.01"/> - 颜色只在 TS 里定义一次(不再有 `.signal.good/.mid/.low/.off` 这类 CSS 颜色规则;
</svg> 页面 CSS 只负责尺寸/间距)。
```
- `v-if` 按 RSSI 阈值动态显示信号格 **后端(两端共用):** 设备列表接口用 pipeline 批量 `HGET device:{imei} rssi`
- `<text>` 不支持 `display: flex`,统一用 `<view>` 包裹 (`routes/cabinet_tree.rs::attach_realtime_status`,字段下标见 `DEVICE_LIST_FIELDS`);
小程序侧 `routes/h5.rs::get_cabinets` 同样带 Redis。
**实现方式(PC 管理端):**
React 组件 `SignalIcon`,同上逻辑用条件渲染 `<path>`。
**后端 RSSI 获取:**
设备列表接口通过 Redis pipeline 批量 HGET 查询 `device:{imei}` 的 `rssi` 字段:
```
HSET device:{imei} online 1 rssi 85
```
**相关文件:** **相关文件:**
- PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件 - 纯函数(权威): `software/web/src/utils/deviceStatus.ts`、`software/h5-uniapp/src/utils/deviceStatus.ts`
- 小程序: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板 - PC 消费点: `software/web/src/pages/devices/CabinetGrid.tsx`(`SignalIconComponent` + 卡片头部)
- 小程序 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline - 小程序消费点: `software/h5-uniapp/src/pages/home/index.vue`、`pages/device-detail/index.vue`
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi` - 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
- 一致性用例: `test/rssi_signal.test.mjs`
> ⚠️ **行为变化(2026-09,小程序端显示会变 —— 这是有意为之)**
> 收敛前:PC 5 档(≥70 绿/≥55 蓝/≥40 黄/≥30 橙/<30 灰,格数随档位增删);
> 小程序只有 **4 档(无 ≥70)**、**30–39 用红色**、且**恒渲染 5 格只改颜色**。
> 收敛后小程序:**新增 ≥70 档(绿/满格)**、**30–39 由红改橙**、**≥55 由绿改蓝**、
> **离线灰由 `#94a3b8` 改为 `#9ca3af`**,且**格数随档位变化**(不再恒 5 格)。
> 影响页面:小程序首页设备卡片、小程序设备详情页头部。
### 近期 UI 改动 ### 近期 UI 改动
1. **设备卡片头部**:去掉"在线/离线"文字 badge,改为信号图标 + RSSI 数值 1. **设备卡片头部**:**PC 管理端**(`web/src/pages/devices/CabinetGrid.tsx`)去掉"在线/离线"文字 badge,
改为信号图标 + RSSI 数值。
✅ **小程序保留"在线/离线"文字 badge —— 这是有意差异,不是缺陷,不要当 bug 改掉**:
`h5-uniapp/src/pages/home/index.vue:43`(设备卡片,`cab.status===1 ? '在线' : '离线'`)与
`pages/device-detail/index.vue:18`(设备详情;同文件 `:74` 还有**板级**的在线/离线 badge)。
理由(产品决定):PC 用"信号图标 + RSSI 数值"表达;小程序屏幕小、文字 badge 更直观。
> PC 卡片:**图标 + 数值**;小程序:**文字 badge**(有意保留)。
另外 **PC 设备详情页**(`web/src/pages/devices/cabinet.tsx:503`)也仍用 `Tag` 渲染
`CABINET_STATUS_MAP`(`0 离线 / 1 在线 / 2 禁用`)—— 只是**卡片**去掉了,别理解成"全平台都去掉了"。
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中) 2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐 3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
@ -1044,8 +1355,13 @@ HSET device:{imei} online 1 rssi 85
> 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。 > 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。
1. **柜级状态位 → 显示在"柜子"层面** 1. **柜级状态位 → 显示在"柜子"层面**
- 数据:`cabinet.status1` / `cabinet.status2`(2 位 hex,**设备上报原样落 Redis** - 数据:**⚠️ 这两个名字不是 DB 列**(实测 `cabinets` 表**没有** `status1`/`status2` 列,
`device:{imei}.cabinet_status1/2`,接口原样透出为 `status1` / `status2`)。 它的列是 `id/project_id/abstract_id/imei/iccid/auth_str/name/address/status/created_at/updated_at/
latitude/longitude/work_mode/auth_str_issued/cell`)。它们是:
**Redis 字段** `device:{imei}` 的 `cabinet_status1` / `cabinet_status2`(设备上报原样落,2 位 hex)
→ 接口**原样透出**为响应字段 `status1` / `status2`。
写:`report_worker.rs::write_device_state`;读:`cabinet_tree.rs::attach_realtime_status`(列表)、
`cabinets.rs::fetch_cabinet_realtime`(详情)。
- 位定义(§3.8):`status1` bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|**bit4 柜门打开**| - 位定义(§3.8):`status1` bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|**bit4 柜门打开**|
bit5 加热风扇|bit6 接触器断开|bit7 喷淋;`status2` bit0 声光报警|**bit1 柜门打开超时** bit5 加热风扇|bit6 接触器断开|bit7 喷淋;`status2` bit0 声光报警|**bit1 柜门打开超时**
(阈值硬件定义,**平台只呈现、不自行判定**);`status2` bit2–7 为保留位,**不展示**。 (阈值硬件定义,**平台只呈现、不自行判定**);`status2` bit2–7 为保留位,**不展示**。

127
README.md
View File

@ -20,54 +20,77 @@
``` ```
charging-cabinet/ charging-cabinet/
├── tasks/ # 任务文件(opencode执行) ├── tasks/ # 任务文件(opencode执行)
├── docs/ # 开发文档、协议文档 ├── docs/ # 开发文档、协议文档(API.md、柜控板固件对接文档.md …)
├── deploy/ # 部署配置与脚本
│ ├── nginx/ # Nginx 配置(app / test 两份,与线上一致)
│ ├── *.service # systemd 单元(pms-api / pms-device × prod·test)
│ ├── deploy-prod.ps1 # 正式环境一键部署(推荐入口)
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出)
├── tools/ # 开发工具
│ ├── db.py # 数据库工具(默认连测试库 pms_dev)
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
├── software/ ├── software/
│ ├── api-server/ # Rust API服务 │ ├── api-server/ # Rust API服务(产物 pms-api)
│ ├── device-server/ # Rust TCP设备服务 │ ├── device-server/ # Rust TCP设备服务(产物 pms-device-server)
│ ├── web/ # React管理端前端 │ ├── web/ # React管理端前端
│ └── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin) │ └── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
├── hardware/ # 硬件资料(原理图/PCB等) ├── hardware/ # 硬件资料(原理图/PCB等)
├── deploy/ # 部署配置 └── test/ # 测试/验收脚本
└── test/ # 测试用例/报告 ├── tcp_e2e_smoke.py # TCP 协议端到端(模拟柜控板打测试环境 :3001)
├── schema_alignment_e2e.py # 结构对齐端到端(造临时行并自动清理)
├── device_status_parse.test.mjs # PC 与小程序状态位解析一致性
├── rssi_signal.test.mjs # PC 与小程序 RSSI 分档一致性(0–99 逐值)
└── security-hardening/ # 安全加固探针脚本
``` ```
> 本仓库不存放技能库(skills)。开发期技能从全局技能目录加载(`~/.dsh/skills`、`~/.agents/skills`),请勿在仓库内重新 vendor。 > 本仓库不存放技能库(skills)。开发期技能从全局技能目录加载(`~/.dsh/skills`、`~/.agents/skills`),请勿在仓库内重新 vendor。
## 部署架构 ## 部署架构
| 环境 | 域名 | 说明 | | 环境 | 域名 | API 服务 | TCP 服务 | MySQL | Redis DB | 部署路径 | 前端路径 |
|------|------|------| |------|------|----------|----------|-------|----------|----------|----------|
| 正式环境 | app.anzhizhichong.com | 后台管理 + API服务 | | 正式环境 | app.anzhizhichong.com | `:9000` | `:9001` | `pms` | db5 | `/data/pms` | `/pms/` |
| 测试环境 | test.anzhizhichong.com | 测试环境 | | 测试环境 | test.anzhizhichong.com | `:3000` | `:3001` | `pms_dev` | db6 | `/data/pms_dev` | `/pms/` |
**服务器**:CentOS Stream 9 > ⚠️ 两个环境**完全隔离**(独立库 + 独立 Redis DB + 独立端口 + 独立部署目录)。
> ⛔ **绝不要把测试域名的 API 指向生产端口 `:9000`** —— 历史事故:测试 nginx 曾指向当时的生产端口,
> 等于"测试后台在操作生产数据"。判断方法:`test` 域名 `/api/pms/` 返回 **401**(有测试后端)是**对的**;
> 返回 **502** 说明测试后端没起。
> ⚠️ 代码里的默认端口(api `3000` / tcp `3002`)只在**没设环境变量**时生效,线上由 `.env` 的
> `PORT` / `TCP_PORT` 覆盖 —— 以本表为准。
**服务器**:CentOS Stream 9(2核/3.5G/49G),`118.31.119.206`(ZeroTier `10.8.0.252`)
**内部服务**: **内部服务**:
- Gitea: https://app.anzhizhichong.com/doc/ - Gitea: https://app.anzhizhichong.com/doc/
- 文档Wiki: https://app.anzhizhichong.com/doc/anzhizhichong/charging-cabinet/wiki/ - 文档Wiki: https://app.anzhizhichong.com/doc/anzhizhichong/charging-cabinet/wiki/
**部署方式**:本地交叉编译(`cargo zigbuild --release --target x86_64-unknown-linux-gnu`)后传二进制,
**服务器上不装 Rust/Node**(会 OOM)。一键脚本与「部署注意事项」见 `AGENTS.md`「部署与维护」。
## 服务架构 ## 服务架构
``` ```
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ API服务 (api-server) — 主服务 │ │ API服务 (api-server / pms-api) — 主服务 │
│ - HTTP API(后台管理 + 小程序)端口: 3000 │ │ - HTTP API(后台管理 + 小程序)端口: 正式 9000 / 测试 3000 │
│ - 前端轮询刷新(每5秒) │ │ - 业务逻辑处理(验签/存DB/建单结单/告警/能耗) │
│ - 业务逻辑处理(验证/存DB/告警) │
│ - 节点管理(设备服务注册/心跳) │ │ - 节点管理(设备服务注册/心跳) │
│ - 后台协程处理Redis队列 │ │ - 后台 Worker:上报消费(RPOP 200ms 轮询) / 回执 / 订单超时 / │
│ 日志清理 / 占用对账 │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
↑ Redis LIST ↑ Redis LIST ↑ Redis LIST device:reports ↑ Redis LIST device:commands
│ device:report:{imei} │ device:cmd:{imei} │ (设备上报,device-server LPUSH) │ (平台指令,api-server LPUSH)
│ (设备上报) │ (平台指令) │ │ device-server 按 HGET device:{imei} node_id
│ │ │ │ 路由到 device:commands:{node_id}
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ 设备服务 (device-server) — 轻量化 │ │ 设备服务 (device-server / pms-device-server) — 轻量化 │
│ - TCP监听: 3002(设备长连接) │ │ - TCP监听: 正式 9001 / 测试 3001(设备长连接) │
│ - 只处理通信和指令解析 │ │ - 只处理通信和指令解析(归属/签名/业务全在 api-server) │
│ - 不存DB、不验证签名、不处理业务 │ │ - 不存DB、不验证签名、不处理业务 │
│ - 注册到API服务(启动/心跳/注销) │ │ - 节点注册/心跳:Redis `node:{node_id}`(含 ip/tcp_port/ │
│ - Redis连接注册: device:{imei} → {node_id, ip, port} │ │ status/last_heartbeat),TTL = DEVICE_REGISTER_TTL │
│ - 设备路由:`device:{imei}` 的 `node_id` 字段 │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
@ -78,21 +101,57 @@ charging-cabinet/
4. **协程处理任务** — API服务用tokio::spawn处理Redis队列消息 4. **协程处理任务** — API服务用tokio::spawn处理Redis队列消息
**通信流程:** **通信流程:**
- **设备上报**:设备 → TCP → 设备服务 → Redis LIST → API服务协程处理 - **设备上报**:设备 → TCP → 设备服务(`LPUSH device:reports`,带 `LTRIM` 上限)→ API服务 worker 消费
- **平台指令**:API服务 → Redis LIST → 设备服务 → TCP → 设备 - **平台指令**:API服务(`LPUSH device:commands`)→ 设备服务按 `node_id` 路由 → TCP → 设备
- **指令响应**:设备 → TCP → 设备服务 → Redis LIST → API服务匹配msg_id - **指令响应**:设备 → TCP → 设备服务 → Redis LIST → API服务按 `msg_id` + `act` 匹配回执
- **死信**:投递失败的指令进 `device:commands:dead`
> ⚠️ **队列消费一律用 `RPOP` 非阻塞轮询(200ms)+ 空队列 `sleep`,禁止 `BRPOP`** ——
> Redis 经 nginx stream 代理 + 多路复用连接时 `BRPOP` 语义不可靠(`BRPOP 0` 会永久卡死且无日志)。
> 依据:`workers/report_worker.rs` 的 `REDIS_POLL_INTERVAL_MS` 注释。
**节点管理:** **节点管理:**
- 设备服务启动时注册到API服务 - 设备服务启动时注册到 Redis `node:{node_id}`(并调用 API 的注册接口)
- 每60秒心跳,180秒无心跳视为离线 - 每 **60 秒**心跳(`HEARTBEAT_INTERVAL=60`),**180 秒**无心跳视为离线(`DEVICE_REGISTER_TTL=180`,
- 支持多节点扩展(当前单节点) 线上 `.env` 显式设置;代码默认值是 300,仅在没有该环境变量时生效)
- 支持多节点扩展(当前单节点 `node-1`)
## TCP 设备通讯协议 ## TCP 设备通讯协议
> TCP 设备通讯协议见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。 > TCP 设备通讯协议见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。
> 全局整数单位(voltage mV、current mA、temp K×100、soc ‰)见 [docs/API.md](docs/API.md#数据单位规范)。 > 全局整数单位(voltage mV、current mA、temp K×100、soc ‰)见 [docs/API.md](docs/API.md#数据单位规范)。
> 平台侧 Redis 实时状态的实际存储结构(key/字段/写入时机)见 [AGENTS.md](AGENTS.md#redis-实时状态存储)。
- 任务需求写到 `tasks/` 目录的md文件 ## 开发任务与质量约束
- 通过Agent子代理包裹opencode执行,异步通知
- 质量约束模板固定追加到每个任务文件 - 任务需求写到 `tasks/` 目录的md文件(编号递增)
- 完成代码后执行对应栈静态校验(Rust→cargo check,TS→tsc --noEmit),零报错零警告 - 通过 Agent 子代理包裹 opencode 执行,异步通知
- 质量约束模板固定追加到每个任务文件(模板见 [AGENTS.md](AGENTS.md#质量约束模板))
- 完成代码后执行对应栈静态校验:**Rust → `cargo check`**,**TS → `npm run typecheck`(即 `tsc -b`)**,零报错零警告
- ⛔ **不要写 `tsc --noEmit`**:`software/web/tsconfig.json` 是 solution 型(`files: []` + `references`),
不带 `-b` 时它**一个文件都不编译、直接 exit 0**,是假绿。实测依据见 [AGENTS.md](AGENTS.md#踩过的坑历史教训改动前必读) 「踩过的坑」第 2 条
## 验收与自检
```bash
# 后端:静态校验 + 单测
cd software/api-server && cargo check --all-targets && cargo test --bin pms-api
cd software/device-server && cargo check --all-targets && cargo test --bin pms-device-server
# 前端:真实类型检查
cd software/web && npm run typecheck
# 结构漂移巡检(改了表结构/迁移必跑)
python tools/db_schema_diff.py
python tools/db_schema_diff.py --vs-ddl
# 端到端
python test/tcp_e2e_smoke.py
python test/schema_alignment_e2e.py --env dev --jwt-secret <JWT_SECRET>
# 两端一致性(PC ↔ 小程序)
node test/device_status_parse.test.mjs
node test/rssi_signal.test.mjs
```
完整验收流程(含自证能失败的要求)见 [AGENTS.md](AGENTS.md#验收流程)。

View File

@ -56,6 +56,20 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# 扫码入口(免认证,协议要求:扫二维码 → abstract_id 查柜子)
# 限流依据:abstract_id 形如 `10-00000001`,可被脚本枚举(能判断柜子是否存在、
# 柜名与仓体结构)。正常扫码是"一次扫码 1~2 个请求"的高频正常行为,
# 故阈值比登录/验证码宽松得多:30r/m 持续 + burst=20 瞬时
# (同一 IP 连扫 20 次不排队),429 只可能来自脚本枚举/刷取。
location /api/pms/cabinets/abstract/ {
limit_req zone=scan_abstract_zone burst=20 nodelay;
proxy_pass http://127.0.0.1:9000/api/cabinets/abstract/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/pms/ { location /api/pms/ {
proxy_pass http://127.0.0.1:9000/api/; proxy_pass http://127.0.0.1:9000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;

116
deploy/nginx/nginx.conf Normal file
View File

@ -0,0 +1,116 @@
# For more information on configuration, see:
worker_shutdown_timeout 30s;
# * Official English Documentation: http://nginx.org/en/docs/
# * Official Russian Documentation: http://nginx.org/ru/docs/
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
# ==== 安全加固:按客户端 IP 限流 zone(必须位于 http 上下文)====
# ⛔ 本文件是线上 /etc/nginx/nginx.conf 的仓库镜像(与线上逐字节一致;改动后请双向核对 md5)。
# ⛔ 三条硬性约束(任一条违反都会让整站起不来或行为异常,历史已各踩过一次):
# 1. limit_req_zone 必须写在 http{} 内 —— conf.d/*.conf 的 location 引用了这些 zone,
# 引用未定义 zone 会让 nginx -t 直接失败;
# 2. stream{} 全配置**只能有一个**(MySQL 3306 / Redis 6379 反代就在本文件下面那个),
# ⛔ 不要在 conf.d/*.conf 里再写一个(报 stream directive is duplicate,整站不可用);
# 3. worker_shutdown_timeout 30s 不要调短 —— 调短会中断正在传输的响应。
limit_req_zone $binary_remote_addr zone=auth_login_zone:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=auth_challenge_zone:10m rate=20r/m;
limit_req_zone $binary_remote_addr zone=uploads_zone:10m rate=120r/m;
limit_req_zone $binary_remote_addr zone=scan_abstract_zone:10m rate=30r/m;
limit_req_status 429;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 4096;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Load modular configuration files from the /etc/nginx/conf.d directory.
# See http://nginx.org/en/docs/ngx_core_module.html#include
# for more information.
include /etc/nginx/conf.d/*.conf;
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;
error_page 404 /404.html;
location = /404.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
# Settings for a TLS enabled server.
#
# server {
# listen 443 ssl http2;
# listen [::]:443 ssl http2;
# server_name _;
# root /usr/share/nginx/html;
#
# ssl_certificate "/etc/pki/nginx/server.crt";
# ssl_certificate_key "/etc/pki/nginx/private/server.key";
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 10m;
# ssl_ciphers PROFILE=SYSTEM;
# ssl_prefer_server_ciphers on;
#
# # Load configuration files for the default server block.
# include /etc/nginx/default.d/*.conf;
#
# error_page 404 /404.html;
# location = /40x.html {
# }
#
# error_page 500 502 503 504 /50x.html;
# location = /50x.html {
# }
# }
}
stream {
server {
listen 127.0.0.1:3306;
listen 10.8.0.252:3306;
proxy_pass rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306;
proxy_timeout 10m;
proxy_connect_timeout 10s;
}
server {
listen 127.0.0.1:6379;
listen 10.8.0.252:6379;
proxy_pass r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379;
proxy_timeout 10m;
proxy_connect_timeout 10s;
}
}

View File

@ -45,6 +45,20 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# 扫码入口(免认证,协议要求:扫二维码 → abstract_id 查柜子)
# 限流依据:abstract_id 形如 `10-00000001`,可被脚本枚举(能判断柜子是否存在、
# 柜名与仓体结构)。正常扫码是"一次扫码 1~2 个请求"的高频正常行为,
# 故阈值比登录/验证码宽松得多:30r/m 持续 + burst=20 瞬时
# (同一 IP 连扫 20 次不排队),429 只可能来自脚本枚举/刷取。
location /api/pms/cabinets/abstract/ {
limit_req zone=scan_abstract_zone burst=20 nodelay;
proxy_pass http://127.0.0.1:3000/api/cabinets/abstract/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/pms/ { location /api/pms/ {
proxy_pass http://127.0.0.1:3000/api/; proxy_pass http://127.0.0.1:3000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@ -22,7 +22,11 @@
> 或**"免认证"**(公开路由),不臆造权限码。 > 或**"免认证"**(公开路由),不臆造权限码。
> >
> ⚠️ `mod.rs` 里还有一条 `.nest_service("/uploads", ServeDir::new("uploads"))`(静态头像目录,**免认证**), > ⚠️ `mod.rs` 里还有一条 `.nest_service("/uploads", ServeDir::new("uploads"))`(静态头像目录,**免认证**),
> 它不是 `.route(...)` 注册,故不计入上表的 79 条。 > 它不是 `.route(...)` 注册,故不计入上表的 **80** 条。
>
> 上表 80 行按 `(方法, 路径)` 展开后共 **93** 对,与 `mod.rs` 的 93 对**逐条双向一致**
> (用 `(方法,路径)` 多重集比对,**不按路径合并** —— 例如 `/api/cabinets` 被注册两次:
> `cabinet_routes` 里 `POST` 建柜、`h5_routes` 里 `GET` 小程序设备列表,二者是两条独立路由)。
| 方法 | 路径 | 说明 | 权限 | | 方法 | 路径 | 说明 | 权限 |
|------|------|------|------| |------|------|------|------|
@ -32,10 +36,10 @@
| POST | `/api/auth/avatar/upload` | 上传头像文件(multipart) | 仅需登录 | | POST | `/api/auth/avatar/upload` | 上传头像文件(multipart) | 仅需登录 |
| POST | `/api/devices/{dev_id}/command` | 下发设备指令 | `device:operate` | | POST | `/api/devices/{dev_id}/command` | 下发设备指令 | `device:operate` |
| GET | `/api/devices/stats` | 设备服务节点统计 | `device:view` | | GET | `/api/devices/stats` | 设备服务节点统计 | `device:view` |
| POST | `/api/nodes/register` | 设备服务节点注册(device-server 调用) | 仅需登录 | | POST | `/api/nodes/register` | 节点注册(**device-server 不调用**,见下) | **仅总管理员**(`role_level >= 2`) |
| POST | `/api/nodes/{node_id}/heartbeat` | 节点心跳 | 仅需登录 | | POST | `/api/nodes/{node_id}/heartbeat` | 节点心跳 | **仅总管理员** |
| POST | `/api/nodes/{node_id}/deregister` | 节点注销 | 仅需登录 | | POST | `/api/nodes/{node_id}/deregister` | 节点注销(**会真删注册表条目**) | **仅总管理员** |
| GET | `/api/nodes` | 节点列表 | 仅需登录 | | GET | `/api/nodes` | 节点列表(**含内网 `ip:tcp_port`**) | **仅总管理员** |
| GET / POST | `/api/roles` | 角色列表 / 创建角色 | GET 仅需登录;POST `role:create` | | GET / POST | `/api/roles` | 角色列表 / 创建角色 | GET 仅需登录;POST `role:create` |
| PUT / DELETE | `/api/roles/{id}` | 更新 / 删除角色 | `role:edit` / `role:delete` | | PUT / DELETE | `/api/roles/{id}` | 更新 / 删除角色 | `role:edit` / `role:delete` |
| PUT | `/api/roles/{id}/permissions` | 角色赋权 | `role:edit` | | PUT | `/api/roles/{id}/permissions` | 角色赋权 | `role:edit` |
@ -89,7 +93,7 @@
| GET | `/api/dashboard` | 小程序首页仪表盘 | 仅需登录(内部按角色做组织隔离) | | GET | `/api/dashboard` | 小程序首页仪表盘 | 仅需登录(内部按角色做组织隔离) |
| GET | `/api/my-organizations` | 小程序「选择组织」:当前用户**可见**的组织列表(工厂全量 / 客户仅本组织) | 仅需登录(不校验 `org:view`,内部按 `role_level` + `users.organization_id` 隔离) | | GET | `/api/my-organizations` | 小程序「选择组织」:当前用户**可见**的组织列表(工厂全量 / 客户仅本组织) | 仅需登录(不校验 `org:view`,内部按 `role_level` + `users.organization_id` 隔离) |
| GET | `/api/cabinets` | 设备列表(含实时 RSSI) | `device:view` | | GET | `/api/cabinets` | 设备列表(含实时 RSSI) | `device:view` |
| GET | `/api/compartments/{id}` | 仓体/通道详情(实时 BMS + 充电记录) | `device:view` | | GET | `/api/compartments/{id}` | 仓体/通道详情(实时通道状态 + 充电记录) | `device:view` |
| POST | `/api/charge/start` | 开始充电 | `charge:start` | | POST | `/api/charge/start` | 开始充电 | `charge:start` |
| POST | `/api/charge/stop` | 停止充电 | `charge:stop` | | POST | `/api/charge/stop` | 停止充电 | `charge:stop` |
| POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` | | POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` |
@ -126,7 +130,13 @@
| `GET /api/energy-stats/projects` | `[...]` 数组 | ⚠️ `data` 直接是数组,无分页包装 | | `GET /api/energy-stats/projects` | `[...]` 数组 | ⚠️ `data` 直接是数组,无分页包装 |
| `GET /api/energy-stats/trend` | `{granularity,list}` | ⚠️ 无 `total` / `page` / `page_size` | | `GET /api/energy-stats/trend` | `{granularity,list}` | ⚠️ 无 `total` / `page` / `page_size` |
| `GET /api/roles`、`GET /api/permissions` | `[...]` 数组 | ⚠️ `data` 直接是数组 | | `GET /api/roles`、`GET /api/permissions` | `[...]` 数组 | ⚠️ `data` 直接是数组 |
| `GET /api/organizations`、`GET /api/my-organizations` | `[...]` 数组 | ⚠️ `data` 直接是组织数组(字段 `id`/`name`) |
| `GET /api/cabinets`(**小程序**) | `[...]` 数组 | ⚠️ **无任何包装**,也没有分页参数;**与 `GET /api/cabinets/filter` 的 `{total,data}` 不同** |
| `GET /api/cabinets/options`、`GET /api/cabin-boards/options`、`GET /api/compartments/options` | `[...]` 数组 | ⚠️ `data` 直接是数组 |
| `GET /api/organization-tree` | `[...]` 树形数组 | 根节点数组 | | `GET /api/organization-tree` | `[...]` 树形数组 | 根节点数组 |
| `GET /api/cabinets/realtime-status` | `{device,boards}` | ⚠️ 无列表/分页 |
| `GET /api/energy-stats/summary` | `{today_energy,month_energy,estimated_cost,cabinet_count,online_count}` | 单对象 |
| `GET /api/users/{id}/scopes` | `[...]` 数组 | ⚠️ `data` 直接是范围记录数组(带 `valid`) |
> ⚠️ **内部接口例外**:`/api/nodes/*` 与 `/api/devices/*` 直接返回 `{"suc":1}` / > ⚠️ **内部接口例外**:`/api/nodes/*` 与 `/api/devices/*` 直接返回 `{"suc":1}` /
> `{"nodes":[...],"count":N}` / `{"node_count":N}`,**不带** `{code,data,message}` 外层 —— 它们面向 > `{"nodes":[...],"count":N}` / `{"node_count":N}`,**不带** `{code,data,message}` 外层 —— 它们面向
@ -140,12 +150,16 @@
| `today_energy` / `month_energy` | 能耗概览 | **Wh**(整数) | `/ 1000` → kWh | | `today_energy` / `month_energy` | 能耗概览 | **Wh**(整数) | `/ 1000` → kWh |
| `max_temperature` | 充电记录 | **K×100**(整数) | `/ 100 - 273.15` → °C | | `max_temperature` | 充电记录 | **K×100**(整数) | `/ 100 - 273.15` → °C |
| `soc` / `start_soc` / `end_soc` | 电池台账 / 充电订单 / 通道状态 | **‰**(整数,0–10000) | `/ 10` → % | | `soc` / `start_soc` / `end_soc` | 电池台账 / 充电订单 / 通道状态 | **‰**(整数,0–10000) | `/ 10` → % |
| `voltage` / `cell_voltages[]` | 仓体详情 / BMS | **mV** | `/ 1000` → V | | `voltage` | 仓体详情 / 通道状态 | **mV** | `/ 1000` → V |
| `current` | 仓体详情 / BMS | **mA** | `/ 1000` → A | | `current` | 仓体详情 / 通道状态 | **mA** | `/ 1000` → A |
| `temp` | 仓体详情 / 设备列表 / 设备详情(柜温与电芯最高温同口径) | **K×100** | `/ 100 - 273.15` → °C |
| `meter` | 设备级 Redis(协议 `cabinet.meter`,未在 HTTP 响应透出) | **Wh**(电表读数) | `/ 1000` → kWh |
| `rssi` | 设备列表 / 通道状态 | **0–99 信号等级**(不是 dBm) | 直接显示 | | `rssi` | 设备列表 / 通道状态 | **0–99 信号等级**(不是 dBm) | 直接显示 |
| `estimated_cost` | 能耗概览 | **元**(`f64`,业务计算值) | 不转换 | | `estimated_cost` | 能耗概览 | **元**(`f64`,业务计算值) | 不转换 |
| `cell_voltages[]` / `cell_temps[]` | 仓体详情 | ⚠️ **恒为空数组**(无写入方,保留字段类型) | — |
> 完整单位规范见文末 [数据单位规范](#数据单位规范);后端**不做任何单位换算**,一律返回原始整数值。 > 完整单位规范见文末 [数据单位规范](#数据单位规范);后端**不做任何单位换算**,一律返回原始整数值
> (唯一例外是 `estimated_cost`,见 `GET /api/energy-stats/summary`)。
--- ---
@ -260,42 +274,95 @@
} }
``` ```
### GET /api/my-organizations
小程序「选择组织」弹层专用:返回当前用户**可见**的组织列表。
- **权限:仅需登录**(`routes/h5.rs::list_my_organizations` 里**没有** `check_permission` 调用),
**不校验 `org:view`** —— 操作员的授权清单里没有该码,若沿用管理端
`GET /api/organizations`(硬校验 `org:view`)会 **403、组织弹层永远为空**;
- **隔离规则**(`visible_org_scope`,按 `role_level` + `users.organization_id`):
工厂(`role_level >= 2`)**全量**;组织管理员 `1` / 操作员 `0` **仅 `users.organization_id` 指向的那一个**;
`organization_id` 为 `NULL` → **空数组**(不是错误);
- **与管理端 `GET /api/organizations` 的分工不可混用**:后者语义冻结(`org:view` + 全量)。
**响应:** `data` 直接是组织数组(与管理端同构,字段 `id` / `name`):
```json
{
"code": 0,
"data": [
{ "id": 1, "name": "无锡某某换电科技有限公司" }
],
"message": "ok"
}
```
### GET /api/cabinets ### GET /api/cabinets
设备列表,支持 `project_id` 和 `org_id` 过滤。响应包含实时 RSSI。 设备列表,支持 `project_id` 和 `org_id` 过滤。响应包含实时 RSSI。
**响应字段:** > ⚠️ **`data` 是数组本身**(`{"code":0,"data":[...],"message":"ok"}`),**不是** `{list,total}` 包装,
> 也没有分页参数 —— 与 `GET /api/cabinets/filter` 的 `{total,data}` 完全不同,前端不要套通用分页逻辑。
**数组元素字段**(依据 `routes/h5.rs::build_cabinet_list`):
```json ```json
{ {
"id": 1, "id": 1,
"abstract_id": "10-00000001", "abstract_id": "10-00000001",
"name": "太湖新城换电柜A", "name": "太湖新城换电柜A",
"status": 1, "imei": "864720562557021",
"iccid": "8986012520983901841",
"status": 0,
"project_id": 1, "project_id": 1,
"rssi": 85, "address": "无锡市新吴区…",
"latitude": "31.550000",
"longitude": "120.360000",
"channel_count": 36, "channel_count": 36,
"idle_count": 30, "idle_count": 30,
"charging_count": 6, "charging_count": 6,
"full_count": 0, "full_count": 0,
"fault_count": 0 "fault_count": 0,
"rssi": 85,
"pow_fail_dc": false,
"pow_fail_ac": false,
"temp": 29800,
"status1": "10",
"status2": null,
"org_disabled": false
} }
``` ```
- `status`:柜子级状态,**沿用 `cabinets.status`**(`0`/`1`/`2`,`2`=禁用,见
`PUT /api/cabinets/{id}/status`),**不是**仓体的 `0/1/2/3` 派生档;
- `idle_count` / `charging_count`:**来自 DB `compartments.status` 的占用值**(`0`/`1`),
不做派生覆盖(`derive_channel_counts` 派生出的 idle/charging 分区不写回响应);
- `full_count` / `fault_count`:**由 Redis 通道状态派生**(`batt_status` bit0=充满、
bit4/5/6 或 `pow_status` bit0=故障);设备离线/无通道数据时**恒为 0**(无法判定,不猜测);
- `status1` / `status2`:**柜级**状态位(协议 §3.8),2 位小写 hex **原样透出**,
**取不到为 `null`(不是 `"00"`)** —— `"00"` 是"全部正常"的有效值,两者语义不同;
- `temp`:柜温,单位 **K×100**(`29800` = 25.5 °C),取不到为 `null`;
- `pow_fail_dc` / `pow_fail_ac`:布尔,取不到为 `null`;
- `org_disabled`:所属项目对应组织是否被禁用(前端徽标用)。
### GET /api/cabinets/abstract/{abstract_id} ### GET /api/cabinets/abstract/{abstract_id}
扫码入口,免认证。通过二维码中的抽象ID查找设备。 扫码入口,**免认证**(注册在 `mod.rs::public_routes()`,不经过认证中间件;
`mod.rs:211` → `h5::get_cabinet_by_abstract_id`)。通过二维码中的抽象 ID 查找设备。
**响应(最小字段集):** `data` 恰好 5 个键 —— `id` / `abstract_id` / `name` / `status` / `cabin_boards`;
`cabin_boards[]` 每项 4 个键 —— `id` / `board_id` / `status` / `compartments[]`;
`compartments[]` 每项 3 个键 —— `id` / `channel_id` / `status`。
**响应:**
```json ```json
{ {
"code": 0, "code": 0,
"data": { "data": {
"id": 1, "id": 1,
"abstract_id": "10-00000001", "abstract_id": "10-00000001",
"imei": "864720562557021",
"iccid": "8986012520983901841",
"name": "太湖新城换电柜A", "name": "太湖新城换电柜A",
"status": 1, "status": 0,
"cabin_boards": [ "cabin_boards": [
{ {
"id": 1, "id": 1,
@ -310,16 +377,87 @@
} }
``` ```
> 🔓 **为什么必须是"最小集"**:本接口**没有 token 也能调**,所以响应刻意只给**扫码进入充电流程必需**的字段 ——
> `id`(后续 `POST /api/charge/start` / `POST /api/door/open` 要用的**内部主键**)与拓扑,
> 用来渲染"哪个仓可用"。**不含**任何设备身份或归属信息:
> `imei` / `iccid` / `auth_str`(敏感凭据)/ `project_id` / `organization_id` / `address` /
> `latitude` / `longitude` / `rssi` / 实时状态。依据:`h5.rs::get_cabinet_by_abstract_id` 的 SQL
> 只 `SELECT id, abstract_id, imei, name, status`,且把 `imei` 绑到 **`_imei` 后丢弃**,其余列根本没查。
> 需要完整设备信息请走需认证的 `GET /api/cabinets/{id}`。
> 这里的仓体 `status` 是 **DB 原始占用值**(`0`/`1`),未做实时派生(本接口不读 Redis)。
> 🔒 **`abstract_id` 可枚举 —— 已加 nginx 限流(2026-09 上线,含实测依据)**
>
> 格式固定为 `10-%08d`(如 `10-00000001`),即 **1 亿个可穷举的连续 ID**;本接口免认证,
> 攻击者无需 token 即可逐个探测"柜子是否存在 + 柜名 + 仓体结构"。
> **现已由 nginx 限流兜住**:
>
> ```nginx
> # /etc/nginx/nginx.conf
> limit_req_zone $binary_remote_addr zone=scan_abstract_zone:10m rate=30r/m;
> limit_req_status 429;
>
> # conf.d/{app,test}.anzhizhichong.com.conf(两份都配了)
> location /api/pms/cabinets/abstract/ {
> limit_req zone=scan_abstract_zone burst=20 nodelay;
> proxy_pass http://127.0.0.1:9000/api/cabinets/abstract/; # 测试域为 :3000
> }
> ```
>
> - **`zone=scan_abstract_zone`**,`rate=30r/m` + `burst=20 nodelay`(比登录/验证码宽松得多:
> 正常扫码是"一次扫码 1~2 个请求",同一 IP 连扫 20 次不排队),超限返回 **`429`**
> (`limit_req_status 429`);
> - **实测(2026-09,本机同 IP 连发 60 次 `GET /api/pms/cabinets/abstract/10-99999999`)**:
> - `test.anzhizhichong.com`:**`404` × 23 + `429` × 37**
> - `app.anzhizhichong.com`:**`404` × 23 + `429` × 37**
> (取证前先单发一次得 `404`,确认 1 分钟窗口已回填、不是"假拦截");
> - 23 次通过 ≈ `burst=20` + 窗口内回填,符合配置预期;
> - `nginx -t` 通过;仓库镜像 `deploy/nginx/{app,test}.anzhizhichong.com.conf` 与线上一致。
> - ⚠️ 顺序前提:本节的 `location` 是**前缀匹配**,`/api/pms/cabinets/abstract/` 与
> 需认证的 `/api/pms/cabinets/scan/` 是两条独立路径,**限流只覆盖前者**。
### GET /api/compartments/{id} ### GET /api/compartments/{id}
仓体/通道详情,含实时 BMS 数据和充电记录。 仓体/通道详情,含通道实时状态与充电记录(`routes/h5.rs::get_compartment_detail`)。
**响应字段:** **响应字段:**
- `voltage` — mV(整数)
- `current` — mA(整数) ```json
- `energy` — Wh(整数) {
- `soc` — ‰(0-10000,前端已自动转为 %) "code": 0,
- `cell_voltages` — mV 数组 "data": {
"id": 1,
"channel_id": 1,
"cabin_board_id": 1,
"status": 2,
"voltage": 54600,
"current": 0,
"energy": null,
"soc": 1000,
"temp": 29800,
"batt_status": "81",
"pow_status": "02",
"cell_voltages": [],
"cell_temps": [],
"bms_alerts": [],
"charge_records": []
},
"message": "ok"
}
```
- `status` — **派生值 `0 空闲 / 1 充电中 / 2 充满 / 3 故障`**(优先级 `fault > full > charging > idle`,
由 Redis 通道 `batt_status` / `pow_status` 经 `cabinet_tree::derive_channel_flags` 推导)。
**拿不到通道实时数据时回落到 DB 的占用值 `0`/`1`**(离线无法判定,不猜测);
**没有"禁用"这一档**(仓体级禁用不存在)。
- `voltage` — mV(整数);`current` — mA(整数);`temp` — **K×100**(整数);
`soc` — ‰(0–10000,前端 `/ 10` 转 %)。**取不到为 `null`,绝不落 `0`**。
- `batt_status` / `pow_status` — 通道 18 字符 hex 的 **byte0 / byte1**,2 位小写 hex 原样透出
(位定义见协议 §3.8),取不到为 `null`。
- `energy` — **恒为 `null`**:通道级 Redis Hash **没有**累计电量字段(累加器是订单维度的内部键),
此处如实返回 null 而不是 `0`。
- `cell_voltages` / `cell_temps` / `bms_alerts` — **恒为空数组 `[]`**:
协议未上报 BMS 单体明细,**当前没有任何写入方**,保留字段仅为维持既有前端类型(`number[]`/`string[]`)。
### POST /api/charge/start ### POST /api/charge/start
@ -424,6 +562,7 @@
| `GET /api/charge-records`、`GET /api/charge-records/export` | `charge_records.cabinet_id` → 项目 | | `GET /api/charge-records`、`GET /api/charge-records/export` | `charge_records.cabinet_id` → 项目 |
| `GET /api/charge-orders` | `charge_orders.cabinet_id` → 项目 | | `GET /api/charge-orders` | `charge_orders.cabinet_id` → 项目 |
| `GET /api/device-logs` | `device_logs.cabinet_id` → 项目 | | `GET /api/device-logs` | `device_logs.cabinet_id` → 项目 |
| `GET /api/batteries` | **间接归属**:`battery_id` → `charge_orders` → `cabinets.project_id`(叠加在同一个 `EXISTS` 子查询里;语义见该接口一节) |
| `GET /api/energy-stats/{summary,cabinets,projects,trend,export}` | 项目/柜子维度 | | `GET /api/energy-stats/{summary,cabinets,projects,trend,export}` | 项目/柜子维度 |
| `GET /api/organizations/{org_id}/projects` | `projects.id` | | `GET /api/organizations/{org_id}/projects` | `projects.id` |
| 所有柜子写操作(`PUT/DELETE /api/cabinets/{id}`、`work-mode`、`status`、`bind`、`replace-imei`、`regenerate-auth`、`/api/devices/{dev_id}/command`、`/api/projects/{id}`) | `scope_guard`(不在范围内 → `403`) | | 所有柜子写操作(`PUT/DELETE /api/cabinets/{id}`、`work-mode`、`status`、`bind`、`replace-imei`、`regenerate-auth`、`/api/devices/{dev_id}/command`、`/api/projects/{id}`) | `scope_guard`(不在范围内 → `403`) |
@ -469,8 +608,8 @@
"end_time": "2026-07-05 12:00:00", "end_time": "2026-07-05 12:00:00",
"energy": 1200, "energy": 1200,
"max_temperature": 30435, "max_temperature": 30435,
"status": 1, "status": 0,
"status_text": "正常结束" "status_text": "正常"
} }
], ],
"total": 50, "total": 50,
@ -481,7 +620,10 @@
} }
``` ```
**状态码:** 0=充电中,1=正常结束,2=异常中断,3=手动停止 **状态码:** `0` 正常 / `1` 异常 / `2` 手动停止(仅 3 档;`charge_records.status` 列注释即
`0正常 1异常 2手动停止`,`routes/charge_records.rs::status_text` 逐值对应,越界返回 `"未知"`)。
⚠️ 历史文档曾写成"0=充电中,1=正常结束,2=异常中断,3=手动停止",**四档且值全错** ——
`charge_records` **没有"充电中"档**(充电中的会话在 `charge_orders`,`status=0`)。
**单位:** `energy` = **Wh**(整数,前端 `/ 1000` 显示 kWh);`max_temperature` = **K×100**(整数,`/ 100 - 273.15` 显示 °C)。 **单位:** `energy` = **Wh**(整数,前端 `/ 1000` 显示 kWh);`max_temperature` = **K×100**(整数,`/ 100 - 273.15` 显示 °C)。
`start_time` / `end_time` 为字符串时间,非数值单位。 `start_time` / `end_time` 为字符串时间,非数值单位。
@ -497,7 +639,22 @@
"data": { "data": {
"total": 2, "total": 2,
"data": [ "data": [
{ "id": 1, "abstract_id": "10-00000000", "name": "太湖新城换电柜A", "status": 1, "online": 1, "rssi": 85 } {
"id": 1,
"abstract_id": "10-00000000",
"name": "太湖新城换电柜A",
"status": 1,
"is_online": true,
"rssi": 85,
"pow_fail_dc": false,
"pow_fail_ac": false,
"status1": "10",
"status2": null,
"idle_count": 30,
"charging_count": 6,
"full_count": 2,
"fault_count": 0
}
] ]
}, },
"message": "ok" "message": "ok"
@ -505,6 +662,74 @@
``` ```
> ⚠️ 前端解析时不要套用 `{list,total,page,page_size}` 的通用分页逻辑。 > ⚠️ 前端解析时不要套用 `{list,total,page,page_size}` 的通用分页逻辑。
>
> - 在线字段名是 **`is_online`**(布尔,Redis `online == "1"`;Redis 不可用/无键时为 `null`),
> **不是 `online`**;
> - `status1` / `status2`:**柜级**状态位,2 位 hex 原样透出,**取不到为 `null`(不是 `"00"`)**;
> - `idle_count` / `charging_count`:**DB 占用值**(`compartments.status` 的 `0`/`1`),不被派生覆盖;
> - `full_count` / `fault_count`:**由 Redis 通道状态派生**后**覆盖** SQL 返回值,
> 设备离线/无通道数据时**按 0 计**(`cabinet_tree::apply_derived_counts`);
> - 查询参数:`project_id` / `organization_id`(**含其直接子组织**)/ `bound`(`"1"` 已绑定 / `"0"` 未绑定)。
> 🔒 **`?project_id=` / `?organization_id=` 是「纯筛选参数」,不构成归属边界**(2026-09 修复):
> 它们只表达"我想看哪个项目/组织",**决定"能不能看"的永远是另外两道**,且**无条件叠加**:
> 1. **组织隔离**(`auth::org_condition_for_project`)—— 总管理员豁免(全量);
> 客户侧收窄到 `users.organization_id`;**无组织则 `1=0`(空集,绝不退化成全量)**;
> 2. **数据范围**(`user_scopes` 项目白名单,`effective_scope()`)—— 未配置 = 不限制。
>
> 三道条件是 `AND` 关系**取交集**(书写顺序 = 占位符顺序 = 绑定值顺序,见 `build_filter_where`)。
> **传别人的 `project_id` / `organization_id` 只会得到 `{"data":[],"total":0}`,不会越权。**
> `total` 与列表走同一个 `where_clause`,不存在"总数含不可见行"。
>
> ⚠️ **修复前的真实越权(历史缺陷,勿再退回)**:旧实现把这两个入参**直接当作唯一过滤条件**
> (`WHERE c.project_id = ?` / `JOIN projects ... WHERE p.organization_id = ?`),
> **没有叠加组织隔离** —— 客户账号只要传别人的 `project_id`,就能拿到**对方柜子的全字段**
> (含 `imei` / `iccid` / `address` / 经纬度)。这与数据范围 `user_scopes` 无关:
> **未配置 scope 的用户同样能被利用**。现已改为"入参只当筛选、边界无条件叠加"。
### GET /api/cabinets/realtime-status
单设备实时状态(板级 + 通道级),权限 `device:view` + **设备归属校验**
(非总管理员必须命中「设备 → 项目 → 组织」链路,且项目落在数据范围内,否则 `403`)。
**查询参数:** `imei`(必填)
```json
{
"code": 0,
"data": {
"device": {
"online": true,
"rssi": 85,
"pow_fail_dc": false,
"pow_fail_ac": false
},
"boards": {
"1": {
"status_hex": "820281028002",
"channels": {
"1": { "on": true, "full": false, "fault": false, "status": "charging",
"hex": "8202", "batt_status": "82", "pow_status": "02",
"soc": 4500, "voltage": 54600, "current": 0, "temp": 29800 }
}
}
}
},
"message": "ok"
}
```
- **`device` 只有 4 个字段**:`online`(布尔,**缺失时 `false` 而不是 `null`**)、
`rssi`(取不到为 `null`)、`pow_fail_dc` / `pow_fail_ac`(**缺失时 `false`**)。
⚠️ **本接口不返回 `cabinet_status1/2`,也不返回柜温 `temp`** ——
要柜级状态位/柜温请用 `GET /api/cabinets/{id}` 或 `GET /api/cabinets`(小程序列表);
- `boards` 的键是**仓控板号字符串**(`"1"`…),`channels` 的键是**通道号字符串**;
- `status_hex` **不是 Redis 字段**:它由该板各通道的 `batt_status + pow_status`
(各 2 位 hex)按**通道号升序**拼回(`cabinet_tree::assemble_boards`);
- `channels` 里**只出现 Redis 中真实存在的通道键**(设备从未上报 / 键已失效的通道**整条省略**),
不会凭空多出"全空通道";
- 通道内 `on` / `full` / `fault` / `hex` / `status` 都是**由 `batt_status` / `pow_status` 派生**的响应字段
(Redis 里没有这些字段名),推导规则见 [`Redis 实时状态存储`](#redis-实时状态存储)。
### PUT /api/cabinets/{id}/work-mode ### PUT /api/cabinets/{id}/work-mode
@ -515,12 +740,53 @@
**响应:** `{ "code": 0, "data": { "success": true }, "message": "ok" }` **响应:** `{ "code": 0, "data": { "success": true }, "message": "ok" }`
### GET /api/cabinets/{id}
设备详情(柜级 + 仓控板 + 仓体),权限 `device:view` + 组织归属校验(`scope_guard`)。
**关键字段与语义:**
| 字段 | 说明 |
|------|------|
| `status` | **柜子级**状态,**原样取自 `cabinets.status`**(`2` = 禁用,见下方 `PUT .../status`)。⚠️ **不是**派生值、**不是** `0/1/2/3` 档 |
| `status1` / `status2` | **柜级状态位**(协议 §3.8),2 位小写 hex **原样透出**(如 `"10"` = 柜门打开、`"82"` = 喷淋+烟雾);**取不到为 `null`,绝不落 `"00"`**(`"00"` 是"全部正常"的有效值,两者必须区分) |
| `temp` | 柜温,**K×100**(Redis `device:{imei}.temp`);取不到为 `null`(前端显示 `--°C`),不落 `0` |
| `rssi` / `pow_fail_dc` / `pow_fail_ac` | 取不到均为 `null` |
| `work_mode` | 字符串 `"0"` / `"1"`,无值时为 `null` |
| `cabin_boards[].compartments[].status` | **仓体级派生值** `0 空闲 / 1 充电中 / 2 充满 / 3 故障`(由通道 `batt_status` / `pow_status` 经 `derive_channel_flags` 推导,优先级 `fault > full > charging > idle`);**拿不到通道数据时保留 DB 占用值 `0`/`1`**;**没有"禁用"档** |
| `compartments[].batt_status` / `pow_status` | 2 位 hex,取不到为 `null` |
| `compartments[].soc` / `voltage` / `current` / `temp` | 单位同下表(‰ / mV / mA / K×100) |
> 🔎 **两个 `status` 不是一回事,前端必须分清**:
> 顶层的 `status` 是**柜子**的(0/1/2,含"禁用");`cabin_boards[].compartments[].status`
> 是**仓体**的派生展示值(0/1/2/3,**无"禁用"**)。
> **`cabinets.status` / `compartments.status` 两列在 DB 里都只表达"占用/启用"**,
> "充满"/"故障"**不落库**,永远由 Redis 通道状态实时派生。
### PUT /api/cabinets/{id}/status ### PUT /api/cabinets/{id}/status
禁用 / 启用柜子。权限 `device:edit` + 组织归属校验,**且仅总管理员(`role_level=2`)可执行**, 禁用 / 启用柜子。权限 `device:edit` + 组织归属校验,**且仅总管理员(`role_level=2`)可执行**,
否则 403「仅总管理员可禁用/启用柜子」。 否则 403「仅总管理员可禁用/启用柜子」。
**请求:** `{ "status": 2 }`;**响应:** `{ "code": 0, "data": null, "message": "ok" }` **请求:** `{ "status": 2 }`(`2` = 禁用,**其它任何值都按"启用"处理**,
`routes/cabinets.rs::set_cabinet_status` 的 `if body.status == 2` 分支);
**响应:** `{ "code": 0, "data": null, "message": "ok" }`
> ⚠️ `cabinets.status = 2` 是**指令链路拦截**的判据:`h5.rs::ensure_cabinet_enabled`
> 与 `CABINET_DISABLED_SQL`(`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)
> 据此拒绝开合闸/开门/充电。
> ⚠️ **列注释已对齐(本轮修复)**:`db/migrate.rs` 里 `cabinets.status` 的注释原为
> `'0离线 1在线'`(**漏了 `2`**),代码里实际是 `0` 离线 / `1` 在线(`energy-stats/summary` 的
> `online_count` 即按 `status = 1` 计数)/ `2` 禁用;现已改为
> `'0离线 1在线 2禁用(禁用后指令链路拦截)'`,并由启动迁移幂等对齐(见下)。
> ⚠️ **在线的"权威判据"是 Redis `device:{imei}` 的 TTL 300s**(协议 §2.3),
> `cabinets.status` 只是库里的一个静态标记,两者不保证同步。
> 🔧 **注释口径的落库方式(`cabinets.status` 与 `charge_orders.status` 同一套)**:
> `migrate.rs::run()` 用 `ensure_column_comment(pool, 表, 列, marker, ddl)` **只在现有注释不含
> `marker` 时才 ALTER**(marker 分别是 `2禁用` / `保留未使用`),因此**重启不会每次做 DDL**;
> 老库(含生产库 `pms`)在**下一次启动新版 api-server 时自动对齐**。
> 实测:改动后首次启动打印 `[migrate] 更新列注释 ...`,再次启动即 `未执行任何 DDL`。
### PUT /api/organizations/{id}/status ### PUT /api/organizations/{id}/status
@ -545,6 +811,13 @@
充电订单列表。订单记录一次充电事件,含电池ID、SOC变化、结束原因。 充电订单列表。订单记录一次充电事件,含电池ID、SOC变化、结束原因。
> 🔒 **组织隔离 + 数据范围(本轮新增)**:此前该接口**完全没有组织隔离**,任何登录用户都能看到
> 全部组织的订单。现在按 `charge_orders.cabinet_id → cabinets.project_id → projects.organization_id` 过滤:
> 工厂(`role_level >= 2`)全量;客户侧仅本组织;`users.organization_id` 为 NULL 的账号**返回空集**
> (SQL 里追加 `AND 1=0`,`routes/charge_orders.rs::order_scope_conditions`)。
> 组织条件之后再叠加**项目级数据范围**(`cabinet_id IN (SELECT id FROM cabinets WHERE project_id IN (...))`),
> 未配置范围时不产生额外条件。**列表与 `total` 计数走同一套条件**,不会出现"总数含不可见行"。
**查询参数:** **查询参数:**
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
@ -576,10 +849,10 @@
"start_soc": 200, "start_soc": 200,
"end_soc": 950, "end_soc": 950,
"energy": 1200, "energy": 1200,
"reason": null, "reason": "normal",
"reason_text": "-", "reason_text": "正常充满",
"status": 1, "status": 1,
"status_text": "正常结束", "status_text": "已完成",
"created_at": "2026-07-05 10:00:05" "created_at": "2026-07-05 10:00:05"
} }
], ],
@ -591,13 +864,43 @@
} }
``` ```
**订单状态:** 0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束 **订单状态(`status`):** `0` 充电中 / `1` 已完成(终态)。
⚠️ **只有 2 档**,且历史文档写的"0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束"
是**错的**:`charge_orders.status` 列注释为 `0充电中 1已完成 2异常`,但**生产代码没有任何写入点会写
`2`** —— 所有结束路径(设备上报 `off`/`bat_out`、换电池、10 小时超时、无电池对账)都走
`report_worker::finalize_order*`,`order_status` 恒为 `ORDER_STATUS_DONE = 1`
(`report_worker.rs:2421`、调用点 `:2854` / `:2970` / `:2513`)。
唯一的 `status = 2` 出现在 **`test/reset_fixture.py:27`**(一次性夹具脚本,**非交付物**,连 `pms_dev`)。
> 📌 **`2` 未被删除,而是标注为"保留未使用"(本轮)**:`2` **有消费方** —— PC 订单页
> `web/src/pages/charge-orders/index.tsx` 的筛选下拉提供 `<Option value={2}>异常</Option>`、
> `STATUS_COLOR[2] = 'red'`,`routes/charge_orders.rs::status_text` 也把 `2` 映射成"异常"。
> 即"异常终止"曾被设计成一个**状态**,而现实现把它降级成 `reason` 里的**结束原因** ——
> 这是**产品语义缺口,不是笔误**,故列注释改为
> `'0充电中 1已完成 2异常(保留未使用:平台当前无写入方,结束原因见 reason)'`
> (保留 `2`、注明现状),并作为**待产品决策项**:
> 要么承认"异常"只是 `reason`(则后续可移除 UI 里的死筛选档),
> 要么补上写入方(真正把异常订单落成 `status = 2`)。
**结束原因在 `reason` / `reason_text` 里,不在 `status` 里**(`charge_orders.reason` 列注释
`normal/user_stop/forced/timeout`;`routes/charge_orders.rs::reason_text`):
| `reason` | `reason_text` | 触发场景 |
|---|---|---|
| `normal` | 正常充满 | 设备上报充满结束 |
| `user_stop` | 手动停止 | 平台下发 `off`(小程序/管理端停止充电) |
| `forced` | 强制结束 | 拔电池 / 异常中断等强制收尾 |
| `timeout` | 超时结束 | 订单 10 小时超时兜底结单 |
| 其它 / NULL | 未知 | — |
**单位:** `energy` = **Wh**(整数);`start_soc` / `end_soc` = **‰**(整数,0–10000,前端 `/ 10` 显示 %)。 **单位:** `energy` = **Wh**(整数);`start_soc` / `end_soc` = **‰**(整数,0–10000,前端 `/ 10` 显示 %)。
`end_soc` 可为 `null`(订单未结束或收尾时取不到末帧 SOC)。
### GET /api/batteries ### GET /api/batteries
电池台账列表。记录每块电池的插入/拔出事件和当前状态。 电池台账列表(`batteries` 表)。**每块电池一行**,记录 `battery_id` / 当前电量 / 状态;
插入 / 拔出事件本身在 `charge_orders`(订单)里体现,本表**不记录**事件流水。
**查询参数:** **查询参数:**
@ -618,14 +921,12 @@
{ {
"id": 1, "id": 1,
"battery_id": "BAT001", "battery_id": "BAT001",
"soc": 4500,
"brand": "星恒", "brand": "星恒",
"model": "XH-48V", "model": "XH-48V",
"voltage": 52000, "sn": "SN123456",
"soc": 4500,
"status": 1, "status": 1,
"cabinet_id": 1, "status_text": "使用中",
"compartment_id": 1,
"last_seen": "2026-07-05 12:00:00",
"created_at": "2026-07-01 08:00:00" "created_at": "2026-07-01 08:00:00"
} }
], ],
@ -637,9 +938,51 @@
} }
``` ```
**单位:** `voltage` = **mV**(52000 = 52.000V);`soc` = **‰**(4500 = 45.00%,表字段注释即 `电量(‰)`)。 **电池状态:** `0` 空闲 / `1` 使用中 / `2` 故障 / `3` 报废(`batteries` 表字段注释与
`batteries.rs::status_text` 一致;越界值返回 `"未知"`)。
**单位:** `soc` = **‰**(4500 = 45.00%,表字段注释即 `电量(‰)`)。
⚠️ 历史文档曾把示例写成 `"soc": 45`,那是按"百分比"写的**错误示例**,实现返回的是千分比。 ⚠️ 历史文档曾把示例写成 `"soc": 45`,那是按"百分比"写的**错误示例**,实现返回的是千分比。
> ⚠️ **本接口没有 `voltage` 字段**(历史文档的示例里有 `"voltage": 52000`,是**臆造**):
> `batteries` 表只有 `soc`,**没有任何电压列**(实测 `SHOW COLUMNS FROM batteries` 无 `voltage`,
> 只有 `rated_voltage` 额定电压且不在响应里)。
> 同样不存在的响应字段还有 `cabinet_id` / `compartment_id` / `last_seen` ——
> `BatteryItem` 的完整字段就是上例这 9 个(`routes/batteries.rs::BatteryItem`)。
> 需要电压/电流请走 `GET /api/compartments/{id}` 或设备详情的通道级实时字段。
> 🔒 **组织隔离 + 数据范围:走「间接归属」(2026-09 修复)**
>
> `batteries` 表**没有** `organization_id` / `project_id` / `cabinet_id`(也无外键、无关联列),
> 所以归属只能**间接推导**(`routes/batteries.rs::build_battery_filters`):
>
> ```
> batteries.battery_id → charge_orders.battery_id → charge_orders.cabinet_id
> → cabinets.project_id → projects.organization_id
> ```
>
> 落成 SQL 是在原过滤器**之前**无条件追加一个 `EXISTS` 子查询,外加数据范围条件:
> 总管理员(`role_level >= 2`)→ 不加归属条件(全量);
> 客户侧 → `EXISTS (SELECT 1 FROM charge_orders co JOIN cabinets c ... JOIN projects p ...
> WHERE co.battery_id = batteries.battery_id AND p.organization_id = ?)`,
> 并在**同一子查询**的 `c.project_id` 上叠加数据范围(未配置 = 不限制);
> `users.organization_id` 为 `NULL` → `1=0` **空集**(绝不退化成全量)。
>
> **这个归属口径的语义(必须按此理解,否则会误判"少数据"是 bug)**:
> - 判据是「**这块电池被本组织用过**」—— 依据是多笔 `charge_orders`
> (设备上报 `bat_in` / 平台建单时写入 `battery_id`);
> - ⚠️ **"用过即可见",不区分"谁的电池"**:同一块 `battery_id` 若被**多个组织**用过,
> 则这些组织**都能看到它**(归属是"多对多"的,不是独占);
> - ⚠️ **从未建过单的电池对客户不可见**:`batteries` 表有行、但 `charge_orders` 里没有任何
> 该 `battery_id` 的记录 ⇒ `EXISTS` 不成立 ⇒ **客户查不到**(**这是有意为之**:
> 无归属证据就不展示,宁可少给也不越权);总管理员不受此限。
> - `status` / `battery_id LIKE` 等过滤器**排在归属条件之后**(有单测钉住顺序:
> 归属 → 模糊 → 状态),避免绑定值错位。
>
> ⚠️ **修复前**该接口只做 `check_permission(&user, "device:view")` + `SELECT * FROM batteries WHERE 1=1`,
> **没有任何组织隔离**(全文无 `effective_scope` / `org_condition`)——
> 任何持 `device:view` 的客户账号都能看到**全部组织**的电池台账。现已修复如上。
### GET /api/energy-stats/summary ### GET /api/energy-stats/summary
能耗概览,按组织数据隔离返回今日/本月能耗数据。 能耗概览,按组织数据隔离返回今日/本月能耗数据。
@ -660,8 +1003,14 @@
} }
``` ```
- `today_energy` / `month_energy` — Wh(整数) - `today_energy` / `month_energy` — **Wh**(整数)。数据源是**实时聚合 `charge_records`**
- `estimated_cost` — 预估费用(float,元),按 `COST_PER_WH` 单价计算 (按 `start_time` 的今日/本月窗口),**不是**旧实现读 `energy_stats.stat_date`;
- `estimated_cost` — 预估费用(**`f64`,单位元**,本文档唯一的非整数业务字段),
由 **`month_energy`** 按常量 **`COST_PER_WH_CENT`(分/Wh)** 计算:
`(month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0`
(`routes/energy_stats.rs::get_summary`;**不是** `COST_PER_WH`,旧文档常量名写错了);
- `cabinet_count` / `online_count` — 可见柜子总数 / 其中在线数(`cabinets.status = 1`,
且同样叠加**组织隔离 + 项目级数据范围**)。
--- ---
@ -692,14 +1041,24 @@ Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/
| 接口 | 方法 | 权限 | 响应 | | 接口 | 方法 | 权限 | 响应 |
|------|------|------|------| |------|------|------|------|
| `/api/nodes/register` | POST | 仅需登录 | `{"suc":1}` | | `/api/nodes/register` | POST | **仅总管理员** | `{"suc":1}` |
| `/api/nodes/{node_id}/heartbeat` | POST | 仅需登录 | `{"suc":1}`;未注册 `{"suc":0,"err":"节点未注册"}` | | `/api/nodes/{node_id}/heartbeat` | POST | **仅总管理员** | `{"suc":1}`;未注册 `{"suc":0,"err":"节点未注册"}` |
| `/api/nodes/{node_id}/deregister` | POST | 仅需登录 | `{"suc":1}` | | `/api/nodes/{node_id}/deregister` | POST | **仅总管理员** | `{"suc":1}` |
| `/api/nodes` | GET | 仅需登录 | `{"nodes":[...],"count":N}` | | `/api/nodes` | GET | **仅总管理员** | `{"nodes":[{node_id,ip,tcp_port}],"count":N}` |
| `/api/devices/stats` | GET | `device:view` | `{"node_count":N}` | | `/api/devices/stats` | GET | `device:view` | `{"node_count":N}` |
| `/api/devices/{dev_id}/command` | POST | `device:operate` | `{"suc":1}` | | `/api/devices/{dev_id}/command` | POST | `device:operate` | `{"suc":1}` |
> `nodes/*` 是 device-server 内部调用;`devices/*` 是管理端/后端调用。两者都**不返回** > 🔒 **`/api/nodes/*` 于 2026-09 由"仅需登录"收紧为「仅总管理员」**(`routes/nodes.rs::require_super_admin`,
> 4 个处理器全部前置;判据是 `role_level >= SUPER_ADMIN_ROLE_LEVEL`(= `2`,`middleware/auth.rs:297`))——
> 客户侧(含**企业管理员 `role_level=1`**)返回 `403`。
> 收紧原因:这是**设备服务内部管理面** —— `GET /api/nodes` 会返回节点的**内网 `ip:tcp_port`**,
> 而 `POST /api/nodes/{node_id}/deregister` **真能把节点从注册表里删掉**(不是软标记)。
> ⚠️ **`device-server` 并不调用这些端点**(它启动时**直接写 Redis** `node:{node_id}`,
> 见 `device-server/src/main.rs`;且该写入含 `registered_at`,与 `nodes.rs::register()` 写的内容不同),
> 全仓(含前端)**没有任何调用方** —— 所以收紧**不影响设备上报链路**。
> 因此上表"设备服务节点注册"的调用方一栏也一并更正:**不是** device-server 调用。
>
> `devices/*` 才是管理端/后端调用。`nodes/*` 与 `devices/*` 都**不返回**
> `{code,data,message}` 标准外层(见上表"内部接口例外")。 > `{code,data,message}` 标准外层(见上表"内部接口例外")。
--- ---
@ -715,13 +1074,15 @@ Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/
| 字段 | 类型 | 单位 | 前端显示 | 转换公式 | | 字段 | 类型 | 单位 | 前端显示 | 转换公式 |
|------|------|------|----------|---------| |------|------|------|----------|---------|
| `energy` / `total_energy` / `today_energy` / `month_energy` / `energy_kwh`¹ | i64 | **Wh** | kWh | `/ 1000` | | `energy` / `total_energy` / `today_energy` / `month_energy` / `energy_kwh`¹ | i64 | **Wh** | kWh | `/ 1000` |
| `voltage` / `cell_voltages[]` | i64 | **mV** | V | `/ 1000` | | `voltage` | i64 | **mV** | V | `/ 1000` |
| `current` | i64 | **mA** | A | `/ 1000` | | `current` | i64 | **mA** | A | `/ 1000` |
| `soc` / `start_soc` / `end_soc` | i64 | **‰** | % | `/ 10` | | `soc` / `start_soc` / `end_soc` | i64 | **‰** | % | `/ 10` |
| `temp` / `cell_temps[]` / `max_temperature` | i64 | **K×100** | °C | `/ 100 - 273.15` | | `temp` / `max_temperature` | i64 | **K×100** | °C | `/ 100 - 273.15` |
| `meter` | i64 | **Wh**(电表读数) | kWh | `/ 1000` |
| `rssi` | i64 | **0–99 信号等级** | 直接显示 | 不转换(不是 dBm) | | `rssi` | i64 | **0–99 信号等级** | 直接显示 | 不转换(不是 dBm) |
| `cost` / `estimated_cost` | i64/f64 | **元** | ¥ | 不转换(后端业务计算) | | `cost` / `estimated_cost` | i64/f64 | **元** | ¥ | 不转换(后端业务计算) |
| `today_charge_count` / `today_charge_hours` | i64 | 计数值 | 直接显示 | 不转换 | | `today_charge_count` / `today_charge_hours` | i64 | 计数值 | 直接显示 | 不转换 |
| `cell_voltages[]` / `cell_temps[]` | — | ⚠️ **恒为空数组**(协议未上报单体明细,无写入方) | — | — |
¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。 ¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。
@ -736,8 +1097,12 @@ Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/
| `850` | SOC(‰) | `85.0%` | | `850` | SOC(‰) | `85.0%` |
| `29865` | 温度(K×100) | `25.5°C` | | `29865` | 温度(K×100) | `25.5°C` |
| `85` | RSSI 信号等级(0–99) | `85` | | `85` | RSSI 信号等级(0–99) | `85` |
| `[32456,32500,32300]` | 单体电压(mV) | `32.456V, 32.500V, 32.300V` | | `125500` | 电表读数(Wh) | `125.5 kWh` |
| `[29865,29900]` | 单体温度(K×100) | `25.5°C, 25.9°C` | | `54600` | 通道电压(mV) | `54.6 V` |
| `"82"` | 通道 `batt_status`(hex,bit7 有电池 + bit1 充电中) | 位域由前端纯函数解析 |
> ⚠️ 单体电压/温度的示例值(`[...]` 数组)**已删除**:`cell_voltages[]` / `cell_temps[]`
> 当前**恒为 `[]`**(协议未上报单体明细),写一个非空示例会误导前端按数组长度渲染。
--- ---
@ -749,47 +1114,100 @@ Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/
## Redis 实时状态存储 ## Redis 实时状态存储
### 层级结构 > **这一节是平台内部实现契约**(不属于 HTTP 接口),权威来源是代码,不是本文档:
> `software/api-server/src/workers/report_worker.rs` **模块头注释**(第 13–26 行)+
> 写入点 `write_device_state` / `write_channel_state` / `save_channel_battery` / `handle_pow_fail`,
> 以及消费方 `routes/cabinet_tree.rs`、`routes/cabinets.rs`、`routes/h5.rs`。
> 接口只把这些 key 的字段**原样透出**(hex 透 hex、缺失透 `null`),位域解析在前后端各自的纯函数里做。
> ⚠️ **`AGENTS.md` 的「Redis 实时状态存储」一节仍是旧口径**(`hex` 4 字符 / `on`·`full`·`fault` /
> 板级 `status_hex`),以本节与上述代码为准。
### key 与字段(逐字段核对 `report_worker.rs`)
``` ```
device:{imei} # 设备级 Hash device:{imei} # 设备级 Hash,TTL 300s(= 在线判定窗口)
├─ online = "1" # 在线标记(TTL 300秒) ├─ online = "1" # 标记在线(任意上行都会刷新 TTL)
├─ rssi = "85" # 信号强度 ├─ rssi = "85" # 0–99 信号等级(不是 dBm)
├─ pow_fail_dc = "0/1" # 直流停电 ├─ temp = "29800" # 柜温 K×100
├─ pow_fail_ac = "0/1" # 交流停电 ├─ meter = "125500" # 电表读数 Wh(125.5 kWh)
└─ device:{imei}:board:{board_idx} # 板级 Hash ├─ node_id = "node-1" # 设备连接所在节点(**device-server 写**,用于多节点指令路由)
├─ status_hex = "00010002..." # 原始hex状态 ├─ cabinet_status1 = "10" # 柜级状态位1,2 位小写 hex(取不到 → 不写字段)
└─ device:{imei}:board:{board_idx}:ch:{ch} # 通道级 Hash(1~6) ├─ cabinet_status2 = "00" # 柜级状态位2,2 位小写 hex(取不到 → 不写字段)
├─ on = "0/1" # 充电中 ├─ pow_fail_dc = "0"/"1" # 直流停电(pow_fail_dc 指令置 1;status1 bit0=0 时清 0)
├─ full = "0/1" # 充满 └─ pow_fail_ac = "0"/"1" # 交流停电(同上)
├─ fault = "0/1" # 故障
├─ hex = "0001" # 原始hex(4字符) device:{imei}:board:{板号}:ch:{通道号} # 通道级 Hash(板号/通道号**十进制不补零**,写入时不设 TTL)
├─ battery_id = "BAT001" # 电池ID(bat_in 写入,bat_out 清除) ├─ batt_status = "82" # 通道 18 字符 hex 的 byte0,2 位小写 hex
├─ soc = "4500" # 电量 ‰ ├─ pow_status = "02" # 通道 18 字符 hex 的 byte1(与电池状态相互独立)
├─ voltage = "52000" # 电压 mV ├─ soc = "4500" # ‰
├─ current = "10000" # 电流 mA ├─ voltage = "54600" # mV
├─ temp = "29865" # 温度 K×100 ├─ current = "0" # mA
├─ brand = "星恒" # BMS品牌 └─ temp = "29800" # 电芯最高温 K×100
├─ model = "XH-48V" # BMS型号
└─ sn = "SN123456" # BMS序列号 device:{imei}:board:{板号}:ch:{通道号}:bat # 最近一次 bat_in 的电池登记(内部键,TTL 48h)
├─ battery_id / soc / info # info = 18 字符 hex(与通道 hex 同布局)
├─ brand_code / model_code / material_code # 属性原值透传
└─ cell_count / rated_voltage / rated_capacity # 额定参数(mV / mAh)
device:{imei}:board:{板号}:ch:{通道号}:order # 订单电量累加器(内部键,energy.rs)
├─ mwh / maxtemp / ts / nobat # 毫瓦时累计 / 最高温 K×100 / 上次采样秒 / 连续无电池次数
``` ```
> HEX 解析由 device-server 负责,api-server 只接收结构化数据写入 Redis。 **已废弃、代码里不存在的旧 key/字段**(旧文档曾记录,勿再引用):
### BMS 字段(全局整数单位) - 板级 Hash `device:{imei}:board:{板号}` 及其 `status_hex` —— **新协议板级没有状态字节**;
`routes/cabinet_tree.rs` 的 `status_hex` 是**按通道 `batt_status + pow_status` 升序拼回来的响应字段**,
**不是 Redis key**;
- 通道 hash 里的 `on` / `full` / `fault` / `hex` —— **Redis 里没有这些字段**,
它们只作为**接口响应字段**由 `derive_channel_flags` 从 `batt_status` / `pow_status` 实时推导
(`hex` = 通道 18 字符 hex 的**前 4 个字符**,缺任一字节则为空串);
- 通道 hash 里的 `battery_id` / `brand` / `model` / `sn` —— 已迁到 `:bat` 内部键
(且品牌/型号存的是 **`brand_code` / `model_code` 原值**,"名称映射"用的 `brand` / `model` 列代码表未定,
目前为空);
- 通道 hex 的 **`"0001"`(4 字符)** —— 实际是 **18 字符 = 9 字节**
(`CHANNEL_HEX_LEN = 18`):byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) /
byte3–4 电压(0.01V, 大端) / byte5–6 电流(0.01A, 大端) / byte7–8 电芯最高温(1K, 大端);
平台换算为 ‰ / mV / mA / K×100(`parse_channel_hex`);
- **`cell_voltages` / `cell_temps` 单体明细** —— 协议未上报,**没有任何写入方**,
接口恒返回 `[]`(见 `GET /api/compartments/{id}`)。
| 字段 | 单位 | 精度 | 说明 | ### 协议 `bat_in` 的 `battery` 块(**不是**旧文档的"BMS 字段表")
|------|------|------|------|
| `soc` | ‰ | 0.1% | 4500 = 45.00% | 协议 §5.5 的 `battery` 块 = **电池属性 + `info`(18 字符 hex)+ `cells`(电芯明细 hex)**:
| `voltage` | mV | 1 | 52000 = 52.000V |
| `current` | mA | 1 | 10000 = 10.000A | | 字段 | 落库 / 落 Redis | 说明 |
| `energy` | Wh | 1 | 累计充电量 | |------|----------------|------|
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C | | `battery_id` | `batteries.battery_id`、`:bat.battery_id` | 电池唯一标识,**可能为空串**(BMS 读不到序列号,不影响充电) |
| `battery_id` | — | — | 电池唯一标识 | | `info` | `:bat.info`(原样 hex) | **恰好 18 个 hex 字符**,布局与 `status_post.channels[]` 元素**完全相同**(§5.3) |
| `brand` | — | — | BMS品牌 | | `cells` | ❌ **不存** | 电芯明细整段 hex,长度 `cell_count × 4 + 16`;平台**当前不消费、不落库** |
| `model` | — | — | BMS型号 | | `brand_code` / `model_code` / `material_code` | `batteries` + `:bat` | 原值透传(`brand` / `model` 名称列待代码表) |
| `sn` | — | — | BMS序列号 | | `cell_count` / `rated_voltage` / `rated_capacity` | `batteries` + `:bat` | 节数 / 额定电压 mV / 额定容量 mAh |
| `cell_voltages` | mV | 1 | 单体电芯电压数组 |
> `:bat` 里的 `soc` 是**该次 `bat_in` 的起始 SOC(‰)**,供 `on` 建单取起始电量
> (协议里 `on` 报文**不带 `info`**);`bat_out` 时按 `battery_id` 比对后才清除登记。
### 队列与缓存 key(api-server / device-server 之间)
| key | 类型 | 说明 |
|-----|------|------|
| `device:reports` | LIST | 设备上报队列,device-server `LPUSH` → api-server `RPOP` **非阻塞轮询 + sleep 200ms** |
| `device:commands` | LIST | 下行指令队列,api-server `LPUSH` → device-server 消费 |
| `device:commands:dead` | LIST | 死信队列 |
| `pending:{imei}` | Hash(TTL 120s) | 已下发指令挂起项:`field = msg_id` / `value = act`,收到带 `suc` 的回执时按 **`msg_id` + `act` 双条件** `HDEL` |
| `auth_str:{imei}` | STRING | 安全码**缓存**(权威存储在 DB `cabinets.auth_str`,未命中回源并回填) |
| `auth_str:issued:{imei}` | STRING | 安全码已下发标记(下发后不可再次申领,须管理员重置) |
| `node:{node_id}` | Hash(TTL:device-server 用 `DEVICE_REGISTER_TTL`(默认 300s)、api-server 侧固定 300s,心跳刷新) | 节点注册信息:`node_id` / `ip` / `tcp_port` / `status` / `registered_at` / `last_heartbeat`;**两侧都写**(device-server 启动注册 + api-server 的 `/api/nodes/*`),`GET /api/nodes` 用 `SCAN node:*` 读取 |
### 读这一节时需要知道的实现细节
- **`cabinet_status1/2` 取不到时"不写字段"**(`HSET` 只在 `normalize_hex_byte` 得到合法 2 位 hex 时才带这两个参数),
接口侧把"字段不存在 / 空串 / 纯空白"统一判为 `null`,**绝不落 `"00"`**;
- **`pow_fail_dc` / `pow_fail_ac` 是一条指令一个字段**(`act` 即字段名,不再用 `info` 区分),
且 `status_post` 的 `cabinet.status1` **bit0 = 0(正常)时会被清成 `"0"`**,
bit0 = 1 时不动(避免抹掉刚置位的停电标记);
- 板号量程 **1–15**、通道号量程 **1–8**(协议 §3.7,**不是 0–7**);
越界的板/通道**整条跳过并记 WARN**,不会写出 `:board:0` / `:ch:9` 这类垃圾 key;
- 通道 hex 长度 ≠ 18 或含非 hex 字符 → 该通道**跳过**(记 WARN),不影响同板其它通道。
--- ---

File diff suppressed because one or more lines are too long

View File

@ -174,9 +174,22 @@ impl NodeRegistry {
keys keys
} }
/// 检查设备是否在线(通过 Redis 中的在线标记) /// 检查设备是否**可下发**(即:是否存在一条**已 `login` 成功**的连接)。
///
/// # 为什么不是查 `device:{imei}`
/// 该键在**验签之前**就会被写(设备一发 `auth_str`/`login` 就写),断连后还能存活 300 秒 ⇒
/// 用它判在线会得到"管理端显示已下发成功、设备实际收不到"这种最难排查的故障。
///
/// 改用 **连接级认证标记** `device:authed:{imei}`(与 `device-server` 的**跨服务契约**,
/// 见 `device-server/src/tcp/connection.rs::auth_marker_key`):
/// - **写入**:向该连接投递 `login` 成功应答(含 `spec` + `suc=1`)时;
/// - **刷新**:该连接每次上行 / 每次下发;
/// - **删除**:连接断开、或被同一 IMEI 的新连接接管时(新连接须重新 `login`)。
///
/// 它是"**可下发**"的必要条件,不是充分条件:`device-server` 侧还有第二道门控
/// (未认证连接一律不投递,见 `PRE_LOGIN_ALLOWED_ACTS`)。
pub async fn is_device_online(&self, imei: &str) -> bool { pub async fn is_device_online(&self, imei: &str) -> bool {
let key = format!("device:{}", imei); let key = auth_marker_key(imei);
let mut conn = self.redis.clone(); let mut conn = self.redis.clone();
let exists: Result<bool, _> = redis::cmd("EXISTS") let exists: Result<bool, _> = redis::cmd("EXISTS")
.arg(&key) .arg(&key)
@ -186,6 +199,15 @@ impl NodeRegistry {
} }
} }
/// 连接级认证标记的键前缀 —— **必须与 `device-server` 的 `auth_marker_key` 完全一致**
/// (跨服务契约;两侧各有常量/键格式断言守住,见 `commands.rs` 与 `connection.rs` 的单测)。
const AUTH_MARKER_PREFIX: &str = "device:authed:";
/// 连接级认证标记的键。
fn auth_marker_key(imei: &str) -> String {
format!("{}{}", AUTH_MARKER_PREFIX, imei)
}
/// 应用共享状态 /// 应用共享状态
#[derive(Clone)] #[derive(Clone)]
#[allow(dead_code)] #[allow(dead_code)]
@ -418,12 +440,16 @@ pub async fn send_command(
Ok(Json(json!({ "suc": 1 }))) Ok(Json(json!({ "suc": 1 })))
} }
/// GET /api/devices/stats — 在线设备统计 /// GET /api/devices/stats — 在线设备统计(**仅总管理员**)
///
/// 返回的是**全平台**设备服务节点数(`node_count`),属内部设施信息:
/// 原先只校验 `device:view`,任何客户账号登录后都能读到"我们有多少台设备服务节点"。
/// 与 `/api/nodes/*` 同一口径,收紧为 `role_level >= 2`(内部管理面,不对客户开放)。
pub async fn device_stats( pub async fn device_stats(
user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?; auth::require_super_admin(&user)?;
let nodes = state.nodes.list().await; let nodes = state.nodes.list().await;
Ok(Json(json!({ Ok(Json(json!({
"node_count": nodes.len(), "node_count": nodes.len(),
@ -484,4 +510,31 @@ mod tests {
let mut numeric = Some(json!({ "id": 3 })); let mut numeric = Some(json!({ "id": 3 }));
assert!(matches!(take_command_target(&mut numeric), Err(AppError::BadRequest(_)))); assert!(matches!(take_command_target(&mut numeric), Err(AppError::BadRequest(_))));
} }
/// **跨服务契约**:连接级认证标记的键必须与 `device-server` 的 `auth_marker_key`
/// 逐字一致(那边同样有 `test_auth_marker_key_format_contract` 守门)——
/// 不一致的后果是"所有设备都被判为不在线、指令全下发不出去"。
#[test]
fn test_auth_marker_key_matches_device_server_contract() {
assert_eq!(AUTH_MARKER_PREFIX, "device:authed:");
assert_eq!(auth_marker_key("864720562557021"), "device:authed:864720562557021");
// 不能退化成旧的在线键(那正是"假在线"的根因)
assert_ne!(auth_marker_key("864720562557021"), "device:864720562557021");
}
/// `/api/devices/stats`(全平台节点数)属内部设施面:只有总管理员可读
#[test]
fn test_device_stats_requires_super_admin() {
let make = |role_level: i32| CurrentUser {
user_id: 1,
phone: "13800138000".into(),
permissions: std::collections::HashSet::new(),
role_level,
organization_id: Some(7),
scope: crate::middleware::auth::UserScope::default(),
};
assert!(auth::require_super_admin(&make(2)).is_ok(), "总管理员放行");
assert!(auth::require_super_admin(&make(1)).is_err(), "企业管理员必须被拒");
assert!(auth::require_super_admin(&make(0)).is_err(), "操作员必须被拒");
}
} }

View File

@ -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在线', status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)',
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)
@ -111,7 +111,7 @@ const SCHEMA_SQL: &str = r#"
end_soc INT COMMENT '结束电量(‰)', end_soc INT COMMENT '结束电量(‰)',
energy INT DEFAULT 0 COMMENT '充电量(Wh)', energy INT DEFAULT 0 COMMENT '充电量(Wh)',
reason VARCHAR(20) COMMENT '结束原因: normal/user_stop/forced/timeout', reason VARCHAR(20) COMMENT '结束原因: normal/user_stop/forced/timeout',
status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常', status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常(保留未使用:平台当前无写入方,结束原因见 reason)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (compartment_id) REFERENCES compartments(id), FOREIGN KEY (compartment_id) REFERENCES compartments(id),
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id), FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
@ -313,6 +313,49 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
) )
.await?; .await?;
// 更新 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(
pool,
"cabinets",
"status",
"2禁用",
r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)'"#,
)
.await?;
// 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。
//
// 实测(全仓 grep):**生产代码只有 0 与 1 两个写入值** ——
// 建单 `report_worker.rs::try_create_order` 固定写 `0`;所有结束路径
// (`off` / `bat_out` / 换电池 / 10 小时超时 / 无电池对账)都走
// `finalize_order*`,`order_status` 恒为 `ORDER_STATUS_DONE = 1`。
// 唯一的 `2` 出现在 `test/reset_fixture.py`(一次性夹具脚本,非交付物)。
//
// 但 `2` **有消费方**:PC 订单页 `web/src/pages/charge-orders/index.tsx` 的筛选下拉
// 提供 `<Option value={2}>异常</Option>`、`STATUS_COLOR[2] = 'red'`,
// 且 `routes/charge_orders.rs::status_text` 把 `2` 映射为"异常" ——
// 即"异常"曾被设计成一个**状态**,而现实现把它降级成 `reason` 里的**结束原因**。
// 这是**产品语义缺口,不是笔误**,故**不删除 `2`**,只在注释里注明"保留未使用",
// 并作为待产品决策项上报(见 docs/API.md 的 `GET /api/charge-orders` 一节)。
ensure_column_comment(
pool,
"charge_orders",
"status",
"保留未使用",
r#"ALTER TABLE charge_orders MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常(保留未使用:平台当前无写入方,结束原因见 reason)'"#,
)
.await?;
// 创建关键索引(IF NOT EXISTS 避免重复) // 创建关键索引(IF NOT EXISTS 避免重复)
create_indexes(pool).await?; create_indexes(pool).await?;

View File

@ -296,6 +296,24 @@ pub fn check_permission(user: &CurrentUser, code: &str) -> Result<(), AppError>
/// 总管理员角色等级(`users.role`,与 `roles` 表的 `super_admin` 对应) /// 总管理员角色等级(`users.role`,与 `roles` 表的 `super_admin` 对应)
pub const SUPER_ADMIN_ROLE_LEVEL: i32 = 2; pub const SUPER_ADMIN_ROLE_LEVEL: i32 = 2;
/// **内部接口**访问校验:仅总管理员(`role_level >= 2`)放行,其余一律 `Forbidden`
///
/// 用在**没有对应业务权限码、也不属于任何客户**的内部设施面上(节点注册表
/// `/api/nodes/*`、在线节点统计 `/api/devices/stats`)。这些接口原先只校验"已登录"
/// (`device:view` 甚至没有),任何客户账号都能读到设备服务节点的内网 `ip:tcp_port`
/// 或篡改节点注册表。
///
/// 为什么不新造一个权限码:`db/migrate.rs::seed_permissions` 的 31 条权限码都是**客户业务
/// 权限**(设备/订单/能耗/用户…),把"内部设施"混进去会让管理端角色配置页出现
/// 客户根本不该看到的开关;按角色等级硬判断的语义更准确,也与
/// 「总管理员=工厂、客户=组织」这套层级一致(失败安全:判不出就不放行)。
pub fn require_super_admin(user: &CurrentUser) -> Result<(), AppError> {
if user.role_level >= SUPER_ADMIN_ROLE_LEVEL {
return Ok(());
}
Err(AppError::Forbidden("无权访问该资源".into()))
}
/// 内置角色名常量(与 `db/migrate.rs::insert_default_roles` 的种子角色名严格一致) /// 内置角色名常量(与 `db/migrate.rs::insert_default_roles` 的种子角色名严格一致)
const ROLE_NAME_NORMAL_USER: &str = "normal_user"; const ROLE_NAME_NORMAL_USER: &str = "normal_user";
const ROLE_NAME_ORG_ADMIN: &str = "org_admin"; const ROLE_NAME_ORG_ADMIN: &str = "org_admin";
@ -568,6 +586,50 @@ pub fn org_condition(user: &CurrentUser, cabinet_table: &str) -> (String, Vec<St
} }
} }
/// **组织 ID 列**(`organizations.id` / `projects.organization_id`)的组织隔离条件
///
/// 与 [`org_condition`] 同构:`(带 " AND " 前缀的片段, 绑定值)`。
/// - 总管理员(`role_level >= 2`)→ 空片段(不限制);
/// - 客户侧有组织 → ` AND {org_col} = ?`;
/// - 客户侧无组织 → ` AND 1=0`(**空集,绝不退化成全量**)。
///
/// 为什么单独有这么一条:`organizations` / `projects` 两张表**自己就带组织列**
/// (不需要像 `cabinets` 那样绕 `project → organization` 子查询),
/// 组织树(`routes/cabinet_tree.rs::organization_tree`)按这条条件过滤组织与项目。
pub fn org_condition_on_column(user: &CurrentUser, org_col: &str) -> (String, Vec<String>) {
if user.role_level >= SUPER_ADMIN_ROLE_LEVEL {
return (String::new(), vec![]);
}
match user.organization_id {
Some(org_id) => (format!(" AND {} = ?", org_col), vec![org_id.to_string()]),
None => (" AND 1=0".to_string(), vec![]),
}
}
/// **项目 ID 列**(如 `c.project_id`)的组织隔离条件:该项目必须属于本组织
///
/// 与 [`UserScope::project_condition`] 同一形态(都收窄到项目这一层),但语义是**组织归属**
/// 而不是数据范围,两者必须**同时**叠加(取交集)。
///
/// 为什么需要它:设备列表的下拉/筛选接口原先允许直接用入参 `project_id` /
/// `organization_id` 当过滤条件(=把"用户想看什么"当成了"用户能看什么"),
/// 客户账号传别人的 id 就能越权取数;本条件无条件叠加,使入参退化为**纯筛选**。
pub fn org_condition_for_project(user: &CurrentUser, project_col: &str) -> (String, Vec<String>) {
if user.role_level >= SUPER_ADMIN_ROLE_LEVEL {
return (String::new(), vec![]);
}
match user.organization_id {
Some(org_id) => (
format!(
" AND {} IN (SELECT pj.id FROM projects pj WHERE pj.organization_id = ?)",
project_col
),
vec![org_id.to_string()],
),
None => (" AND 1=0".to_string(), vec![]),
}
}
/// 操作日志的组织过滤(通过 users.organization_id 链路) /// 操作日志的组织过滤(通过 users.organization_id 链路)
pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, Vec<String>) { pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, Vec<String>) {
if user.role_level >= 2 { if user.role_level >= 2 {
@ -783,6 +845,62 @@ mod tests {
assert!(!scope.allows_project(None), "有限制时未划拨设备一律不在范围内"); assert!(!scope.allows_project(None), "有限制时未划拨设备一律不在范围内");
} }
// -----------------------------------------------------------------------
// 组织隔离条件(组织树 / 设备列表筛选)
// -----------------------------------------------------------------------
/// 组织 ID 列:总管理员不限、客户侧按组织、无组织 `1=0`(绝不退化成全量)
#[test]
fn test_org_condition_on_column_rules() {
let make = |role_level: i32, org: Option<i64>| CurrentUser {
user_id: 1,
phone: "13800138000".into(),
permissions: HashSet::new(),
role_level,
organization_id: org,
scope: UserScope::default(),
};
let (cond, binds) = org_condition_on_column(&make(2, None), "id");
assert!(cond.is_empty(), "总管理员不得有任何组织收窄:{}", cond);
assert!(binds.is_empty());
let (cond, binds) = org_condition_on_column(&make(1, Some(42)), "p.organization_id");
assert_eq!(cond, " AND p.organization_id = ?");
assert_eq!(binds, vec!["42"]);
let (cond, binds) = org_condition_on_column(&make(0, None), "id");
assert_eq!(cond, " AND 1=0", "无组织的客户侧账号必须是空集");
assert!(binds.is_empty());
}
/// 项目列:客户侧经 `projects` 子查询收窄到本组织;无组织 `1=0`
#[test]
fn test_org_condition_for_project_rules() {
let make = |role_level: i32, org: Option<i64>| CurrentUser {
user_id: 1,
phone: "13800138000".into(),
permissions: HashSet::new(),
role_level,
organization_id: org,
scope: UserScope::default(),
};
let (cond, binds) = org_condition_for_project(&make(2, None), "c.project_id");
assert!(cond.is_empty());
assert!(binds.is_empty());
let (cond, binds) = org_condition_for_project(&make(1, Some(42)), "c.project_id");
assert_eq!(
cond,
" AND c.project_id IN (SELECT pj.id FROM projects pj WHERE pj.organization_id = ?)"
);
assert_eq!(binds, vec!["42"]);
let (cond, _) = org_condition_for_project(&make(1, None), "c.project_id");
assert_eq!(cond, " AND 1=0");
}
/// 追加条件助手:空片段时不得留下前导 ` AND `(否则 SQL 语法错误) /// 追加条件助手:空片段时不得留下前导 ` AND `(否则 SQL 语法错误)
#[test] #[test]
fn test_append_condition_handles_empty_clause() { fn test_append_condition_handles_empty_clause() {

View File

@ -2,6 +2,19 @@
//! //!
//! 设备服务启动时注册到 API 服务,定期发送心跳。 //! 设备服务启动时注册到 API 服务,定期发送心跳。
//! API 服务通过节点健康状况判断设备服务是否在线。 //! API 服务通过节点健康状况判断设备服务是否在线。
//!
//! # 访问条件(2026-09 收紧)
//! 这四个端点原先**仅需登录**(任意客户账号可用),而它们是**内部管理面**:
//! 节点注册表存在 Redis `node:{node_id}` 里,`GET /api/nodes` 会返回节点的
//! **内网 IP : TCP 端口**,`deregister` 还能把节点从注册表里删掉。
//! 现已统一收紧为**总管理员(`role_level >= 2`)专用**(见 [`require_super_admin`])。
//!
//! ⚠️ **为什么不影响设备侧**:`device-server` **不调用**这些 HTTP 端点 ——
//! 它启动时**直接写 Redis** `node:{node_id}`(`software/device-server/src/main.rs`,
//! 含 `registered_at` 字段,与本文件的 `register()` 写入内容不同),
//! api-server 的 `workers::node_checker` 只从 Redis 读心跳。全仓(含前端)没有任何
//! `/api/nodes/register|heartbeat|deregister` 调用方,生产 Redis 里也从未出现过该
//! 端点写入的键,故收紧不会打断设备上报链路。
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::Json; use axum::Json;
@ -10,6 +23,15 @@ use serde_json::{json, Value};
use crate::commands::AppState; use crate::commands::AppState;
use crate::error::AppError; use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 内部接口访问校验:节点注册表属设备服务内部管理面,仅总管理员可读写
///
/// 实现委托给 [`auth::require_super_admin`](与 `/api/devices/stats` **同一把闸门**,
/// 避免两处各写一套角色判断后行为漂移)。
fn require_super_admin(user: &CurrentUser) -> Result<(), AppError> {
auth::require_super_admin(user)
}
/// 节点注册请求体 /// 节点注册请求体
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -19,21 +41,27 @@ pub struct NodeRegisterRequest {
pub tcp_port: u16, pub tcp_port: u16,
} }
/// POST /api/nodes/register — 设备服务注册 /// POST /api/nodes/register — 设备服务注册(仅总管理员)
pub async fn register_node( pub async fn register_node(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<NodeRegisterRequest>, Json(req): Json<NodeRegisterRequest>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
require_super_admin(&user)?;
state.nodes.register(&req.node_id, &req.ip, req.tcp_port).await; state.nodes.register(&req.node_id, &req.ip, req.tcp_port).await;
tracing::info!("[节点注册] node_id={}", req.node_id); tracing::info!("[节点注册] node_id={}", req.node_id);
Ok(Json(json!({"suc": 1}))) Ok(Json(json!({"suc": 1})))
} }
/// POST /api/nodes/:node_id/heartbeat — 节点心跳 /// POST /api/nodes/:node_id/heartbeat — 节点心跳(仅总管理员)
pub async fn node_heartbeat( pub async fn node_heartbeat(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(node_id): Path<String>, Path(node_id): Path<String>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
require_super_admin(&user)?;
if state.nodes.heartbeat(&node_id).await { if state.nodes.heartbeat(&node_id).await {
Ok(Json(json!({"suc": 1}))) Ok(Json(json!({"suc": 1})))
} else { } else {
@ -41,20 +69,26 @@ pub async fn node_heartbeat(
} }
} }
/// POST /api/nodes/:node_id/deregister — 节点注销 /// POST /api/nodes/:node_id/deregister — 节点注销(仅总管理员)
pub async fn deregister_node( pub async fn deregister_node(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(node_id): Path<String>, Path(node_id): Path<String>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
require_super_admin(&user)?;
state.nodes.remove(&node_id).await; state.nodes.remove(&node_id).await;
tracing::info!("[节点注销] node_id={}", node_id); tracing::info!("[节点注销] node_id={}", node_id);
Ok(Json(json!({"suc": 1}))) Ok(Json(json!({"suc": 1})))
} }
/// GET /api/nodes — 节点列表 /// GET /api/nodes — 节点列表(仅总管理员;返回节点 `ip:tcp_port`)
pub async fn list_nodes( pub async fn list_nodes(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
require_super_admin(&user)?;
let nodes = state.nodes.list().await; let nodes = state.nodes.list().await;
let list: Vec<Value> = nodes let list: Vec<Value> = nodes
.into_iter() .into_iter()
@ -68,3 +102,28 @@ pub async fn list_nodes(
.collect(); .collect();
Ok(Json(json!({"nodes": list, "count": list.len()}))) Ok(Json(json!({"nodes": list, "count": list.len()})))
} }
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn user(role_level: i32) -> CurrentUser {
CurrentUser {
user_id: 1,
phone: "13800138000".into(),
permissions: HashSet::new(),
role_level,
organization_id: Some(7),
scope: crate::middleware::auth::UserScope::default(),
}
}
/// 节点管理面:客户侧(含企业管理员)必须被拒,总管理员放行
#[test]
fn test_require_super_admin_gates_internal_node_api() {
assert!(require_super_admin(&user(2)).is_ok());
assert!(require_super_admin(&user(1)).is_err(), "企业管理员不得访问内部节点面");
assert!(require_super_admin(&user(0)).is_err(), "操作员不得访问内部节点面");
}
}

View File

@ -52,100 +52,120 @@ struct BatteryRow {
created_at: Option<NaiveDateTime>, created_at: Option<NaiveDateTime>,
} }
/// 构建列表 / 计数 SQL /// 构建列表 / 计数 SQL 的 WHERE 子句与绑定值
/// ///
/// 仅判断筛选条件是否存在以拼接 SQL,具体取值由调用方绑定。 /// # 归属(`batteries` 表**没有任何柜子/项目/组织列**)
fn build_battery_sql(params: &BatteryQuery) -> (String, String) { /// 见 `db/migrate.rs` 的建表语句:`batteries(battery_id, soc, brand, model, sn, …, status)`,
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string(); /// 与 `cabinets` / `projects` / `organizations` **无外键、无关联列**。
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string(); /// 因此归属只能走**间接路径**:
/// `batteries.battery_id → charge_orders.battery_id → charge_orders.cabinet_id
/// → cabinets.project_id → projects.organization_id`。
///
/// # 可见性规则(与全系统统一口径一致)
/// - 工厂(`role_level >= 2`)→ 全量(含从未建单、无法归属的电池);
/// - 客户侧(0/1)→ **只可见"在本组织柜子上产生过充电订单"的电池**;
/// `users.organization_id` 为 `NULL` → `1=0` 空集(**绝不退化成全量**);
/// - 数据范围(`user_scopes`,项目级)叠加在同一子查询的 `c.project_id` 上(未配置 = 不限制)。
///
/// ⚠️ 固有语义(不是缺陷):电池会在客户之间流转,一块电池若在**多个**组织都用过,
/// 则这些组织都能看到它 —— 间接归属只能表达"用过",无法表达"现在归谁"。
/// 「从未建单的电池」对客户侧一律不可见(**没有归属证据就不给看**,而不是猜一个归属)。
///
/// # 绑定顺序
/// 条件按 SQL 中占位符出现的先后 push:**归属子查询(文本)→ `battery_id LIKE`(文本)
/// → `status`(整型,最后)**。调用方([`count_batteries`] / [`fetch_battery_rows`])
/// 因此先绑全部文本值,最后单独绑 `status`(整型/字符串不可混在一起按位次绑)。
fn build_battery_filters(params: &BatteryQuery, user: &CurrentUser) -> (String, Vec<String>) {
let mut conditions: Vec<String> = vec!["1=1".to_string()];
let mut binds: Vec<String> = Vec::new();
if params.status.is_some() { // ① 归属(文本绑定)
query.push_str(" AND status = ?"); if user.role_level < auth::SUPER_ADMIN_ROLE_LEVEL {
count_query.push_str(" AND status = ?"); match user.organization_id {
Some(org_id) => {
let (scope_cond, scope_binds) =
user.effective_scope().project_condition("c.project_id");
conditions.push(format!(
"EXISTS (SELECT 1 FROM charge_orders co \
JOIN cabinets c ON co.cabinet_id = c.id \
JOIN projects p ON c.project_id = p.id \
WHERE co.battery_id = batteries.battery_id AND p.organization_id = ?{})",
scope_cond
));
binds.push(org_id.to_string());
binds.extend(scope_binds);
} }
// 无组织归属的客户侧账号:空集(组织隔离本身已给出"看不到任何东西")
None => conditions.push("1=0".to_string()),
}
}
// ② 电池编号模糊筛选(文本绑定)
if params.battery_id.is_some() { if params.battery_id.is_some() {
query.push_str(" AND battery_id LIKE ?"); conditions.push("batteries.battery_id LIKE ?".to_string());
count_query.push_str(" AND battery_id LIKE ?"); }
// ③ 状态筛选(整型绑定,**必须最后**)
if params.status.is_some() {
conditions.push("batteries.status = ?".to_string());
} }
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); (format!("WHERE {}", conditions.join(" AND ")), binds)
(query, count_query) }
/// `battery_id LIKE ?` 的模糊匹配值(与列表/计数共用,避免两处漂移)
fn battery_like_pattern(battery_id: Option<&str>) -> Option<String> {
battery_id.map(|bid| format!("%{}%", bid))
} }
/// 按筛选条件统计电池总数 /// 按筛选条件统计电池总数
async fn count_batteries( async fn count_batteries(
pool: &sqlx::MySqlPool, pool: &sqlx::MySqlPool,
count_query: &str, where_clause: &str,
text_binds: &[String],
status: Option<i8>, status: Option<i8>,
battery_id: Option<&str>, battery_id: Option<&str>,
) -> Result<i64, sqlx::Error> { ) -> Result<i64, sqlx::Error> {
let total: i64 = if let Some(s) = status { let sql = format!("SELECT COUNT(*) FROM batteries {}", where_clause);
if let Some(bid) = battery_id { let mut query = sqlx::query_scalar::<_, i64>(&sql);
sqlx::query_scalar(count_query) for bind in text_binds {
.bind(s) query = query.bind(bind);
.bind(format!("%{}%", bid))
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(count_query)
.bind(s)
.fetch_one(pool)
.await?
} }
} else if let Some(bid) = battery_id { if let Some(bid) = battery_like_pattern(battery_id) {
sqlx::query_scalar(count_query) query = query.bind(bid);
.bind(format!("%{}%", bid)) }
.fetch_one(pool) // ⚠️ `status` 必须最后绑定:SQL 里它是最后一个占位符之前唯一的整型条件,
.await? // 而整型/字符串绑定不可混放(顺序错了条件就张冠李戴)
} else { if let Some(s) = status {
sqlx::query_scalar(count_query) query = query.bind(s);
.fetch_one(pool) }
.await? query.fetch_one(pool).await
};
Ok(total)
} }
/// 按筛选条件查询当前页电池行 /// 按筛选条件查询当前页电池行
async fn fetch_battery_rows( async fn fetch_battery_rows(
pool: &sqlx::MySqlPool, pool: &sqlx::MySqlPool,
query: &str, where_clause: &str,
text_binds: &[String],
status: Option<i8>, status: Option<i8>,
battery_id: Option<&str>, battery_id: Option<&str>,
page_size: i64, page_size: i64,
offset: i64, offset: i64,
) -> Result<Vec<BatteryRow>, sqlx::Error> { ) -> Result<Vec<BatteryRow>, sqlx::Error> {
let rows: Vec<BatteryRow> = if let Some(s) = status { let sql = format!(
if let Some(bid) = battery_id { "SELECT * FROM batteries {} ORDER BY id DESC LIMIT ? OFFSET ?",
sqlx::query_as(query) where_clause
.bind(s) );
.bind(format!("%{}%", bid)) let mut query = sqlx::query_as::<_, BatteryRow>(&sql);
.bind(page_size) for bind in text_binds {
.bind(offset) query = query.bind(bind);
.fetch_all(pool)
.await?
} else {
sqlx::query_as(query)
.bind(s)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
} }
} else if let Some(bid) = battery_id { if let Some(bid) = battery_like_pattern(battery_id) {
sqlx::query_as(query) query = query.bind(bid);
.bind(format!("%{}%", bid)) }
.bind(page_size) if let Some(s) = status {
.bind(offset) query = query.bind(s);
.fetch_all(pool) }
.await? query.bind(page_size).bind(offset).fetch_all(pool).await
} else {
sqlx::query_as(query)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
};
Ok(rows)
} }
/// 将数据库行映射为响应项列表 /// 将数据库行映射为响应项列表
@ -164,6 +184,11 @@ fn batteries_to_items(rows: Vec<BatteryRow>) -> Vec<BatteryItem> {
} }
/// GET /api/batteries — 电池台账列表 /// GET /api/batteries — 电池台账列表
///
/// **归属**:`batteries` 表无组织/项目关联列,归属走
/// `battery_id → charge_orders → cabinets → projects.organization_id` 间接路径;
/// 工厂(`role_level >= 2`)全量,客户侧只可见"本组织柜子上用过"的电池,
/// 无组织 = 空集,再叠加数据范围(见 [`build_battery_filters`] 的完整说明)。
pub async fn list_batteries( pub async fn list_batteries(
user: CurrentUser, user: CurrentUser,
State(state): State<crate::commands::AppState>, State(state): State<crate::commands::AppState>,
@ -175,14 +200,22 @@ pub async fn list_batteries(
let page_size = params.page_size.unwrap_or(20).min(100); let page_size = params.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size; let offset = (page - 1) * page_size;
// 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定 // 归属 + 筛选条件(文本绑定与 status 分开,见函数注释的绑定顺序)
let (query, count_query) = build_battery_sql(&params); let (where_clause, text_binds) = build_battery_filters(&params, &user);
let battery_id = params.battery_id.as_deref(); let battery_id = params.battery_id.as_deref();
let total = count_batteries(&state.mysql, &count_query, params.status, battery_id).await?; let total = count_batteries(
&state.mysql,
&where_clause,
&text_binds,
params.status,
battery_id,
)
.await?;
let rows = fetch_battery_rows( let rows = fetch_battery_rows(
&state.mysql, &state.mysql,
&query, &where_clause,
&text_binds,
params.status, params.status,
battery_id, battery_id,
page_size, page_size,
@ -203,3 +236,84 @@ pub async fn list_batteries(
"message": "ok" "message": "ok"
}))) })))
} }
// ---------------------------------------------------------------------------
// 单元测试:电池台账的归属(间接路径)与绑定顺序
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::middleware::auth::UserScope;
fn query() -> BatteryQuery {
BatteryQuery { status: None, battery_id: None, page: None, page_size: None }
}
fn user(role_level: i32, org: Option<i64>, scope: UserScope) -> CurrentUser {
CurrentUser {
user_id: 1,
phone: "13800138000".into(),
permissions: std::collections::HashSet::new(),
role_level,
organization_id: org,
scope,
}
}
/// 工厂(role_level >= 2):**不加任何归属条件**(含无法归属的电池也能看)
#[test]
fn test_battery_filters_super_admin_has_no_ownership_condition() {
let (where_clause, binds) = build_battery_filters(&query(), &user(2, None, UserScope::default()));
assert_eq!(where_clause, "WHERE 1=1");
assert!(binds.is_empty());
}
/// 客户侧:必须按「charge_orders → cabinets → projects.organization_id」间接归属收窄
#[test]
fn test_battery_filters_customer_narrows_by_indirect_ownership() {
let (where_clause, binds) = build_battery_filters(&query(), &user(1, Some(42), UserScope::default()));
assert!(
where_clause.contains("co.battery_id = batteries.battery_id"),
"必须走订单间接归属:{}",
where_clause
);
assert!(where_clause.contains("p.organization_id = ?"), "{}", where_clause);
assert_eq!(binds, vec!["42"]);
// 有数据范围时叠加在同一子查询的 `c.project_id` 上(取交集)
let (where_clause, binds) = build_battery_filters(
&query(),
&user(1, Some(42), UserScope { project_ids: vec![7, 9] }),
);
assert!(where_clause.contains("c.project_id IN (?,?)"), "{}", where_clause);
assert_eq!(binds, vec!["42", "7", "9"], "绑定顺序 = 占位符顺序");
}
/// 无组织归属的客户侧账号:`1=0` 空集(绝不退化成全量)
#[test]
fn test_battery_filters_no_org_customer_is_empty() {
let (where_clause, binds) = build_battery_filters(&query(), &user(0, None, UserScope::default()));
assert!(where_clause.contains("1=0"), "{}", where_clause);
assert!(binds.is_empty());
}
/// 绑定顺序:文本条件(归属 → LIKE)在后、`status` 必须最后
#[test]
fn test_battery_filters_place_status_last() {
let params = BatteryQuery {
status: Some(1),
battery_id: Some("SN-".into()),
page: None,
page_size: None,
};
let (where_clause, binds) = build_battery_filters(&params, &user(1, Some(42), UserScope::default()));
let ownership_pos = where_clause.find("EXISTS").expect("归属条件必须在");
let like_pos = where_clause.find("battery_id LIKE ?").expect("模糊条件必须在");
let status_pos = where_clause.find("status = ?").expect("状态条件必须在");
assert!(ownership_pos < like_pos && like_pos < status_pos, "{}", where_clause);
// 文本绑定只有归属部分;LIKE 与 status 由调用方按位次补齐
assert_eq!(binds, vec!["42"]);
assert_eq!(battery_like_pattern(Some("SN-")).as_deref(), Some("%SN-%"));
assert_eq!(battery_like_pattern(None), None);
}
}

View File

@ -72,11 +72,16 @@ impl From<CabinetSqlRow> for CabinetRow {
// 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。 // 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// 全部组织(组织树用) /// 全部组织(工厂/总管理员可见全量)
const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"; const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations WHERE 1=1{org_cond} ORDER BY id";
/// 全部项目(经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题) /// 全部项目(工厂/总管理员可见全量;经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题)
const PROJECTS_ALL_SQL: &str = "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id"; ///
/// `{org_cond}` = 组织隔离片段(`p.organization_id = ?` / `1=0` / 空),
/// `{scope_cond}` = 数据范围片段(`p.id IN (...)` / 空),**两者都在 `WHERE 1=1` 之后追加**。
const PROJECTS_ALL_SQL: &str = "SELECT p.id, p.organization_id, p.name, p.address, \
CAST(p.latitude AS CHAR) AS latitude, CAST(p.longitude AS CHAR) AS longitude \
FROM projects p WHERE 1=1{org_cond}{scope_cond} ORDER BY p.id";
/// 各项目下的设备数量 /// 各项目下的设备数量
const PROJECT_CABINET_COUNTS_SQL: &str = const PROJECT_CABINET_COUNTS_SQL: &str =
@ -128,6 +133,16 @@ const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// 组织树接口 — 返回多级组织->项目树(含设备数量) /// 组织树接口 — 返回多级组织->项目树(含设备数量)
///
/// **组织隔离(与 `GET /api/my-organizations` 同一口径,见 AGENTS.md「层级与可见性」)**:
/// 工厂(`role_level >= 2`)→ 全量组织与全量项目;
/// 客户侧(组织管理员 `1` / 操作员 `0`)→ **只有自己所属组织及其项目**;
/// `users.organization_id` 为 `NULL` → 空集(**绝不退化成全量**)。
///
/// 历史缺陷:本接口原先直接 `SELECT ... FROM organizations` / `FROM projects` 全量查询,
/// 只校验了 `device:view` ⇒ 任何客户账号都能拿到全平台组织名 + 项目树(含地址/经纬度)。
///
/// 数据范围(`user_scopes`)在**项目**这一层再收窄(未配置 = 不限制,与今天一致)。
pub async fn organization_tree( pub async fn organization_tree(
user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
@ -136,13 +151,22 @@ pub async fn organization_tree(
let db = &state.mysql; let db = &state.mysql;
// 获取所有组织 // 可见组织(含隔离条件)与可见项目(组织隔离 ∧ 数据范围)
let orgs: Vec<OrganizationRow> = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?; let (orgs_sql, orgs_binds) = visible_orgs_sql(&user);
let mut orgs_query = sqlx::query_as::<_, OrganizationRow>(&orgs_sql);
for bind in &orgs_binds {
orgs_query = orgs_query.bind(bind);
}
let orgs: Vec<OrganizationRow> = orgs_query.fetch_all(db).await?;
// 获取所有项目 let (projects_sql, projects_binds) = visible_projects_sql(&user);
let projects: Vec<ProjectRow> = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?; let mut projects_query = sqlx::query_as::<_, ProjectRow>(&projects_sql);
for bind in &projects_binds {
projects_query = projects_query.bind(bind);
}
let projects: Vec<ProjectRow> = projects_query.fetch_all(db).await?;
// 获取每个项目的设备数量 // 获取每个项目的设备数量(只统计柜子数,项目集合已被上面收窄)
let project_count_map = fetch_project_counts(db).await?; let project_count_map = fetch_project_counts(db).await?;
// 递归构建树 // 递归构建树
@ -151,6 +175,29 @@ pub async fn organization_tree(
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" }))) Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
} }
/// 可见组织的查询 SQL 与绑定值(纯函数,便于单测断言组织隔离)
///
/// 组织表自带组织 ID 列 ⇒ 直接按 `id` 过滤(客户侧 `id = users.organization_id`)。
fn visible_orgs_sql(user: &CurrentUser) -> (String, Vec<String>) {
let (org_cond, org_binds) = auth::org_condition_on_column(user, "id");
(ORGS_ALL_SQL.replace("{org_cond}", &org_cond), org_binds)
}
/// 可见项目的查询 SQL 与绑定值(纯函数,便于单测断言两道收窄)
///
/// 组织隔离(本组织)**之后**再叠加数据范围(项目白名单),占位符顺序 = 绑定值顺序。
fn visible_projects_sql(user: &CurrentUser) -> (String, Vec<String>) {
let (org_cond, mut binds) = auth::org_condition_on_column(user, "p.organization_id");
let (scope_cond, scope_binds) = user.effective_scope().project_condition("p.id");
binds.extend(scope_binds);
(
PROJECTS_ALL_SQL
.replace("{org_cond}", &org_cond)
.replace("{scope_cond}", &scope_cond),
binds,
)
}
/// 查询「项目ID -> 设备数量」映射 /// 查询「项目ID -> 设备数量」映射
async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result<HashMap<i64, i64>, AppError> { async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result<HashMap<i64, i64>, AppError> {
let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL) let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL)
@ -287,58 +334,82 @@ pub async fn filter_cabinets(
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" }))) Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
} }
/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致) /// 构造设备列表的 WHERE 子句及其绑定参数
/// ///
/// **数据范围叠加**:组织隔离给出"能看到哪个组织",`CurrentUser::scope` 给出 /// **三道边界,全部取交集(书写顺序 = 占位符顺序 = 绑定值顺序)**:
/// "在这个组织里还能看哪些项目"(未配置 = 不限制)。两者**取交集**, /// 1. **显式筛选**(入参 `project_id` / `organization_id`)—— 只表达"用户想看什么",
/// 收窄条件统一落在 `c.project_id` 上(粒度到项目为止,不往下钻到单台柜子)。 /// **不构成归属边界**。历史缺陷:这两个分支只按入参过滤,客户账号传别人的
/// `project_id` / `organization_id` 即可越权取数(**组织隔离层面的漏洞**,
/// 与数据范围 `user_scopes` 无关 —— 未配置 scope 的用户同样被利用);
/// 2. **组织隔离**([`auth::org_condition_for_project`])—— **无条件叠加**:
/// 总管理员豁免(全量),客户侧收窄到 `users.organization_id`,
/// 无组织则 `1=0`(空集,**绝不退化成全量**);
/// 3. **数据范围**(`user_scopes` 项目白名单,`effective_scope()`)—— 未配置 = 不限制。
///
/// 「已绑定/未绑定」(`bound`)只是附加筛选,插在组织隔离之后、数据范围之前,
/// 不影响上面三道边界。注意:**这里不再 `JOIN projects`** —— 组织/范围条件统一用
/// `c.project_id IN (SELECT ...)` 子查询表达,避免 JOIN 与 count/分页 SQL 的耦合。
fn build_filter_where( fn build_filter_where(
params: &CabinetFilterParams, params: &CabinetFilterParams,
user: &CurrentUser, user: &CurrentUser,
) -> (String, Vec<String>) { ) -> (String, Vec<String>) {
let (where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id { let mut conditions: Vec<String> = Vec::new();
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()]) let mut binds: Vec<String> = Vec::new();
} else if let Some(org_id) = params.organization_id {
("JOIN projects p ON c.project_id = p.id
WHERE p.organization_id IN (
SELECT id FROM organizations WHERE id = ? OR parent_id = ?
)".into(), vec![org_id.to_string(), org_id.to_string()])
} else if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(org_id) = user.organization_id {
("JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?".into(), vec![org_id.to_string()])
} else {
("WHERE 1=0".into(), vec![])
};
// 已绑定/未绑定过滤(绑定 = 分配到项目) // ① 显式筛选:项目 / 组织(组织含其直接子组织,沿用原口径)
let mut where_clause = match params.bound.as_deref() { if let Some(proj_id) = params.project_id {
Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"), conditions.push("c.project_id = ?".to_string());
Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"), binds.push(proj_id.to_string());
_ => where_clause, }
}; if let Some(org_id) = params.organization_id {
conditions.push(
"c.project_id IN (SELECT p.id FROM projects p WHERE p.organization_id IN \
(SELECT o.id FROM organizations o WHERE o.id = ? OR o.parent_id = ?))"
.to_string(),
);
binds.push(org_id.to_string());
binds.push(org_id.to_string());
}
// 数据范围(项目级)收窄:占位符在 SQL 文本中位于最后 ⇒ 绑定值也必须追加在最后。 // ② 组织隔离:无论有没有入参都必须叠加(这一步才是"能不能看"的边界)
// 用 `effective_scope()`(而不是 `user.scope`):**总管理员恒为"不限制"**, let (org_cond, org_binds) = auth::org_condition_for_project(user, "c.project_id");
// 即便别处构造的 CurrentUser 带着非空 scope 也不会把工厂自己收窄。 push_condition(&mut conditions, &mut binds, &org_cond, org_binds);
let mut binds = binds;
// 附加:已绑定/未绑定过滤(绑定 = 分配到项目)
match params.bound.as_deref() {
Some("1") => conditions.push("c.project_id IS NOT NULL".to_string()),
Some("0") => conditions.push("c.project_id IS NULL".to_string()),
_ => {}
}
// ③ 数据范围(项目级)收窄:用 `effective_scope()`(而不是 `user.scope`)——
// **总管理员恒为"不限制"**,即便别处构造的 CurrentUser 带着非空 scope 也不会把工厂自己收窄。
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id"); let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
auth::append_where(&mut where_clause, &scope_cond); push_condition(&mut conditions, &mut binds, &scope_cond, scope_binds);
binds.extend(scope_binds);
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(where_clause, binds) (where_clause, binds)
} }
/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接 /// 追加 ` AND xxx` 形式的条件片段及其绑定值(片段为空 = 不限制,原样跳过)
fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String { ///
if where_clause.is_empty() { /// **绑定值必须与片段一起追加**:占位符在 SQL 文本里的先后顺序即绑定顺序,
where_new.to_string() /// 分开追加必然错位(错位的结果是条件张冠李戴,比不加条件更危险)。
} else { fn push_condition(
let mut clause = where_clause; conditions: &mut Vec<String>,
clause.push_str(and_tail); binds: &mut Vec<String>,
clause fragment: &str,
fragment_binds: Vec<String>,
) {
if fragment.is_empty() {
return;
} }
conditions.push(fragment.trim_start_matches(" AND ").to_string());
binds.extend(fragment_binds);
} }
/// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致) /// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致)
@ -639,7 +710,8 @@ pub async fn device_realtime_status(
let mut conn = open_redis_conn(&state).await?; let mut conn = open_redis_conn(&state).await?;
let device_status = load_device_status(&mut conn, &params.imei).await; let device_status = load_device_status(&mut conn, &params.imei).await;
let boards = load_boards_status(&mut conn, &params.imei).await; // 板级+通道级:按 DB 拓扑拼 key + 一次 pipeline(**零 KEYS 扫描**,见函数注释)
let boards = load_boards_status(&state.mysql, &mut conn, &params.imei).await;
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,
@ -701,8 +773,6 @@ async fn load_device_status(
}) })
} }
/// 通道级 key `device:{imei}:board:{板号}:ch:{通道号}` 的段数(板号/通道号均十进制不补零)
const CHANNEL_KEY_SEGMENTS: usize = 6;
/// 仓控板号量程:协议 §3.7「仓控板 1–15」 /// 仓控板号量程:协议 §3.7「仓控板 1–15」
const BOARD_ID_MIN: i64 = 1; const BOARD_ID_MIN: i64 = 1;
const BOARD_ID_MAX: i64 = 15; const BOARD_ID_MAX: i64 = 15;
@ -710,90 +780,196 @@ const BOARD_ID_MAX: i64 = 15;
const CHANNEL_ID_MIN: i64 = 1; const CHANNEL_ID_MIN: i64 = 1;
const CHANNEL_ID_MAX: i64 = 8; const CHANNEL_ID_MAX: i64 = 8;
/// 从通道级 key 解析 `(板号, 通道号)`:段数为 6 / 第 3 段是 `board` / 第 5 段是 `ch` / /// 通道级 Hash 的**权威字段名**(与 `workers/report_worker.rs::write_channel_state` 一致)
/// 板号在 1–15、通道号在 1–8 才算合法,否则 `None`。 const CHANNEL_STATUS_FIELDS: [&str; 6] =
["batt_status", "pow_status", "soc", "voltage", "current", "temp"];
/// 通道级 Redis key:`device:{imei}:board:{板号}:ch:{通道号}`(板号/通道号**十进制不补零**)。
/// ///
/// 为什么必须按段解析:`KEYS device:{imei}:board:*:ch:*` 会把内部键 /// ⚠️ 必须与上报写入侧(`workers/report_worker.rs::channel_status_key`)**逐字一致**,
/// `...:ch:{通道号}:bat`(电池台账)与 `...:ch:{通道号}:order`(订单电量累加器)也扫出来, /// 否则读不到设备写入的状态。
/// 按段校验能把它们挡掉,**不会生成幽灵板/幽灵通道**。 fn channel_redis_key(imei: &str, board_id: i64, channel_id: i64) -> String {
fn parse_channel_key(key: &str) -> Option<(i64, i64)> { format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id)
let parts: Vec<&str> = key.split(':').collect();
if parts.len() != CHANNEL_KEY_SEGMENTS
|| parts.get(2) != Some(&"board")
|| parts.get(4) != Some(&"ch")
{
return None;
}
let board_no: i64 = parts.get(3)?.parse().ok()?;
let channel_no: i64 = parts.get(5)?.parse().ok()?;
if !(BOARD_ID_MIN..=BOARD_ID_MAX).contains(&board_no)
|| !(CHANNEL_ID_MIN..=CHANNEL_ID_MAX).contains(&channel_no)
{
return None;
}
Some((board_no, channel_no))
} }
/// 扫描并组装全部仓控板状态(板级 + 通道级)。 /// 该通道级 key 是否落在协议量程内(板号 1–15 / 通道号 1–8)。
/// ///
/// ⚠️ 设备上报链路**只写通道级 key**(`device:{imei}:board:{板号}:ch:{通道号}`, /// 为什么要有这道闸门:本接口现在**按 DB 拓扑拼 key**,一旦 `compartments` / `cabin_boards`
/// 见 `workers/report_worker.rs::write_channel_state`),**新协议里根本没有板级 hash** /// 里存在越界脏数据(`board_id = 0` / `channel_id = 9`),拼出来的就是
/// (老实现的 `device:{imei}:board:{板号}.status_hex` 已不复存在)。 /// `...:board:0:ch:9` 这类**永远不会被上报写入的垃圾 key**;挡掉它们同时也拦住了
/// 旧代码按 `KEYS device:{imei}:board:*` 取"板级 key",实际扫到的全是 `...:ch:{通道号}` /// `...:ch:{通道号}:bat`(电池台账)/ `...:ch:{通道号}:order`(电量累加器)内部键。
/// 这类 key,把**通道号当成了板号** → 返回一堆幽灵板(每个还都没有 channels), fn is_valid_channel_key_parts(board_id: i64, channel_id: i64) -> bool {
/// 这也是本接口"永远返回空"的另一半原因。 (BOARD_ID_MIN..=BOARD_ID_MAX).contains(&board_id)
/// 现在改为扫描通道级 key、**按解析出的板号分组**,再由通道数据组装板级视图。 && (CHANNEL_ID_MIN..=CHANNEL_ID_MAX).contains(&channel_id)
async fn load_boards_status( }
conn: &mut redis::aio::MultiplexedConnection,
imei: &str,
) -> Value {
let ch_pattern = format!("device:{}:board:*:ch:*", imei);
let ch_keys: Vec<String> = redis::cmd("KEYS")
.arg(&ch_pattern)
.query_async(&mut *conn)
.await
.unwrap_or_default();
// 板号 → [(通道号, key)]:BTreeMap 保证板号升序,输出顺序稳定 /// 一条通道的原始字段快照;`None` = 该通道 key 在 Redis 里**不存在**。
let mut grouped: BTreeMap<i64, Vec<(i64, String)>> = BTreeMap::new(); type ChannelSnapshot = Option<HashMap<String, String>>;
for key in ch_keys {
if let Some((board_no, channel_no)) = parse_channel_key(&key) { /// 纯函数:把快照里的真实字段翻译成响应 JSON(字段名与旧实现完全一致)。
grouped.entry(board_no).or_default().push((channel_no, key)); fn channel_view_from_snapshot(snapshot: &HashMap<String, String>) -> Value {
let field = |name: &str| -> Option<&str> { snapshot.get(name).map(String::as_str) };
let number = |name: &str| -> Option<i64> { field(name).and_then(|v| v.parse::<i64>().ok()) };
derive_channel_view(
field(CHANNEL_STATUS_FIELDS[0]),
field(CHANNEL_STATUS_FIELDS[1]),
number(CHANNEL_STATUS_FIELDS[2]),
number(CHANNEL_STATUS_FIELDS[3]),
number(CHANNEL_STATUS_FIELDS[4]),
number(CHANNEL_STATUS_FIELDS[5]),
)
}
/// 纯函数:把(DB 拓扑行 + 对应通道快照)组装成 `boards` 响应 JSON。
///
/// `snapshots` 与 `topology` **下标一一对应**:`None` 表示该通道在 Redis 里没有键
/// (设备从未上报 / 键已过期),此时**整条通道省略** —— 这与旧实现
/// `KEYS device:{imei}:board:*:ch:*`「只返回实际存在的键」语义一致,
/// 不会凭空多出"全空通道"。
///
/// 板号用 `BTreeMap` 保证升序输出(响应顺序稳定);`status_hex` 由各通道的
/// `batt_status + pow_status`(各 2 位 hex)按**通道号升序**拼回(板级 `status_hex`
/// 在新协议里不存在,该字段名保持不变,语义完全由真实字段解释)。
fn assemble_boards(topology: &[(i8, i8)], snapshots: &[ChannelSnapshot]) -> Value {
// 板号 → [(通道号, 快照)],仅收录 Redis 里真实存在的通道
let mut grouped: BTreeMap<i64, Vec<(i64, &HashMap<String, String>)>> = BTreeMap::new();
for ((board_id, channel_id), snapshot) in topology.iter().zip(snapshots.iter()) {
if let Some(snapshot) = snapshot {
grouped
.entry(i64::from(*board_id))
.or_default()
.push((i64::from(*channel_id), snapshot));
} }
} }
let mut boards = json!({}); let mut boards = json!({});
for (board_no, mut entries) in grouped { for (board_no, entries) in grouped {
entries.sort_by_key(|(channel_no, _)| *channel_no);
let board = load_board_status(conn, &entries).await;
boards[board_no.to_string().as_str()] = board;
}
boards
}
/// 由**已排序**的通道级 key 组装单块仓控板的响应(板级 + 通道级)。
async fn load_board_status(
conn: &mut redis::aio::MultiplexedConnection,
entries: &[(i64, String)],
) -> Value {
let mut channels = json!({}); let mut channels = json!({});
let mut status_hex = String::new(); let mut status_hex = String::new();
for (channel_no, ch_key) in entries { for (channel_no, snapshot) in entries {
let channel = load_channel_status(conn, ch_key).await; let channel = channel_view_from_snapshot(snapshot);
// 板级 `status_hex` 已废弃(新协议板级没有状态字节):
// 用各通道的 `batt_status + pow_status`(各 2 位 hex)按**通道号升序**拼回,
// 与旧格式 `0001000200...` 同构,语义完全由真实字段解释 —— **字段名保持不变**。
if let Some(hex) = channel.get("hex").and_then(|v| v.as_str()) { if let Some(hex) = channel.get("hex").and_then(|v| v.as_str()) {
status_hex.push_str(hex); status_hex.push_str(hex);
} }
channels[channel_no.to_string().as_str()] = channel; channels[channel_no.to_string().as_str()] = channel;
} }
boards[board_no.to_string().as_str()] = json!({
json!({
"status_hex": status_hex, "status_hex": status_hex,
"channels": channels, "channels": channels,
});
}
boards
}
/// 读取该柜的通道拓扑 `(板号, 通道号)`,按(板号, 通道号)升序。
///
/// 为什么用 DB 而不是扫 Redis:这是本接口**零扫描**的基础 —— 通道级 key 的构成
/// 完全由 DB 拓扑决定(板号/通道号十进制不补零),拼出来与设备上报写入的 key 逐字相同。
async fn fetch_channel_topology(
db: &sqlx::MySqlPool,
imei: &str,
) -> Result<Vec<(i8, i8)>, AppError> {
let rows: Vec<(i8, i8)> = sqlx::query_as(
"SELECT b.board_id, comp.channel_id
FROM cabinets c
JOIN cabin_boards b ON b.cabinet_id = c.id
JOIN compartments comp ON comp.cabin_board_id = b.id
WHERE c.imei = ?
ORDER BY b.board_id, comp.channel_id",
)
.bind(imei)
.fetch_all(db)
.await?;
Ok(rows)
}
/// 组装全部仓控板状态(板级 + 通道级)。
///
/// ⚠️ 设备上报链路**只写通道级 key**(`device:{imei}:board:{板号}:ch:{通道号}`,
/// 见 `workers/report_worker.rs::write_channel_state`),**新协议里根本没有板级 hash**
/// (老实现的 `device:{imei}:board:{板号}` + `status_hex` 已不复存在;生产 Redis 里若还能
/// 搜到这类键,那是**旧版本残留**,不是当前写入契约)。
///
/// # 为什么不再用 `KEYS`(2026-09 修复)
/// 旧实现是 `KEYS device:{imei}:board:*:ch:*`。`KEYS` 会**阻塞 Redis 单线程**地遍历
/// 整个 keyspace,而本项目的 Redis 实例是**与既有充电站生产平台(db1–4)共用的**:
/// 单柜详情一次 `KEYS` 就足以在规模化后拖慢/拖死别人的业务。
/// 现在改为:**按 DB 拓扑拼出候选 key(零扫描)→ 一次 pipeline `HGETALL`**。
/// `HGETALL` 返回空 map ⇒ 该 key 不存在 ⇒ 按"无数据"略过,
/// 因此**语义与 `KEYS` 完全一致**(只少了对"DB 里没有对应行的孤儿 key"的收录 ——
/// 那正是旧代码注释里要避免的"幽灵板/幽灵通道")。
///
/// Redis 不可用 / pipeline 失败只记 ERROR 并返回空对象(等价于全部无数据),
/// 绝不因此让详情接口整体失败。
async fn load_boards_status(
db: &sqlx::MySqlPool,
conn: &mut redis::aio::MultiplexedConnection,
imei: &str,
) -> Value {
let topology_rows = match fetch_channel_topology(db, imei).await {
Ok(rows) => rows,
Err(e) => {
tracing::error!(
"[realtime_status] 查询通道拓扑失败,板级状态降级为空 imei={} err={}",
imei,
e
);
return json!({});
}
};
// 量程闸门:越界脏数据(board_id=0 / channel_id=9)拼不出有效 key,直接跳过并记 WARN
let topology: Vec<(i8, i8)> = topology_rows
.into_iter()
.filter(|(board_id, channel_id)| {
let ok = is_valid_channel_key_parts(i64::from(*board_id), i64::from(*channel_id));
if !ok {
tracing::warn!(
"[realtime_status] 拓扑越界已跳过(协议 §3.7 允许板 1–15 / 通道 1–8)\
imei={} board={} ch={}",
imei,
board_id,
channel_id
);
}
ok
}) })
.collect();
if topology.is_empty() {
// 拓扑未配置(或全是越界脏数据):没有候选 key,直接返回空对象
return json!({});
}
// 一次 pipeline 拿全部通道(每通道 1 次 HGETALL),1 次网络往返
let mut pipe = redis::pipe();
for (board_id, channel_id) in &topology {
let key = channel_redis_key(imei, i64::from(*board_id), i64::from(*channel_id));
pipe.cmd("HGETALL").arg(&key);
}
let queried: Result<Vec<HashMap<String, String>>, redis::RedisError> =
pipe.query_async(&mut *conn).await;
let snapshots: Vec<ChannelSnapshot> = match queried {
Ok(values) => values
.into_iter()
// Redis 里不保留空 Hash ⇒ 空 map 即"键不存在"(与 KEYS 只返回存在键一致)
.map(|fields| if fields.is_empty() { None } else { Some(fields) })
.collect(),
Err(e) => {
tracing::error!(
"[realtime_status] 通道状态 pipeline 读取失败,板级状态降级为空 imei={} err={}",
imei,
e
);
return json!({});
}
};
assemble_boards(&topology, &snapshots)
} }
/// 电池状态位(协议 §3.8 `batt_status`):bit0 充满 / bit1 正在充电 / /// 电池状态位(协议 §3.8 `batt_status`):bit0 充满 / bit1 正在充电 /
@ -955,46 +1131,12 @@ fn derive_channel_view(
}) })
} }
/// 读取单个通道状态。
///
/// ⚠️ 旧实现读的是 `on` / `full` / `fault` / `hex`,但设备上报链路
/// (`workers/report_worker.rs::write_channel_state`)**只写**:
/// `batt_status` / `pow_status`(各 2 位 hex)/ `soc`(‰)/ `voltage`(mV)/
/// `current`(mA)/ `temp`(K×100)—— 旧字段根本不存在,于是本接口恒返回
/// `status:"idle"` + 三个 `false` + `hex:""`,管理端"设备实时详情"一直是坏的。
/// 现在读**真实存在**的字段,再由它们推导旧字段(字段名保持不变,推导规则见 [`derive_channel_view`])。
async fn load_channel_status(
conn: &mut redis::aio::MultiplexedConnection,
ch_key: &str,
) -> Value {
let ch_fields = vec!["batt_status", "pow_status", "soc", "voltage", "current", "temp"];
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(ch_key)
.arg(&ch_fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let text = |idx: usize| -> Option<&str> {
ch_values.get(idx).and_then(|v| v.as_ref()).map(|v| v.as_str())
};
let number = |idx: usize| -> Option<i64> { text(idx).and_then(|v| v.parse::<i64>().ok()) };
derive_channel_view(
text(0),
text(1),
number(2),
number(3),
number(4),
number(5),
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 单元测试:通道状态推导(协议 §3.8,口径必须与 web 端 deviceStatus.ts 一致) // 单元测试:通道状态推导(协议 §3.8,口径必须与 web 端 deviceStatus.ts 一致)
// //
// 只测纯函数(不连 Redis / DB):这一层是"设备实时详情"接口正确性的关键, // 只测纯函数(不连 Redis / DB):这一层是"设备实时详情"接口正确性的关键,
// 因为它负责把 `batt_status` / `pow_status` 翻译成前端消费的旧字段名。 // 因为它负责把 `batt_status` / `pow_status` 翻译成前端消费的旧字段名,
// 以及把 DB 拓扑 + Redis 快照组装成 `boards` 响应。
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@ -1248,55 +1390,96 @@ mod tests {
); );
} }
// ---- parse_channel_key:通道级 key 分组(板号 1–15 / 通道号 1–8)------- // ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)-------
/// 正常通道 key → (板号, 通道号) /// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key)
#[test] #[test]
fn test_parse_channel_key_accepts_real_channel_keys() { fn test_is_valid_channel_key_parts_accepts_protocol_range() {
assert!(is_valid_channel_key_parts(1, 1));
assert!(is_valid_channel_key_parts(6, 6));
assert!(is_valid_channel_key_parts(15, 8));
}
/// 越界与 0 值必须拒绝:老实现按 KEYS 末段取"板号"时,内部键
/// `...:ch:{通道号}:bat` / `...:ch:{通道号}:order` 会生成幽灵板/幽灵通道
#[test]
fn test_is_valid_channel_key_parts_rejects_out_of_range() {
assert!(!is_valid_channel_key_parts(0, 1));
assert!(!is_valid_channel_key_parts(16, 1));
assert!(!is_valid_channel_key_parts(1, 0));
assert!(!is_valid_channel_key_parts(1, 9));
}
/// 通道级 key 必须与上报写入侧逐字一致(板号/通道号十进制不补零)
#[test]
fn test_channel_redis_key_matches_writer_format() {
assert_eq!( assert_eq!(
parse_channel_key("device:864720562557021:board:1:ch:3"), channel_redis_key("864720562557021", 1, 3),
Some((1, 3)) "device:864720562557021:board:1:ch:3"
); );
assert_eq!( assert_eq!(
parse_channel_key("device:864720562557021:board:15:ch:8"), channel_redis_key("864720562557021", 15, 8),
Some((15, 8)) "device:864720562557021:board:15:ch:8"
); );
} }
/// **内部键必须被挡掉**:`...:ch:{通道号}:bat`(电池台账)/ `...:ch:{通道号}:order`(电量累加器) /// 组装:Redis 里**不存在**的通道必须整条省略(与旧 `KEYS` 只返回存在键语义一致)
/// 若被当成通道,会生成幽灵板(旧实现就是按 KEYS 末段取"板号"的)
#[test] #[test]
fn test_parse_channel_key_rejects_internal_keys() { fn test_assemble_boards_omits_channels_absent_from_redis() {
assert_eq!( let topology = vec![(1i8, 1i8), (1, 2), (2, 1)];
parse_channel_key("device:864720562557021:board:1:ch:3:bat"), let present = HashMap::from([
None ("batt_status".to_string(), "82".to_string()),
); ("pow_status".to_string(), "82".to_string()),
assert_eq!( ("soc".to_string(), "850".to_string()),
parse_channel_key("device:864720562557021:board:1:ch:3:order"), ("voltage".to_string(), "54000".to_string()),
None ("current".to_string(), "8000".to_string()),
); ("temp".to_string(), "29800".to_string()),
// 旧实现会扫到这种"板级 key"(其实不存在),现在也必须拒绝 ]);
assert_eq!(parse_channel_key("device:864720562557021:board:1"), None); let snapshots: Vec<ChannelSnapshot> = vec![Some(present), None, None];
assert_eq!(parse_channel_key("device:864720562557021"), None);
assert_eq!(parse_channel_key(""), None); let boards = assemble_boards(&topology, &snapshots);
// 板 1 只有通道 1(通道 2 无 key ⇒ 省略),板 2 整块不存在 ⇒ 不出现
assert_eq!(boards["1"]["channels"]["1"]["status"], "charging");
assert!(boards["1"]["channels"].get("2").is_none(), "缺 key 的通道不得出现");
assert!(boards.get("2").is_none(), "整块板无数据时不得生成幽灵板");
assert_eq!(boards["1"]["status_hex"], "8282");
} }
/// 量程与结构校验:板号 0/16、通道号 0/9、非数字、段内关键字写错都拒绝 /// 组装:`status_hex` 按**通道号升序**拼接(拓扑有序即输出有序)
#[test] #[test]
fn test_parse_channel_key_rejects_out_of_range_and_malformed() { fn test_assemble_boards_status_hex_follows_topology_order() {
assert_eq!(parse_channel_key("device:864720562557021:board:0:ch:3"), None); let topology = vec![(1i8, 1i8), (1, 2)];
assert_eq!(parse_channel_key("device:864720562557021:board:16:ch:3"), None); let snap = |batt: &str, pow: &str| -> ChannelSnapshot {
assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:0"), None); Some(HashMap::from([
assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:9"), None); ("batt_status".to_string(), batt.to_string()),
assert_eq!(parse_channel_key("device:864720562557021:board:a:ch:3"), None); ("pow_status".to_string(), pow.to_string()),
assert_eq!(parse_channel_key("device:864720562557021:board:1:xx:3"), None); ]))
assert_eq!( };
parse_channel_key("device:864720562557021:brd:1:ch:3"), // SQL 已 `ORDER BY board_id, channel_id`,故按拓扑顺序拼接即可得到通道升序
None let boards = assemble_boards(&topology, &[snap("01", "82"), snap("03", "82")]);
); assert_eq!(boards["1"]["status_hex"], "01820382");
}
/// 组装:空拓扑 / 全无数据 ⇒ 空对象(不得 panic、不得造板)
#[test]
fn test_assemble_boards_empty_inputs_return_empty_object() {
assert_eq!(assemble_boards(&[], &[]), json!({}));
let topology = vec![(1i8, 1i8)];
assert_eq!(assemble_boards(&topology, &[None]), json!({}));
}
/// 快照字段缺失时 `channel_view_from_snapshot` 不得 panic,按"无数据"落空值
#[test]
fn test_channel_view_from_snapshot_handles_missing_fields() {
let empty = HashMap::new();
let view = channel_view_from_snapshot(&empty);
assert_eq!(view["status"], "idle");
assert_eq!(view["hex"], "");
assert_eq!(view["soc"], Value::Null);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 设备列表的数据范围(user_scopes)叠加 // 设备列表:组织隔离 + 数据范围(user_scopes)叠加
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// 构造一个客户侧用户(role=1,属组织 42),scope 由调用方给定 /// 构造一个客户侧用户(role=1,属组织 42),scope 由调用方给定
@ -1311,9 +1494,9 @@ mod tests {
} }
} }
/// **未配置 scope = 不限制**:设备列表 WHERE 与原实现逐字一致(向后兼容) /// 无任何入参的客户账号 ⇒ **组织隔离条件必须出现**(未配置 scope 时不额外收窄)
#[test] #[test]
fn test_build_filter_where_without_scope_is_unchanged() { fn test_build_filter_where_without_scope_keeps_org_isolation() {
let params = CabinetFilterParams { let params = CabinetFilterParams {
organization_id: None, organization_id: None,
project_id: None, project_id: None,
@ -1322,10 +1505,75 @@ mod tests {
bound: None, bound: None,
}; };
let (clause, binds) = build_filter_where(&params, &make_user(UserScope::default())); let (clause, binds) = build_filter_where(&params, &make_user(UserScope::default()));
assert_eq!(clause, "JOIN projects p ON c.project_id = p.id \n WHERE p.organization_id = ?"); assert_eq!(
clause,
"WHERE c.project_id IN (SELECT pj.id FROM projects pj WHERE pj.organization_id = ?)"
);
assert_eq!(binds, vec!["42"]); assert_eq!(binds, vec!["42"]);
} }
/// **核心越权回归**:客户传别人的 `project_id` / `organization_id`,
/// 组织隔离条件**必须仍然叠加** ⇒ 结果只可能是本组织数据(越权取数不可发生)
#[test]
fn test_build_filter_where_foreign_project_and_org_params_keep_org_isolation() {
let user = make_user(UserScope::default());
let foreign_project = CabinetFilterParams {
organization_id: None,
project_id: Some(9999),
page: None,
page_size: None,
bound: None,
};
let (clause, binds) = build_filter_where(&foreign_project, &user);
assert_eq!(
clause,
"WHERE c.project_id = ? AND c.project_id IN \
(SELECT pj.id FROM projects pj WHERE pj.organization_id = ?)"
);
assert_eq!(binds, vec!["9999", "42"], "占位符顺序 = 绑定顺序");
let foreign_org = CabinetFilterParams {
organization_id: Some(9999),
project_id: None,
page: None,
page_size: None,
bound: None,
};
let (clause, binds) = build_filter_where(&foreign_org, &user);
assert!(
clause.contains("o.id = ? OR o.parent_id = ?"),
"入参组织分支保持原口径:{}",
clause
);
assert!(
clause.ends_with("AND c.project_id IN (SELECT pj.id FROM projects pj WHERE pj.organization_id = ?)"),
"组织参数分支也必须叠加归属隔离:{}",
clause
);
assert_eq!(binds, vec!["9999", "9999", "42"]);
}
/// 无组织归属的客户侧账号 ⇒ `1=0` 空集(**绝不退化成全量**),
/// 且此时传任何入参都不能把自己"放开"
#[test]
fn test_build_filter_where_no_org_user_is_empty_even_with_params() {
let mut user = make_user(UserScope::default());
user.organization_id = None;
for project_id in [None, Some(9999)] {
let params = CabinetFilterParams {
organization_id: None,
project_id,
page: None,
page_size: None,
bound: None,
};
let (clause, _) = build_filter_where(&params, &user);
assert!(clause.contains("1=0"), "无组织必须空集:{}", clause);
}
}
/// **配置了项目范围**:组织条件之后必须再叠加 `c.project_id IN (...)`(取交集) /// **配置了项目范围**:组织条件之后必须再叠加 `c.project_id IN (...)`(取交集)
#[test] #[test]
fn test_build_filter_where_with_scope_appends_project_narrowing() { fn test_build_filter_where_with_scope_appends_project_narrowing() {
@ -1339,7 +1587,7 @@ mod tests {
let user = make_user(UserScope { project_ids: vec![7, 9] }); let user = make_user(UserScope { project_ids: vec![7, 9] });
let (clause, binds) = build_filter_where(&params, &user); let (clause, binds) = build_filter_where(&params, &user);
assert!(clause.contains("WHERE p.organization_id = ?"), "组织隔离不能丢:{}", clause); assert!(clause.contains("pj.organization_id = ?"), "组织隔离不能丢:{}", clause);
assert!(clause.contains("AND c.project_id IN (?,?)"), "数据范围必须叠加:{}", clause); assert!(clause.contains("AND c.project_id IN (?,?)"), "数据范围必须叠加:{}", clause);
assert_eq!(binds, vec!["42", "7", "9"], "绑定顺序必须与占位符顺序一致"); assert_eq!(binds, vec!["42", "7", "9"], "绑定顺序必须与占位符顺序一致");
} }
@ -1357,8 +1605,9 @@ mod tests {
bound: None, bound: None,
}; };
let (clause, binds) = build_filter_where(&by_project, &user); let (clause, binds) = build_filter_where(&by_project, &user);
assert_eq!(clause, "WHERE c.project_id = ? AND c.project_id IN (?)"); assert!(clause.starts_with("WHERE c.project_id = ? AND c.project_id IN (SELECT pj.id"));
assert_eq!(binds, vec!["999", "7"]); assert!(clause.ends_with("AND c.project_id IN (?)"), "{}", clause);
assert_eq!(binds, vec!["999", "42", "7"]);
let by_org = CabinetFilterParams { let by_org = CabinetFilterParams {
organization_id: Some(43), organization_id: Some(43),
@ -1369,10 +1618,10 @@ mod tests {
}; };
let (clause, binds) = build_filter_where(&by_org, &user); let (clause, binds) = build_filter_where(&by_org, &user);
assert!(clause.contains("AND c.project_id IN (?)"), "组织参数分支也要叠加范围:{}", clause); assert!(clause.contains("AND c.project_id IN (?)"), "组织参数分支也要叠加范围:{}", clause);
assert_eq!(binds, vec!["43", "43", "7"]); assert_eq!(binds, vec!["43", "43", "42", "7"]);
} }
/// 已绑定/未绑定过滤与数据范围可以共存,且绑定值顺序正确 /// 已绑定/未绑定过滤与组织隔离、数据范围可以共存,且绑定值顺序正确
#[test] #[test]
fn test_build_filter_where_scope_combines_with_bound_filter() { fn test_build_filter_where_scope_combines_with_bound_filter() {
let params = CabinetFilterParams { let params = CabinetFilterParams {
@ -1384,11 +1633,13 @@ mod tests {
}; };
let user = make_user(UserScope { project_ids: vec![7] }); let user = make_user(UserScope { project_ids: vec![7] });
let (clause, binds) = build_filter_where(&params, &user); let (clause, binds) = build_filter_where(&params, &user);
assert!(clause.ends_with("AND c.project_id IS NOT NULL AND c.project_id IN (?)"), "{}", clause); assert!(clause.contains("c.project_id IS NOT NULL"), "{}", clause);
assert!(clause.ends_with("AND c.project_id IN (?)"), "{}", clause);
assert_eq!(binds, vec!["42", "7"]); assert_eq!(binds, vec!["42", "7"]);
} }
/// 总管理员(role_level>=2)不受数据范围收窄(豁免) /// 总管理员(role_level>=2)不受组织隔离与数据范围收窄(豁免);
/// 但**显式入参仍然生效**(只是纯筛选,不是边界)
#[test] #[test]
fn test_build_filter_where_super_admin_ignores_scope() { fn test_build_filter_where_super_admin_ignores_scope() {
let params = CabinetFilterParams { let params = CabinetFilterParams {
@ -1404,6 +1655,69 @@ mod tests {
let (clause, binds) = build_filter_where(&params, &user); let (clause, binds) = build_filter_where(&params, &user);
assert!(clause.is_empty(), "总管理员无任何过滤:{}", clause); assert!(clause.is_empty(), "总管理员无任何过滤:{}", clause);
assert!(binds.is_empty()); assert!(binds.is_empty());
let by_project = CabinetFilterParams {
organization_id: None,
project_id: Some(5),
page: None,
page_size: None,
bound: None,
};
let (clause, binds) = build_filter_where(&by_project, &user);
assert_eq!(clause, "WHERE c.project_id = ?");
assert_eq!(binds, vec!["5"]);
}
// -----------------------------------------------------------------------
// 组织树:组织隔离 + 数据范围
// -----------------------------------------------------------------------
/// 组织树组织查询:工厂不限 / 客户仅本组织 / 无组织空集
#[test]
fn test_visible_orgs_sql_isolation() {
let (sql, binds) = visible_orgs_sql(&make_user(UserScope::default()));
assert!(sql.contains("WHERE 1=1 AND id = ?"), "{}", sql);
assert_eq!(binds, vec!["42"]);
let (sql, binds) = visible_orgs_sql(&{
let mut u = make_user(UserScope::default());
u.role_level = 2;
u.organization_id = None;
u
});
assert!(sql.contains("WHERE 1=1 ORDER BY id"), "总管理员全量:{}", sql);
assert!(binds.is_empty());
let (sql, _) = visible_orgs_sql(&{
let mut u = make_user(UserScope::default());
u.organization_id = None;
u
});
assert!(sql.contains("WHERE 1=1 AND 1=0"), "无组织必须空集:{}", sql);
}
/// 组织树项目查询:组织隔离 ∧ 数据范围(未配置 scope 时不额外收窄)
#[test]
fn test_visible_projects_sql_isolation_and_scope() {
let (sql, binds) = visible_projects_sql(&make_user(UserScope::default()));
assert!(sql.contains("p.organization_id = ?"), "{}", sql);
assert!(!sql.contains("p.id IN"), "未配置 scope 不得收窄:{}", sql);
assert_eq!(binds, vec!["42"]);
let (sql, binds) = visible_projects_sql(&make_user(UserScope { project_ids: vec![7, 9] }));
assert!(sql.contains("p.organization_id = ?"), "{}", sql);
assert!(sql.contains("AND p.id IN (?,?)"), "数据范围必须叠加:{}", sql);
assert_eq!(binds, vec!["42", "7", "9"], "占位符顺序 = 绑定顺序");
let (sql, binds) = visible_projects_sql(&{
let mut u = make_user(UserScope { project_ids: vec![7] });
u.role_level = 2;
u.organization_id = None;
u
});
assert!(!sql.contains("organization_id = ?"), "总管理员不限组织:{}", sql);
assert!(!sql.contains("p.id IN"), "总管理员不受范围收窄:{}", sql);
assert!(binds.is_empty());
} }
/// 设备归属校验 SQL:未配置范围时不收窄,配置后必须叠加项目收窄 /// 设备归属校验 SQL:未配置范围时不收窄,配置后必须叠加项目收窄

View File

@ -998,7 +998,14 @@ const CABINET_OPTION_COLUMNS: &str =
/// GET /api/cabinets/options — 柜子下拉列表 /// GET /api/cabinets/options — 柜子下拉列表
/// ///
/// **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。 /// **两道收窄(取交集)**:
/// 1. **组织隔离**([`auth::org_condition_for_project`])—— 无条件叠加:
/// 总管理员全量、客户侧仅本组织、无组织空集(**绝不退化成全量**);
/// 2. **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。
///
/// 历史缺陷:本接口只叠加了数据范围、**没有组织隔离** ⇒ 未配置 scope 的客户账号
/// 不带 `project_id` 调用时能看到**全平台所有柜子**(含 IMEI/ICCID/地址)。
/// `project_id` 入参只是纯筛选,不构成归属边界。
pub async fn list_cabinet_options( pub async fn list_cabinet_options(
user: CurrentUser, user: CurrentUser,
State(state): State<super::AppState>, State(state): State<super::AppState>,
@ -1006,14 +1013,18 @@ pub async fn list_cabinet_options(
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?; auth::check_permission(&user, "device:view")?;
// 动态拼 WHERE:项目筛选在前、数据范围收窄在后,绑定顺序与占位符顺序严格一致 // 动态拼 WHERE:项目筛选 → 组织隔离 → 数据范围收窄,
let (scope_cond, scope_binds) = user.effective_scope().project_condition("project_id"); // 书写顺序 = 占位符顺序 = 绑定顺序,三者严格一致
let mut where_clause = String::new(); let mut where_clause = String::new();
let mut binds: Vec<String> = Vec::new(); let mut binds: Vec<String> = Vec::new();
if let Some(pid) = params.project_id { if let Some(pid) = params.project_id {
where_clause.push_str("project_id = ?"); where_clause.push_str("project_id = ?");
binds.push(pid.to_string()); binds.push(pid.to_string());
} }
let (org_cond, org_binds) = auth::org_condition_for_project(&user, "project_id");
auth::append_condition(&mut where_clause, &org_cond);
binds.extend(org_binds);
let (scope_cond, scope_binds) = user.effective_scope().project_condition("project_id");
auth::append_condition(&mut where_clause, &scope_cond); auth::append_condition(&mut where_clause, &scope_cond);
binds.extend(scope_binds); binds.extend(scope_binds);
@ -1034,12 +1045,17 @@ pub async fn list_cabinet_options(
} }
/// GET /api/cabin-boards/options — 仓板下拉列表 /// GET /api/cabin-boards/options — 仓板下拉列表
///
/// **归属校验**:`cabinet_id` 是外部输入,必须先过 [`scope_guard::verify_cabinet_ownership`]
/// (组织归属 + 数据范围,总管理员豁免)——历史缺陷:只按 `cabinet_id` 直查,
/// 传别人的柜子 id 就能拿到它的仓板列表。归属不符直接 403(与 `/api/cabinets/{id}` 同一口径)。
pub async fn list_cabin_board_options( pub async fn list_cabin_board_options(
user: CurrentUser, user: CurrentUser,
State(state): State<super::AppState>, State(state): State<super::AppState>,
Query(params): Query<CabinBoardListQuery>, Query(params): Query<CabinBoardListQuery>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?; auth::check_permission(&user, "device:view")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, params.cabinet_id).await?;
let rows = sqlx::query_as::<_, CabinBoardOption>( let rows = sqlx::query_as::<_, CabinBoardOption>(
"SELECT id, board_id FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id" "SELECT id, board_id FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"
@ -1052,6 +1068,10 @@ pub async fn list_cabin_board_options(
} }
/// GET /api/compartments/options — 仓体下拉列表 /// GET /api/compartments/options — 仓体下拉列表
///
/// **归属校验**:`cabin_board_id` → `cabin_boards.cabinet_id` → 柜子归属校验
/// (组织归属 + 数据范围)。历史缺陷:只按 `cabin_board_id` 直查,
/// 传别人的仓板 id 就能拿到它的仓体列表。仓板不存在 → 404;归属不符 → 403。
pub async fn list_compartment_options( pub async fn list_compartment_options(
user: CurrentUser, user: CurrentUser,
State(state): State<super::AppState>, State(state): State<super::AppState>,
@ -1059,6 +1079,16 @@ pub async fn list_compartment_options(
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?; auth::check_permission(&user, "device:view")?;
// 仓板 → 柜子:拿不到说明仓板不存在(外部输入的 id 不保证有效)
let cabinet_id: Option<i64> =
sqlx::query_scalar("SELECT cabinet_id FROM cabin_boards WHERE id = ?")
.bind(params.cabin_board_id)
.fetch_optional(&state.mysql)
.await?;
let cabinet_id =
cabinet_id.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, cabinet_id).await?;
let rows = sqlx::query_as::<_, CompartmentOption>( let rows = sqlx::query_as::<_, CompartmentOption>(
"SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id" "SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"
) )

View File

@ -133,6 +133,12 @@ pub async fn list_downloads(
} }
/// GET /api/downloads/:id — 获取下载链接 /// GET /api/downloads/:id — 获取下载链接
///
/// **归属**:导出任务按 `user_id` **天然私有**(与 [`list_downloads`] / [`download_file`] 同口径)。
/// 历史缺陷:本接口原先只按 `id` 查询,任何持 `charge_record:export` 的客户账号
/// 传别人的任务 id 就能拿到**别人的导出任务信息**(文件名、任务是否存在),
/// 并可继续调 `/file`(`/file` 有 `user_id` 过滤 ⇒ 拿到 404,但元数据已泄露)。
/// 现在统一加 `AND user_id = ?`:别人的任务一律 `NotFound`(与 `/file` 的 404 表现一致)。
pub async fn get_download( pub async fn get_download(
user: CurrentUser, user: CurrentUser,
State(state): State<super::AppState>, State(state): State<super::AppState>,
@ -142,9 +148,10 @@ pub async fn get_download(
let row: Option<DownloadTaskRow> = sqlx::query_as( let row: Option<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \ "SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \ error_message, created_at, completed_at \
FROM download_tasks WHERE id = ?" FROM download_tasks WHERE id = ? AND user_id = ?"
) )
.bind(id) .bind(id)
.bind(user.user_id)
.fetch_optional(&state.mysql) .fetch_optional(&state.mysql)
.await?; .await?;
@ -233,22 +240,37 @@ pub async fn download_file(
} }
/// POST /api/downloads/cleanup — 清理过期文件 /// POST /api/downloads/cleanup — 清理过期文件
///
/// **范围(本次修复)**:导出任务是**按用户私有**的数据,清理也必须按 `user_id` 限定 ——
/// 历史缺陷:原先不分用户、直接删除**全平台**已完成的过期任务文件,
/// 任何持 `charge_record:export` 的客户账号都能删掉别人的导出文件(破坏性操作)。
/// 现在:工厂(`role_level >= 2`)保留全量清理(运维需要),客户侧**只清理自己的任务**,
/// 别人的任务与文件一概不动。
pub async fn cleanup_downloads( pub async fn cleanup_downloads(
user: CurrentUser, user: CurrentUser,
State(state): State<super::AppState>, State(state): State<super::AppState>,
Json(req): Json<CleanupRequest>, Json(req): Json<CleanupRequest>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?; auth::check_permission(&user, "charge_record:export")?;
// 查询过期任务(状态为完成且超过保留天数)
let expired: Vec<DownloadTaskRow> = sqlx::query_as( // 工厂:全量清理;客户侧:仅本人(`AND user_id = ?` 追加在保留天数条件之后)
let only_own = user.role_level < auth::SUPER_ADMIN_ROLE_LEVEL;
let sql = if only_own {
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks \
WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY) AND user_id = ?"
} else {
"SELECT id, task_type, status, file_name, file_path, progress, \ "SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \ error_message, created_at, completed_at \
FROM download_tasks \ FROM download_tasks \
WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)" WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
) };
.bind(req.retain_days) let mut expired_query = sqlx::query_as::<_, DownloadTaskRow>(sql).bind(req.retain_days);
.fetch_all(&state.mysql) if only_own {
.await?; expired_query = expired_query.bind(user.user_id);
}
let expired: Vec<DownloadTaskRow> = expired_query.fetch_all(&state.mysql).await?;
let mut cleaned = 0u32; let mut cleaned = 0u32;

View File

@ -1987,6 +1987,73 @@ struct FeedbackRow {
created_at: chrono::NaiveDateTime, created_at: chrono::NaiveDateTime,
} }
/// 反馈列表的 WHERE 子句与**文本**绑定值
///
/// - 组织隔离条件(文本)在 SQL 中**先于** `status` ⇒ 调用方必须先绑文本值、
/// 最后单独绑 `status`(整型);
/// - 工厂(`role_level >= 2`)不加组织条件(全量);
/// - 客户侧无组织 → `1=0` 空集。
fn feedback_filter(user: &CurrentUser, has_status: bool) -> (String, Vec<String>) {
let mut conditions: Vec<String> = Vec::new();
let mut binds: Vec<String> = Vec::new();
// 归属:反馈提交人必须属于当前用户所在组织(`user_feedback.user_id → users.organization_id`)
if user.role_level < auth::SUPER_ADMIN_ROLE_LEVEL {
match user.organization_id {
Some(org_id) => {
conditions.push(
"EXISTS (SELECT 1 FROM users u \
WHERE u.id = user_feedback.user_id AND u.organization_id = ?)"
.to_string(),
);
binds.push(org_id.to_string());
}
None => conditions.push("1=0".to_string()),
}
}
if has_status {
conditions.push("status = ?".to_string());
}
if conditions.is_empty() {
(String::new(), binds)
} else {
(format!(" WHERE {}", conditions.join(" AND ")), binds)
}
}
/// 校验当前用户是否有权处置(查看状态/改状态)某条反馈
///
/// 归属口径与 [`feedback_filter`] 完全一致:工厂豁免;客户侧必须是**本组织成员**提交的反馈。
/// 反馈不存在 → `NotFound`(保持原有 404 语义);存在但不属于本组织 → `Forbidden`
/// (**不返回 404**,避免"改别人的反馈"被静默当成"已处理成功")。
async fn verify_feedback_ownership(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
feedback_id: i64,
) -> Result<(), AppError> {
if user.role_level >= auth::SUPER_ADMIN_ROLE_LEVEL {
return Ok(());
}
let Some(org_id) = user.organization_id else {
return Err(AppError::Forbidden("无权访问该资源".into()));
};
let owner_org: Option<Option<i64>> = sqlx::query_scalar(
"SELECT u.organization_id FROM user_feedback f \
JOIN users u ON u.id = f.user_id WHERE f.id = ?",
)
.bind(feedback_id)
.fetch_optional(pool)
.await?;
match owner_org {
None => Err(AppError::NotFound("反馈记录不存在".into())),
Some(Some(org)) if org == org_id => Ok(()),
_ => Err(AppError::Forbidden("无权访问该资源".into())),
}
}
/// GET /api/h5/feedback — 反馈列表(管理端) /// GET /api/h5/feedback — 反馈列表(管理端)
/// ///
/// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据, /// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据,
@ -1995,6 +2062,15 @@ struct FeedbackRow {
/// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近; /// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近;
/// 操作员即使登录也会被 403 拦住。 /// 操作员即使登录也会被 403 拦住。
/// ///
/// **组织隔离(本次修复)**:反馈按**提交人**归属 ——
/// `user_feedback.user_id → users.organization_id`(与操作日志同一口径)。
/// 历史缺陷:本接口只校验了 `operation_log:view` 就全量查询 ⇒ 任何**企业管理员**
/// 都能读到其它客户组织的反馈(含联系方式)并改其处理状态。
/// 规则:工厂(`role_level >= 2`)全量;客户侧仅本组织成员提交的反馈;
/// `organization_id` 为 `NULL` → 空集(**绝不退化成全量**)。
/// 数据范围(`user_scopes`)**不叠加**:反馈与项目/设备没有关联列,项目级范围无法映射
/// (与操作日志完全同一口径,见 AGENTS.md「数据范围」一节的说明)。
///
/// `status` 为可选筛选条件,落到 SQL(不影响未传参时的原有查询语义)。 /// `status` 为可选筛选条件,落到 SQL(不影响未传参时的原有查询语义)。
pub async fn list_feedback( pub async fn list_feedback(
user: CurrentUser, user: CurrentUser,
@ -2016,12 +2092,16 @@ pub async fn list_feedback(
} }
} }
// `filter` 只可能是下面这两个常量字面量之一(不含任何用户输入), // 条件与绑定值一一对应:**文本条件(组织隔离)在前、`status`(整型)在后**,
// 参数依旧全部走 bind,不拼接外部数据 // 因此绑定顺序同样是"先文本占位、后 status"(见下面的 count/list 绑定)
let filter = if status.is_some() { " WHERE status = ?" } else { "" }; let (filter, org_binds) = feedback_filter(&user, status.is_some());
let mut binds = org_binds;
let count_sql = format!("SELECT COUNT(*) FROM user_feedback{}", filter); let count_sql = format!("SELECT COUNT(*) FROM user_feedback{}", filter);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for bind in &binds {
count_query = count_query.bind(bind);
}
if let Some(s) = status { if let Some(s) = status {
count_query = count_query.bind(s); count_query = count_query.bind(s);
} }
@ -2033,6 +2113,9 @@ pub async fn list_feedback(
filter filter
); );
let mut list_query = sqlx::query_as::<_, FeedbackRow>(&list_sql); let mut list_query = sqlx::query_as::<_, FeedbackRow>(&list_sql);
for bind in binds.drain(..) {
list_query = list_query.bind(bind);
}
if let Some(s) = status { if let Some(s) = status {
list_query = list_query.bind(s); list_query = list_query.bind(s);
} }
@ -2148,6 +2231,10 @@ pub async fn update_feedback_status(
return Err(AppError::BadRequest("状态值不合法".into())); return Err(AppError::BadRequest("状态值不合法".into()));
} }
// 归属校验:客户侧只能处置本组织成员提交的反馈(工厂豁免)。
// 历史缺陷:原先只校验权限码就直接 UPDATE ⇒ 企业管理员能改别的组织的反馈状态。
verify_feedback_ownership(pool, &user, id).await?;
let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?") let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?")
.bind(body.status) .bind(body.status)
.bind(id) .bind(id)

View File

@ -19,8 +19,13 @@
//! 该通道最近一次 `bat_in` 的电池信息(`on` 建单时取电池 id 与起始 SOC) //! 该通道最近一次 `bat_in` 的电池信息(`on` 建单时取电池 id 与起始 SOC)
//! //!
//! 本模块消费 / 删除的 key(由 device-server 写入,见协议 §3.5 / §3.6): //! 本模块消费 / 删除的 key(由 device-server 写入,见协议 §3.5 / §3.6):
//! - `pending:{imei}`(Hash):`field = msg_id`、`value = act`、TTL 120 秒; //! - `pending:{imei}`(Hash):`field = msg_id`、`value = "{act}:{deadline_unix_ms}"`、
//! 收到设备回执(带 `suc`)时按 `msg_id` + `act` 双条件命中并 `HDEL` //! 整表 TTL **60 秒**(= 协议 §3.6 的交互窗口);收到设备回执(带 `suc`)时按
//! `msg_id` + `act` 双条件命中、且**必须仍在 `deadline` 之前**才配对,随后 `HDEL`。
//! ⚠️ 判定用的是值里的 `deadline`(精确到每条指令各自 60 秒),不是 Hash 的 TTL ——
//! 同一 IMEI 后续再有指令下发会刷新 Hash TTL,只靠 TTL 不够精确。
//! - `device:authed:{imei}`(String,TTL 300s,**由 device-server 维护**):连接级认证标记,
//! API 侧下发前的"设备可下发"判据见 `crate::commands::NodeRegistry::is_device_online`
//! //!
//! 另:协议 §5.7 的"10 小时订单超时"结单由 `workers/order_timeout.rs` 负责, //! 另:协议 §5.7 的"10 小时订单超时"结单由 `workers/order_timeout.rs` 负责,
//! 它**不复位**占用状态,故本模块额外启动周期性对账(见 [`reconcile_usage_loop`])。 //! 它**不复位**占用状态,故本模块额外启动周期性对账(见 [`reconcile_usage_loop`])。
@ -319,12 +324,10 @@ async fn handle_auth_str(
redis: &ConnectionManager, redis: &ConnectionManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if !AUTH_STR_LIMITER.check(dev_id).await { // ⚠️ **顺序很重要:登记校验必须在频率限制之前**。
tracing::warn!("[auth_str] 频率超限 dev_id={}", dev_id); // 协议 §5.1 / §8.1 规定"未登记的 IMEI:平台**不发码、不写缓存、不回任何包**(静默丢弃)";
let resp = ServerResponse::error_for(&msg, "rate limit exceeded"); // 若先跑限流,未登记设备在第 6 次请求时会收到 `rate limit exceeded` 的**应答**,与协议不符。
send_response_to_device(redis, dev_id, &resp).await?; // 反过来把登记校验放前面,未登记设备永远得不到任何回应,也不会白占限流额度。
return Ok(());
}
// 以 DB(cabinets.auth_str)为权威存储,Redis 仅作缓存:读取已登记的安全码与下发标记 // 以 DB(cabinets.auth_str)为权威存储,Redis 仅作缓存:读取已登记的安全码与下发标记
let db_row: Option<(Option<String>, Option<i32>)> = let db_row: Option<(Option<String>, Option<i32>)> =
@ -347,6 +350,16 @@ async fn handle_auth_str(
} }
}; };
// 频率限制:同一 IMEI 5 次 / 5 分钟(协议 §5.1 的 `rate limit exceeded`)。
// ⚠️ 该限制器是**进程内 `LazyLock` 计数器**(不落 Redis)⇒ 服务重启会清零、
// 多节点各算各的。属平台实现细节、不影响协议语义(协议只约定"5 次 / 5 分钟"这一上限)。
if !AUTH_STR_LIMITER.check(dev_id).await {
tracing::warn!("[auth_str] 频率超限 dev_id={}", dev_id);
let resp = ServerResponse::error_for(&msg, "rate limit exceeded");
send_response_to_device(redis, dev_id, &resp).await?;
return Ok(());
}
// 安全码只能下发一次:DB 已标记下发即拒绝,须管理员「重置安全码」后才能重新申领 // 安全码只能下发一次:DB 已标记下发即拒绝,须管理员「重置安全码」后才能重新申领
if issued == Some(1) { if issued == Some(1) {
tracing::warn!("[auth_str] 安全码已下发过,拒绝重复申领 dev_id={}", dev_id); tracing::warn!("[auth_str] 安全码已下发过,拒绝重复申领 dev_id={}", dev_id);
@ -563,6 +576,8 @@ async fn mark_device_online(redis: &ConnectionManager, dev_id: &str) {
enum SpecSource { enum SpecSource {
/// 真实拓扑现算(`cabin_boards` / `compartments`),最准 /// 真实拓扑现算(`cabin_boards` / `compartments`),最准
Topology, Topology,
/// 真实拓扑**超出协议量程**(>15 板 / >8 通道每板)→ 已收敛到 1–15 / 1–8(协议 §3.7)
TopologyClamped,
/// 拓扑不完整 → 用 `cabinets.cell`(仓控板数)× 每板通道数 /// 拓扑不完整 → 用 `cabinets.cell`(仓控板数)× 每板通道数
LegacyCell, LegacyCell,
/// `cell` 也为空 / 0 → 用常量默认 [`DEFAULT_BOARD_COUNT`] × [`CHANNELS_PER_BOARD`] /// `cell` 也为空 / 0 → 用常量默认 [`DEFAULT_BOARD_COUNT`] × [`CHANNELS_PER_BOARD`]
@ -573,7 +588,12 @@ enum SpecSource {
/// ///
/// 优先级:**真实拓扑 > `cabinets.cell` > 常量默认**;无论输入多离谱, /// 优先级:**真实拓扑 > `cabinets.cell` > 常量默认**;无论输入多离谱,
/// **任何分支都不会返回 `"0000"`**(`"0000"` 会让固件认为"0 板 × 0 通道"→ 一块板都不上报)。 /// **任何分支都不会返回 `"0000"`**(`"0000"` 会让固件认为"0 板 × 0 通道"→ 一块板都不上报)。
/// 兜底值只做「协议量程内」收敛(仓控板 1–15,协议 §3.7),保证结果恒为 4 位。 /// 每个分支都做「协议量程内」收敛(仓控板 1–15、每板通道 1–8,协议 §3.7),保证结果恒为 4 位。
///
/// ⚠️ **真实拓扑分支也必须 clamp**(历史缺陷):若平台把某柜配成 >15 块板、或 >8 通道/板,
/// 未收敛的 `spec`(如 `"1602"` / `"0112"`)会指挥固件去报**超出协议量程**的板号/通道号,
/// 而平台侧随后会把越界项静默跳过(见 [`write_board_channels`])⇒ 该柜数据缺失。
/// 生产实测当前全部为 `"0606"`(无越界),此改动属防御。
fn build_spec( fn build_spec(
board_count: i64, board_count: i64,
compartment_count: i64, compartment_count: i64,
@ -582,7 +602,15 @@ fn build_spec(
// 1) 真实拓扑:仓控板数与每板通道数都必须 > 0 才算"拓扑完整" // 1) 真实拓扑:仓控板数与每板通道数都必须 > 0 才算"拓扑完整"
let per_board = if board_count > 0 { compartment_count / board_count } else { 0 }; let per_board = if board_count > 0 { compartment_count / board_count } else { 0 };
if board_count > 0 && per_board > 0 { if board_count > 0 && per_board > 0 {
return (format!("{:02}{:02}", board_count, per_board), SpecSource::Topology); let boards = board_count.clamp(1, MAX_BOARD_ID);
let per = per_board.clamp(1, MAX_CHANNEL_ID);
let source = if boards == board_count && per == per_board {
SpecSource::Topology
} else {
// 拓扑越界:已收敛到协议量程,记 WARN 让运维发现该柜配置有问题
SpecSource::TopologyClamped
};
return (format!("{:02}{:02}", boards, per), source);
} }
// 2) 拓扑不完整 → 兜底:优先用遗留列 `cabinets.cell`(仓控板数量), // 2) 拓扑不完整 → 兜底:优先用遗留列 `cabinets.cell`(仓控板数量),
@ -648,7 +676,18 @@ async fn fetch_spec(mysql: &MySqlPool, dev_id: &str) -> Result<String, AppError>
}; };
let (spec, source) = build_spec(board_count, compartment_count, legacy_cell); let (spec, source) = build_spec(board_count, compartment_count, legacy_cell);
if source != SpecSource::Topology { if source == SpecSource::TopologyClamped {
// 拓扑齐全但越界(>15 板 / >8 通道每板):spec 已收敛到协议量程,
// 该柜配置有问题必须让运维看到(否则固件按越界 spec 上报、平台静默跳过 → 数据缺失)
tracing::warn!(
"[login] 该柜拓扑超出协议量程(协议 §3.7 允许 15 板 × 8 通道),\
spec 已收敛 spec={}(cabin_boards={} compartments={})dev_id={}",
spec,
board_count,
compartment_count,
dev_id
);
} else if source != SpecSource::Topology {
tracing::warn!( tracing::warn!(
"[login] 该柜拓扑未配置(cabin_boards={} compartments={} cabinets.cell={:?}),\ "[login] 该柜拓扑未配置(cabin_boards={} compartments={} cabinets.cell={:?}),\
spec 使用兜底值 spec={} 来源={:?} dev_id={}", spec 使用兜底值 spec={} 来源={:?} dev_id={}",
@ -2165,7 +2204,7 @@ async fn reconcile_usage_loop(mysql: MySqlPool, redis: ConnectionManager) {
/// 2. **"无订单却标占用"兜底复位**([`reconcile_orderless_compartments`]):`status=1` 但 /// 2. **"无订单却标占用"兜底复位**([`reconcile_orderless_compartments`]):`status=1` 但
/// **一笔订单都没有**的行走不到第 1 步(它要求 `EXISTS(已结束订单)`),必须单独兜底 /// **一笔订单都没有**的行走不到第 1 步(它要求 `EXISTS(已结束订单)`),必须单独兜底
/// 3. **"无电池"状态兜底**(**待确认第 18 项**):该通道 `batt_status` 的 **bit7 连续 /// 3. **"无电池"状态兜底**(**待确认第 18 项**):该通道 `batt_status` 的 **bit7 连续
/// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §5.8 位定义:0=无电池)却还挂着活跃订单 /// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §3.8 位定义:0=无电池)却还挂着活跃订单
/// ⇒ 判定"人把电池拔走了但设备没机会上报 `bat_out`",**提前结单**,避免订单一直挂到 10 小时超时。 /// ⇒ 判定"人把电池拔走了但设备没机会上报 `bat_out`",**提前结单**,避免订单一直挂到 10 小时超时。
/// ///
/// 为什么放在这里而不是 `status_post` 处理路径:这里只需扫**活跃订单**(数量少), /// 为什么放在这里而不是 `status_post` 处理路径:这里只需扫**活跃订单**(数量少),
@ -2219,7 +2258,7 @@ async fn reconcile_usage_once(
/// ///
/// 判据(先严后宽,只清"确实空闲"的行): /// 判据(先严后宽,只清"确实空闲"的行):
/// 1. 无任何订单(`NOT EXISTS`)—— 有活跃订单必然保留;有已结束订单的交给主复位分支; /// 1. 无任何订单(`NOT EXISTS`)—— 有活跃订单必然保留;有已结束订单的交给主复位分支;
/// 2. **优先**用 Redis 通道状态 `batt_status` 的 **bit7**(协议 §5.8:bit7=1 有电池 / 0 无电池): /// 2. **优先**用 Redis 通道状态 `batt_status` 的 **bit7**(协议 §3.8:bit7=1 有电池 / 0 无电池):
/// - bit7=0 ⇒ 通道里没有电池 ⇒ 确实空闲 ⇒ 复位; /// - bit7=0 ⇒ 通道里没有电池 ⇒ 确实空闲 ⇒ 复位;
/// - bit7=1 ⇒ 电池还在仓里(可能是"已插入待充电"的正常状态)⇒ **保留**; /// - bit7=1 ⇒ 电池还在仓里(可能是"已插入待充电"的正常状态)⇒ **保留**;
/// 3. 拿不到通道状态(键不存在 / Redis 故障 / Redis 重启后状态丢失)⇒ 退回 /// 3. 拿不到通道状态(键不存在 / Redis 故障 / Redis 重启后状态丢失)⇒ 退回
@ -2293,7 +2332,7 @@ async fn reconcile_orderless_compartments(
/// 读取通道 `batt_status` 的 **bit7**(有无电池):`Some(true)`=有电池 / `Some(false)`=无电池 / /// 读取通道 `batt_status` 的 **bit7**(有无电池):`Some(true)`=有电池 / `Some(false)`=无电池 /
/// `None`=拿不到状态(键不存在或 Redis 读取失败)。 /// `None`=拿不到状态(键不存在或 Redis 读取失败)。
/// ///
/// 位定义见协议 §5.8 与 [`energy::accumulate`] 的同一判据(`batt_status & 0x80`)。 /// 位定义见协议 §3.8 与 [`energy::accumulate`] 的同一判据(`batt_status & 0x80`)。
/// 键为无 TTL 的通道实时状态 Hash(`report_worker` 每次 `status_post` 覆盖), /// 键为无 TTL 的通道实时状态 Hash(`report_worker` 每次 `status_post` 覆盖),
/// 因此"键不存在"只可能是设备长期离线或 Redis 重启 —— 调用方需自行兜底。 /// 因此"键不存在"只可能是设备长期离线或 Redis 重启 —— 调用方需自行兜底。
async fn channel_battery_present( async fn channel_battery_present(
@ -2417,6 +2456,33 @@ async fn reconcile_no_battery_orders(
/// 两处都改才算真正调整阈值。 /// 两处都改才算真正调整阈值。
pub const ORDER_TIMEOUT_HOURS: i64 = 10; pub const ORDER_TIMEOUT_HOURS: i64 = 10;
/// **超时选单 SQL —— 方向即语义**(`start_time` 必须早于 `NOW() - ORDER_TIMEOUT_HOURS`)。
///
/// ⛔ **方向极易写反,且写反了不报错**:
/// 若写成 `start_time < TIMESTAMPADD(HOUR, ?, NOW())` 并**绑正数**,得到的是**未来时刻**,
/// "`start_time` < 未来"对**几乎所有订单恒真** ⇒ 刚建几分钟的活跃订单会被当成超时单提前结掉、
/// 电量被提前结算(`workers/order_timeout.rs` 的注释记录了这个坑)。
///
/// ✅ 两种**等价**的正确形态(本项目同时存在,改一处必须确认另一处):
/// - **绑负数**(本常量,`finalize_timeout_order` 用):`start_time < TIMESTAMPADD(HOUR, -10, NOW())`
/// - **把 start_time 往前推**(`order_timeout.rs::scan_once` 用):
/// `TIMESTAMPADD(HOUR, +10, start_time) < NOW()`
///
/// 守门单测(**抽成常量就是为了让方向能被断言**):
/// [`tests::test_expired_order_condition_points_backwards`]、
/// [`tests::test_is_expired_two_way_assertions_catch_reversed_direction`]。
const FINALIZE_EXPIRED_ORDER_SQL: &str = "SELECT id FROM charge_orders
WHERE compartment_id = ? AND status = 0 AND start_time IS NOT NULL
AND start_time < TIMESTAMPADD(HOUR, ?, NOW())
ORDER BY start_time ASC, id ASC LIMIT 1";
/// 判定方向标记:SQL 里出现的这个片段必须**逐字**存在(`?` 由调用方绑 `-ORDER_TIMEOUT_HOURS`)。
///
/// 单独抽出来是为了让单测断言"方向"而不是断言一整段 SQL 文本(后者改格式就会误报)。
/// 仅测试使用(生产路径不需要它),故用 `#[cfg(test)]` 关掉,避免 `dead_code` 警告。
#[cfg(test)]
const TIMEOUT_CONDITION_POINTS_BACKWARDS: &str = "TIMESTAMPADD(HOUR, ?, NOW())";
/// `charge_orders.status`:1 = 已完成(正常结束 / 超时兜底结单) /// `charge_orders.status`:1 = 已完成(正常结束 / 超时兜底结单)
const ORDER_STATUS_DONE: i8 = 1; const ORDER_STATUS_DONE: i8 = 1;
@ -2439,12 +2505,8 @@ pub async fn finalize_timeout_order(
redis: &ConnectionManager, redis: &ConnectionManager,
compartment_id: i64, compartment_id: i64,
) -> Result<u64, sqlx::Error> { ) -> Result<u64, sqlx::Error> {
let order_id: Option<i64> = sqlx::query_scalar( // 方向由 [`FINALIZE_EXPIRED_ORDER_SQL`] 承载并被单测守门;这里绑定的是**负数**
"SELECT id FROM charge_orders let order_id: Option<i64> = sqlx::query_scalar(FINALIZE_EXPIRED_ORDER_SQL)
WHERE compartment_id = ? AND status = 0 AND start_time IS NOT NULL
AND start_time < TIMESTAMPADD(HOUR, ?, NOW())
ORDER BY start_time ASC, id ASC LIMIT 1",
)
.bind(compartment_id) .bind(compartment_id)
.bind(-ORDER_TIMEOUT_HOURS) .bind(-ORDER_TIMEOUT_HOURS)
.fetch_optional(pool) .fetch_optional(pool)
@ -3110,14 +3172,13 @@ async fn handle_device_on(
/// 处理指令执行回执(`open` / `pow_on` / `pow_off` / `reset` / 回执形态的 `on` / `off`)。 /// 处理指令执行回执(`open` / `pow_on` / `pow_off` / `reset` / 回执形态的 `on` / `off`)。
/// ///
/// 协议 §3.5:**回执是上行应答,平台一律不再回包**(双方都对"应答"回包会形成回环)。 /// 协议 §3.5:**回执是上行应答,平台一律不再回包**(双方都对"应答"回包会形成回环)。
/// - **A4 配对**:device-server 下发时写挂起项 `pending:{dev_id}` → field = `msg_id`、value = `act`。 /// - **A4 配对**:device-server 下发时写挂起项 `pending:{dev_id}` →
/// 这里先 `HGET` 取挂起项的 `act`,与回执的 `act` **双条件命中**才 `HDEL` /// field = `msg_id`、value = `"{act}:{deadline_unix_ms}"`。
/// (协议 §3.5:配对必须同时校验 `msg_id` 与 `act`;仅凭 `msg_id` 可能错配另一个交互)。 /// 这里 `HGET` 取挂起项,与回执的 `act` **双条件命中**(§3.5)**且未超过 60 秒窗口**(§3.6)
/// 字段不存在 → 记 WARN「迟到的回执,已作废」,仍然不回包。 /// 才 `HDEL`;`act` 不一致 / 已超窗 / 字段不存在 → 记 WARN 并**不配对**(见 [`classify_pending`])。
/// - **60 秒语义**:挂起项没有入队时间,且 device-server 已完工(挂起项 TTL 120 秒兜底自动清理), /// - **60 秒语义**:窗口由挂起项值里的 `deadline`(下发时刻 + 60 秒)精确判定,
/// 因此这里**只按"字段是否存在"判定**:命中即视为 60 秒内到达;字段不存在即视为已作废。 /// 不依赖 Hash 的 TTL —— 同一 IMEI 后续再有指令下发会刷新 Hash TTL。
/// 120 秒内到达的"真迟到"回执会被记为已配对——回执本就只用于记录与配对,不触发任何业务动作, /// 超窗回执一律丢弃、不补配、不重传(协议 §3.6),并**记 WARN**。
/// 所以不影响正确性(这也正是协议 §3.6「超时后不重做、不补记业务动作」的要求)。
/// - **A6 记录**:设备回执里的 `err`(`suc=0` 时的失败原因)写入 `device_logs.content`。 /// - **A6 记录**:设备回执里的 `err`(`suc=0` 时的失败原因)写入 `device_logs.content`。
async fn handle_cmd_result( async fn handle_cmd_result(
msg: &serde_json::Value, msg: &serde_json::Value,
@ -3164,22 +3225,74 @@ async fn handle_cmd_result(
Ok(()) Ok(())
} }
/// 挂起项查表结果(协议 §3.5 双条件配对 + §3.6 六十秒窗口)。
///
/// 抽成纯函数([`classify_pending`])的原因:这是"**回执到底算不算配对成功**"的唯一判据,
/// 而它同时决定**要不要记 WARN** —— 必须能被双向断言(窗口内命中 / 超窗作废)。
#[derive(Debug, PartialEq, Eq)]
enum PendingLookup {
/// 命中:`act` 一致**且**仍在 60 秒窗口内 → 配对成功并 `HDEL`
Matched,
/// 挂起项不存在:已超时被清理 / 从未登记 → 按§3.6"超时后一律丢弃",记 WARN
Missing,
/// 存在但**已超过 60 秒窗口**(Hash TTL 可能被后续登记刷新)→ 作废并 `HDEL`,记 WARN
Expired,
/// 存在但 `act` 不一致 → 不是这条指令的回执(§3.5 要求 `msg_id` + `act` 双条件),记 WARN
ActMismatch,
}
/// 解析挂起项值 `"{act}:{deadline_unix_ms}"` → `(act, 截止时间)`。
///
/// 兼容**老格式**(`value = act`,没有截止时间)与脏值:此时返回 `None`,
/// 读侧按"无截止时间"处理(不因格式升级把在途指令全判超时)。
fn parse_pending(raw: &str) -> (&str, Option<i64>) {
match raw.rsplit_once(':') {
Some((act, ts)) => match ts.parse::<i64>() {
Ok(v) => (act, Some(v)),
Err(_) => (raw, None),
},
None => (raw, None),
}
}
/// 判定一条回执与挂起项的关系(纯函数,见 [`PendingLookup`] 的四个分支)。
///
/// `now_ms` 由调用方传入(便于测试构造边界),`deadline` 由 device-server 在下发时写入。
fn classify_pending(raw: Option<&str>, act: &str, now_ms: i64) -> PendingLookup {
let Some(raw) = raw else {
return PendingLookup::Missing;
};
let (pending_act, deadline) = parse_pending(raw);
if pending_act != act {
return PendingLookup::ActMismatch;
}
match deadline {
// 超过截止时间:协议 §3.6「超时后即使应答到达,也一律丢弃」
Some(deadline) if now_ms > deadline => PendingLookup::Expired,
_ => PendingLookup::Matched,
}
}
/// A4 配对:拿回执的 `(msg_id, act)` 去挂起表 `pending:{dev_id}` 命中并删除。 /// A4 配对:拿回执的 `(msg_id, act)` 去挂起表 `pending:{dev_id}` 命中并删除。
/// ///
/// 挂起表由 device-server 在**下发时**写入:field = `msg_id`(十进制字符串)、 /// 挂起表由 device-server 在**下发时**写入:field = `msg_id`(十进制字符串)、
/// value = `act`、整个 hash TTL 120 秒(超时自动清理,平台侧不重传、协议 §3.6)。 /// value = `"{act}:{deadline_unix_ms}"`、整个 hash TTL **60 秒**(协议 §3.6 的交互窗口;
/// 平台侧超时即作废、不重传、**记 WARN**)。
///
/// ⚠️ **窗口判定用值里的 `deadline`**,不用 Hash TTL:同一 IMEI 后续再有指令下发会刷新
/// Hash TTL,只靠 TTL 会让早先那条指令的挂起项活过 60 秒(与协议不符)。
async fn pair_pending_command(redis: &ConnectionManager, dev_id: &str, act: &str, msg_id: u64) { async fn pair_pending_command(redis: &ConnectionManager, dev_id: &str, act: &str, msg_id: u64) {
let key = pending_key(dev_id); let key = pending_key(dev_id);
let mut conn = redis.clone(); let mut conn = redis.clone();
// 先查 value(下发的 act),实现 §3.5 要求的 `msg_id` + `act` 双条件配对 // 先查 value(下发的 act + 截止时间),实现 §3.5 的 `msg_id` + `act` 双条件 + §3.6 的 60 秒窗口
let pending_act: Result<Option<String>, redis::RedisError> = redis::cmd("HGET") let pending_raw: Result<Option<String>, redis::RedisError> = redis::cmd("HGET")
.arg(&key) .arg(&key)
.arg(msg_id) .arg(msg_id)
.query_async(&mut conn) .query_async(&mut conn)
.await; .await;
let pending_act = match pending_act { let pending_raw = match pending_raw {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::error!("[cmd_result] 读取挂起项失败 key={} msg_id={} err={}", key, msg_id, e); tracing::error!("[cmd_result] 读取挂起项失败 key={} msg_id={} err={}", key, msg_id, e);
@ -3187,30 +3300,51 @@ async fn pair_pending_command(redis: &ConnectionManager, dev_id: &str, act: &str
} }
}; };
let matched = match pending_act.as_deref() { let lookup = classify_pending(
None => false, pending_raw.as_deref(),
Some(pending) => pending == act, act,
}; chrono::Utc::now().timestamp_millis(),
);
if !matched { match lookup {
match pending_act.as_deref() { PendingLookup::Missing => {
// 字段不存在:已超时作废(或无对应下发)→ 不改动挂起表,留给真正的回执 // 字段不存在:已超时被清理(或无对应下发)。§3.6:超时后一律丢弃,不补配。
None => tracing::warn!( tracing::warn!(
"[cmd_result] 迟到的回执,已作废 dev_id={} act={} msg_id={}", "[cmd_result] 迟到的回执,挂起项已作废(协议 §3.6:超时后一律丢弃)dev_id={} act={} msg_id={}",
dev_id, dev_id,
act, act,
msg_id msg_id
), );
Some(pending) => tracing::warn!(
"[cmd_result] 回执 act 与挂起项不一致,忽略 dev_id={} recv_act={} pending_act={} msg_id={}",
dev_id,
act,
pending,
msg_id
),
}
return; return;
} }
PendingLookup::Expired => {
// 超过 60 秒窗口 ⇒ 该 msg_id 已作废:删掉它并记 WARN(协议 §3.6 明确要求"记 WARN")
tracing::warn!(
"[cmd_result] 回执超过 60 秒窗口,挂起项作废、不配对 dev_id={} act={} msg_id={} pending={:?}",
dev_id,
act,
msg_id,
pending_raw
);
let _: Result<i64, _> = redis::cmd("HDEL")
.arg(&key)
.arg(msg_id)
.query_async(&mut conn)
.await;
return;
}
PendingLookup::ActMismatch => {
tracing::warn!(
"[cmd_result] 回执 act 与挂起项不一致,忽略 dev_id={} recv_act={} pending={:?} msg_id={}",
dev_id,
act,
pending_raw,
msg_id
);
return;
}
PendingLookup::Matched => {}
}
let deleted: Result<i64, redis::RedisError> = redis::cmd("HDEL") let deleted: Result<i64, redis::RedisError> = redis::cmd("HDEL")
.arg(&key) .arg(&key)
@ -3482,6 +3616,39 @@ mod tests {
); );
} }
/// **真实拓扑分支也必须收敛到协议量程**(协议 §3.7:板号 1–15、通道 1–8)。
///
/// 未收敛的后果:`"1602"` / `"0112"` 会指挥固件去报越界的板号/通道号,
/// 而平台侧随后把它们静默跳过 ⇒ 该柜数据缺失。
#[test]
fn test_build_spec_topology_clamped_into_protocol_range() {
// 20 板 × 每板 2 仓 → 板数越界,收敛到 15
assert_eq!(
build_spec(20, 40, None),
("1502".to_string(), SpecSource::TopologyClamped)
);
// 1 板 × 12 仓 → 每板通道越界,收敛到 8
assert_eq!(
build_spec(1, 12, None),
("0108".to_string(), SpecSource::TopologyClamped)
);
// 量程内不受影响(仍然是"拓扑现算",不触发 clamp 的 WARN)
assert_eq!(
build_spec(6, 36, Some(6)),
("0606".to_string(), SpecSource::Topology)
);
assert_eq!(
build_spec(15, 120, None),
("1508".to_string(), SpecSource::Topology)
);
// 边界:恰好 15 板 × 8 通道 → 不 clamp
assert_eq!(
build_spec(15, 120, None).1,
SpecSource::Topology,
"15 板 × 8 通道是协议上限,不该被判越界"
);
}
/// **绝不允许下发 `"0000"`**:遍历各种输入组合,spec 必须恒为 4 位且两段都非 0 /// **绝不允许下发 `"0000"`**:遍历各种输入组合,spec 必须恒为 4 位且两段都非 0
#[test] #[test]
fn test_build_spec_never_produces_all_zero_spec() { fn test_build_spec_never_produces_all_zero_spec() {
@ -3531,6 +3698,73 @@ mod tests {
assert_eq!(pending_key("864720562557021"), "pending:864720562557021"); assert_eq!(pending_key("864720562557021"), "pending:864720562557021");
} }
// ---- 挂起项 60 秒窗口(协议 §3.6,双向断言)----------------------------
/// 值格式是**跨服务契约**:device-server 写 `"{act}:{deadline_ms}"`(见其
/// `pending_value` + `test_pending_value_carries_60s_deadline`),这里必须按同一格式解析。
#[test]
fn test_parse_pending_value_format_contract() {
assert_eq!(parse_pending("pow_on:1700000060000"), ("pow_on", Some(1_700_000_060_000)));
// act 里含 '-'/'_' 也照最后一个 ':' 切分
assert_eq!(
parse_pending("pow_fail_dc:1700000060000"),
("pow_fail_dc", Some(1_700_000_060_000))
);
// 老格式(无 deadline)→ 按"无截止时间"兼容,不误判超时
assert_eq!(parse_pending("status_post"), ("status_post", None));
// 脏值(`:` 后面不是数字)→ **不猜**:整串当 act(必然与真实 act 不等 ⇒ ActMismatch,不配对)
assert_eq!(parse_pending("status_post:abc"), ("status_post:abc", None));
}
/// **双向断言(本项目已定的规矩)**:60 秒窗口内 → 命中;超过 60 秒 → 作废(不命中 + 记 WARN)。
///
/// 只断言"窗口内命中"是无效测试:判定写反(`now_ms < deadline` 写成 `>`)时它照样通过。
#[test]
fn test_classify_pending_inside_and_outside_60s_window() {
let deadline = 1_700_000_060_000i64;
let raw = format!("on:{}", deadline);
// ① 窗口内(含边界)→ 命中
assert_eq!(
classify_pending(Some(&raw), "on", 1_700_000_000_000),
PendingLookup::Matched
);
assert_eq!(
classify_pending(Some(&raw), "on", 1_700_000_059_999),
PendingLookup::Matched
);
assert_eq!(
classify_pending(Some(&raw), "on", deadline),
PendingLookup::Matched,
"恰好 60.000 秒算窗口内"
);
// ② 超过 60 秒 1 毫秒 → 作废(该 msg_id 已作废,绝不配对)
assert_eq!(
classify_pending(Some(&raw), "on", deadline + 1),
PendingLookup::Expired
);
assert_eq!(
classify_pending(Some(&raw), "on", deadline + 60_000),
PendingLookup::Expired
);
// ③ 字段不存在(已被 TTL 清理)→ Missing(同样记 WARN、不配对)
assert_eq!(classify_pending(None, "on", 1_700_000_000_000), PendingLookup::Missing);
// ④ `act` 不一致 → 不配对(§3.5 双条件配对,不能只看 msg_id)
assert_eq!(
classify_pending(Some(&raw), "off", 1_700_000_000_000),
PendingLookup::ActMismatch
);
// ⑤ 老格式(无 deadline)→ 兼容为命中,不能因格式升级把在途指令全判超时
assert_eq!(
classify_pending(Some("on"), "on", 1_900_000_000_000),
PendingLookup::Matched
);
}
// ---- 停电标记:cabinet.status1 bit0(§3.8 / §5.4)--------------------- // ---- 停电标记:cabinet.status1 bit0(§3.8 / §5.4)---------------------
/// bit0 = 0(供电正常)→ 清除停电标记;bit0 = 1(仍在报警)→ 保留标记。 /// bit0 = 0(供电正常)→ 清除停电标记;bit0 = 1(仍在报警)→ 保留标记。
@ -3727,14 +3961,94 @@ mod tests {
drop(guard_a); drop(guard_a);
} }
/// 超时兜底的"安全线":阈值必须**往回** 10 小时(`TIMESTAMPADD` 绑定负数)。 /// **纯函数孪生体(仅测试)**:与 [`FINALIZE_EXPIRED_ORDER_SQL`] 及
/// 若写成 `+10`,`start_time < NOW() + 10h` 会命中**所有**活跃订单, /// `workers/order_timeout.rs::scan_once` 的 SQL **同义** —— `start_time + hours < now` ⇒ 已超时。
/// 刚建的新单也会被当成超时单(`workers/order_timeout.rs` 现存缺陷); ///
/// [`finalize_timeout_order`] 正是靠这个负号自保。 /// 为什么需要它:SQL 的方向写反**不会报错、也不会有任何日志**,而旧的单测只断言了
/// `ORDER_TIMEOUT_HOURS == 10` 与 `-ORDER_TIMEOUT_HOURS == -10`(**常量符号**),
/// 方向写反时它照样绿 —— 属于「踩过的坑」第 1、2 条说的**假绿**。
/// 把方向固化成纯函数后,才能做"刚建的不命中 + 超期的命中"双向断言,
/// 并**反证**"写反的形态必然命中刚建的单"。
fn is_expired(
start_time: chrono::NaiveDateTime,
now: chrono::NaiveDateTime,
hours: i64,
) -> bool {
start_time + chrono::Duration::hours(hours) < now
}
/// 方向守门(SQL 文本层):阈值必须**往回**推(`start_time` 在 `<` 左侧 + 绑负数)。
///
/// 保留旧断言(常量值),并补上真正能抓到"方向写反"的两条。
#[test] #[test]
fn test_order_timeout_bound_goes_backwards() { fn test_expired_order_condition_points_backwards() {
// ── 旧断言(保留:常量符号)────────────────────────────────────────
assert_eq!(ORDER_TIMEOUT_HOURS, 10); assert_eq!(ORDER_TIMEOUT_HOURS, 10);
assert_eq!(-ORDER_TIMEOUT_HOURS, -10); assert_eq!(-ORDER_TIMEOUT_HOURS, -10, "绑定值必须是负数(把阈值往回推)");
assert_eq!(
TIMEOUT_CONDITION_POINTS_BACKWARDS, "TIMESTAMPADD(HOUR, ?, NOW())",
"方向标记不得改动"
);
// ── 新断言(能抓到方向写反)────────────────────────────────────────
assert!(
FINALIZE_EXPIRED_ORDER_SQL.contains("start_time < TIMESTAMPADD("),
"超时条件必须是「start_time 早于阈值(过去时刻)」,当前:{FINALIZE_EXPIRED_ORDER_SQL}"
);
assert!(
FINALIZE_EXPIRED_ORDER_SQL.contains(TIMEOUT_CONDITION_POINTS_BACKWARDS),
"阈值形态变了,请复核方向:{FINALIZE_EXPIRED_ORDER_SQL}"
);
// 反证:把正数小时加到 NOW() 上(等价于 SQL 里绑正数)是**写反**的形态
assert!(
!FINALIZE_EXPIRED_ORDER_SQL.contains("TIMESTAMPADD(HOUR, 10, NOW())")
&& !FINALIZE_EXPIRED_ORDER_SQL.contains("NOW() + INTERVAL"),
"方向写反:对几乎所有活跃订单恒真,刚建的单会被提前结掉。当前:{FINALIZE_EXPIRED_ORDER_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(10) - chrono::Duration::minutes(1);
let just_under = now - chrono::Duration::hours(10) + chrono::Duration::minutes(1);
// ① 刚建的不命中(防"恒真" ⇒ 防止提前结单、电量提前结算)
assert!(
!is_expired(fresh, now, ORDER_TIMEOUT_HOURS),
"刚建 5 分钟的订单绝不能被判超时"
);
// ② 超期的命中(防"恒假" ⇒ 防止超时单永不结单)
assert!(
is_expired(just_over, now, ORDER_TIMEOUT_HOURS),
"已过 10 小时 1 分钟的订单必须判超时"
);
// ③ 边界:距超时还差 1 分钟 ⇒ 不命中
assert!(
!is_expired(just_under, now, ORDER_TIMEOUT_HOURS),
"距超时还差 1 分钟不得判超时"
);
// ④ 反证:方向写反(`start_time < now + 10h`,等价于 SQL 绑正数)时,
// ① 中的"刚建单"必然被判超时 ⇒ 断言 ① 就是方向写反的守门人。
let reversed = |start: chrono::NaiveDateTime, now: chrono::NaiveDateTime, hours: i64| {
start < now + chrono::Duration::hours(hours)
};
assert!(
reversed(fresh, now, ORDER_TIMEOUT_HOURS),
"写反的方向确实会命中刚建的单(证明断言 ① 能拦住方向问题)"
);
assert_ne!(
is_expired(fresh, now, ORDER_TIMEOUT_HOURS),
reversed(fresh, now, ORDER_TIMEOUT_HOURS),
"正确方向与写反方向在「刚建单」上必须给出相反结论"
);
} }
/// 非"数据库错误"的 sqlx 错误**不能**被当成唯一键冲突(否则会把真错误静默吞掉)。 /// 非"数据库错误"的 sqlx 错误**不能**被当成唯一键冲突(否则会把真错误静默吞掉)。

View File

@ -41,10 +41,11 @@ use tokio::sync::oneshot;
/// (`auth_str` 是配网第一步,`login` 是握手本身);其余任何 `act` —— 含 `status_post` /// (`auth_str` 是配网第一步,`login` 是握手本身);其余任何 `act` —— 含 `status_post`
/// 等应答、`on`/`off` 业务指令、`open`/`pow_on`/`pow_off`/`reset` —— **一律不投递**。 /// 等应答、`on`/`off` 业务指令、`open`/`pow_on`/`pow_off`/`reset` —— **一律不投递**。
/// ///
/// 依据(`docs/柜控板固件对接文档.md` v1.1):§2.2「登录门控」(L80)与 §10 第 10 条 /// 依据(`docs/柜控板固件对接文档.md` v1.1):§2.2「登录门控」、§10 第 10 条
/// (L1116:只有 `auth_str`/`login` 可以在未登录状态发送,其余 `act` 必须先 `login` 成功)、 /// (只有 `auth_str`/`login` 可以在未登录状态发送,其余 `act` 必须先 `login` 成功)、
/// L372(上行同一规则)、L399/L401/L491(下发要求该 IMEI 存在**已登录的连接**, /// §4.1(上行同一规则)、§4.2(下发要求该 IMEI 存在**已登录的连接**,
/// 设备也是在收到 `suc=1` 且含 `spec` 的应答后才算已登录)。 /// 设备也是在收到 `suc=1` 且含 `spec` 的应答后才算已登录)。
/// ⚠️ 引用一律用**小节号**(不要写 `L372` 这类行号:文档一改行号就漂)。
/// ///
/// 为什么从"敏感指令清单"改成"登录前白名单":原 `SENSITIVE_ACTS` 只列了 /// 为什么从"敏感指令清单"改成"登录前白名单":原 `SENSITIVE_ACTS` 只列了
/// `open`/`pow_on`/`pow_off`/`reset`/`auth_str`,**漏了 `on`/`off`** —— 未登录连接会被投递 /// `open`/`pow_on`/`pow_off`/`reset`/`auth_str`,**漏了 `on`/`off`** —— 未登录连接会被投递
@ -54,11 +55,105 @@ use tokio::sync::oneshot;
/// 天然在白名单内;`login` 成功应答投递后连接即置为已认证,后续 `act` 随之放行。 /// 天然在白名单内;`login` 成功应答投递后连接即置为已认证,后续 `act` 随之放行。
const PRE_LOGIN_ALLOWED_ACTS: &[&str] = &["auth_str", "login"]; const PRE_LOGIN_ALLOWED_ACTS: &[&str] = &["auth_str", "login"];
/// 挂起登记(`pending:{dev_id}`)的 TTL(秒)。 /// 挂起登记(`pending:{dev_id}`)的 **TTL(秒)**,必须等于协议 §3.6 的交互窗口 **60 秒**。
/// ///
/// 取值 120 = 协议 60 秒等待窗口的 2 倍:既保证超时后自动作废, /// 历史取值 120(= 协议窗口的 2 倍)与协议不符:协议 §3.6 明确"平台对每条下发的指令只等
/// 又留出余量避免登记项在回执到达前先过期(api-server 命中时会 `HDEL`)。 /// **60 秒**,超时即**丢弃该 `msg_id` 的挂起项**、不重传、**记 WARN**"。
const PENDING_TTL_SECS: u32 = 120; /// 现在 TTL = 60 秒(兜底清理),另有每个挂起项自带的 **deadline**(见 [`pending_value`])
/// 做**精确**判定 —— 因为 Redis Hash 的 TTL 会被同一 IMEI 的后续登记刷新,
/// 单靠 Hash TTL 无法保证"每条指令各自 60 秒"。
const PENDING_TTL_SECS: u32 = 60;
/// 挂起项的有效窗口(毫秒):与协议 §3.6 的 60 秒一致(api-server 读取时按此判定)。
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` 的应答)时写入、
/// 该连接每次上行/下行时刷新、断连或被接管时删除。
const AUTH_MARKER_TTL_SECS: u32 = 300;
/// 连接级认证标记的 Redis 键(**跨服务契约**:device-server 写、api-server 读)。
pub fn auth_marker_key(dev_id: &str) -> String {
format!("device:authed:{}", dev_id)
}
/// 挂起项的存储值:`"{act}:{deadline_unix_ms}"`。
///
/// 为什么把截止时间写进值里:Redis 只能对整个 Hash 设 TTL,同一 IMEI 后续再有指令下发时
/// Hash TTL 会被刷新 ⇒ 只靠 Hash TTL 无法保证"每条指令各自 60 秒"。
/// 读侧(api-server)用 deadline 精确判定"是否已超过 60 秒"(协议 §3.6)。
/// 与 api-server 的解析函数是**同一份格式契约**(`parse_pending` / `classify_pending`)。
pub fn pending_value(act: &str, now_ms: i64) -> String {
format!("{}:{}", act, now_ms + PENDING_WINDOW_MS)
}
/// 该挂起项值的截止时间(毫秒);缺失/非法 → `None`(老格式或脏数据,读侧按"无截止时间"处理)。
pub fn pending_deadline_ms(raw: &str) -> Option<i64> {
raw.rsplit_once(':').and_then(|(_, ts)| ts.parse::<i64>().ok())
}
/// 写入连接级认证标记:`SET device:authed:{imei} 1 EX 300`。
///
/// 只应在 **`login` 验签成功 / 向已认证连接投递报文**时调用(见 [`ConnectionPool::send_with_msgid`])。
/// Redis 失败只记 WARN:标记是**给 api-server 的辅助判据**,绝不能因为它失败而影响登录应答的下发。
pub async fn mark_device_authed(redis: &redis::aio::ConnectionManager, dev_id: &str) {
let key = auth_marker_key(dev_id);
let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("SET")
.arg(&key)
.arg("1")
.arg("EX")
.arg(AUTH_MARKER_TTL_SECS)
.query_async(&mut conn)
.await;
if let Err(e) = result {
tracing::warn!("[auth] 写连接级认证标记失败 dev_id={} err={}", dev_id, e);
}
}
/// 刷新连接级认证标记的 TTL(该连接有上行/下行时调用)。
///
/// 用 `EXPIRE`(**不是** `SET`)是刻意的:键不存在时它什么都不做,
/// 因此**未登录连接不可能凭空造出"已登录"标记**。
pub async fn refresh_device_authed(redis: &redis::aio::ConnectionManager, dev_id: &str) {
let key = auth_marker_key(dev_id);
let mut conn = redis.clone();
let result: Result<i64, _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(AUTH_MARKER_TTL_SECS)
.query_async(&mut conn)
.await;
if let Err(e) = result {
tracing::warn!("[auth] 刷新连接级认证标记失败 dev_id={} err={}", dev_id, e);
}
}
/// 清除连接级认证标记(断连 / 被接管时调用)。
///
/// 必须清:否则"设备已断连"与"仍然在线可下发"会不一致 —— 正是本标记要消灭的那类故障。
pub async fn clear_device_authed(redis: &redis::aio::ConnectionManager, dev_id: &str) {
let key = auth_marker_key(dev_id);
let mut conn = redis.clone();
let result: Result<i64, _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
if let Err(e) = result {
tracing::warn!("[auth] 清除连接级认证标记失败 dev_id={} err={}", dev_id, e);
}
}
/// 一次出站投递的计划([`ConnectionPool::prepare_outbound`] 的返回值)。
///
/// - `pending`:`Some((msg_id, act))` = 这是**指令**(需登记挂起项);`None` = 这是**应答**
/// - `authenticated`:投递后该连接是否处于**已认证**状态 —— 调用方据此写连接级认证标记
/// ([`mark_device_authed`]),供 api-server 判断"该 IMEI 是否存在已登录连接"(协议 §4.2)
#[derive(Debug, Clone, PartialEq, Eq)]
struct Outbound {
pending: Option<(u32, String)>,
authenticated: bool,
}
/// 单台设备的连接状态。 /// 单台设备的连接状态。
pub struct DeviceConnection { pub struct DeviceConnection {
@ -286,11 +381,17 @@ impl ConnectionPool {
dev_id: &str, dev_id: &str,
json: &str, json: &str,
) -> Result<(), &'static str> { ) -> Result<(), &'static str> {
let pending = self.prepare_outbound(dev_id, json).await?; let outbound = self.prepare_outbound(dev_id, json).await?;
// 连接级认证标记:本条报文投递到了**已认证连接** ⇒ 该 IMEI 确有已登录连接
// (`login` 成功应答投递时这里首次置位;后续任意下行都会顺带刷新)。
if outbound.authenticated {
mark_device_authed(redis, dev_id).await;
}
// 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。 // 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。
// 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。 // 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。
if let Some((msg_id, act)) = pending { if let Some((msg_id, act)) = outbound.pending {
register_pending(redis, dev_id, msg_id, &act).await; register_pending(redis, dev_id, msg_id, &act).await;
} }
Ok(()) Ok(())
@ -298,13 +399,14 @@ impl ConnectionPool {
/// 出站报文的校验与投递:跑安全门控、按需注入 `msg_id`,最后写入设备写通道。 /// 出站报文的校验与投递:跑安全门控、按需注入 `msg_id`,最后写入设备写通道。
/// ///
/// 返回 `Some((msg_id, act))` 表示这是**指令**(需登记挂起项); /// 返回 [`Outbound`]:`pending` 为 `Some((msg_id, act))` 表示这是**指令**
/// 返回 `None` 表示这是**应答**(无平台挂起项,不登记)。 /// (需登记挂起项);`None` 表示这是**应答**(无平台挂起项,不登记)。
/// `authenticated` = 投递后该连接是否处于**已认证**状态(`login` 成功应答投递后即为 true)。
async fn prepare_outbound( async fn prepare_outbound(
&self, &self,
dev_id: &str, dev_id: &str,
json: &str, json: &str,
) -> Result<Option<(u32, String)>, &'static str> { ) -> Result<Outbound, &'static str> {
let mut map = self.inner.write().await; let mut map = self.inner.write().await;
let conn = map.get_mut(dev_id).ok_or("设备不在线")?; let conn = map.get_mut(dev_id).ok_or("设备不在线")?;
let mut cmd: serde_json::Value = let mut cmd: serde_json::Value =
@ -315,7 +417,7 @@ impl ConnectionPool {
let is_response = cmd.get("suc").is_some(); let is_response = cmd.get("suc").is_some();
// **登录门控(下行)**:未认证连接只允许投递 [`PRE_LOGIN_ALLOWED_ACTS`], // **登录门控(下行)**:未认证连接只允许投递 [`PRE_LOGIN_ALLOWED_ACTS`],
// 其余一律不投递(协议 §10 第 10 条 / L372:上下行同一规则)。 // 其余一律不投递(协议 §4.2 / §10 第 10 条:上下行同一规则)。
// //
// 这里**不再区分应答/指令**:白名单本身已保证 `auth_str`/`login` 的应答能通过 // 这里**不再区分应答/指令**:白名单本身已保证 `auth_str`/`login` 的应答能通过
// (它们的 `act` 就是这两个值),不会把首次配网锁死;而其它 `act` 的应答 // (它们的 `act` 就是这两个值),不会把首次配网锁死;而其它 `act` 的应答
@ -375,7 +477,7 @@ impl ConnectionPool {
// else: 应答,保持原始 msg_id 不动(透传设备发起的请求的编号) // else: 应答,保持原始 msg_id 不动(透传设备发起的请求的编号)
conn.tx.try_send(cmd.to_string()).map_err(|_| "通道已关闭或已满")?; conn.tx.try_send(cmd.to_string()).map_err(|_| "通道已关闭或已满")?;
Ok(pending) Ok(Outbound { pending, authenticated: conn.authenticated })
} }
/// 更新设备活跃时间(**仅当前持有者**可刷新)。 /// 更新设备活跃时间(**仅当前持有者**可刷新)。
@ -392,11 +494,11 @@ impl ConnectionPool {
} }
} }
/// 写入挂起登记:`HSET pending:{dev_id} {msg_id} {act}` + `EXPIRE pending:{dev_id} 120`。 /// 写入挂起登记:`HSET pending:{dev_id} {msg_id} "{act}:{deadline_ms}"` + `EXPIRE pending:{dev_id} 60`。
/// ///
/// **为什么由 device-server 登记**:`msg_id` 是本服务分配的,api-server 只有拿到 /// **为什么由 device-server 登记**:`msg_id` 是本服务分配的,api-server 只有拿到
/// 「`msg_id` → `act`」映射,才能把设备回执配对回具体的下发指令(§3.5 要求 /// 「`msg_id` → `act`(+截止时间)」映射,才能把设备回执配对回具体的下发指令(§3.5 要求
/// `msg_id` + `act` 双条件配对);平台对每条指令只等 60 秒,超时即作废。 /// `msg_id` + `act` 双条件配对);平台对每条指令只等 **60 秒**(§3.6),超时即作废。
/// ///
/// 失败处理:Redis 异常只记 WARN —— 挂起登记是**辅助功能**, /// 失败处理:Redis 异常只记 WARN —— 挂起登记是**辅助功能**,
/// 绝不能让一条已成功投递到设备的指令因为登记失败而报错(那会误导上层重发)。 /// 绝不能让一条已成功投递到设备的指令因为登记失败而报错(那会误导上层重发)。
@ -412,16 +514,26 @@ async fn register_pending(
} }
let key = format!("pending:{}", dev_id); let key = format!("pending:{}", dev_id);
let now_ms = chrono::Utc::now().timestamp_millis();
let value = pending_value(act, now_ms);
// 回读一次截止时间仅用于日志:排查"回执被判超时"时,日志里直接能对比 deadline 与回执时刻
let deadline_ms = pending_deadline_ms(&value).unwrap_or(now_ms + PENDING_WINDOW_MS);
let mut conn = redis.clone(); let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("HSET") let result: Result<(), _> = redis::cmd("HSET")
.arg(&key) .arg(&key)
.arg(msg_id) .arg(msg_id)
.arg(act) .arg(&value)
.query_async(&mut conn) .query_async(&mut conn)
.await; .await;
match result { match result {
Ok(_) => tracing::debug!("[pending] 已登记 dev_id={} msg_id={} act={}", dev_id, msg_id, act), Ok(_) => tracing::debug!(
"[pending] 已登记 dev_id={} msg_id={} act={} deadline_ms={}(协议 §3.6 窗口 60 秒)",
dev_id,
msg_id,
act,
deadline_ms
),
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
"[pending] 挂起登记失败 dev_id={} msg_id={} err={}(指令已下发,仅影响配对)", "[pending] 挂起登记失败 dev_id={} msg_id={} err={}(指令已下发,仅影响配对)",
@ -656,7 +768,7 @@ mod tests {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 登录门控(下行):`docs/柜控板固件对接文档.md` §10 第 10 条 / §2.2 / L372 / L1116 // 登录门控(下行):`docs/柜控板固件对接文档.md` §2.2 / §4.1 / §10 第 10 条
// 「不管上行下行,同一条 TCP 连接 login 成功前只允许 auth_str / login」 // 「不管上行下行,同一条 TCP 连接 login 成功前只允许 auth_str / login」
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -763,7 +875,7 @@ mod tests {
(r#"{"act":"reset","id":"01"}"#, "reset"), (r#"{"act":"reset","id":"01"}"#, "reset"),
(r#"{"act":"status_post","msg_id":9,"suc":1}"#, "status_post"), (r#"{"act":"status_post","msg_id":9,"suc":1}"#, "status_post"),
] { ] {
let pending = match pool.prepare_outbound("imei-ok", json).await { let outbound = match pool.prepare_outbound("imei-ok", json).await {
Ok(p) => p, Ok(p) => p,
Err(e) => panic!("已认证连接不得被拦: {} ({})", json, e), Err(e) => panic!("已认证连接不得被拦: {} ({})", json, e),
}; };
@ -773,16 +885,95 @@ mod tests {
}; };
let v = parse(&got); let v = parse(&got);
assert_eq!(v["act"], act); assert_eq!(v["act"], act);
assert!(outbound.authenticated, "已认证连接投递后仍应是已认证态: {}", json);
if json.contains("\"suc\"") { if json.contains("\"suc\"") {
// 应答:透传原 msg_id,不登记挂起项 // 应答:透传原 msg_id,不登记挂起项
assert_eq!(v["msg_id"], 9); assert_eq!(v["msg_id"], 9);
assert!(pending.is_none(), "应答不应登记挂起项: {}", json); assert!(outbound.pending.is_none(), "应答不应登记挂起项: {}", json);
} else { } else {
// 指令:注入自增 msg_id(从 1 起)并登记挂起项 // 指令:注入自增 msg_id(从 1 起)并登记挂起项
assert!(pending.is_some(), "指令应登记挂起项: {}", json); assert!(outbound.pending.is_some(), "指令应登记挂起项: {}", json);
assert!(v["msg_id"].as_u64().unwrap_or(0) >= 1, "指令必须注入 msg_id"); assert!(v["msg_id"].as_u64().unwrap_or(0) >= 1, "指令必须注入 msg_id");
} }
} }
} }
// -----------------------------------------------------------------------
// 挂起项 60 秒窗口(协议 §3.6)+ 连接级认证标记(协议 §4.2)
// -----------------------------------------------------------------------
/// 挂起项 TTL **必须等于协议 §3.6 的交互窗口 60 秒**(历史取值 120 与协议不符)。
#[test]
fn test_pending_ttl_matches_protocol_window() {
assert_eq!(PENDING_TTL_SECS, 60, "协议 §3.6:平台对每条指令只等 60 秒");
assert_eq!(PENDING_WINDOW_MS, 60_000);
assert_eq!(i64::from(PENDING_TTL_SECS) * 1000, PENDING_WINDOW_MS);
}
/// 挂起项值 = `"{act}:{deadline_ms}"`,deadline = 写入时刻 + 60 秒。
///
/// **双向断言**(读侧 api-server 的 `classify_pending` 按同一格式判定):
/// - `now == deadline` → 仍在窗口内(边界算命中)
/// - `now == deadline + 1` → 已超窗(必须作废并记 WARN,绝不配对)
#[test]
fn test_pending_value_carries_60s_deadline() {
let value = pending_value("pow_on", 1_700_000_000_000);
assert_eq!(value, "pow_on:1700000060000");
let deadline = match pending_deadline_ms(&value) {
Some(v) => v,
None => panic!("deadline 必须可解析"),
};
assert_eq!(deadline - 1_700_000_000_000, PENDING_WINDOW_MS);
// 窗口内(含边界)→ 未超时;超出一毫秒 → 超时
assert!(1_700_000_059_999 <= deadline, "59.999 秒时仍应在窗口内");
assert!(1_700_000_060_000 <= deadline, "60.000 秒边界算窗口内");
assert!(1_700_000_060_001 > deadline, "60.001 秒必须判超时作废");
}
/// 老格式 / 脏值(没有 `:deadline`)→ `None`:读侧按"无截止时间"兼容,
/// 不会因为格式升级把在途指令全判超时。
#[test]
fn test_pending_deadline_ms_tolerates_legacy_and_garbage() {
assert_eq!(pending_deadline_ms("status_post"), None);
assert_eq!(pending_deadline_ms("status_post:abc"), None);
assert_eq!(pending_deadline_ms(""), None);
// act 本身含 '-'/'_' 都不影响(按最后一个 ':' 切分)
assert_eq!(pending_deadline_ms("pow_fail_dc:1700000060000"), Some(1_700_000_060_000));
}
/// 连接级认证标记的键格式是**跨服务契约**:api-server 用同一个键名判断"是否存在已登录连接"。
#[test]
fn test_auth_marker_key_format_contract() {
assert_eq!(auth_marker_key("864720562557021"), "device:authed:864720562557021");
assert_eq!(AUTH_MARKER_TTL_SECS, 300, "与设备在线 TTL 一致");
}
/// 登录成功应答投递后,`Outbound.authenticated` 必须为 true ——
/// 调用方据此写连接级认证标记(api-server 的"可下发"判据)。
#[tokio::test]
async fn test_prepare_outbound_reports_authenticated_after_login_success() {
let pool = ConnectionPool::new();
let (c, _rx, _close_rx) = conn_harness(41);
assert_eq!(pool.register("imei-auth".to_string(), c).await, RegisterOutcome::Inserted);
// 登录成功应答(spec + suc=1)→ authenticated = true
let login_ok = r#"{"act":"login","msg_id":1,"suc":1,"spec":"0606","work_mode":"0"}"#;
let outbound = match pool.prepare_outbound("imei-auth", login_ok).await {
Ok(v) => v,
Err(e) => panic!("login 成功应答必须可投递: {}", e),
};
assert!(outbound.authenticated, "login 成功应答投递后必须是已认证态");
assert!(outbound.pending.is_none(), "应答不登记挂起项");
// 未认证连接上投递被拦 → 不会产生任何 Outbound(调用方也就不会写标记)
let (c2, _rx2, _close_rx2) = conn_harness(42);
assert_eq!(pool.register("imei-noauth".to_string(), c2).await, RegisterOutcome::Inserted);
assert!(
pool.prepare_outbound("imei-noauth", r#"{"act":"open","msg_id":1}"#).await.is_err(),
"未认证连接不得投递业务指令"
);
assert!(!pool.is_authenticated("imei-noauth", 42).await);
}
} }

View File

@ -6,6 +6,7 @@ use tokio::sync::mpsc;
use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::codec::{FramedRead, LinesCodec};
use super::connection::{ConnectionPool, DeviceConnection, RegisterOutcome, next_conn_id}; use super::connection::{ConnectionPool, DeviceConnection, RegisterOutcome, next_conn_id};
use super::connection::{clear_device_authed, refresh_device_authed};
use super::handler::handle_message; use super::handler::handle_message;
use super::protocol::DeviceMessage; use super::protocol::DeviceMessage;
@ -144,6 +145,10 @@ async fn handle_connection(
); );
current_dev_id = Some(dev_id.clone()); current_dev_id = Some(dev_id.clone());
update_device_route(&redis, dev_id, &node_id).await; update_device_route(&redis, dev_id, &node_id).await;
// 连接级认证标记必须**立即清除**:此刻持有该 IMEI 的是
// 一条**尚未认证**的新连接,api-server 不能再认为它"可下发"。
// 新连接 `login` 成功后标记会重新写入。
clear_device_authed(&redis, dev_id).await;
} }
RegisterOutcome::ReplacedAuthenticated { evicted_conn_id } => { RegisterOutcome::ReplacedAuthenticated { evicted_conn_id } => {
// **连接接管**:与 MQTT 同 clientId 语义一致(新连接赢)。 // **连接接管**:与 MQTT 同 clientId 语义一致(新连接赢)。
@ -162,6 +167,8 @@ async fn handle_connection(
); );
current_dev_id = Some(dev_id.clone()); current_dev_id = Some(dev_id.clone());
update_device_route(&redis, dev_id, &node_id).await; update_device_route(&redis, dev_id, &node_id).await;
// 同上:旧连接的认证态不能顺延给新连接(接管方须重新 login)
clear_device_authed(&redis, dev_id).await;
} }
} }
} }
@ -214,6 +221,15 @@ async fn handle_connection(
// 转发到 Redis 前由 handler 补写,api-server 依赖它识别设备)。 // 转发到 Redis 前由 handler 补写,api-server 依赖它识别设备)。
// 无返回值:本服务不直接回复设备,应答由 api-server 经 device:commands 下发。 // 无返回值:本服务不直接回复设备,应答由 api-server 经 device:commands 下发。
handle_message(msg, current_dev_id.as_deref(), &pool, &redis).await; handle_message(msg, current_dev_id.as_deref(), &pool, &redis).await;
// 连接级认证标记:已登录连接**每次上行**刷新 TTL(`device:authed:{imei}`),
// 供 api-server 判断"该 IMEI 是否存在已登录连接"(协议 §4.2)。
// 这里用 `EXPIRE`(只刷新已存在的键)—— 未登录连接不可能凭空造出该标记。
if let Some(ref dev_id) = current_dev_id
&& pool.is_authenticated(dev_id, conn_id).await
{
refresh_device_authed(&redis, dev_id).await;
}
} }
Some(Err(e)) => { Some(Err(e)) => {
tracing::warn!("[{}] 读取错误: {}", peer, e); tracing::warn!("[{}] 读取错误: {}", peer, e);
@ -293,6 +309,9 @@ async fn handle_connection(
if let Some(ref dev_id) = current_dev_id { if let Some(ref dev_id) = current_dev_id {
if pool.remove_if(dev_id, conn_id).await { if pool.remove_if(dev_id, conn_id).await {
tracing::info!("[断连] dev_id={} conn_id={} 池条目已清理", dev_id, conn_id); tracing::info!("[断连] dev_id={} conn_id={} 池条目已清理", dev_id, conn_id);
// 本连接是该 IMEI 的**当前持有者**且已断开 ⇒ 连接级认证标记必须立刻删除,
// 否则 api-server 在 TTL 窗口内仍会认为"设备在线可下发"(本标记要消灭的正是这类假在线)。
clear_device_authed(&redis, dev_id).await;
} else { } else {
tracing::info!( tracing::info!(
"[断连] dev_id={} conn_id={} 池条目已易主,保留当前持有者(不清理)", "[断连] dev_id={} conn_id={} 池条目已易主,保留当前持有者(不清理)",

View File

@ -16,7 +16,7 @@
<view><text class="info-id">{{ data.abstract_id }}</text></view> <view><text class="info-id">{{ data.abstract_id }}</text></view>
<view class="info-status-row"> <view class="info-status-row">
<view class="status-badge" :class="data.status === 1 ? 'online' : 'offline'"><text>{{ data.status === 1 ? '在线' : '离线' }}</text></view> <view class="status-badge" :class="data.status === 1 ? 'online' : 'offline'"><text>{{ data.status === 1 ? '在线' : '离线' }}</text></view>
<view class="signal" :class="signalClass(data.rssi)"><view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view></view> <view class="signal"><view v-for="(color, i) in rssiBarColors(data.rssi)" :key="i" class="signal-bar" :class="'l' + (i + 1)" :style="{ background: color }"></view></view>
<text class="rssi-val">{{ data.rssi != null ? data.rssi : '—' }}</text> <text class="rssi-val">{{ data.rssi != null ? data.rssi : '—' }}</text>
<view class="pow-tags"> <view class="pow-tags">
<!-- 三态:停电(红)/ 正常(绿)/ 未知(灰)—— 无数据绝不能显示成"正常" --> <!-- 三态:停电(红)/ 正常(绿)/ 未知(灰)—— 无数据绝不能显示成"正常" -->
@ -115,6 +115,7 @@ import {
powerFlagOf, powerFlagOf,
cabinetDoorTag, cabinetDoorTag,
cabinetStatusTags, cabinetStatusTags,
rssiBarColors,
} from '../../utils/deviceStatus' } from '../../utils/deviceStatus'
const statusBarHeight = ref(47) const statusBarHeight = ref(47)
@ -170,12 +171,9 @@ async function fetchDetail(id: number) {
finally { loading.value = false } finally { loading.value = false }
} }
function signalClass(rssi?: number): string { // RSSI 分档/配色/格数统一走 `utils/deviceStatus.ts::rssiBarColors()`(与 PC 端同源,5 档)。
if (!rssi || rssi < 30) return 'off' // ⚠️ 历史实现是本文件里的 `signalClass()`:只有 4 档(无 ≥70)、30–39 用红色、
if (rssi >= 55) return 'good' // 且恒渲染 5 格只改颜色 —— 2026-09 已收敛掉,不要再在页面里另写一套阈值。
if (rssi >= 40) return 'mid'
return 'low'
}
/** /**
* 通道状态 → 卡片配色 class * 通道状态 → 卡片配色 class
* *
@ -253,7 +251,8 @@ function openLocation() {
.signal { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; } .signal { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; }
.signal-bar { width: 2.5px; border-radius: 1px; } .signal-bar { width: 2.5px; border-radius: 1px; }
.signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 14px; } .signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 14px; }
.signal.good .signal-bar { background: #22c55e; } .signal.mid .signal-bar { background: #f59e0b; } .signal.off .signal-bar { background: #94a3b8; } /* 信号格颜色由 `rssiBarColors()` 内联给出(与 PC 端同源);
⚠️ 不要再在此加 .signal.good/.mid/.off 颜色规则 —— 会出现两套口径。 */
.rssi-val { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; } .rssi-val { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; }
.pow-tags { display: flex; gap: 4px; } .pow-tags { display: flex; gap: 4px; }
.pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; white-space: nowrap; } .pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; white-space: nowrap; }

View File

@ -45,8 +45,15 @@
<view class="header-right"> <view class="header-right">
<text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text> <text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text>
<view class="signal-inline"> <view class="signal-inline">
<view class="signal" :class="signalClass(cab.rssi)"> <!-- 信号格:格数与颜色都由 `rssiBarColors()` 决定(与 PC 端同源,5 档;格数随档位变化) -->
<view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view> <view class="signal">
<view
v-for="(color, i) in rssiBarColors(cab.rssi)"
:key="i"
class="signal-bar"
:class="'l' + (i + 1)"
:style="{ background: color }"
></view>
</view> </view>
<text class="rssi-value">{{ cab.rssi ?? '—' }}</text> <text class="rssi-value">{{ cab.rssi ?? '—' }}</text>
</view> </view>
@ -93,7 +100,7 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api' import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api'
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { formatCelsius, powerFlagOf, cabinetDoorState } from '../../utils/deviceStatus' import { formatCelsius, powerFlagOf, cabinetDoorState, rssiBarColors } from '../../utils/deviceStatus'
const statusBarHeight = ref(47) const statusBarHeight = ref(47)
const navBarHeight = ref(44) const navBarHeight = ref(44)
@ -111,12 +118,9 @@ setTimeout(calcNav, 600)
const authStore = useAuthStore() const authStore = useAuthStore()
function signalClass(rssi?: number): string { // RSSI 分档/配色/格数统一走 `utils/deviceStatus.ts::rssiBarColors()`(与 PC 端同源,5 档)。
if (!rssi || rssi < 30) return 'off' // ⚠️ 历史实现是本文件里的 `signalClass()`:只有 4 档(无 ≥70)、30–39 用红色、
if (rssi >= 55) return 'good' // 且恒渲染 5 格只改颜色 —— 2026-09 已收敛掉,不要再在页面里另写一套阈值。
if (rssi >= 40) return 'mid'
return 'low'
}
const stats = ref<DashboardData>({ online_cabinets: 0, total_channels: 0, charging_count: 0, idle_channels: 0, full_count: 0, fault_channels: 0, fault_count: 0, today_charge_count: 0, today_energy_kwh: 0, today_charge_hours: 0, alerts: [], projects: [] }) const stats = ref<DashboardData>({ online_cabinets: 0, total_channels: 0, charging_count: 0, idle_channels: 0, full_count: 0, fault_channels: 0, fault_count: 0, today_charge_count: 0, today_energy_kwh: 0, today_charge_hours: 0, alerts: [], projects: [] })
const projects = computed(() => stats.value.projects || []) const projects = computed(() => stats.value.projects || [])
@ -268,9 +272,10 @@ function showScanInput() {
.header-right { display: flex; align-items: center; gap: 8px; } .header-right { display: flex; align-items: center; gap: 8px; }
.signal-inline { display: flex; align-items: center; gap: 3px; } .signal-inline { display: flex; align-items: center; gap: 3px; }
.signal { display: flex; align-items: flex-end; gap: 1.5px; } .signal { display: flex; align-items: flex-end; gap: 1.5px; }
/* 信号格:颜色由 `rssiBarColors()` 内联给出(与 PC 端同源),这里只定义尺寸与间距。
⚠️ 不要在此再加 .signal.good/.mid/.low/.off 的颜色规则 —— 会出现两套口径。 */
.signal-bar { width: 3px; border-radius: 1px; } .signal-bar { width: 3px; border-radius: 1px; }
.signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 16px; } .signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 16px; }
.signal.good .signal-bar { background: #16a34a; } .signal.mid .signal-bar { background: #f59e0b; } .signal.low .signal-bar { background: #ef4444; } .signal.off .signal-bar { background: #94a3b8; }
.rssi-value { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; } .rssi-value { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; }
.pow-tags { display: flex; gap: 4px; } .pow-tags { display: flex; gap: 4px; }
.pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; } .pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; }

View File

@ -309,3 +309,91 @@ export function formatCelsius(temp?: number | null): string {
const celsius = kelvinX100ToCelsius(temp) const celsius = kelvinX100ToCelsius(temp)
return celsius === null ? '—' : `${celsius.toFixed(1)}°C` return celsius === null ? '—' : `${celsius.toFixed(1)}°C`
} }
// ---------------------------------------------------------------------------
// RSSI 信号强度(**两端同源同名:改一边必须同步另一边**)
//
// 口径:协议定稿 —— `rssi` 是 **0–99 的信号等级**(**不是 dBm**)。
//
// ⚠️ 2026-09 **收敛为统一 5 档**(此前小程序只有 4 档、**没有 ≥70**、30–39 用**红色**,
// 且**恒渲染 5 格只改颜色**;PC 是 5 档且格数随档位增删 —— 两端不一致,现已统一)。
// 现在**两端共用本节的阈值与颜色**,且**格数都随档位变化**。
//
// 🔗 与 PC 端 `web/src/utils/deviceStatus.ts` 的同名实现**逐字一致**;
// 一致性用例见 `test/rssi_signal.test.mjs`(遍历 0–99 逐值断言两端的 `level`/`bars`/`color` 相同)。
// ---------------------------------------------------------------------------
/** 档位名(仅供用例断言与调试,**不要**当 UI 文案用) */
export type RssiKey = 'none' | 'weak' | 'fair' | 'good' | 'strong'
/** RSSI 分档结果(两端契约) */
export interface RssiSignal {
/** 档位:`0` 无信号/离线,`1` 最弱 … `4` 最强(共 **5 档**) */
level: 0 | 1 | 2 | 3 | 4
/** 应显示的信号格数(**统一 5 格制**:`level 0` → 1 格底格,`level 4` → 5 格满格) */
bars: number
/** 颜色(两端共用同一值) */
color: string
/** 档位名 */
key: RssiKey
}
/**
* RSSI 分档表(**唯一权威,两端必须逐字一致**)
*
* | 阈值 | level | 颜色 | 5 格制格数 |
* |---|---|---|---|
* | ≥ 70 | 4 strong | `#16a34a` 绿 | 5 |
* | ≥ 55 | 3 good | `#2563eb` 蓝 | 4 |
* | ≥ 40 | 2 fair | `#f59e0b` 黄 | 3 |
* | ≥ 30 | 1 weak | `#f97316` 橙 | 2 |
* | < 30 / null | 0 none | `#9ca3af` 灰 | 1 |
*/
export const RSSI_TIERS: ReadonlyArray<{
min: number
level: 1 | 2 | 3 | 4
bars: number
color: string
key: RssiKey
}> = [
{ min: 70, level: 4, bars: 5, color: '#16a34a', key: 'strong' },
{ min: 55, level: 3, bars: 4, color: '#2563eb', key: 'good' },
{ min: 40, level: 2, bars: 3, color: '#f59e0b', key: 'fair' },
{ min: 30, level: 1, bars: 2, color: '#f97316', key: 'weak' },
]
/** 无信号档(`< 30`、`null`、`undefined`、非数字) */
export const RSSI_NONE: RssiSignal = { level: 0, bars: 1, color: '#9ca3af', key: 'none' }
/** 统一 5 格制:一格底格 + 最多 4 格递增 */
export const RSSI_MAX_BARS = 5
/**
* RSSI → 档位/颜色/格数(**两端唯一权威入口**)
*
* 无数据(`null`/`undefined`/非有限数)与"信号过弱(< 30)"**都落到 `level 0`** ——
* 两者视觉上都是灰色底格("离线"语义另由 `status` / `online` 字段表达)。
*/
export function rssiSignal(rssi?: number | null): RssiSignal {
if (rssi === null || rssi === undefined) return { ...RSSI_NONE }
const value = Number(rssi)
if (!Number.isFinite(value)) return { ...RSSI_NONE }
for (const tier of RSSI_TIERS) {
if (value >= tier.min) {
return { level: tier.level, bars: tier.bars, color: tier.color, key: tier.key }
}
}
return { ...RSSI_NONE }
}
/**
* 供模板 `v-for` 直接消费:长度为 `bars` 的**颜色数组**(每格同色)。
*
* 为什么返回数组:模板里声明不了局部变量,逐格调 `rssiSignal` 会重复求值;
* 返回数组后 `v-for="(c, i) in rssiBarColors(x)"` 一次成型,
* 且**格数真的随档位变化**(不再恒渲染 5 格)。
*/
export function rssiBarColors(rssi?: number | null): string[] {
const signal = rssiSignal(rssi)
return Array.from({ length: signal.bars }, () => signal.color)
}

View File

@ -30,7 +30,7 @@ import QRCode from 'qrcode';
import { cabinetApi } from '@/api/devices'; import { cabinetApi } from '@/api/devices';
import type { Cabinet } from '@/api/devices'; import type { Cabinet } from '@/api/devices';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { cabinetDoorState, formatTempK100, normalizeFlag } from '@/utils/deviceStatus'; import { cabinetDoorState, formatTempK100, normalizeFlag, rssiSignal } from '@/utils/deviceStatus';
interface CabinetGridProps { interface CabinetGridProps {
cabinets: Cabinet[]; cabinets: Cabinet[];
@ -86,25 +86,25 @@ function QRCodeModal({ abstractId, onDownload }: { abstractId: string; onDownloa
); );
} }
/** 信号图标 */ /**
* 信号图标
*
* 分档阈值与颜色**统一取自** `@/utils/deviceStatus` 的 `rssiSignal()`(与小程序端同源同名,5 档)。
* 图标只有 4 条 `<path>`(第 5 格是底部圆点 `M2 20h.01`),故按 `level` 决定亮几条:
* `level 0` → 全灭只剩圆点;`level 4` → 4 条全亮。
*/
function SignalIconComponent({ rssi }: { rssi?: number }) { function SignalIconComponent({ rssi }: { rssi?: number }) {
const color = !rssi || rssi < 30 ? '#9ca3af' const signal = rssiSignal(rssi);
: rssi >= 70 ? '#16a34a' /** 4 条 `<path>`,顺序即档位顺序(第 i 条对应 `level > i`) */
: rssi >= 55 ? '#2563eb' const paths = [
: rssi >= 40 ? '#f59e0b' { key: 30, d: 'M7 20v-4' },
: '#f97316'; { key: 40, d: 'M12 20v-8' },
const bars = !rssi || rssi < 30 ? [] as number[] { key: 55, d: 'M17 20V8' },
: rssi >= 70 ? [30, 40, 55, 70] { key: 70, d: 'M22 4v16' },
: rssi >= 55 ? [30, 40, 55] ];
: rssi >= 40 ? [30, 40]
: rssi >= 30 ? [30]
: [];
return ( return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke={signal.color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{bars.includes(70) && <path d="M22 4v16"/>} {paths.map((p, i) => (signal.level > i ? <path key={p.key} d={p.d} /> : null))}
{bars.includes(55) && <path d="M17 20V8"/>}
{bars.includes(40) && <path d="M12 20v-8"/>}
{bars.includes(30) && <path d="M7 20v-4"/>}
<path d="M2 20h.01"/> <path d="M2 20h.01"/>
</svg> </svg>
); );
@ -344,11 +344,7 @@ export default function CabinetGrid({ cabinets, loading, onRefresh }: CabinetGri
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16, width: '100%' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16, width: '100%' }}>
{cabinets.map((cab) => { {cabinets.map((cab) => {
const signalColor = !cab.rssi || cab.rssi < 30 ? '#9ca3af' const signalColor = rssiSignal(cab.rssi).color;
: cab.rssi >= 70 ? '#16a34a'
: cab.rssi >= 55 ? '#2563eb'
: cab.rssi >= 40 ? '#f59e0b'
: '#f97316';
return ( return (
<Card <Card
key={cab.id} key={cab.id}

View File

@ -257,3 +257,98 @@ export function formatTempK100(value?: number | null, placeholder = '-'): string
const celsius = value / 100 - 273.15; const celsius = value / 100 - 273.15;
return `${celsius.toFixed(1)}°C`; return `${celsius.toFixed(1)}°C`;
} }
// ---------------------------------------------------------------------------
// RSSI 信号强度(**两端同源同名:改一边必须同步另一边**)
//
// 口径:协议定稿 —— `rssi` 是 **0–99 的信号等级**(**不是 dBm**)。
//
// ⚠️ 2026-09 **收敛为统一 5 档**。收敛前的真实差异(已消除,勿再引入):
// - PC 是 5 档(≥70 绿 / ≥55 蓝 / ≥40 黄 / ≥30 橙 / <30 灰);
// - 小程序只有 4 档(**没有 ≥70**)、30–39 用**红色**、且**恒渲染 5 格只改颜色**(格数不变)。
// 现在**两端共用本节的阈值与颜色**,且**格数都随档位变化**。
//
// 🔗 小程序端同名实现在 `h5-uniapp/src/utils/deviceStatus.ts`;一致性用例见
// `test/rssi_signal.test.mjs`(遍历 0–99 逐值断言两端的 `level`/`bars`/`color` 完全相同)。
// ---------------------------------------------------------------------------
/** 档位名(仅供用例断言与调试,**不要**当 UI 文案用) */
export type RssiKey = 'none' | 'weak' | 'fair' | 'good' | 'strong';
/** RSSI 分档结果(两端契约) */
export interface RssiSignal {
/** 档位:`0` 无信号/离线,`1` 最弱 … `4` 最强(共 **5 档**) */
level: 0 | 1 | 2 | 3 | 4;
/**
* 应显示的信号格数(**统一 5 格制**):
* `level 0` → 1 格(底格);`level 4` → 5 格(满格)
*
* PC 的图标只有 4 条 `<path>`(第 5 格是底部圆点),因此 PC 按 `level` 决定亮几条,
* 视觉总格数与 `bars` 一致;小程序直接用 `bars` 渲染。
*/
bars: number;
/** 颜色(两端共用同一值) */
color: string;
/** 档位名 */
key: RssiKey;
}
/**
* RSSI 分档表(**唯一权威,两端必须逐字一致**)
*
* | 阈值 | level | 颜色 | 5 格制格数 |
* |---|---|---|---|
* | ≥ 70 | 4 strong | `#16a34a` 绿 | 5 |
* | ≥ 55 | 3 good | `#2563eb` 蓝 | 4 |
* | ≥ 40 | 2 fair | `#f59e0b` 黄 | 3 |
* | ≥ 30 | 1 weak | `#f97316` 橙 | 2 |
* | < 30 / null | 0 none | `#9ca3af` 灰 | 1 |
*/
export const RSSI_TIERS: ReadonlyArray<{
min: number;
level: 1 | 2 | 3 | 4;
bars: number;
color: string;
key: RssiKey;
}> = [
{ min: 70, level: 4, bars: 5, color: '#16a34a', key: 'strong' },
{ min: 55, level: 3, bars: 4, color: '#2563eb', key: 'good' },
{ min: 40, level: 2, bars: 3, color: '#f59e0b', key: 'fair' },
{ min: 30, level: 1, bars: 2, color: '#f97316', key: 'weak' },
];
/** 无信号档(`< 30`、`null`、`undefined`、非数字) */
export const RSSI_NONE: RssiSignal = { level: 0, bars: 1, color: '#9ca3af', key: 'none' };
/** 统一 5 格制:一格底格 + 最多 4 格递增 */
export const RSSI_MAX_BARS = 5;
/**
* RSSI → 档位/颜色/格数(**两端唯一权威入口**)
*
* 无数据(`null`/`undefined`/非有限数)与"信号过弱(< 30)"**都落到 `level 0`** ——
* 两者视觉上都是灰色底格(PC 端另有"离线"语义由 `online` 字段表达)。
*/
export function rssiSignal(rssi?: number | null): RssiSignal {
if (rssi === null || rssi === undefined) return { ...RSSI_NONE };
const value = Number(rssi);
if (!Number.isFinite(value)) return { ...RSSI_NONE };
for (const tier of RSSI_TIERS) {
if (value >= tier.min) {
return { level: tier.level, bars: tier.bars, color: tier.color, key: tier.key };
}
}
return { ...RSSI_NONE };
}
/**
* 供模板 `v-for` 直接消费:长度为 `bars` 的**颜色数组**(每格同色)。
*
* 为什么返回数组而不是让模板逐格调 `rssiSignal`:小程序模板里声明不了局部变量,
* 逐格调用会重复求值;返回数组后一次成型,且**格数真的随档位变化**
* (这正是本次要修掉的"恒渲染 5 格只改颜色")。
*/
export function rssiBarColors(rssi?: number | null): string[] {
const signal = rssiSignal(rssi);
return Array.from({ length: signal.bars }, () => signal.color);
}

194
test/rssi_signal.test.mjs Normal file
View File

@ -0,0 +1,194 @@
/**
* RSSI 信号强度分档(协议定点:`rssi` 是 **0–99 的信号等级**,非 dBm)
*
* 覆盖**两端**(PC `software/web/src/utils/deviceStatus.ts` 与
* 小程序 `software/h5-uniapp/src/utils/deviceStatus.ts`)的 `rssiSignal` / `rssiBarColors`,
* 并断言**两端口径完全一致**(同一输入 → 同一 `level` / `bars` / `color`)。
*
* 为什么需要它:2026-09 之前两端**本来就不一致**——
* - PC 是 5 档(≥70 绿 / ≥55 蓝 / ≥40 黄 / ≥30 橙 / <30 灰),格数随档位增删;
* - 小程序只有 4 档(**没有 ≥70**)、30–39 用**红色**、且**恒渲染 5 格只改颜色**。
* 本次收敛为统一 5 档后,用本用例把"两端一致"钉死:**只改一端就会红**。
*
* 另外断言**档位边界**与**颜色表**(防止有人"统一"成另一套阈值却没人发现)。
*
* 运行:node test/rssi_signal.test.mjs
*/
import assert from 'node:assert/strict'
import * as web from '../software/web/src/utils/deviceStatus.ts'
import * as mp from '../software/h5-uniapp/src/utils/deviceStatus.ts'
/** 极简用例收集器(保持仓库零依赖) */
const cases = []
function test(name, fn) {
cases.push([name, fn])
}
// ---------------------------------------------------------------------------
// 权威分档表(写死在这里 = 契约;与两个实现文件里的 RSSI_TIERS 必须一致)
// ---------------------------------------------------------------------------
const EXPECTED_TIERS = [
{ min: 70, level: 4, bars: 5, color: '#16a34a', key: 'strong' },
{ min: 55, level: 3, bars: 4, color: '#2563eb', key: 'good' },
{ min: 40, level: 2, bars: 3, color: '#f59e0b', key: 'fair' },
{ min: 30, level: 1, bars: 2, color: '#f97316', key: 'weak' },
]
const EXPECTED_NONE = { level: 0, bars: 1, color: '#9ca3af', key: 'none' }
/**
* 只取契约字段:`rssiSignal()` 返回 `{level,bars,color,key}`,
* **不含** `RSSI_TIERS` 里的 `min`(阈值是实现细节,不进返回值)。
*/
function contract({ level, bars, color, key }) {
return { level, bars, color, key }
}
/** 权威分档表的契约形态(供边界断言直接用) */
const T = EXPECTED_TIERS.map(contract)
/** 该值按权威契约应落在哪一档 */
function expectedFor(rssi) {
if (rssi === null || rssi === undefined) return EXPECTED_NONE
const value = Number(rssi)
if (!Number.isFinite(value)) return EXPECTED_NONE
for (const tier of EXPECTED_TIERS) {
if (value >= tier.min) return contract(tier)
}
return EXPECTED_NONE
}
// ---------------------------------------------------------------------------
// 1. 两端一致性:遍历 0–99(协议全量程)逐值断言
// ---------------------------------------------------------------------------
test('test_web_and_mp_rssi_signal_agree_over_0_99', () => {
for (let rssi = 0; rssi <= 99; rssi++) {
assert.deepEqual(
web.rssiSignal(rssi),
mp.rssiSignal(rssi),
`rssi=${rssi} 两端 rssiSignal 不一致`,
)
assert.deepEqual(
web.rssiBarColors(rssi),
mp.rssiBarColors(rssi),
`rssi=${rssi} 两端 rssiBarColors 不一致`,
)
}
})
test('test_web_and_mp_rssi_agree_on_missing_and_boundary_values', () => {
const values = [null, undefined, Number.NaN, Number.POSITIVE_INFINITY, -1, 29, 30, 39, 40, 54, 55, 69, 70, 99, 100, 255]
for (const value of values) {
assert.deepEqual(web.rssiSignal(value), mp.rssiSignal(value), `rssi=${String(value)} 两端不一致`)
assert.deepEqual(
web.rssiBarColors(value),
mp.rssiBarColors(value),
`rssi=${String(value)} 两端 rssiBarColors 不一致`,
)
}
})
test('test_web_and_mp_rssi_tier_tables_are_identical', () => {
assert.deepEqual(web.RSSI_TIERS, mp.RSSI_TIERS, '两端 RSSI_TIERS 必须逐字一致')
assert.deepEqual(web.RSSI_NONE, mp.RSSI_NONE, '两端 RSSI_NONE 必须一致')
assert.equal(web.RSSI_MAX_BARS, mp.RSSI_MAX_BARS, '两端 RSSI_MAX_BARS 必须一致')
assert.equal(web.RSSI_MAX_BARS, 5, '统一 5 格制')
})
// ---------------------------------------------------------------------------
// 2. 契约本身:档位边界 + 颜色表(防止"统一"成另一套阈值而无人发现)
// ---------------------------------------------------------------------------
test('test_rssi_tier_boundaries_match_agreed_contract', () => {
const boundaries = [
[-1, EXPECTED_NONE],
[0, EXPECTED_NONE],
[29, EXPECTED_NONE],
[30, T[3]],
[39, T[3]],
[40, T[2]],
[54, T[2]],
[55, T[1]],
[69, T[1]],
[70, T[0]],
[99, T[0]],
]
for (const [rssi, expected] of boundaries) {
assert.deepEqual(web.rssiSignal(rssi), expected, `rssi=${rssi} 档位不符契约`)
}
// 无数据 / 非数字一律落"无信号"档(灰色底格),不得猜测成"信号好"
for (const bad of [null, undefined, Number.NaN]) {
assert.deepEqual(web.rssiSignal(bad), EXPECTED_NONE, `rssi=${String(bad)} 必须落无信号档`)
}
})
test('test_rssi_colors_match_agreed_contract', () => {
// 收敛后的配色:绿 → 蓝 → 黄 → 橙 → 灰(**注意 ≥55 是蓝、30–39 是橙而非红**)
assert.deepEqual(
[99, 60, 45, 33, 10].map((v) => web.rssiSignal(v).color),
['#16a34a', '#2563eb', '#f59e0b', '#f97316', '#9ca3af'],
)
})
test('test_rssi_bars_increase_with_level_and_stay_within_max', () => {
let prev = 0
// 逐档递增,且 1..5 全部落在 5 格制内
for (const rssi of [10, 30, 40, 55, 70]) {
const { bars } = web.rssiSignal(rssi)
assert.ok(bars > prev, `rssi=${rssi} 的格数应比上一档多(bars=${bars})`)
assert.ok(bars >= 1 && bars <= web.RSSI_MAX_BARS, `rssi=${rssi} 格数越界:${bars}`)
prev = bars
}
// 满档 = 5 格;离线也是 1 格底格(不是 0 格 —— 0 格会让图标"消失")
assert.equal(web.rssiSignal(70).bars, 5)
assert.equal(web.rssiSignal(null).bars, 1)
})
test('test_rssi_bar_colors_length_equals_bars_and_single_color', () => {
for (let rssi = 0; rssi <= 99; rssi++) {
const signal = web.rssiSignal(rssi)
const colors = web.rssiBarColors(rssi)
assert.equal(colors.length, signal.bars, `rssi=${rssi} 颜色数组长度必须等于格数`)
assert.ok(colors.every((c) => c === signal.color), `rssi=${rssi} 每格必须同色`)
}
})
// ---------------------------------------------------------------------------
// 3. 反证:把"收敛前的两端不一致"作为反例 —— 若谁把某一端改回去,本组会红
// ---------------------------------------------------------------------------
test('test_mp_no_longer_uses_the_old_four_tier_red_30_39_scale', () => {
// 旧小程序口径:30–39 → 红;且没有 ≥70 档(55 就是最高)
// 收敛后:30–39 必须是**橙** `#f97316`,且 70 必须是**独立的一档**(绿,5 格)
assert.equal(mp.rssiSignal(35).color, '#f97316', '小程序 30–39 必须是橙色(旧实现是红 #ef4444)')
assert.notEqual(mp.rssiSignal(35).color, '#ef4444', '不得再出现旧的红色 30–39 档')
assert.notEqual(mp.rssiSignal(80).color, mp.rssiSignal(60).color, '≥70 必须是独立档(不与 ≥55 同色)')
assert.equal(mp.rssiSignal(80).bars, 5, '≥70 应为满格 5 格')
})
test('test_bars_are_not_constant_five_for_all_levels', () => {
// 收敛前的 bug:小程序恒渲染 5 格、只改颜色 ⇒ 所有档位 bars 都是 5。
// 现在格数必须随档位变化(否则本用例红)。
const barsSet = new Set([10, 30, 40, 55, 70].map((v) => mp.rssiSignal(v).bars))
assert.ok(barsSet.size >= 4, `格数必须随档位变化,实际集合:${[...barsSet].join(',')}`)
assert.notEqual(mp.rssiSignal(10).bars, mp.rssiSignal(70).bars, '离线档与满档格数必须不同')
})
// ---------------------------------------------------------------------------
// 运行
// ---------------------------------------------------------------------------
let failed = 0
for (const [name, fn] of cases) {
try {
fn()
console.log(`ok ${name}`)
} catch (err) {
failed += 1
console.error(`FAIL ${name}\n ${err.message}`)
}
}
console.log(`\n${cases.length - failed} passed, ${failed} failed (共 ${cases.length} 个用例)`)
process.exit(failed === 0 ? 0 : 1)