diff --git a/AGENTS.md b/AGENTS.md index 2f42753..7b4e293 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - 数据库:MySQL(阿里云RDS),开发/生产共用 - 缓存:Redis(阿里云Redis),开发/生产共用 - 设备通讯: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\ ├── tasks/ # 任务文件(opencode执行) ├── docs/ # 开发文档、协议文档 -├── deploy/ # 部署配置文件 -│ ├── nginx/ # Nginx配置 -│ ├── *.service # systemd服务单元 -│ └── deploy-*.sh # 部署脚本 +├── deploy/ # 部署配置与脚本 +│ ├── nginx/ # Nginx 配置镜像(**三份**:nginx.conf 主配置 + app/test 两份 conf.d,均与线上逐字节一致) +│ ├── *.service # systemd 单元(pms-api / pms-device × prod·test 共 4 个) +│ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口) +│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」) ├── tools/ # 开发工具 -│ └── db.py # 数据库工具 +│ ├── db.py # 数据库工具(默认连测试库 pms_dev) +│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL) ├── software/ -│ ├── api-server/ # Rust API服务 -│ ├── device-server/ # Rust TCP设备服务 +│ ├── api-server/ # Rust API服务(产物 pms-api) +│ ├── device-server/ # Rust TCP设备服务(产物 pms-device-server) │ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin) │ └── web/ # React前端(WEB管理端) ├── 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 技能副本。 @@ -163,8 +165,11 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret --yes-p ``` > ⚠️ **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`** +> (实测依据:`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`"的偏离状态, > 所以**这些表的 `updated_at` 从"不会自动刷新"变成"随 `UPDATE` 自动刷新"**, > 看到 `updated_at` 变动属预期行为。同批变更还把两库全部时间列统一成 `DATETIME` @@ -196,7 +201,7 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret --yes-p - 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md) - 用户手工将任务交给 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 --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行,命名语义化,完整注释 3. 所有外部IO/网络请求异常捕获,禁止裸panic 4. 分支逻辑全覆盖,不遗漏兜底分支 5. 常量抽离,不使用废弃API 6. Rust:内存安全,合理管理所有权,禁用unsafe无合理理由 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 +python test/schema_alignment_e2e.py --env prod --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. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束) 3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号) 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` 兼容) + +- 本仓 Rust 侧时间列全是 `NaiveDateTime` / `Option`;`TIMESTAMP` 列一碰就 500: + `mismatched types; Rust type Option (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 编码规范 @@ -386,7 +512,7 @@ src/ |------|------|------| | 单元测试 | Rust `#[test]` / Vitest | 核心业务逻辑必须覆盖 | | 集成测试 | 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 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret - 密码存储: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)豁免 - 敏感操作(删除/禁用/开门)需二次确认 - **TCP 连接登录门控(上行 + 下行,同一规则)**:同一条 TCP 连接在 `login` 成功前**只允许 `auth_str` / `login`**; @@ -569,6 +695,18 @@ python tools/db.py exec "UPDATE users SET ..." # 写入 ### 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`): ```nginx server { @@ -708,27 +846,97 @@ npm run build:mp-weixin ``` **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 # 传输后端二进制(测试环境) 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/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/web/ # 正式环境 ``` -> ⚠️ 后端二进制必须用 `cargo zigbuild --release --target x86_64-unknown-linux-gnu` 交叉编译,服务器 2核/3.5G 会 OOM,禁止在服务器上编译 Rust。 -> -> ⚠️ **`pscp`/`scp` 上传后必须 `chmod +x`**(否则 systemd 报 `203/EXEC`); -> 且**运行中的二进制不可直接覆盖**(Linux 报 `ETXTBSY`)—— 正确姿势: -> 传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。 -> -> ⚠️ **`.env` 不支持行内注释**:`PORT=3000 # 注释` 会让配置解析失败、服务起不来,注释必须独占一行。 +> 服务器实测(2026-09,`ls -la` + `ss -lntp`): +> `/data/pms/api-server/pms-api`(:9000)、`/data/pms/device-server/pms-device-server`(:9001)、 +> `/data/pms_dev/api-server/pms-api`(:3000)、`/data/pms_dev/device-server/device-server`(:3001)。 + +#### 部署注意事项(最容易踩的坑,逐条都栽过) + +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 "" "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 "" "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=_zone:10m rate=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/ { + limit_req zone=_zone burst=N nodelay; + proxy_pass http://127.0.0.1:/api/; + ... + } + ``` + ⛔ **`limit_req` 引用的 zone 必须在主配置里存在**,否则 `nginx -t` 报 + `unknown limit_req_zone "_zone"` 而拒绝启动。 + 3. **先 `nginx -t`,通过后才 reload**:`nginx -t && systemctl reload nginx`。 + ⛔ 不要跳过 `-t` 直接 `restart` —— 配置错会让**整站**(含生产域)起不来。 + 4. 把线上改动同步回 `deploy/nginx/` 的对应文件(见第 10 条),并在报告里注明。 **2.1 微信小程序上传** @@ -863,78 +1071,172 @@ tail -f /var/log/nginx/error.log organization(组织/企业) ├── project(项目/放置地点) │ └── cabinet(充电柜) -│ ├── cabin_board(仓控板×N) -│ │ └── compartment(仓体×6) -│ ├── charge_record(充电记录) -│ └── energy_stat(能耗统计) -├── user(用户) -└── device_log(设备日志) +│ ├── cabin_board(仓控板×N) board_id 1–15 +│ │ └── compartment(仓体×6) channel_id 1–8 +│ ├── charge_order(充电订单,进行中/已结算;active_flag 唯一键防并发) +│ ├── charge_record(充电记录,结算后留痕) +│ ├── energy_stat(能耗统计,按 柜×日 唯一) +│ └── 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 | 组织表 | -| projects | 项目表 | -| cabinets | 充电柜表(IMEI、abstract_id、auth_str) | -| cabin_boards | 仓控板表 | -| compartments | 仓体表 | -| users | 用户表(手机号登录) | -| charge_records | 充电记录表 | -| device_logs | 设备日志表 | -| energy_stats | 能耗统计表 | -| operation_logs | 操作日志表 | +| `organizations` | 组织/企业(`parent_id` 支持父组织,NULL=顶级) | +| `projects` | 项目/放置地点(隶属组织) | +| `cabinets` | 充电柜(`imei` 唯一、`abstract_id`、`auth_str`+`auth_str_issued`、`cell`、`work_mode`、`status`、经纬度、`address`) | +| `cabin_boards` | 仓控板(`board_id` 1–15) | +| `compartments` | 仓体/通道(`channel_id` 1–8;`status` **只表示占用** 0 空闲/1 充电中,充满与故障不落库) | +| `batteries` | 电池台账(`battery_id` 唯一、SOC‰、厂家/型号/材料代码原值、额定电压容量、状态) | +| `charge_orders` | 充电订单(进行中/已结算;`reason` normal/user_stop/forced/timeout;`active_flag` 唯一键防并发建单) | +| `charge_records` | 充电记录(结算后留痕;`energy` 单位 **Wh**、`max_temperature` K×100) | +| `device_logs` | 设备日志(`log_type` 1 状态上报 / 2 故障 / 3 告警 / 4 停电) | +| `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 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 -├─ online = "1" # 在线标记(TTL 300秒) -├─ rssi = "85" # 信号强度 -├─ pow_fail_dc = "0/1" # 直流停电(12V掉电) -├─ pow_fail_ac = "0/1" # 交流停电(380V掉电) -└─ device:{imei}:board:{board_idx} # 板级 Hash(仓控板1~N) - ├─ status_hex = "000100020003000400050006" # 原始hex状态 - └─ device:{imei}:board:{board_idx}:ch:{0-5} # 通道级 Hash(6仓) - ├─ on = "0/1" # 充电中 - ├─ full = "0/1" # 充满 - ├─ fault = "0/1" # 故障 - └─ hex = "00010002" # 原始hex +device:{imei} # 设备级 Hash(TTL 300 秒,写它的每一条路径都会 EXPIRE 300) +├─ online = "1" # 在线标记(login 成功 / status_post 均会置) +├─ rssi = "85" # 信号强度(协议口径 0–99 等级,非 dBm) +├─ temp = "29800" # 柜温 K×100(status_post 的 cabinet.temp) +├─ meter = "123456" # 电表读数(status_post 的 cabinet.meter) +├─ cabinet_status1 = "01" # 柜级状态位 byte0(2 位 hex,**取不到不写**) +├─ cabinet_status2 = "00" # 柜级状态位 byte1(2 位 hex,**取不到不写**) +├─ pow_fail_dc = "0/1" # 直流停电(接触器分闸) +├─ pow_fail_ac = "0/1" # 交流停电(柜控板自身断电) +└─ node_id = "node-1" # 该设备 TCP 连接所在节点(device-server 写,用于多节点路由) + +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) -- 充电中(charging) -- 充满(full) -- 故障(fault) +| 事件 | 写入内容 | +|------|----------| +| `login`(验签通过) | `device:{imei}`:`online=1` + `EXPIRE 300`;另写 `node_id`(device-server 侧) | +| `status_post` | `device:{imei}`:`online/rssi/temp/meter`(+`cabinet_status1/2`) + `EXPIRE 300`;
逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;
`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);
并累加 `...:order` 的电量 | +| `pow_fail_dc` | `device:{imei}.pow_fail_dc = 1` | +| `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 接口:** -| 接口 | 说明 | 查询方式 | -|------|------|----------| -| `GET /api/cabinets/filter` | 设备列表(设备级状态) | Pipeline 批量 HGET,1次往返 | -| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | KEYS 扫描 + HMGET | +**通道状态位(`batt_status` / `pow_status`)—— 按协议 §3.8 位定义,不按"五态枚举":** + +`batt_status`(byte0):bit0 充满|bit1 正在充电|bit2–3 保留(不展示)|bit4 过欠压|bit5 过流| +bit6 热失控|**bit7 有电池**(决定"空仓/在位")。 +`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 = 键不存在(等价于只返回存在的键)。
✅ **2026-09 已修**:旧实现用 `KEYS device:{imei}:board:*:ch:*`,在**与既有充电站平台共用**的 Redis 实例上会阻塞单线程(实测每次请求 `cmdstat_keys` +1),已彻底移除 | + +> ⛔ **本项目禁止在任何请求路径上用 `KEYS`** —— Redis 实例是共用的,`KEYS` 会阻塞单线程遍历整个 +> keyspace。要枚举就用「按 DB 拓扑拼 key」或 `SCAN` 游标。查是否有人用了: +> `grep -rn '"KEYS"' software/*/src/`。 ## TCP通讯协议 详见 **[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#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。 @@ -989,53 +1291,62 @@ const BASE_URL = '/api/pms' ### 信号强度显示(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) | 信号格 | 颜色 | -|-----------|--------------|--------|------| -| ≥ 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 灰色(离线) | +**统一分档表(协议口径:`rssi` 是 0–99 的信号等级,不是 dBm)** -**实现方式(小程序):** +| 阈值 | `level` | 颜色 | 5 格制格数 | PC 图标亮的 `` | +|------|---------|------|-----------|---------------------| +| ≥ 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 - - - - - - - -``` +**两端如何消费同一份纯函数:** +- **PC**(`web/src/pages/devices/CabinetGrid.tsx::SignalIconComponent`):`rssiSignal(rssi)` 取 `color` 与 `level`, + 4 条 `` 按 `level > i` 决定亮几条;卡片头部数值颜色用同一个 `color`。 +- **小程序**(`home/index.vue`、`device-detail/index.vue`): + `v-for="(color, i) in rssiBarColors(cab.rssi)"` 渲染 `bars` 根 ``, + 颜色**内联** `:style="{ background: color }"`;**格数真的随档位变化**。 +- 颜色只在 TS 里定义一次(不再有 `.signal.good/.mid/.low/.off` 这类 CSS 颜色规则; + 页面 CSS 只负责尺寸/间距)。 -- `v-if` 按 RSSI 阈值动态显示信号格 -- `` 不支持 `display: flex`,统一用 `` 包裹 - -**实现方式(PC 管理端):** - -React 组件 `SignalIcon`,同上逻辑用条件渲染 ``。 - -**后端 RSSI 获取:** - -设备列表接口通过 Redis pipeline 批量 HGET 查询 `device:{imei}` 的 `rssi` 字段: -``` -HSET device:{imei} online 1 rssi 85 -``` +**后端(两端共用):** 设备列表接口用 pipeline 批量 `HGET device:{imei} rssi` +(`routes/cabinet_tree.rs::attach_realtime_status`,字段下标见 `DEVICE_LIST_FIELDS`); +小程序侧 `routes/h5.rs::get_cabinets` 同样带 Redis。 **相关文件:** -- PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件 -- 小程序: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板 -- 小程序 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline +- 纯函数(权威): `software/web/src/utils/deviceStatus.ts`、`software/h5-uniapp/src/utils/deviceStatus.ts` +- PC 消费点: `software/web/src/pages/devices/CabinetGrid.tsx`(`SignalIconComponent` + 卡片头部) +- 小程序消费点: `software/h5-uniapp/src/pages/home/index.vue`、`pages/device-detail/index.vue` - 类型: `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 改动 -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. **通道统计**:图标+数字一行排列,图标用 `` 内联 Lucide(不加容器,flex 居中) 3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐 @@ -1044,8 +1355,13 @@ HSET device:{imei} online 1 rssi 85 > 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。 1. **柜级状态位 → 显示在"柜子"层面** - - 数据:`cabinet.status1` / `cabinet.status2`(2 位 hex,**设备上报原样落 Redis** - `device:{imei}.cabinet_status1/2`,接口原样透出为 `status1` / `status2`)。 + - 数据:**⚠️ 这两个名字不是 DB 列**(实测 `cabinets` 表**没有** `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 柜门打开**| bit5 加热风扇|bit6 接触器断开|bit7 喷淋;`status2` bit0 声光报警|**bit1 柜门打开超时** (阈值硬件定义,**平台只呈现、不自行判定**);`status2` bit2–7 为保留位,**不展示**。 diff --git a/README.md b/README.md index 0abf522..d1c582a 100644 --- a/README.md +++ b/README.md @@ -20,54 +20,77 @@ ``` charging-cabinet/ ├── 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/ -│ ├── api-server/ # Rust API服务 -│ ├── device-server/ # Rust TCP设备服务 +│ ├── api-server/ # Rust API服务(产物 pms-api) +│ ├── device-server/ # Rust TCP设备服务(产物 pms-device-server) │ ├── web/ # React管理端前端 │ └── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin) ├── 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。 ## 部署架构 -| 环境 | 域名 | 说明 | -|------|------|------| -| 正式环境 | app.anzhizhichong.com | 后台管理 + API服务 | -| 测试环境 | test.anzhizhichong.com | 测试环境 | +| 环境 | 域名 | API 服务 | TCP 服务 | MySQL | Redis DB | 部署路径 | 前端路径 | +|------|------|----------|----------|-------|----------|----------|----------| +| 正式环境 | app.anzhizhichong.com | `:9000` | `:9001` | `pms` | db5 | `/data/pms` | `/pms/` | +| 测试环境 | 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/ - 文档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) — 主服务 │ -│ - HTTP API(后台管理 + 小程序)端口: 3000 │ -│ - 前端轮询刷新(每5秒) │ -│ - 业务逻辑处理(验证/存DB/告警) │ -│ - 节点管理(设备服务注册/心跳) │ -│ - 后台协程处理Redis队列 │ +│ API服务 (api-server / pms-api) — 主服务 │ +│ - HTTP API(后台管理 + 小程序)端口: 正式 9000 / 测试 3000 │ +│ - 业务逻辑处理(验签/存DB/建单结单/告警/能耗) │ +│ - 节点管理(设备服务注册/心跳) │ +│ - 后台 Worker:上报消费(RPOP 200ms 轮询) / 回执 / 订单超时 / │ +│ 日志清理 / 占用对账 │ └─────────────────────────────────────────────────────────────┘ - ↑ Redis LIST ↑ Redis LIST - │ device:report:{imei} │ device:cmd:{imei} - │ (设备上报) │ (平台指令) - │ │ + ↑ Redis LIST device:reports ↑ Redis LIST device:commands + │ (设备上报,device-server LPUSH) │ (平台指令,api-server LPUSH) + │ │ device-server 按 HGET device:{imei} node_id + │ │ 路由到 device:commands:{node_id} ┌─────────────────────────────────────────────────────────────┐ -│ 设备服务 (device-server) — 轻量化 │ -│ - TCP监听: 3002(设备长连接) │ -│ - 只处理通信和指令解析 │ -│ - 不存DB、不验证签名、不处理业务 │ -│ - 注册到API服务(启动/心跳/注销) │ -│ - Redis连接注册: device:{imei} → {node_id, ip, port} │ +│ 设备服务 (device-server / pms-device-server) — 轻量化 │ +│ - TCP监听: 正式 9001 / 测试 3001(设备长连接) │ +│ - 只处理通信和指令解析(归属/签名/业务全在 api-server) │ +│ - 不存DB、不验证签名、不处理业务 │ +│ - 节点注册/心跳:Redis `node:{node_id}`(含 ip/tcp_port/ │ +│ status/last_heartbeat),TTL = DEVICE_REGISTER_TTL │ +│ - 设备路由:`device:{imei}` 的 `node_id` 字段 │ └─────────────────────────────────────────────────────────────┘ ``` @@ -78,21 +101,57 @@ charging-cabinet/ 4. **协程处理任务** — API服务用tokio::spawn处理Redis队列消息 **通信流程:** -- **设备上报**:设备 → TCP → 设备服务 → Redis LIST → API服务协程处理 -- **平台指令**:API服务 → Redis LIST → 设备服务 → TCP → 设备 -- **指令响应**:设备 → TCP → 设备服务 → Redis LIST → API服务匹配msg_id +- **设备上报**:设备 → TCP → 设备服务(`LPUSH device:reports`,带 `LTRIM` 上限)→ API服务 worker 消费 +- **平台指令**:API服务(`LPUSH device:commands`)→ 设备服务按 `node_id` 路由 → TCP → 设备 +- **指令响应**:设备 → 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服务 -- 每60秒心跳,180秒无心跳视为离线 -- 支持多节点扩展(当前单节点) +- 设备服务启动时注册到 Redis `node:{node_id}`(并调用 API 的注册接口) +- 每 **60 秒**心跳(`HEARTBEAT_INTERVAL=60`),**180 秒**无心跳视为离线(`DEVICE_REGISTER_TTL=180`, + 线上 `.env` 显式设置;代码默认值是 300,仅在没有该环境变量时生效) +- 支持多节点扩展(当前单节点 `node-1`) ## TCP 设备通讯协议 > TCP 设备通讯协议见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。 > 全局整数单位(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执行,异步通知 -- 质量约束模板固定追加到每个任务文件 -- 完成代码后执行对应栈静态校验(Rust→cargo check,TS→tsc --noEmit),零报错零警告 +## 开发任务与质量约束 + +- 任务需求写到 `tasks/` 目录的md文件(编号递增) +- 通过 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 + +# 两端一致性(PC ↔ 小程序) +node test/device_status_parse.test.mjs +node test/rssi_signal.test.mjs +``` + +完整验收流程(含自证能失败的要求)见 [AGENTS.md](AGENTS.md#验收流程)。 diff --git a/deploy/nginx/app.anzhizhichong.com.conf b/deploy/nginx/app.anzhizhichong.com.conf index 42be291..f18bcf6 100644 --- a/deploy/nginx/app.anzhizhichong.com.conf +++ b/deploy/nginx/app.anzhizhichong.com.conf @@ -56,6 +56,20 @@ server { 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/ { proxy_pass http://127.0.0.1:9000/api/; proxy_set_header Host $host; diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..b0a15ef --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -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; + } +} diff --git a/deploy/nginx/test.anzhizhichong.com.conf b/deploy/nginx/test.anzhizhichong.com.conf index c2e7bda..87b2426 100644 --- a/deploy/nginx/test.anzhizhichong.com.conf +++ b/deploy/nginx/test.anzhizhichong.com.conf @@ -45,6 +45,20 @@ server { 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/ { proxy_pass http://127.0.0.1:3000/api/; proxy_set_header Host $host; diff --git a/docs/API.md b/docs/API.md index 9b260f3..81241c8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -22,7 +22,11 @@ > 或**"免认证"**(公开路由),不臆造权限码。 > > ⚠️ `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/devices/{dev_id}/command` | 下发设备指令 | `device:operate` | | GET | `/api/devices/stats` | 设备服务节点统计 | `device:view` | -| POST | `/api/nodes/register` | 设备服务节点注册(device-server 调用) | 仅需登录 | -| POST | `/api/nodes/{node_id}/heartbeat` | 节点心跳 | 仅需登录 | -| POST | `/api/nodes/{node_id}/deregister` | 节点注销 | 仅需登录 | -| GET | `/api/nodes` | 节点列表 | 仅需登录 | +| POST | `/api/nodes/register` | 节点注册(**device-server 不调用**,见下) | **仅总管理员**(`role_level >= 2`) | +| POST | `/api/nodes/{node_id}/heartbeat` | 节点心跳 | **仅总管理员** | +| POST | `/api/nodes/{node_id}/deregister` | 节点注销(**会真删注册表条目**) | **仅总管理员** | +| GET | `/api/nodes` | 节点列表(**含内网 `ip:tcp_port`**) | **仅总管理员** | | GET / POST | `/api/roles` | 角色列表 / 创建角色 | GET 仅需登录;POST `role:create` | | PUT / DELETE | `/api/roles/{id}` | 更新 / 删除角色 | `role:edit` / `role:delete` | | PUT | `/api/roles/{id}/permissions` | 角色赋权 | `role:edit` | @@ -89,7 +93,7 @@ | GET | `/api/dashboard` | 小程序首页仪表盘 | 仅需登录(内部按角色做组织隔离) | | GET | `/api/my-organizations` | 小程序「选择组织」:当前用户**可见**的组织列表(工厂全量 / 客户仅本组织) | 仅需登录(不校验 `org:view`,内部按 `role_level` + `users.organization_id` 隔离) | | 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/stop` | 停止充电 | `charge:stop` | | POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` | @@ -126,7 +130,13 @@ | `GET /api/energy-stats/projects` | `[...]` 数组 | ⚠️ `data` 直接是数组,无分页包装 | | `GET /api/energy-stats/trend` | `{granularity,list}` | ⚠️ 无 `total` / `page` / `page_size` | | `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/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}` / > `{"nodes":[...],"count":N}` / `{"node_count":N}`,**不带** `{code,data,message}` 外层 —— 它们面向 @@ -140,12 +150,16 @@ | `today_energy` / `month_energy` | 能耗概览 | **Wh**(整数) | `/ 1000` → kWh | | `max_temperature` | 充电记录 | **K×100**(整数) | `/ 100 - 273.15` → °C | | `soc` / `start_soc` / `end_soc` | 电池台账 / 充电订单 / 通道状态 | **‰**(整数,0–10000) | `/ 10` → % | -| `voltage` / `cell_voltages[]` | 仓体详情 / BMS | **mV** | `/ 1000` → V | -| `current` | 仓体详情 / BMS | **mA** | `/ 1000` → A | +| `voltage` | 仓体详情 / 通道状态 | **mV** | `/ 1000` → V | +| `current` | 仓体详情 / 通道状态 | **mA** | `/ 1000` → A | +| `temp` | 仓体详情 / 设备列表 / 设备详情(柜温与电芯最高温同口径) | **K×100** | `/ 100 - 273.15` → °C | +| `meter` | 设备级 Redis(协议 `cabinet.meter`,未在 HTTP 响应透出) | **Wh**(电表读数) | `/ 1000` → kWh | | `rssi` | 设备列表 / 通道状态 | **0–99 信号等级**(不是 dBm) | 直接显示 | | `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 设备列表,支持 `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 { "id": 1, "abstract_id": "10-00000001", "name": "太湖新城换电柜A", - "status": 1, + "imei": "864720562557021", + "iccid": "8986012520983901841", + "status": 0, "project_id": 1, - "rssi": 85, + "address": "无锡市新吴区…", + "latitude": "31.550000", + "longitude": "120.360000", "channel_count": 36, "idle_count": 30, "charging_count": 6, "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} -扫码入口,免认证。通过二维码中的抽象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 { "code": 0, "data": { "id": 1, "abstract_id": "10-00000001", - "imei": "864720562557021", - "iccid": "8986012520983901841", "name": "太湖新城换电柜A", - "status": 1, + "status": 0, "cabin_boards": [ { "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} -仓体/通道详情,含实时 BMS 数据和充电记录。 +仓体/通道详情,含通道实时状态与充电记录(`routes/h5.rs::get_compartment_detail`)。 **响应字段:** -- `voltage` — mV(整数) -- `current` — mA(整数) -- `energy` — Wh(整数) -- `soc` — ‰(0-10000,前端已自动转为 %) -- `cell_voltages` — mV 数组 + +```json +{ + "code": 0, + "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 @@ -424,6 +562,7 @@ | `GET /api/charge-records`、`GET /api/charge-records/export` | `charge_records.cabinet_id` → 项目 | | `GET /api/charge-orders` | `charge_orders.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/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`) | @@ -469,8 +608,8 @@ "end_time": "2026-07-05 12:00:00", "energy": 1200, "max_temperature": 30435, - "status": 1, - "status_text": "正常结束" + "status": 0, + "status_text": "正常" } ], "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)。 `start_time` / `end_time` 为字符串时间,非数值单位。 @@ -497,7 +639,22 @@ "data": { "total": 2, "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" @@ -505,6 +662,74 @@ ``` > ⚠️ 前端解析时不要套用 `{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 @@ -515,12 +740,53 @@ **响应:** `{ "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 禁用 / 启用柜子。权限 `device:edit` + 组织归属校验,**且仅总管理员(`role_level=2`)可执行**, 否则 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 @@ -545,6 +811,13 @@ 充电订单列表。订单记录一次充电事件,含电池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, "end_soc": 950, "energy": 1200, - "reason": null, - "reason_text": "-", + "reason": "normal", + "reason_text": "正常充满", "status": 1, - "status_text": "正常结束", + "status_text": "已完成", "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` 的筛选下拉提供 ``、 +> `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` 显示 %)。 +`end_soc` 可为 `null`(订单未结束或收尾时取不到末帧 SOC)。 ### GET /api/batteries -电池台账列表。记录每块电池的插入/拔出事件和当前状态。 +电池台账列表(`batteries` 表)。**每块电池一行**,记录 `battery_id` / 当前电量 / 状态; +插入 / 拔出事件本身在 `charge_orders`(订单)里体现,本表**不记录**事件流水。 **查询参数:** @@ -618,14 +921,12 @@ { "id": 1, "battery_id": "BAT001", + "soc": 4500, "brand": "星恒", "model": "XH-48V", - "voltage": 52000, - "soc": 4500, + "sn": "SN123456", "status": 1, - "cabinet_id": 1, - "compartment_id": 1, - "last_seen": "2026-07-05 12:00:00", + "status_text": "使用中", "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`,那是按"百分比"写的**错误示例**,实现返回的是千分比。 +> ⚠️ **本接口没有 `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 能耗概览,按组织数据隔离返回今日/本月能耗数据。 @@ -660,8 +1003,14 @@ } ``` -- `today_energy` / `month_energy` — Wh(整数) -- `estimated_cost` — 预估费用(float,元),按 `COST_PER_WH` 单价计算 +- `today_energy` / `month_energy` — **Wh**(整数)。数据源是**实时聚合 `charge_records`** + (按 `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/{node_id}/heartbeat` | POST | 仅需登录 | `{"suc":1}`;未注册 `{"suc":0,"err":"节点未注册"}` | -| `/api/nodes/{node_id}/deregister` | POST | 仅需登录 | `{"suc":1}` | -| `/api/nodes` | GET | 仅需登录 | `{"nodes":[...],"count":N}` | +| `/api/nodes/register` | POST | **仅总管理员** | `{"suc":1}` | +| `/api/nodes/{node_id}/heartbeat` | POST | **仅总管理员** | `{"suc":1}`;未注册 `{"suc":0,"err":"节点未注册"}` | +| `/api/nodes/{node_id}/deregister` | POST | **仅总管理员** | `{"suc":1}` | +| `/api/nodes` | GET | **仅总管理员** | `{"nodes":[{node_id,ip,tcp_port}],"count":N}` | | `/api/devices/stats` | GET | `device:view` | `{"node_count":N}` | | `/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}` 标准外层(见上表"内部接口例外")。 --- @@ -715,13 +1074,15 @@ Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/ | 字段 | 类型 | 单位 | 前端显示 | 转换公式 | |------|------|------|----------|---------| | `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` | | `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) | | `cost` / `estimated_cost` | i64/f64 | **元** | ¥ | 不转换(后端业务计算) | | `today_charge_count` / `today_charge_hours` | i64 | 计数值 | 直接显示 | 不转换 | +| `cell_voltages[]` / `cell_temps[]` | — | ⚠️ **恒为空数组**(协议未上报单体明细,无写入方) | — | — | ¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。 @@ -736,8 +1097,12 @@ Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/ | `850` | SOC(‰) | `85.0%` | | `29865` | 温度(K×100) | `25.5°C` | | `85` | RSSI 信号等级(0–99) | `85` | -| `[32456,32500,32300]` | 单体电压(mV) | `32.456V, 32.500V, 32.300V` | -| `[29865,29900]` | 单体温度(K×100) | `25.5°C, 25.9°C` | +| `125500` | 电表读数(Wh) | `125.5 kWh` | +| `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 实时状态存储 -### 层级结构 +> **这一节是平台内部实现契约**(不属于 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 -├─ online = "1" # 在线标记(TTL 300秒) -├─ rssi = "85" # 信号强度 -├─ pow_fail_dc = "0/1" # 直流停电 -├─ pow_fail_ac = "0/1" # 交流停电 -└─ device:{imei}:board:{board_idx} # 板级 Hash - ├─ status_hex = "00010002..." # 原始hex状态 -└─ device:{imei}:board:{board_idx}:ch:{ch} # 通道级 Hash(1~6) - ├─ on = "0/1" # 充电中 - ├─ full = "0/1" # 充满 - ├─ fault = "0/1" # 故障 - ├─ hex = "0001" # 原始hex(4字符) - ├─ battery_id = "BAT001" # 电池ID(bat_in 写入,bat_out 清除) - ├─ soc = "4500" # 电量 ‰ - ├─ voltage = "52000" # 电压 mV - ├─ current = "10000" # 电流 mA - ├─ temp = "29865" # 温度 K×100 - ├─ brand = "星恒" # BMS品牌 - ├─ model = "XH-48V" # BMS型号 - └─ sn = "SN123456" # BMS序列号 +device:{imei} # 设备级 Hash,TTL 300s(= 在线判定窗口) +├─ online = "1" # 标记在线(任意上行都会刷新 TTL) +├─ rssi = "85" # 0–99 信号等级(不是 dBm) +├─ temp = "29800" # 柜温 K×100 +├─ meter = "125500" # 电表读数 Wh(125.5 kWh) +├─ node_id = "node-1" # 设备连接所在节点(**device-server 写**,用于多节点指令路由) +├─ cabinet_status1 = "10" # 柜级状态位1,2 位小写 hex(取不到 → 不写字段) +├─ cabinet_status2 = "00" # 柜级状态位2,2 位小写 hex(取不到 → 不写字段) +├─ pow_fail_dc = "0"/"1" # 直流停电(pow_fail_dc 指令置 1;status1 bit0=0 时清 0) +└─ pow_fail_ac = "0"/"1" # 交流停电(同上) + +device:{imei}:board:{板号}:ch:{通道号} # 通道级 Hash(板号/通道号**十进制不补零**,写入时不设 TTL) +├─ batt_status = "82" # 通道 18 字符 hex 的 byte0,2 位小写 hex +├─ pow_status = "02" # 通道 18 字符 hex 的 byte1(与电池状态相互独立) +├─ soc = "4500" # ‰ +├─ voltage = "54600" # mV +├─ current = "0" # mA +└─ temp = "29800" # 电芯最高温 K×100 + +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}`)。 -| 字段 | 单位 | 精度 | 说明 | -|------|------|------|------| -| `soc` | ‰ | 0.1% | 4500 = 45.00% | -| `voltage` | mV | 1 | 52000 = 52.000V | -| `current` | mA | 1 | 10000 = 10.000A | -| `energy` | Wh | 1 | 累计充电量 | -| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C | -| `battery_id` | — | — | 电池唯一标识 | -| `brand` | — | — | BMS品牌 | -| `model` | — | — | BMS型号 | -| `sn` | — | — | BMS序列号 | -| `cell_voltages` | mV | 1 | 单体电芯电压数组 | +### 协议 `bat_in` 的 `battery` 块(**不是**旧文档的"BMS 字段表") + +协议 §5.5 的 `battery` 块 = **电池属性 + `info`(18 字符 hex)+ `cells`(电芯明细 hex)**: + +| 字段 | 落库 / 落 Redis | 说明 | +|------|----------------|------| +| `battery_id` | `batteries.battery_id`、`:bat.battery_id` | 电池唯一标识,**可能为空串**(BMS 读不到序列号,不影响充电) | +| `info` | `:bat.info`(原样 hex) | **恰好 18 个 hex 字符**,布局与 `status_post.channels[]` 元素**完全相同**(§5.3) | +| `cells` | ❌ **不存** | 电芯明细整段 hex,长度 `cell_count × 4 + 16`;平台**当前不消费、不落库** | +| `brand_code` / `model_code` / `material_code` | `batteries` + `:bat` | 原值透传(`brand` / `model` 名称列待代码表) | +| `cell_count` / `rated_voltage` / `rated_capacity` | `batteries` + `:bat` | 节数 / 额定电压 mV / 额定容量 mAh | + +> `: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),不影响同板其它通道。 --- diff --git a/docs/柜控板固件对接文档.md b/docs/柜控板固件对接文档.md index 7a8a165..202225b 100644 --- a/docs/柜控板固件对接文档.md +++ b/docs/柜控板固件对接文档.md @@ -2,7 +2,7 @@ | 项目 | 内容 | |------|------| -| 文档版本 | **v1.1**(2026-09-14) | +| 文档版本 | **v1.1.1**(2026-09-14 定稿,2026-09-20 文档修订)
**修订只改文档自身的错误 / 矛盾 / 缺失说明,协议行为(`act`、字段名、位定义、签名、`id` 规则、上报节奏、超时语义)一字未动**(见附 B 变更记录 ㊳) | | 交付对象 | 柜控板固件开发工程师 | | 接口定义方 | 平台侧(本接口由平台方定义,固件按本文实现) | | 协议性质 | 已冻结:报文格式、字段名、`act` 取值、签名算法一经确认不再变更 | @@ -15,6 +15,9 @@ > **除下表 7 项外,本文其余内容均已定稿。** 确认前固件可按"现状"(即本文当前写法)实现; > 我们拿到答复后会同步更新本文并通知。**这 7 项都不阻塞 4G 层开工。** +> +> 📌 **其中第 13 / 17 / 18 项的「平台侧行为」已经落地(不依赖硬件答复)**,第 14 / 15 项平台侧也已按"原值透传 / 只取 SOC"实现 —— +> 逐项状态见本表之后的「**这 7 项的平台侧落地情况**」。**硬件侧只需按"现状"发报文即可,不必等我们改平台。** | # | 事项 | 现状(本文当前写法) | 需硬件确认 | 影响范围 | |---|------|---------------------|-----------|----------| @@ -34,6 +37,18 @@ > **关于第 14 项**:「代码 → 名称」的**映射责任已定稿** —— **4G 层按字符串原值透传(不补零、不转义、不改写),映射在平台侧完成**(见第 10 节第 7 条)。 > 所以第 14 项**只缺"取值内容"**,不改变 `brand_code` / `model_code` / `material_code` 的字段格式,**不阻塞 4G 层实现**。 +### 这 7 项的平台侧落地情况(给硬件侧的输入:**哪些已经不用等**) + +| # | 事项 | 平台侧现状 | 还需要硬件确认什么 | +|---|------|-----------|-------------------| +| 13 | `energy` 字段 | ✅ **已落地**(兜底方案已生效):平台按 `电压 × 电流 × 时间`(固定 60 秒)积分算每单电量 | 只问"硬件是否有该数据源";**有**则平台改为直接取硬件值,**没有**则现状即可,**固件两种情况下都不用改报文** | +| 14 | 代码表取值内容 | ✅ **原值透传 / 入库已落地**:`brand_code` / `model_code` / `material_code` 原值存电池台账;⏳ **中文名映射未落地**(`batteries.brand` / `model` 列为空,待取值表确认后回填) | 只缺**取值内容**(及"型号/材料是否写反"、几位几进制);**字段格式与报文结构不变**;⚠️ 交付时**不要**理解为"平台已能显示中文名" | +| 15 | 电芯数据字节数 | ✅ **平台侧无影响**:平台只从 `info` 取 SOC,**不解析 `cells` 的内容与长度**(整段原样留痕) | 该问题只影响**固件侧**的解析与缓冲分配;平台不依赖 48 / 52 的结论 | +| 16 | 仓控板离线如何上报 | ⏳ **仍未定,且平台侧确实没有实现**:平台目前**没有**任何"仓控板离线 / 仓控板故障"的判定与告警(`status_post` 里也没有可承载它的字段) | 仍需硬件答复"能否提供在线/离线信息"**以及"板离线时是省略该板还是照报全 0 的板"**(见 §5.6 的误判风险) | +| 17 | 是否保留 `pow_fail_dc` | ✅ **两条路径平台都已实现**:① `pow_fail_dc` 事件指令(收到即置停电状态)② `status_post` 的 `status1` bit0(bit0=0 时平台**清除**停电状态) | 仍需硬件确认"**接触器分闸时 `status1` bit0 是否置 1**",以决定要不要取消这条 `act`;**在答复前请照现状发** `pow_fail_dc` | +| 18 | 未充满强制拔出能否检测 | ✅ **已落地**(兜底已生效):`batt_status` bit7 **连续 2 次为 0** → 平台判定"电池已被取走",**提前异常结单**(`forced`);另有 10 小时订单超时双保险 | 仍需硬件答复"能否检测、检测到能否保证先发 `off` 再发 `bat_out`";**能**则订单更准,**不能**则靠上述兜底 | +| 19 | 停电恢复后如何兜底 | ⏳ **仍未定,平台侧按"以当前状态为准"运行**:恢复后设备重连 `login` + 发全量 `status_post`,平台据此刷新状态;`status1` bit0=0 时清停电标记;遗留订单靠 **10 小时超时**兜底(**不补报停电期间的事件**) | 仍需硬件确认恢复后是否补报;**若硬件选择"不补报",平台侧现状即为最终方案,固件无需额外动作** | + --- ## 1. 接入信息 @@ -73,19 +88,25 @@ | 已有安全码(重启、断线重连) | **`login`** | — | - **首次开机必须先 `auth_str`**:拿到安全码并保存后,才能发 `login`。**本地没有安全码时无法计算 `sign`,因此固件不可能、也不应该发送 `login`。** -- 因此平台对 `login` **只做签名比对**:与会话中保存的安全码一致 → 成功;不一致 → `invalid signature`。**平台无法判断设备本地是否持有安全码,也不需要判断。** +- 因此平台对 `login` **只做签名比对**:与**平台侧保存的该设备安全码**一致 → 成功;不一致 → `invalid signature`。**平台无法判断设备本地是否持有安全码,也不需要判断。** + (该安全码由平台**持久保存**,不随 TCP 连接重建或平台服务重启失效,见 5.1 / 5.2。) - 安全码**必须持久保存、掉电不丢**。 - 平台在收到 `auth_str` / `login` 时把 TCP 连接与 `dev_id` 绑定,并且**只认这条连接上出现的第一个 `dev_id`**。一条 TCP 连接只服务一个 `dev_id`。 #### ⛔ 登录门控:除 `auth_str` / `login` 外,其余指令必须先 `login` 成功 -| 连接状态 | 允许的 `act` | 其余 `act` 的处理 | -|---|---|---| -| **未登录**(连接刚建立,尚未 `login` 成功) | **只有 `auth_str`、`login`** | **平台一律丢弃,且不回任何应答** | -| **已登录**(收到 `suc=1` 且带 `spec` 的 `login` 应答后) | 全部 `act` | 正常处理 | +| 连接状态 | 允许的 `act`(**上行**:设备 → 平台) | 上行其余 `act` 的处理 | **下行**(平台 → 设备) | +|---|---|---|---| +| **未登录**(连接刚建立,尚未 `login` 成功) | **只有 `auth_str`、`login`** | **平台一律丢弃,且不回任何应答** | **只投递 `auth_str` / `login` 自己的应答**;其余报文(`on`/`off`/`open`/`pow_on`/`pow_off`/`reset` 等指令,以及任何其它应答)**一律不投递** → **设备收不到任何字节** | +| **已登录**(收到 `suc=1` 且带 `spec` 的 `login` 应答后) | 全部 `act` | 正常处理 | 全部报文正常投递 | - 未登录连接**尚未与 `dev_id` 建立可信绑定**,平台无法路由应答,所以被丢弃的报文**不会收到任何回应**(固件表现为"发了没反应")。 +- ⛔ **门控是双向的同一条规则 —— 「上行一律丢弃、下行一律不投递」**(设备侧收不到任何字节),不是两份清单: + - **上行**:未登录时除 `auth_str` / `login` 外的报文被丢弃(上表右二列); + - **下行**:未登录时平台**不会向该连接发送任何报文** —— 唯一例外是 `auth_str` / `login` **自己的应答**(否则首次配网就死锁了)。 + - 因此固件可以这样理解:**`login` 成功之前,这条 TCP 上除握手报文外不会有任何字节**;业务指令(含 `on` / `off`)也不会提前到达。 - ⚠️ **未登录的 TCP 连接平台不予保持**:连接建立后 **30 秒内**未完成 `login` 成功,平台会**主动断开**。固件应在建连后**立即**发 `auth_str` / `login`,不要空闲等待。 +- ⚠️ **同一个 IMEI 只保留一条连接**:若同一 `dev_id` 又建了一条新连接(例如固件异常重连、而旧连接在平台侧还没被感知断开),平台**让新连接接管、并主动关闭旧连接**;**旧连接上不会收到任何通知报文**(设备侧表现为读到 EOF / 连接被关)。因此固件必须保证**同一时刻只维持一条连接**:一旦发现连接被关闭就应重连(并重新 `login`),**不要在旧连接上继续发报文**(平台对它的上行一律不再处理)。 - 设备侧表现:连上后马上 `auth_str`/`login` → 收到 `suc=1` → 才发 `status_post` 等业务报文。 #### `login` 失败的分类处理(含自愈,必须实现) @@ -150,7 +171,7 @@ ### 2.3 保活 —— 由 `status_post` 承担(无独立心跳包) -- **本协议不设独立的心跳 / PING 包**。**`status_post` 即保活报文**:平台对每条上行都会回 `{"act":"status_post","msg_id":N,"suc":1}` 作为存活确认。 +- **本协议不设独立的心跳 / PING 包**。**`status_post` 即保活报文**:平台对每条**请求类**上行(`auth_str` / `login` / `status_post` / `pow_fail_dc` / `pow_fail_ac` / `bat_in` / `bat_out` / 设备自发的 `on` / `off`)都会回一条 `{"act":"<同请求>","msg_id":N,"suc":1}` 作为存活确认(**回执类上行平台不回包**,见 3.5 / 4.1)。 - 平台以「**300 秒内是否收到过该设备的任意报文**」判定在线 / 离线(依据 Redis `device:{imei}` 的 TTL)。 - 上报节奏:**固定每 60 秒一次 `status_post`,每次全量上报**(**不区分空闲 / 充电状态,不做增量**——每次携带全部仓控板及其全部通道的状态)。该间隔远小于 300 秒的离线判定阈值,同时保证 4G 网络的 NAT 映射不被回收。 - 设备**不得**自行增加自定义心跳指令:`act` 取值是本协议的一部分,平台只识别清单内的 `act`,其余一律回 `{"suc":0,"err":"unknown act"}`。 @@ -180,7 +201,8 @@ | `battery` | object | `bat_in`/`bat_out`/`off` | 电池数据块,见 5.5 / 5.6 / 5.7 | | `iccid` | string | `login`(可选) | SIM 卡 ICCID | -> 平台会**忽略未定义的字段**;固件解析下行报文时也**必须忽略未知字段**(平台应答中会额外附带 `id`)。 +> 平台会**忽略未定义的字段**;固件解析下行报文时也**必须忽略未知字段**。 +> ⚠️ **平台的应答不带 `id`** —— `id` 只出现在**平台下发的指令**与**设备上行报文**里(见 3.7 / 5.8 / 8.3)。 #### 📏 数值口径的统一规则(全文唯一口径,务必按此实现) @@ -213,7 +235,7 @@ | `invalid signature` | **`login` 失败的唯一应答**(平台刻意不区分:安全码不一致 / 时间戳超窗 / 无记录) | 重连 → 先 `auth_str`;若返回 `already issued` 则停止重试并**检查本地时钟** | | `auth_str already issued` | 安全码已下发过(未重置),拒绝重复申领 | **停止重试**,用本地已有的码 `login`,等管理员重置 | | `rate limit exceeded` | `auth_str` 申领超过 5 次 / 5 分钟 | 退避后重试 | -| `invalid id` | `id` 格式非法(仓体级段数 ≠ 2 等) | 修正 `id` 格式后重发;**不要**重试同一错误报文 | +| `invalid id` | `id` 目标非法,两种情况平台都回这个错:**① 格式不对**(仓体级段数 ≠ 2、段内非数字、超出量程 —— 仓控板非 1–15 / 通道非 1–8);**② 格式合法但该 `id` 在平台上查不到**(该柜未配置这块仓控板 / 这个通道) | 先核对 `id` 格式(`仓控板-通道`,如 `"01-3"`);格式确实有误 → 修正后重发;格式无误(仍收到 `invalid id`)→ 属平台侧该柜的通道配置问题,**经运维渠道反馈**,**不要**重试同一错误报文 | > 设备侧回执报错时(如 `open` 执行失败),`err` 也请用**英文短语**,例如 `door lock not engaged`。 @@ -276,7 +298,7 @@ **固件侧的隐含义务:60 秒内必须回执** -平台对每条下发的指令只等 60 秒。固件收到指令后须在 **60 秒内**完成执行并回执,否则平台判超时,该次操作在管理端显示为"已下发·超时未确认"。 +平台对每条下发的指令只等 60 秒。固件收到指令后须在 **60 秒内**完成执行并回执,否则平台判超时,该次操作在管理端标记为"未确认"(⚠️ **该管理端状态待实现** —— 平台侧目前只在服务端记 WARN,尚未在管理端界面展示这个状态;这不影响固件的回执义务)。 **超时 ≠ 未执行** @@ -379,9 +401,9 @@ | 4 | `pow_fail_dc` / `pow_fail_ac` | 停电上报(**两条独立指令**:直流 / 交流,见 5.4) | 否 | ✅ | | 5 | `bat_in` | 电池插入(**属性 + 动态首帧 + 电芯数据**) | **是** | ✅ | | 6 | `bat_out` | 电池拔出(末帧动态) | **是** | ✅ | -| 7 | `on` | 开始充电 —— **两种形态**:① 不带 `suc` = **自动充电工作模式**下设备自发上报(**需应答**)② 带 `suc` = 对下行 `on` 的回执(不回包) | 回带原 `id` | 视形态 | -| 8 | `off` | **两种形态**:① 不带 `suc` = 充满自动停止(设备新请求,**需应答**)② 带 `suc` = 对下行 `off` 的回执(不回包) | 回带原 `id` | 视形态 | -| 9 | `open`/`pow_on`/`pow_off`/`reset` | 下行指令的**执行回执** | 回带原 `id` | ❌ 回执不回包(见 3.5) | +| 7 | `on` | 开始充电 —— **两种形态**:① 不带 `suc` = **自动充电工作模式**下设备自发上报(**需应答**)② 带 `suc` = 对下行 `on` 的回执(不回包) | **上报形态:是**(仓体级);**回执形态:可选**(有则原样回带) | 视形态 | +| 8 | `off` | **两种形态**:① 不带 `suc` = 充满自动停止(设备新请求,**需应答**)② 带 `suc` = 对下行 `off` 的回执(不回包) | **上报形态:是**(仓体级);**回执形态:可选**(有则原样回带) | 视形态 | +| 9 | `open`/`pow_on`/`pow_off`/`reset` | 下行指令的**执行回执** | **可选**(有则原样回带;`open`/`pow_on`/`pow_off` 与整柜 `reset` **本就无 `id`**,见 3.7 / 5.8) | ❌ 回执不回包(见 3.5) | > 回执类上行(第 7、9 条,及第 8 条的 `suc` 形态)**平台一律不回包**,固件发出即结束,不要等待应答。 @@ -396,7 +418,9 @@ | `pow_off` | 接触器分闸 | 柜级 | **需已 `login` 成功** | | `reset` | 重启整柜 / 单仓控板 | 柜级(**不带 `id`**)/ `仓控板`(一段) | **需已 `login` 成功** | -> 未 `login` 成功前,平台**不会下发** `open`/`pow_on`/`pow_off`/`reset`(平台侧直接拦截,固件收不到任何报文)。 +> ⛔ **登录门控(双向同一条规则)**:未 `login` 成功前,平台**上行丢弃、下行不投递** —— +> 下表中的 `on` / `off` / `open` / `pow_on` / `pow_off` / `reset` **一条都不会到达设备**,设备**收不到任何字节**(唯一例外是 `auth_str` / `login` 自己的应答,见 2.2)。 +> 平台侧拦截,固件无需为此做任何处理。 > ⚠️ **还有一道「设备在线」前置**:平台侧要求该 IMEI 的设备节点**在线**(即 `device-server` 侧存在已登录的连接),否则**一律不下发**,管理端会提示「设备不在线」。离线时的表现同样是"固件收不到任何报文"。 @@ -488,13 +512,13 @@ - `timestamp` 为**秒**级(不是毫秒),与平台时差须在 **±60 秒**内。 - `iccid` 可选;上报后平台自动比对,SIM 卡更换会被记录。 -- 平台只有在收到 `suc=1` 且含 `spec` 的应答后,才把该连接视为**已登录**(敏感指令下发的前提)。 -- 平台对 `login` **只做签名比对**:与会话中保存的安全码一致 → 成功;否则 → `invalid signature`。平台不判断、也无法判断设备本地是否持有安全码。 +- 平台只有在收到 `suc=1` 且含 `spec` 的应答后,才把该连接视为**已登录**(**这是唯一判据**:`login` 成功前平台上行丢弃、下行不投递,见 2.2 / 10 第 10 条)。 +- 平台对 `login` **只做签名比对**:与**平台侧保存的该设备安全码**(持久保存,不随连接重建 / 平台服务重启失效)一致 → 成功;否则 → `invalid signature`。平台不判断、也无法判断设备本地是否持有安全码。 - **失败应答只有一种**: | 场景(平台内部逻辑,**不对外区分**) | `err` | 固件动作 | |------|-------|----------| -| 时间戳超出 ±60 秒 / 签名与平台会话中的安全码不一致 / 平台 DB 中没有该设备的安全码记录 | **一律 `invalid signature`** | 重连 → 先 `auth_str` 申领新码;若返回 `already issued` → 停止重试并**检查本地时钟** | +| 时间戳超出 ±60 秒 / 签名与**平台侧保存的该设备安全码**不一致 / 平台 DB 中没有该设备的安全码记录 | **一律 `invalid signature`** | 重连 → 先 `auth_str` 申领新码;若返回 `already issued` → 停止重试并**检查本地时钟** | > ⛔ **为什么不区分**:若分别返回"时间戳无效 / 签名错误 / 无记录",外部就能据此**试探系统状态**(例如判断某 IMEI 是否已登记、安全码是否被重置)。**统一返回 `invalid signature` 是刻意的安全设计,不要改动。** @@ -521,6 +545,7 @@ ``` > 上例中 `"008200000000000000"` = **空仓**:byte0 `00` 无电池;byte1 `82` **有电源 + 电源打开**(电源状态与有没有电池无关);SOC/电压/电流/温度全 0。 +> ⚠️ **示例为截断写法**(只列了 `board` 1、2 两块板):实际必须按 `login` 应答的 `spec` 上报**全部**仓控板与每板**全部**通道 —— `"0606"` → `boards[]` **6 项**、每项 `channels[]` **6 个元素**(见 5.2 / 10 第 2、9 条)。**省略任何一块板,平台侧那块板的通道状态就停留在旧值 / 缺失**。 **顶层字段** @@ -974,7 +999,7 @@ sign = hex[56..64] // 第 57~64 个字符,共 8 位 【电池插入 → 设备上报】 → {"act":"bat_in","msg_id":4,"id":"01-3","battery":{ ...属性 + info(18hex) + cells(hex)... }} ← {"act":"bat_in","msg_id":4,"suc":1} - 平台创建充电订单 + 平台登记/更新电池台账(⛔ 不建单 —— 订单由 on 建立,见 §5.7) 【开始充电 —— 取决于工作模式,二者只发生其一】 @@ -1026,8 +1051,10 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 | **`auth_str` / `login` 缺 `dev_id`** | 平台无法把这条 TCP 连接与设备绑定 | | **未登录就发业务报文**(首条不是 `auth_str` / `login`) | 除 `auth_str` / `login` 外,**其余 `act` 必须先 `login` 成功**(见 2.2)。未登录连接尚未与 `dev_id` 建立可信绑定,平台**丢弃且无法回包**;且**连接建立后 30 秒内未 login 成功会被主动断开** | | **发 `auth_str` 的 IMEI 未在平台登记** | 该设备未建档(合法设备必然已录入 IMEI)。平台**不发安全码、不写缓存、不回包**(见 5.1) | +| **平台内部处理异常**(数据库 / 缓存故障等,概率极低) | 平台侧只记 ERROR,**该条报文可能一条应答都收不到**。固件**按 60 秒超时处理**(见 3.6),**不要重发同一业务动作**;下一条周期 `status_post` 照常发(用新的 `msg_id`)即可 | > 固件必须保证**每条上行都带 `act`**、**首条先完成 `auth_str` / `login`(并带上 `dev_id`)**。出现上述情况时,固件侧表现为"发了没反应",超时后才丢弃——**不要误判为网络问题**。 +> ⚠️ 反过来,**平台**在未登录连接上也会"静默不投递"下行报文(见 2.2 / 4.2)—— 设备侧表现同样是"什么都没有收到",这是**设计行为、不是断线**。 ### 8.2 平台会明确返回异常的常见情况 @@ -1041,7 +1068,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 ### 8.3 其它约定 -- 平台下发的 `open`/`pow_on`/`pow_off`/`reset`,**登录成功前不会下发**(平台侧拦截)。 +- **登录门控(双向)**:`login` 成功前,平台**不会下发任何报文**(`on` / `off` / `open` / `pow_on` / `pow_off` / `reset` 及任何其它应答全部拦截)——设备**收不到任何字节**,唯一例外是 `auth_str` / `login` 自己的应答(平台侧拦截,见 2.2 / 4.2)。 - ⚠️ **不要依赖平台应答里出现的"未知字段"**:平台内部可能附带以 **`_` 开头**的服务端字段(如 `_dev_id`、`_close`),这些字段**只用于平台内部路由/控制,不会下发到设备**。固件**必须忽略未知字段**,不要因多字段而解析失败;也**不要在应答里依赖 `id`** —— 应答**不带** `id`(`id` 只出现在指令与设备上行报文里,表示动作目标,见 3.7)。 - 单条报文超过 **65536 字节(64 KB)**会导致连接被断开,固件需具备重连能力。 - 固件侧建议:断线后按 10s / 30s / 60s / 300s 递增间隔重连;**重连后必须重新 `login` 成功**(收到 `suc=1` 且带 `spec`)才能发送业务报文。 @@ -1057,7 +1084,8 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 - [ ] `login` 持续失败时能**检查本地时钟**(平台只回 `invalid signature`,不区分原因) - [ ] 平台管理员重置安全码后,设备**无需人工干预**即可自愈登录 - [ ] **建连后立即**发 `auth_str` 或 `login`(不得先发业务报文;**30 秒内未 login 成功会被平台断开**) -- [ ] **未登录时发其它 `act` 不会得到任何应答**(固件不应依赖它,也不应据此重试) +- [ ] **未登录时发其它 `act` 不会得到任何应答**;同时**平台也不会向该连接下发任何报文**(`auth_str` / `login` 自己的应答除外)—— 固件不应依赖被丢弃的报文,也不应据此重试 +- [ ] **同一时刻只维持一条 TCP 连接**:同一 `dev_id` 的新连接会让平台**接管并断开旧连接**(旧连接收不到任何通知报文) - [ ] 每条报文以 `\n` 结尾,单行 ≤ 65536 字节(64 KB) - [ ] 每条上行都带 `act`;`auth_str` / `login` 带 `dev_id`(其余报文**不带** `dev_id`) - [ ] `msg_id` 在连接内由固件侧独立自增,不复用尚未完成的号 @@ -1113,7 +1141,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 | 7 | **代码类字段的映射责任** | `brand_code` / `model_code` / `material_code`:**4G 层按字符串原值透传,不做任何转换**;**「代码 → 名称」的映射在平台侧完成**。映射表由平台维护(可热更新,不硬编码进固件);4G 层只须保证**原值不丢、不改写、不补零、不转义** | | 8 | **充电启动的两种工作模式** | **① 平台指令模式(默认)**:平台下发 `on` → 设备执行后**回执**(带 `suc`)。**② 自动充电工作模式**:设备检测到电池后**自行开始充电** → 设备**主动上报 `on`(不带 `suc`)** → 平台按新请求处理并回包。两种形态一律靠**有无 `suc`** 区分(见 3.5),与 `off` 规则完全相同。**当前模式由 `login` 成功应答的 `work_mode` 下发**(`"0"`=平台指令模式 / `"1"`=自动充电工作模式),**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 | | 9 | **`login` 应答下发柜子规格与工作模式** | `spec`(**固定 4 位字符串、恒为数字、不会是 `"0000"`**)= **前 2 位仓控板数 + 后 2 位每板通道数**;当前 36 仓柜型为 **`"0606"`**(6 块仓控板 × 每板 6 仓)。**平台在每次 `login` 时按该柜已配置的仓控板 / 通道拓扑现算**(**不是**"设备型号的持久存储"):拓扑已配置 → 按拓扑统计;**拓扑未配置 / 不完整 → 按柜型兜底**(该柜仓控板数 × 每板 6 通道)。
**固件必须以收到的 `spec` 决定上报几块板 / 每板几路通道**(前 2 位 = 板数、后 2 位 = 每板通道数);若与实际不符,**先按 `spec` 上报**并记日志反馈平台,**不得自造新 `act`**。
旧的 `cell`(仓控板数量)**不再下发给设备**(平台仅在生成 `spec` 兜底时内部使用),`work_mode`(`"0"`=平台指令模式 / `"1"`=自动充电工作模式)**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 | -| 10 | **连接登录门控** | **只有 `auth_str` / `login` 可以在未登录状态发送**;**其余所有 `act`(含 `status_post`、`bat_in`、`on`/`off`、回执等)必须先 `login` 成功**,否则平台**丢弃且不回任何应答**。**未登录的 TCP 连接不予保持**:连接建立后 **30 秒内**未 `login` 成功,平台**主动断开**(固件应建连后立即 `auth_str`/`login`) | +| 10 | **连接登录门控(上行 + 下行,同一条规则)** | **只有 `auth_str` / `login` 可以在未登录状态发送**;**其余所有 `act`(含 `status_post`、`bat_in`、`on`/`off`、回执等)必须先 `login` 成功**,否则平台**上行一律丢弃、且不回任何应答**。
⛔ **下行方向同一规则**:`login` 成功前,平台**不会向该连接投递任何报文** —— `on` / `off` / `open` / `pow_on` / `pow_off` / `reset` 等指令与其它应答**全部不下发**,**唯一例外是 `auth_str` / `login` 自己的应答**(否则首次配网会死锁)。因此未登录期间**设备收不到任何字节**。
**未登录的 TCP 连接不予保持**:连接建立后 **30 秒内**未 `login` 成功,平台**主动断开**(固件应建连后立即 `auth_str`/`login`) | | 11 | **停电上报是两条独立指令** | **没有 `pow_fail` 这条指令,也没有 `info` 字段**。停电上报分两条独立 `act`:
`pow_fail_dc` = **直流停电** —— 柜内**接触器分闸**(含远程 `pow_off`)导致**仓控板下电**,柜控板仍有电、可正常上报;
`pow_fail_ac` = **交流停电** —— **柜控板自身断电**(市电断),靠**板载电容余电抢发最后一条**,**与接触器无关**。
两条均**无附加字段**,**事件上报**:平台收到即**记录并置为该柜的停电状态**,**前端显示**(不与其他业务逻辑联动);**供电恢复后消除该状态** | | 12 | **通道数据用 hex 透传,不拆字段** | `status_post.channels[]` 的每个元素 = **恰好 18 个 hex 字符**(硬件「充电动态数据」9 字节**原样拼接**,4G 层**不做任何转换**);`bat_in` 里电池信息用同格式的 **`info`**,电芯数据用 **`cells`**(整段拼接 hex,长度 = `cell_count × 4 + 16`)。
**字节布局**:byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3–4 电压(0.01V) / byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K),多字节一律**大端**。
**通道号由数组下标决定**(第 1 个元素 = 通道 1),数组顺序不得打乱;**空仓** = byte0 `00`(无电池)+ **byte1 照实报电源状态** + 数值全 `0`(例 `"008200000000000000"`)。
**电池状态与电源状态相互独立**,两回事,前端分开显示 | | 13 | **数值口径:hex 由平台换算、JSON 由设备换算** | **统一规则(见 3.1,全文唯一口径)**:
① **hex 字节流**(`channels[]` 元素、`info`、`cells`)= **原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**平台换算**(×10/×10/×10/×100),**固件原样拼接、不做任何换算**;
② **JSON 数值字段**(`cabinet.temp` / `cabinet.meter` / `battery.rated_voltage` / `battery.rated_capacity`)= **平台单位**,**设备换算后上报**:`temp` 送 **K×100**(`29800` = 298.00K,管理端按 `temp/100 − 273.15` 显示 ℃)、`meter` 送 **Wh**(`125500` = 125.5 kWh)、`rated_voltage` 送 **mV**、`rated_capacity` 送 **mAh**。平台对 JSON 数值**原样存库、不再换算** | @@ -1200,7 +1228,9 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 // spec : 柜子规格,固定 4 位(前 2 位仓控板数 + 后 2 位每板通道数) // "0606" = 6 块仓控板 × 每板 6 仓;平台据此把连接置为"已登录" // work_mode : 充电工作模式 "0"=平台指令模式 / "1"=自动充电工作模式 -// 敏感下行指令(open / pow_on / pow_off / reset)以此为前提 +// 【登录门控的前提是双向的】:这条应答(带 spec + suc=1)是"连接已登录"的唯一标志; +// 在此之前平台【上行丢弃 + 下行不投递】,除 auth_str/login 自己的应答外 +// 设备收不到任何字节(含 on/off/open/pow_on/pow_off/reset,见 §2.2 / §10 第 10 条) ``` #### A.1.3 `login` —— 失败(平台随即断开连接) @@ -1236,6 +1266,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 // temp → 柜体温度 K×100(29800 = 298.00K)——【JSON 字段,由设备换算】 // meter → 电表读数 Wh(125500 = 125.5 kWh)——【JSON 字段,由设备换算】 // boards[] : 所有仓控板(board 1–15),每板带全部通道 +// ⚠️ 示例只列了 1 块板:实际必须按 spec 上报【全部】板与每板【全部】通道 // channels = **每通道 18 个 hex 字符**(9 字节原样透传) // 通道号由**数组下标**决定(第 1 个元素 = 通道 1);多字节**大端** // byte0 电池状态 / byte1 电源状态(位定义见 §3.8) @@ -1286,7 +1317,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 "brand_code":"1","model_code":"1","material_code":"1", "cell_count":15,"rated_voltage":48000,"rated_capacity":24000, "info":"80825515180320012a", - "cells":"0e100e150e0e0e120e170e0b0e110e140e0f0e130e160e0d0e100e120e0e0e170e0b012a0128"}}} + "cells":"0e100e150e0e0e120e170e0b0e110e140e0f0e130e160e0d0e100e120e0e0e170e0b012a0128"}} // id : 两段【仓控板-通道】(仓控板补零 01–15、通道 1–8 不补零),段数必须为 2 // battery_id: 电池序列号(ASCII ≤30),电池唯一标识;**BMS 读不到时传空串 ""**(不影响充电) // brand_code / model_code / material_code @@ -1361,7 +1392,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 ### A.5 平台下发指令(下行,全 6 条) > 下行报文**不带 `suc`**(见 3.5);固件收到后立即执行,并在 **60 秒内回执**(见 A.6)。 -> `open` / `pow_on` / `pow_off` / `reset` 要求该连接**已 `login` 成功**,否则平台不投递。 +> ⛔ **登录门控(双向同一规则)**:该连接**未 `login` 成功**时,本节**全部 6 条指令都不会下发**,设备**收不到任何字节**(`auth_str` / `login` 自己的应答除外,见 §2.2 / §10 第 10 条)。 #### A.5.1 `on` —— 开始充电(仓体级) @@ -1434,7 +1465,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 ### A.6 设备对下行指令的执行回执 -> 回执**必带 `act`**、`msg_id` **原样回带**;**不带 `suc` 的报文是指令**(见 3.5)。 +> 回执**必带 `act`**、`msg_id` **原样回带**;⚠️ **固件收到的、不带 `suc` 的下行报文就是指令**(判据见 3.5;本节讲的是固件**发出**的回执,回执**必须带 `suc`**)。 > ⚠️ 回执发出即结束,**不要等待"回执的应答"**——平台不再回包。 #### A.6.1 成功回执(以 `open` 为例) @@ -1445,7 +1476,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 → {"act":"open","msg_id":1,"suc":1} // act : 与收到的指令一致(回执必带) // msg_id : 与收到的指令完全一致 —— 必须原样回带 -// id : 建议回带,与指令一致 +// id : 建议回带,与指令一致(可选 —— 平台配对只靠 msg_id + act,见 §5.8) // suc : 1=执行正常 // 注意 : 【不带 dev_id】—— 回执走同一条连接,平台已知道是哪台设备 ``` @@ -1479,7 +1510,7 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 // t=60s :仍未收到回执 → 平台作废该挂起项,不重传,记 WARN // t=65s :迟到的回执到达 → 该 msg_id 已作废 → 【丢弃】,不补配 -→ {"act":"status_post","msg_id":9, ...} +→ {"act":"status_post","msg_id":9,"rssi":85} // t=0 :固件登记挂起项 // t=60s :仍未收到应答 → 固件作废该交互,【不重试】该业务动作 // (下一条周期 status_post 照常发,用新的 msg_id) @@ -1491,5 +1522,6 @@ t=0.3 → {"act":"on","msg_id":3,"suc":1} ← 命中平台挂起表 → 完 | 版本 | 日期 | 变更内容 | |------|------|----------| +| **v1.1.1** | **2026-09-20** | ㊳ **交付前文档一致性修正(第二轮,逐条回代码核对)—— 只改文档自身的错误 / 矛盾 / 缺失说明,协议行为(`act`、字段名、位定义、签名、`id` 规则、上报节奏、超时语义)一字未动**:
① **§7.3 时序图纠错**:`bat_in` 后的说明"平台创建充电订单"与 §5.5 / §5.7 / §9 / 附 A.3.1 矛盾(`bat_in` **不建单**,建单唯一入口是 `on`)→ 改为"登记/更新电池台账(不建单)"。
② **登录门控补全"下行"方向**(§2.2 表格重写、§2.2 新增双向规则说明、§4.2 前置条件、§8.1 末尾、§8.3、§9 清单、§10 第 10 条、附 A.5 头部、附 A.1.2 注释):原表述只覆盖"上行丢弃",未说明**平台在未登录连接上也不投递任何下行报文**;现明确"**`login` 成功前设备收不到任何字节(唯一例外是 `auth_str`/`login` 自己的应答)**",与 §4.1 的同一规则对齐。
③ **§3.1 纠错**:删去"平台应答中会额外附带 `id`"——**平台应答不带 `id`**(§5.8 / §8.3 与实现一致),原句与 §8.3 自相矛盾。
④ **§2.3 纠错**:"平台对每条上行都会回 `suc=1`"改为"对每条**请求类**上行回包;**回执类上行不回包**",消除与 §3.5 / §4.1 的矛盾。
⑤ **§4.1 表格纠错**:第 7 / 8 / 9 条的"是否必带 `id`"由"回带原 `id`"改为"**上报形态:是(仓体级);回执形态:可选**"——`open`/`pow_on`/`pow_off`/整柜 `reset` **本就无 `id`**,原写法不可能满足(与 §3.7 / §5.8 对齐)。
⑥ **§3.3 `invalid id` 语义补全**:除"格式非法"外,**格式合法但该 `id` 在平台上查不到**(该柜未配置该板/通道)也回同一个 `err`,并给出固件应对。
⑦ **§2.2 新增"同一 IMEI 只保留一条连接"**:新连接会**接管并断开旧连接**,旧连接收不到任何通知报文(固件不得并存两条连接);§9 清单同步加一条自测项。
⑧ **§8.1 新增一行"平台内部处理异常"**:数据库/缓存故障时该条报文可能得不到任何应答,固件按 60 秒超时处理、不重发业务动作;并补充"平台侧静默不投递下行"的表现说明。
⑨ **§5.3 示例标注"截断写法"**(只列了 2 块板,实际须按 `spec` 上报全部板 × 全部通道)、附 A.2.1 注释同步;**附 A.3.1 修正 JSON 多出一个 `}`**、**附 A.8 去掉 `...` 使报文可直接解析**;附 A.6 头部与 A.6.1 注释措辞修正(回执必带 `suc`;`id` 为可选)。
⑩ **§2.2 / §5.2 三处"会话中保存的安全码"改为"平台侧保存的该设备安全码(持久保存)"**,与 ⑨「DB 权威 + Redis 缓存」一致,避免被误读为随连接失效。
⑪ **文首新增「这 7 项的平台侧落地情况」表**:明确第 **13 / 17 / 18** 项的平台侧行为**已落地**、第 **14 / 15** 项平台侧已按"原值透传 / 只取 SOC"实现、第 **16 / 19** 项**仍未定**(保持"待确认")。
⑫ 文首版本号与本文档自检(围栏配对 / 表格列数 / 报文 JSON 可解析)同步更新。 | | v1.1 | 2026-09-14 | ① 签名定稿为 **8 位**(`hex[56..64]`),自测向量更新为 `3957b959` / `269c249d` ② 明确 `act` 双向必带、`suc=0` 必带 `err` ③ 报文性质判定改为**按有无 `suc`**,配对要求 `msg_id` + `act` 双条件 ④ 明确**每个交互 60 秒超时、超时作废、迟到应答丢弃、不重试** ⑤ 新增 `login` 失败分类处理与**安全码自愈闭环**(重置后设备自动重新申领)⑥ 接入端口定稿:测试 `3001`、正式 `9001`,明确 TCP **不经 nginx/域名** ⑦ 第 10 节遗留项全部定稿;新增附 A 带注释报文示例 ⑧ **`login` 失败后平台主动断开 TCP 连接**,设备须重连后重新握手 ⑨ 安全码存储明确为 **DB 权威 + Redis 缓存**(缓存未命中回源 DB,Redis 故障不再导致设备无法登录)⑩ 上报节奏定稿:`status_post` **固定每 60 秒一次、每次全量**(取消原"空闲 60s / 充电 15s"与按板分片设想);单行上限 **4096 → 65536 字节** ⑪ **上报字段按硬件权威数据字典重构**:`status_post` 改为 `cabinet`(柜体状态2 字节 + 柜温 + 电表)+ `boards[].batteries[]`(2 个状态字节 + SOC/电压/电流/温度);`bat_in` / `bat_out` / `off` 的 `bms` 改为 `battery`(属性 + 动态 + 电芯);新增 **§3.8 状态字节 hex 解析表**;`id` 量程改为仓控板 **1–15** / 电池组 **1–8**;`soh` / `power` 移除 ⑫ **柜门整章定稿**:柜门与充电**完全解耦**(§7.2/§7.3 拆分)、`open` 明确为**解锁**(门由人拉开)、**无"关门"指令(任何柜型)**、柜门状态经 `status_post` ≤60 秒回传;`rssi` 口径由 dBm 改为 **0–99 信号等级** ⑬ **挂起 4 项待确认**(`pow_fail` `info` 语义、`energy` 数据源、代码表取值内容、电芯数据字节数)并在**文首新增「⚠️ 待确认事项」**集中列出;删除内部重复文档 `docs/柜控板-平台TCP接口文档.md` ⑭ **`login` 成功应答新增 `spec` 与 `work_mode`**:`spec`(4 位,前 2 位仓控板数 + 后 2 位每板通道数,当前柜型 `"0606"`)作为**柜子规格/型号**由平台下发;`work_mode`(`"0"`=平台指令模式 / `"1"`=自动充电工作模式)下发**充电工作模式**,固件据此决定是否等平台 `on` 指令——**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 ⑮ **新增连接登录门控**:**只有 `auth_str` / `login` 可在未登录状态发送**,其余 `act` 必须先 `login` 成功,否则平台丢弃且不回包;**未登录的 TCP 连接不予保持**(建连后 30 秒未 `login` 成功即主动断开)。同时 `err` 文案全部改为**英文小写短语**(§3.3 新增取值全集表),`id` 段数规则明确(一段=柜级 / 两段=仅 `reset` 单板 / 三段=仓体级)**(⚠️ 该 `id` 段数规则与最终定稿完全相反,已被 ㉛ 修正并经 ㉟ 确认,作废;当前生效规则见 §3.7)** ⑯ **补充 `pow_on` / `pow_off` 业务语义**:远程通断柜内总电(接触器)——**`pow_off` 分闸后仓控板随之下电**(仓体/电池数据不可用,并因此产生 `pow_fail` `info:"0"` 直流停电),属运维动作;`pow_fail` 与 `cabinet.status1` bit0 的关系(事件 vs 状态)在 §5.4 说明 **(⚠️ 本条中的 `pow_fail` / `info:"0"` 已被 ⑱ 取代,作废)** ⑰ **`pow_fail` 的 `info` 区分定稿**(判定依据 = **"谁掉电"**):`"0"`=直流停电(**接触器分闸**→仓控板下电,柜控板仍有电)/ `"1"`=交流停电(**柜控板自身断电**,靠**板载电容余电抢发最后一条**,与接触器无关);该条由「待确认」转入「约定定稿」第 11 条,待确认事项由 4 项缩减为 **3 项** **(⚠️ 已被 ⑱ 取代,作废:`pow_fail` 与 `info` 字段均已取消)** ⑱ **停电上报更正为两条独立指令**:**取消 `pow_fail`(及 `info` 字段)**,改为 **`pow_fail_dc`**(直流停电:接触器分闸→仓控板下电,柜控板仍有电)与 **`pow_fail_ac`**(交流停电:柜控板自身断电,靠板载电容余电抢发最后一条,与接触器无关)两条独立 `act`,**均无附加字段**;`act` 名与平台 Redis 字段 `pow_fail_dc` / `pow_fail_ac` 一一对应。代码侧 `report_worker` 分发与 `handle_pow_fail` 同步改为按 `act` 判定 ⑲ **新增待确认第 12 项**(**该编号后调整为第 16 项,见文首「待确认事项」**):**仓控板离线(485 芯片坏 / 线松 / 掉电)如何上报** —— **4G 层读不到该板数据时该怎么报尚未约定**(省略该板?照报全 0 的板?另有在线标志?),**以硬件答复为准、本文不预设结论**(§5.3 同步加警示) ⑳ **`login` 失败统一为单一应答**:取消 `invalid timestamp` / `auth_str not found`,**只返回 `invalid signature`**(不区分失败原因,避免外部试探系统状态——安全设计);同时修正 `spec` 的定位为「平台对柜子的规格定义(型号)」,**与仓控板实际能否通信无关** **(⚠️ 其中"型号"的措辞已在 ㊲ 更正:`spec` 由平台按拓扑现算、**不承诺"型号存储"**,见 §5.2 / §10 第 9 条)**;`work_mode` 明确**仅两种、不再扩展** ㉑ **`status_post` 字段改名**:`batteries[]` → **`channels[]`**、内层 `group` → **`channel`**(那是**仓板的通道**、可能空着,不是纯电池信息;同时与平台侧 `channel_id` 对齐)**(⚠️ 该 `channel` 字段已在 ㉒ 随之取消:`channels[]` 改为**字符串数组**,元素内不含任何字段名,**通道号由数组下标决定**,见 §5.3 / §10 第 2 条)**;`id` 第三段说明改为「通道」;全文「电池组」按语境改为「通道」(硬件表名「电池组属性」保留) ㉒ **通道状态改为 hex 透传(大幅瘦身)**:`status_post.channels[]` 由"每通道 6 个命名字段"改为 **每通道 18 个 hex 字符**(9 字节原样拼接,**4G 层零转换**),**数组下标 = 通道号**(**精确定义:数组下标从 0 开始,第 1 个元素 = 通道 1**,见 §5.3 / §10 第 2 条);`bat_in`/`bat_out`/`off` 的动态数据同步改为 `info`(同一种 18 字符 hex);定字节布局与**大端**规则,§3.8/§5.3/§5.5/§7/§9/§10/附A 全面同步。体积:6 板 × 6 通道由 ~3.4KB 降至 **~0.8KB** ㉓ **修正空仓写法**:**空仓 ≠ 全 0** —— byte0 = `00`(无电池)但 **byte1 照实报电源状态**(电源与有无电池无关),例 `"008200000000000000"`;明确**电池状态与电源状态是两个独立维度**,平台前端**分两个指示分开显示** ㉔ **字段改名**:`bat_in`/`bat_out`/`off` 里的电池数据字段 `dyn` → **`info`**(它是**这块电池的信息**,不是"充电数据");同时明确 hex **大小写不敏感**(平台两种都解析,建议固件统一小写),示例 hex 全部改小写 ㉕ **`cells` 同样改拼接 hex**:由"JSON 数组 + 4 个统计字段"改为**整段拼接**的 hex 字符串,长度 = **`cell_count × 4 + 16`**(N 节电压 + 最高/最低电压 + 最高/最低温度,大端),15 节 = 76 字符、20 节 = 96、22 节 = 104(**与硬件文档「52 字节」吻合**) ㉖ **`battery_id` 允许为空串**:BMS 读不到序列号时传 `""`,**不影响充电与订单记录**;平台侧 `battery_id` 为空时**不做"换电池"判断、跳过电池台账**,订单照常创建(只是不知道是哪块电池) ㉗ **`bat_in` 重复上报不新建订单**:明确 **`bat_out` 是订单结束标志**——没有 `bat_out` 就说明订单还在进行;因此该通道已有活跃订单且 `battery_id` **相同**(含两边都为空串)时**不新建订单**,仅当 `battery_id` **不同且都非空**才 `forced` 结束旧订单并新建。**依据:一个通道只可能有一块电池**,`bat_in` 带不同 `battery_id` 必然是换了电池 → 老订单必须先结束(⚠️ 该条后续在 ㉝ 修正:`bat_in` 最终定为**与订单无关**,建单只在 `on`) ㉘ **新增订单超时兜底**:设备可能**停电/离线期间电池被取走**而永远发不出 `bat_out` → 平台侧**充电订单"进行中"超过 10 小时即自动结束**(`reason='timeout'`,依据"充电不可能超过 10 小时");固件**不必补报历史事件** ㉙ **补充两条待确认(第 18/19 项)**:**① 人在没充满时强制拔电池**——MCU 能否检测、能否保证"先 `off` 再 `bat_out`"(§5.6 已把该顺序列为**硬性要求**);**② 停电恢复后如何兜底**——掉电期间事件无法上报,恢复后是补报、还是以当前状态为准由平台对账 ㉚ **报文瘦身:`dev_id` 只在 `auth_str` / `login` 带** —— 这两条用于把 TCP 连接与设备绑定,**之后所有报文都不再带 `dev_id`**(设备身份由连接确定;断开重连才需重新 `login`)。同步更新 §3.1 字段总表、§5.3~§5.8、§7 时序、§8.1、§9 清单、附 A 全部示例;平台侧 `device-server` 转发时按连接注入设备身份(内部改动、固件无感) ㉛ **`id` 也去掉 IMEI 前缀**:既然设备身份来自连接,`id` 只表示**动作目标** —— **不带**=柜级、**一段**=单板(仅 `reset`,如 `"01"`)、**两段**=仓体级(如 `"01-3"`);仓体级段数校验由 `≠3` 改为 `≠2`;§3.7 / §4.2 / §5.5 / 附 A 全部示例与注释同步。平台侧 `device-server` 下发时需按目标连接**补回 IMEI 前缀**(内部改动) **(⚠️ 仅本条末句被 ㉟ 取代、作废:`id` 不下发 IMEI,"补回 IMEI 前缀"的做法已废弃,改由**内部路由字段 `_dev_id`** 定位目标设备;`id` 只表示动作目标、不含 IMEI 的规则本身仍然生效,见 §3.7)** ㉜ **`msg_id` 编号规则写实**:**两个方向各自独立计数、都每连接从 `1` 起自增**(`0` 不使用),重连归零;**明确不约定号段**(方向靠有无 `suc` 区分),并说明"同一数字在两个方向同时出现是正常的"。原示例中的大号(`2147483648` 等,共 25 处)全部改为从 `1` 起的小号;删除原先"平台从高位起"的不实描述 ㉝ **订单生命周期定稿(关键修正)**:**`bat_in` 与订单无关**(只登记电池台账,**不建单**);**建立订单的唯一入口是 `on`**(`work_mode="0"` 平台下发 `on` 时建单、`work_mode="1"` 设备上报 `on` 时建单、**回执形态不建单**);**结束** = `bat_out` / `off` / **10 小时订单超时**。建单判定:该通道无活跃订单→新建;有活跃订单且**电池 id 没变**→不新建;**变了**→老订单 `forced` + 新建(依据"一个通道只可能有一块电池");起始 SOC 取最近一次 `bat_in` 的 `info`。**`off` 到达时无活跃订单 → 平台不处理但照常回 `suc:1`**。并在 §5.7 加注释**告知硬件侧**:除充满外,**故障 / 过温 / 电池异常等停止也应上报 `off`** ㉞ **`reset` 回执语义明确**:`reset` 只是**一个动作**,回执表示"**已收到并开始执行(重启)**"= **下发成功**,**不表示重启成功**;固件**必须先回执、再重启**(整柜重启后就没机会回执);**重启是否成功平台不看回执**,而是看设备**是否重新 `login`**;回执的 `id` 明确为**可选**(平台配对只靠 `msg_id` + `act`) ㉟ **平台应答补上 `act`(实现对齐协议)**:`ServerResponse` 新增 `act`(**必带,与请求一致**),新增 `success_for` / `error_for` 从**请求报文**取 `act`+`msg_id`(协议 §3.1 要求 `act` 双向必带、§3.4 要求配对含 `act`,原实现应答无 `act` 属实现缺陷);连带修复 device-server 的「应答 vs 指令」判据(**只看有没有 `suc`**,不再看 `act`)与**敏感指令门控**(只拦指令 —— 否则 `auth_str` 的应答会被自己拦掉 → 首次配网死锁);并新增**内部路由字段 `_dev_id`**(`id` 去掉 IMEI 后 device-server 无法再从 `id` 推导目标设备,改用该字段;下发前摘除、**设备侧不可见**,§8.3 同步说明) **(✅ 本条取代 ㉛ 末句"下发时需按目标连接补回 IMEI 前缀";当前生效口径:`id` 只表示动作目标、**不含 IMEI**,设备身份由 TCP 连接确定,平台内部路由字段一律不下发 —— 与 §3.7 / §8.3 一致)** ㊱ **补两条边界说明**:① **未登记的 IMEI 发 `auth_str` 时平台静默丢弃**(不发码/不写缓存/不回包,见 §5.1 与 §8.1 表)—— 合法设备必然已建档,正常固件不会遇到;② **电池属性按原值入库**(`brand_code`/`model_code`/`material_code`/`cell_count`/`rated_voltage`/`rated_capacity` 均入电池台账,映射出的中文名留待代码表确认后回填,与 §10 定稿第 7 条一致) ㊲ **交付前一致性修正(本文与实现对齐,不改动任何已冻结的协议行为)**:
① **新增 §3.1「数值口径的统一规则」并全文贯彻** —— **hex 字节流(`channels[]` / `info` / `cells`)= 原始硬件单位、由平台换算;JSON 数值字段(`cabinet.temp` / `cabinet.meter` / `rated_voltage` / `rated_capacity`)= 平台单位、由设备换算**;据此改正 §5.3 `temp`/`meter`(设备送 K×100 / Wh,平台原样存库)、§5.5 `info`/`cells`/额定值的说明,使"字段说明"与"JSON 示例"完全自洽。
② **`spec` 口径更正**:由平台**每次 `login` 按该柜已配置的仓控板/通道拓扑现算**(**不再**写"作为柜子型号存储"),**拓扑未配置/不完整时按柜型兜底**,**恒为 4 位数字、不会是 `"0000"`**;§5.2 / §9 自测清单 / §10 第 9 条同步补上"**固件应以收到的 `spec` 决定上报几块板、每板几路通道**;收到与预期不符应先按 `spec` 上报并记日志反馈平台(不得自造新 `act`)"。
③ **附 A.5.3 注释更正**:`open` 为柜级指令**不带 `id`**(原注释"只写 IMEI"与 §3.7 矛盾);并明确 ㉛ 末句"下发时补回 IMEI 前缀"**已被 ㉟ 取代(作废)**,⑮ 的 `id` 段数规则、⑯/⑰ 的 `pow_fail` `info` 规则均**在原处标注作废**、保留可追溯;⑲ 的"第 12 项"标注为**后调整为第 16 项**。
④ **§10 第 2 条更正**:`channels` 是**字符串数组**、元素**没有 `channel` 字段**,**通道号由数组下标决定**(第 1 个元素 = 通道 1);§5.3 同段措辞统一为同一句话。
⑤ **「无电池」状态兜底写实**(§5.6 与 §10 新增第 14 条):判定依据 `batt_status` bit7、**连续 2 次(约 2 分钟)**、后果为 **`forced` 异常结单**,并写明与待确认第 16/18 项的关系及"照报全 0 的板会误判"的风险。
⑥ **结构修复**:文首「⚠️ 待确认事项」表中被引用块切断的 7 行表格恢复连续(引用块移至表格之后);§5.1 示例中不可解析的 `msg_id:N` 占位改为具体编号;附 A.3.1 清理陈旧注释(`soc/voltage/current/temp`、重复的 `cells` 行) | | v1.0 | 2026-09-14 | 首版:基于平台 TCP 服务实现逐项核对生成 | diff --git a/software/api-server/src/commands.rs b/software/api-server/src/commands.rs index 1beec5c..825ba90 100644 --- a/software/api-server/src/commands.rs +++ b/software/api-server/src/commands.rs @@ -174,9 +174,22 @@ impl NodeRegistry { 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 { - let key = format!("device:{}", imei); + let key = auth_marker_key(imei); let mut conn = self.redis.clone(); let exists: Result = redis::cmd("EXISTS") .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)] #[allow(dead_code)] @@ -418,12 +440,16 @@ pub async fn send_command( 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( user: CurrentUser, State(state): State, ) -> Result, AppError> { - auth::check_permission(&user, "device:view")?; + auth::require_super_admin(&user)?; let nodes = state.nodes.list().await; Ok(Json(json!({ "node_count": nodes.len(), @@ -484,4 +510,31 @@ mod tests { let mut numeric = Some(json!({ "id": 3 })); 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(), "操作员必须被拒"); + } } diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index 740a76b..2f454fc 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -46,7 +46,7 @@ const SCHEMA_SQL: &str = r#" cell TINYINT DEFAULT 6 COMMENT '仓控板数量', work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式', name VARCHAR(100) COMMENT '柜子名称', - status TINYINT DEFAULT 0 COMMENT '0离线 1在线', + status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES projects(id) @@ -111,7 +111,7 @@ const SCHEMA_SQL: &str = r#" end_soc INT COMMENT '结束电量(‰)', energy INT DEFAULT 0 COMMENT '充电量(Wh)', 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, FOREIGN KEY (compartment_id) REFERENCES compartments(id), FOREIGN KEY (cabinet_id) REFERENCES cabinets(id), @@ -313,6 +313,49 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { ) .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` 的筛选下拉 + // 提供 ``、`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 避免重复) create_indexes(pool).await?; diff --git a/software/api-server/src/middleware/auth.rs b/software/api-server/src/middleware/auth.rs index ccbd9b5..4563c25 100644 --- a/software/api-server/src/middleware/auth.rs +++ b/software/api-server/src/middleware/auth.rs @@ -296,6 +296,24 @@ pub fn check_permission(user: &CurrentUser, code: &str) -> Result<(), AppError> /// 总管理员角色等级(`users.role`,与 `roles` 表的 `super_admin` 对应) 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` 的种子角色名严格一致) const ROLE_NAME_NORMAL_USER: &str = "normal_user"; const ROLE_NAME_ORG_ADMIN: &str = "org_admin"; @@ -568,6 +586,50 @@ pub fn org_condition(user: &CurrentUser, cabinet_table: &str) -> (String, Vec= 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) { + 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) { + 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 链路) pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, Vec) { if user.role_level >= 2 { @@ -783,6 +845,62 @@ mod tests { assert!(!scope.allows_project(None), "有限制时未划拨设备一律不在范围内"); } + // ----------------------------------------------------------------------- + // 组织隔离条件(组织树 / 设备列表筛选) + // ----------------------------------------------------------------------- + + /// 组织 ID 列:总管理员不限、客户侧按组织、无组织 `1=0`(绝不退化成全量) + #[test] + fn test_org_condition_on_column_rules() { + let make = |role_level: i32, org: Option| 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| 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 语法错误) #[test] fn test_append_condition_handles_empty_clause() { diff --git a/software/api-server/src/nodes.rs b/software/api-server/src/nodes.rs index b50c070..6f49187 100644 --- a/software/api-server/src/nodes.rs +++ b/software/api-server/src/nodes.rs @@ -2,6 +2,19 @@ //! //! 设备服务启动时注册到 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::Json; @@ -10,6 +23,15 @@ use serde_json::{json, Value}; use crate::commands::AppState; 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)] @@ -19,21 +41,27 @@ pub struct NodeRegisterRequest { pub tcp_port: u16, } -/// POST /api/nodes/register — 设备服务注册 +/// POST /api/nodes/register — 设备服务注册(仅总管理员) pub async fn register_node( + user: CurrentUser, State(state): State, Json(req): Json, ) -> Result, AppError> { + require_super_admin(&user)?; + state.nodes.register(&req.node_id, &req.ip, req.tcp_port).await; tracing::info!("[节点注册] node_id={}", req.node_id); Ok(Json(json!({"suc": 1}))) } -/// POST /api/nodes/:node_id/heartbeat — 节点心跳 +/// POST /api/nodes/:node_id/heartbeat — 节点心跳(仅总管理员) pub async fn node_heartbeat( + user: CurrentUser, State(state): State, Path(node_id): Path, ) -> Result, AppError> { + require_super_admin(&user)?; + if state.nodes.heartbeat(&node_id).await { Ok(Json(json!({"suc": 1}))) } 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( + user: CurrentUser, State(state): State, Path(node_id): Path, ) -> Result, AppError> { + require_super_admin(&user)?; + state.nodes.remove(&node_id).await; tracing::info!("[节点注销] node_id={}", node_id); Ok(Json(json!({"suc": 1}))) } -/// GET /api/nodes — 节点列表 +/// GET /api/nodes — 节点列表(仅总管理员;返回节点 `ip:tcp_port`) pub async fn list_nodes( + user: CurrentUser, State(state): State, ) -> Result, AppError> { + require_super_admin(&user)?; + let nodes = state.nodes.list().await; let list: Vec = nodes .into_iter() @@ -68,3 +102,28 @@ pub async fn list_nodes( .collect(); 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(), "操作员不得访问内部节点面"); + } +} diff --git a/software/api-server/src/routes/batteries.rs b/software/api-server/src/routes/batteries.rs index c085e6c..02ba0f3 100644 --- a/software/api-server/src/routes/batteries.rs +++ b/software/api-server/src/routes/batteries.rs @@ -52,100 +52,120 @@ struct BatteryRow { created_at: Option, } -/// 构建列表 / 计数 SQL +/// 构建列表 / 计数 SQL 的 WHERE 子句与绑定值 /// -/// 仅判断筛选条件是否存在以拼接 SQL,具体取值由调用方绑定。 -fn build_battery_sql(params: &BatteryQuery) -> (String, String) { - let mut query = "SELECT * FROM batteries WHERE 1=1".to_string(); - let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string(); +/// # 归属(`batteries` 表**没有任何柜子/项目/组织列**) +/// 见 `db/migrate.rs` 的建表语句:`batteries(battery_id, soc, brand, model, sn, …, status)`, +/// 与 `cabinets` / `projects` / `organizations` **无外键、无关联列**。 +/// 因此归属只能走**间接路径**: +/// `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) { + let mut conditions: Vec = vec!["1=1".to_string()]; + let mut binds: Vec = Vec::new(); - if params.status.is_some() { - query.push_str(" AND status = ?"); - count_query.push_str(" AND status = ?"); + // ① 归属(文本绑定) + if user.role_level < auth::SUPER_ADMIN_ROLE_LEVEL { + 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() { - query.push_str(" AND battery_id LIKE ?"); - count_query.push_str(" AND battery_id LIKE ?"); + conditions.push("batteries.battery_id LIKE ?".to_string()); + } + // ③ 状态筛选(整型绑定,**必须最后**) + if params.status.is_some() { + conditions.push("batteries.status = ?".to_string()); } - query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); - (query, count_query) + (format!("WHERE {}", conditions.join(" AND ")), binds) +} + +/// `battery_id LIKE ?` 的模糊匹配值(与列表/计数共用,避免两处漂移) +fn battery_like_pattern(battery_id: Option<&str>) -> Option { + battery_id.map(|bid| format!("%{}%", bid)) } /// 按筛选条件统计电池总数 async fn count_batteries( pool: &sqlx::MySqlPool, - count_query: &str, + where_clause: &str, + text_binds: &[String], status: Option, battery_id: Option<&str>, ) -> Result { - let total: i64 = if let Some(s) = status { - if let Some(bid) = battery_id { - sqlx::query_scalar(count_query) - .bind(s) - .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 { - sqlx::query_scalar(count_query) - .bind(format!("%{}%", bid)) - .fetch_one(pool) - .await? - } else { - sqlx::query_scalar(count_query) - .fetch_one(pool) - .await? - }; - Ok(total) + let sql = format!("SELECT COUNT(*) FROM batteries {}", where_clause); + let mut query = sqlx::query_scalar::<_, i64>(&sql); + for bind in text_binds { + query = query.bind(bind); + } + if let Some(bid) = battery_like_pattern(battery_id) { + query = query.bind(bid); + } + // ⚠️ `status` 必须最后绑定:SQL 里它是最后一个占位符之前唯一的整型条件, + // 而整型/字符串绑定不可混放(顺序错了条件就张冠李戴) + if let Some(s) = status { + query = query.bind(s); + } + query.fetch_one(pool).await } /// 按筛选条件查询当前页电池行 async fn fetch_battery_rows( pool: &sqlx::MySqlPool, - query: &str, + where_clause: &str, + text_binds: &[String], status: Option, battery_id: Option<&str>, page_size: i64, offset: i64, ) -> Result, sqlx::Error> { - let rows: Vec = if let Some(s) = status { - if let Some(bid) = battery_id { - sqlx::query_as(query) - .bind(s) - .bind(format!("%{}%", bid)) - .bind(page_size) - .bind(offset) - .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 { - sqlx::query_as(query) - .bind(format!("%{}%", bid)) - .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) + let sql = format!( + "SELECT * FROM batteries {} ORDER BY id DESC LIMIT ? OFFSET ?", + where_clause + ); + let mut query = sqlx::query_as::<_, BatteryRow>(&sql); + for bind in text_binds { + query = query.bind(bind); + } + if let Some(bid) = battery_like_pattern(battery_id) { + query = query.bind(bid); + } + if let Some(s) = status { + query = query.bind(s); + } + query.bind(page_size).bind(offset).fetch_all(pool).await } /// 将数据库行映射为响应项列表 @@ -164,6 +184,11 @@ fn batteries_to_items(rows: Vec) -> Vec { } /// GET /api/batteries — 电池台账列表 +/// +/// **归属**:`batteries` 表无组织/项目关联列,归属走 +/// `battery_id → charge_orders → cabinets → projects.organization_id` 间接路径; +/// 工厂(`role_level >= 2`)全量,客户侧只可见"本组织柜子上用过"的电池, +/// 无组织 = 空集,再叠加数据范围(见 [`build_battery_filters`] 的完整说明)。 pub async fn list_batteries( user: CurrentUser, State(state): State, @@ -175,14 +200,22 @@ pub async fn list_batteries( let page_size = params.page_size.unwrap_or(20).min(100); let offset = (page - 1) * page_size; - // 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定 - let (query, count_query) = build_battery_sql(¶ms); + // 归属 + 筛选条件(文本绑定与 status 分开,见函数注释的绑定顺序) + let (where_clause, text_binds) = build_battery_filters(¶ms, &user); 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( &state.mysql, - &query, + &where_clause, + &text_binds, params.status, battery_id, page_size, @@ -203,3 +236,84 @@ pub async fn list_batteries( "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, 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(¶ms, &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); + } +} diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index bc84671..5dea63e 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -72,11 +72,16 @@ impl From for CabinetRow { // 子查询、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 解码问题) -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"; +/// 全部项目(工厂/总管理员可见全量;经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题) +/// +/// `{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 = @@ -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( user: CurrentUser, State(state): State, @@ -136,13 +151,22 @@ pub async fn organization_tree( let db = &state.mysql; - // 获取所有组织 - let orgs: Vec = 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 = orgs_query.fetch_all(db).await?; - // 获取所有项目 - let projects: Vec = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?; + let (projects_sql, projects_binds) = visible_projects_sql(&user); + let mut projects_query = sqlx::query_as::<_, ProjectRow>(&projects_sql); + for bind in &projects_binds { + projects_query = projects_query.bind(bind); + } + let projects: Vec = projects_query.fetch_all(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" }))) } +/// 可见组织的查询 SQL 与绑定值(纯函数,便于单测断言组织隔离) +/// +/// 组织表自带组织 ID 列 ⇒ 直接按 `id` 过滤(客户侧 `id = users.organization_id`)。 +fn visible_orgs_sql(user: &CurrentUser) -> (String, Vec) { + 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) { + 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 -> 设备数量」映射 async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result, AppError> { 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" }))) } -/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致) +/// 构造设备列表的 WHERE 子句及其绑定参数 /// -/// **数据范围叠加**:组织隔离给出"能看到哪个组织",`CurrentUser::scope` 给出 -/// "在这个组织里还能看哪些项目"(未配置 = 不限制)。两者**取交集**, -/// 收窄条件统一落在 `c.project_id` 上(粒度到项目为止,不往下钻到单台柜子)。 +/// **三道边界,全部取交集(书写顺序 = 占位符顺序 = 绑定值顺序)**: +/// 1. **显式筛选**(入参 `project_id` / `organization_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( params: &CabinetFilterParams, user: &CurrentUser, ) -> (String, Vec) { - let (where_clause, binds): (String, Vec) = if let Some(proj_id) = params.project_id { - ("WHERE c.project_id = ?".into(), vec![proj_id.to_string()]) - } 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 conditions: Vec = Vec::new(); + let mut binds: Vec = Vec::new(); - // 已绑定/未绑定过滤(绑定 = 分配到项目) - let mut where_clause = match params.bound.as_deref() { - Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"), - Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"), - _ => where_clause, - }; + // ① 显式筛选:项目 / 组织(组织含其直接子组织,沿用原口径) + if let Some(proj_id) = params.project_id { + conditions.push("c.project_id = ?".to_string()); + binds.push(proj_id.to_string()); + } + 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`):**总管理员恒为"不限制"**, - // 即便别处构造的 CurrentUser 带着非空 scope 也不会把工厂自己收窄。 - let mut binds = binds; + // ② 组织隔离:无论有没有入参都必须叠加(这一步才是"能不能看"的边界) + let (org_cond, org_binds) = auth::org_condition_for_project(user, "c.project_id"); + push_condition(&mut conditions, &mut binds, &org_cond, org_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"); - auth::append_where(&mut where_clause, &scope_cond); - binds.extend(scope_binds); + push_condition(&mut conditions, &mut binds, &scope_cond, scope_binds); + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; (where_clause, binds) } -/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接 -fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String { - if where_clause.is_empty() { - where_new.to_string() - } else { - let mut clause = where_clause; - clause.push_str(and_tail); - clause +/// 追加 ` AND xxx` 形式的条件片段及其绑定值(片段为空 = 不限制,原样跳过) +/// +/// **绑定值必须与片段一起追加**:占位符在 SQL 文本里的先后顺序即绑定顺序, +/// 分开追加必然错位(错位的结果是条件张冠李戴,比不加条件更危险)。 +fn push_condition( + conditions: &mut Vec, + binds: &mut Vec, + fragment: &str, + fragment_binds: Vec, +) { + if fragment.is_empty() { + return; } + conditions.push(fragment.trim_start_matches(" AND ").to_string()); + binds.extend(fragment_binds); } /// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致) @@ -639,7 +710,8 @@ pub async fn device_realtime_status( let mut conn = open_redis_conn(&state).await?; let device_status = load_device_status(&mut conn, ¶ms.imei).await; - let boards = load_boards_status(&mut conn, ¶ms.imei).await; + // 板级+通道级:按 DB 拓扑拼 key + 一次 pipeline(**零 KEYS 扫描**,见函数注释) + let boards = load_boards_status(&state.mysql, &mut conn, ¶ms.imei).await; Ok(Json(json!({ "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」 const BOARD_ID_MIN: i64 = 1; 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_MAX: i64 = 8; -/// 从通道级 key 解析 `(板号, 通道号)`:段数为 6 / 第 3 段是 `board` / 第 5 段是 `ch` / -/// 板号在 1–15、通道号在 1–8 才算合法,否则 `None`。 +/// 通道级 Hash 的**权威字段名**(与 `workers/report_worker.rs::write_channel_state` 一致) +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:*` 会把内部键 -/// `...:ch:{通道号}:bat`(电池台账)与 `...:ch:{通道号}:order`(订单电量累加器)也扫出来, -/// 按段校验能把它们挡掉,**不会生成幽灵板/幽灵通道**。 -fn parse_channel_key(key: &str) -> Option<(i64, i64)> { - let parts: Vec<&str> = key.split(':').collect(); - if parts.len() != CHANNEL_KEY_SEGMENTS - || parts.get(2) != Some(&"board") - || parts.get(4) != Some(&"ch") - { - return None; - } - let board_no: i64 = parts.get(3)?.parse().ok()?; - let channel_no: i64 = parts.get(5)?.parse().ok()?; - if !(BOARD_ID_MIN..=BOARD_ID_MAX).contains(&board_no) - || !(CHANNEL_ID_MIN..=CHANNEL_ID_MAX).contains(&channel_no) - { - return None; - } - Some((board_no, channel_no)) +/// ⚠️ 必须与上报写入侧(`workers/report_worker.rs::channel_status_key`)**逐字一致**, +/// 否则读不到设备写入的状态。 +fn channel_redis_key(imei: &str, board_id: i64, channel_id: i64) -> String { + format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id) } -/// 扫描并组装全部仓控板状态(板级 + 通道级)。 +/// 该通道级 key 是否落在协议量程内(板号 1–15 / 通道号 1–8)。 /// -/// ⚠️ 设备上报链路**只写通道级 key**(`device:{imei}:board:{板号}:ch:{通道号}`, -/// 见 `workers/report_worker.rs::write_channel_state`),**新协议里根本没有板级 hash** -/// (老实现的 `device:{imei}:board:{板号}.status_hex` 已不复存在)。 -/// 旧代码按 `KEYS device:{imei}:board:*` 取"板级 key",实际扫到的全是 `...:ch:{通道号}` -/// 这类 key,把**通道号当成了板号** → 返回一堆幽灵板(每个还都没有 channels), -/// 这也是本接口"永远返回空"的另一半原因。 -/// 现在改为扫描通道级 key、**按解析出的板号分组**,再由通道数据组装板级视图。 -async fn load_boards_status( - conn: &mut redis::aio::MultiplexedConnection, - imei: &str, -) -> Value { - let ch_pattern = format!("device:{}:board:*:ch:*", imei); - let ch_keys: Vec = redis::cmd("KEYS") - .arg(&ch_pattern) - .query_async(&mut *conn) - .await - .unwrap_or_default(); +/// 为什么要有这道闸门:本接口现在**按 DB 拓扑拼 key**,一旦 `compartments` / `cabin_boards` +/// 里存在越界脏数据(`board_id = 0` / `channel_id = 9`),拼出来的就是 +/// `...:board:0:ch:9` 这类**永远不会被上报写入的垃圾 key**;挡掉它们同时也拦住了 +/// `...:ch:{通道号}:bat`(电池台账)/ `...:ch:{通道号}:order`(电量累加器)内部键。 +fn is_valid_channel_key_parts(board_id: i64, channel_id: i64) -> bool { + (BOARD_ID_MIN..=BOARD_ID_MAX).contains(&board_id) + && (CHANNEL_ID_MIN..=CHANNEL_ID_MAX).contains(&channel_id) +} - // 板号 → [(通道号, key)]:BTreeMap 保证板号升序,输出顺序稳定 - let mut grouped: BTreeMap> = BTreeMap::new(); - for key in ch_keys { - if let Some((board_no, channel_no)) = parse_channel_key(&key) { - grouped.entry(board_no).or_default().push((channel_no, key)); +/// 一条通道的原始字段快照;`None` = 该通道 key 在 Redis 里**不存在**。 +type ChannelSnapshot = Option>; + +/// 纯函数:把快照里的真实字段翻译成响应 JSON(字段名与旧实现完全一致)。 +fn channel_view_from_snapshot(snapshot: &HashMap) -> Value { + let field = |name: &str| -> Option<&str> { snapshot.get(name).map(String::as_str) }; + let number = |name: &str| -> Option { field(name).and_then(|v| v.parse::().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)>> = 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!({}); - for (board_no, mut entries) in grouped { - entries.sort_by_key(|(channel_no, _)| *channel_no); - let board = load_board_status(conn, &entries).await; - boards[board_no.to_string().as_str()] = board; + for (board_no, entries) in grouped { + let mut channels = json!({}); + let mut status_hex = String::new(); + for (channel_no, snapshot) in entries { + let channel = channel_view_from_snapshot(snapshot); + if let Some(hex) = channel.get("hex").and_then(|v| v.as_str()) { + status_hex.push_str(hex); + } + channels[channel_no.to_string().as_str()] = channel; + } + boards[board_no.to_string().as_str()] = json!({ + "status_hex": status_hex, + "channels": channels, + }); } boards } -/// 由**已排序**的通道级 key 组装单块仓控板的响应(板级 + 通道级)。 -async fn load_board_status( +/// 读取该柜的通道拓扑 `(板号, 通道号)`,按(板号, 通道号)升序。 +/// +/// 为什么用 DB 而不是扫 Redis:这是本接口**零扫描**的基础 —— 通道级 key 的构成 +/// 完全由 DB 拓扑决定(板号/通道号十进制不补零),拼出来与设备上报写入的 key 逐字相同。 +async fn fetch_channel_topology( + db: &sqlx::MySqlPool, + imei: &str, +) -> Result, 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, - entries: &[(i64, String)], + imei: &str, ) -> Value { - let mut channels = json!({}); - let mut status_hex = String::new(); - for (channel_no, ch_key) in entries { - let channel = load_channel_status(conn, ch_key).await; - // 板级 `status_hex` 已废弃(新协议板级没有状态字节): - // 用各通道的 `batt_status + pow_status`(各 2 位 hex)按**通道号升序**拼回, - // 与旧格式 `0001000200...` 同构,语义完全由真实字段解释 —— **字段名保持不变**。 - if let Some(hex) = channel.get("hex").and_then(|v| v.as_str()) { - status_hex.push_str(hex); + 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!({}); } - channels[channel_no.to_string().as_str()] = channel; + }; + + // 量程闸门:越界脏数据(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!({}); } - json!({ - "status_hex": status_hex, - "channels": channels, - }) + // 一次 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>, redis::RedisError> = + pipe.query_async(&mut *conn).await; + + let snapshots: Vec = 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 正在充电 / @@ -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> = 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 { text(idx).and_then(|v| v.parse::().ok()) }; - - derive_channel_view( - text(0), - text(1), - number(2), - number(3), - number(4), - number(5), - ) -} - // --------------------------------------------------------------------------- // 单元测试:通道状态推导(协议 §3.8,口径必须与 web 端 deviceStatus.ts 一致) // // 只测纯函数(不连 Redis / DB):这一层是"设备实时详情"接口正确性的关键, -// 因为它负责把 `batt_status` / `pow_status` 翻译成前端消费的旧字段名。 +// 因为它负责把 `batt_status` / `pow_status` 翻译成前端消费的旧字段名, +// 以及把 DB 拓扑 + Redis 快照组装成 `boards` 响应。 // --------------------------------------------------------------------------- #[cfg(test)] mod tests { @@ -1248,55 +1390,96 @@ mod tests { ); } - // ---- parse_channel_key:通道级 key 分组(板号 1–15 / 通道号 1–8)------- - /// 正常通道 key → (板号, 通道号) + // ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)------- + /// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key) #[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!( - parse_channel_key("device:864720562557021:board:1:ch:3"), - Some((1, 3)) + channel_redis_key("864720562557021", 1, 3), + "device:864720562557021:board:1:ch:3" ); assert_eq!( - parse_channel_key("device:864720562557021:board:15:ch:8"), - Some((15, 8)) + channel_redis_key("864720562557021", 15, 8), + "device:864720562557021:board:15:ch:8" ); } - /// **内部键必须被挡掉**:`...:ch:{通道号}:bat`(电池台账)/ `...:ch:{通道号}:order`(电量累加器) - /// 若被当成通道,会生成幽灵板(旧实现就是按 KEYS 末段取"板号"的) + /// 组装:Redis 里**不存在**的通道必须整条省略(与旧 `KEYS` 只返回存在键语义一致) #[test] - fn test_parse_channel_key_rejects_internal_keys() { - assert_eq!( - parse_channel_key("device:864720562557021:board:1:ch:3:bat"), - None - ); - assert_eq!( - parse_channel_key("device:864720562557021:board:1:ch:3:order"), - None - ); - // 旧实现会扫到这种"板级 key"(其实不存在),现在也必须拒绝 - assert_eq!(parse_channel_key("device:864720562557021:board:1"), None); - assert_eq!(parse_channel_key("device:864720562557021"), None); - assert_eq!(parse_channel_key(""), None); + fn test_assemble_boards_omits_channels_absent_from_redis() { + let topology = vec![(1i8, 1i8), (1, 2), (2, 1)]; + let present = HashMap::from([ + ("batt_status".to_string(), "82".to_string()), + ("pow_status".to_string(), "82".to_string()), + ("soc".to_string(), "850".to_string()), + ("voltage".to_string(), "54000".to_string()), + ("current".to_string(), "8000".to_string()), + ("temp".to_string(), "29800".to_string()), + ]); + let snapshots: Vec = vec![Some(present), None, 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] - fn test_parse_channel_key_rejects_out_of_range_and_malformed() { - assert_eq!(parse_channel_key("device:864720562557021:board:0:ch:3"), None); - assert_eq!(parse_channel_key("device:864720562557021:board:16:ch:3"), None); - assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:0"), None); - assert_eq!(parse_channel_key("device:864720562557021:board:1:ch:9"), None); - assert_eq!(parse_channel_key("device:864720562557021:board:a:ch:3"), None); - assert_eq!(parse_channel_key("device:864720562557021:board:1:xx:3"), None); - assert_eq!( - parse_channel_key("device:864720562557021:brd:1:ch:3"), - None - ); + fn test_assemble_boards_status_hex_follows_topology_order() { + let topology = vec![(1i8, 1i8), (1, 2)]; + let snap = |batt: &str, pow: &str| -> ChannelSnapshot { + Some(HashMap::from([ + ("batt_status".to_string(), batt.to_string()), + ("pow_status".to_string(), pow.to_string()), + ])) + }; + // SQL 已 `ORDER BY board_id, channel_id`,故按拓扑顺序拼接即可得到通道升序 + 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 由调用方给定 @@ -1311,9 +1494,9 @@ mod tests { } } - /// **未配置 scope = 不限制**:设备列表 WHERE 与原实现逐字一致(向后兼容) + /// 无任何入参的客户账号 ⇒ **组织隔离条件必须出现**(未配置 scope 时不额外收窄) #[test] - fn test_build_filter_where_without_scope_is_unchanged() { + fn test_build_filter_where_without_scope_keeps_org_isolation() { let params = CabinetFilterParams { organization_id: None, project_id: None, @@ -1322,10 +1505,75 @@ mod tests { bound: None, }; let (clause, binds) = build_filter_where(¶ms, &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"]); } + /// **核心越权回归**:客户传别人的 `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(¶ms, &user); + assert!(clause.contains("1=0"), "无组织必须空集:{}", clause); + } + } + /// **配置了项目范围**:组织条件之后必须再叠加 `c.project_id IN (...)`(取交集) #[test] 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 (clause, binds) = build_filter_where(¶ms, &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_eq!(binds, vec!["42", "7", "9"], "绑定顺序必须与占位符顺序一致"); } @@ -1357,8 +1605,9 @@ mod tests { bound: None, }; let (clause, binds) = build_filter_where(&by_project, &user); - assert_eq!(clause, "WHERE c.project_id = ? AND c.project_id IN (?)"); - assert_eq!(binds, vec!["999", "7"]); + assert!(clause.starts_with("WHERE c.project_id = ? AND c.project_id IN (SELECT pj.id")); + assert!(clause.ends_with("AND c.project_id IN (?)"), "{}", clause); + assert_eq!(binds, vec!["999", "42", "7"]); let by_org = CabinetFilterParams { organization_id: Some(43), @@ -1369,10 +1618,10 @@ mod tests { }; let (clause, binds) = build_filter_where(&by_org, &user); 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] fn test_build_filter_where_scope_combines_with_bound_filter() { let params = CabinetFilterParams { @@ -1384,11 +1633,13 @@ mod tests { }; let user = make_user(UserScope { project_ids: vec![7] }); let (clause, binds) = build_filter_where(¶ms, &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"]); } - /// 总管理员(role_level>=2)不受数据范围收窄(豁免) + /// 总管理员(role_level>=2)不受组织隔离与数据范围收窄(豁免); + /// 但**显式入参仍然生效**(只是纯筛选,不是边界) #[test] fn test_build_filter_where_super_admin_ignores_scope() { let params = CabinetFilterParams { @@ -1404,6 +1655,69 @@ mod tests { let (clause, binds) = build_filter_where(¶ms, &user); assert!(clause.is_empty(), "总管理员无任何过滤:{}", clause); 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:未配置范围时不收窄,配置后必须叠加项目收窄 diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index 0805635..eff2369 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -998,7 +998,14 @@ const CABINET_OPTION_COLUMNS: &str = /// GET /api/cabinets/options — 柜子下拉列表 /// -/// **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。 +/// **两道收窄(取交集)**: +/// 1. **组织隔离**([`auth::org_condition_for_project`])—— 无条件叠加: +/// 总管理员全量、客户侧仅本组织、无组织空集(**绝不退化成全量**); +/// 2. **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。 +/// +/// 历史缺陷:本接口只叠加了数据范围、**没有组织隔离** ⇒ 未配置 scope 的客户账号 +/// 不带 `project_id` 调用时能看到**全平台所有柜子**(含 IMEI/ICCID/地址)。 +/// `project_id` 入参只是纯筛选,不构成归属边界。 pub async fn list_cabinet_options( user: CurrentUser, State(state): State, @@ -1006,14 +1013,18 @@ pub async fn list_cabinet_options( ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; - // 动态拼 WHERE:项目筛选在前、数据范围收窄在后,绑定顺序与占位符顺序严格一致 - let (scope_cond, scope_binds) = user.effective_scope().project_condition("project_id"); + // 动态拼 WHERE:项目筛选 → 组织隔离 → 数据范围收窄, + // 书写顺序 = 占位符顺序 = 绑定顺序,三者严格一致 let mut where_clause = String::new(); let mut binds: Vec = Vec::new(); if let Some(pid) = params.project_id { where_clause.push_str("project_id = ?"); 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); binds.extend(scope_binds); @@ -1034,12 +1045,17 @@ pub async fn list_cabinet_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( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, params.cabinet_id).await?; let rows = sqlx::query_as::<_, CabinBoardOption>( "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 — 仓体下拉列表 +/// +/// **归属校验**:`cabin_board_id` → `cabin_boards.cabinet_id` → 柜子归属校验 +/// (组织归属 + 数据范围)。历史缺陷:只按 `cabin_board_id` 直查, +/// 传别人的仓板 id 就能拿到它的仓体列表。仓板不存在 → 404;归属不符 → 403。 pub async fn list_compartment_options( user: CurrentUser, State(state): State, @@ -1059,6 +1079,16 @@ pub async fn list_compartment_options( ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; + // 仓板 → 柜子:拿不到说明仓板不存在(外部输入的 id 不保证有效) + let cabinet_id: Option = + 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>( "SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id" ) diff --git a/software/api-server/src/routes/downloads.rs b/software/api-server/src/routes/downloads.rs index 27c054e..2909e15 100644 --- a/software/api-server/src/routes/downloads.rs +++ b/software/api-server/src/routes/downloads.rs @@ -133,6 +133,12 @@ pub async fn list_downloads( } /// 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( user: CurrentUser, State(state): State, @@ -142,9 +148,10 @@ pub async fn get_download( let row: Option = sqlx::query_as( "SELECT id, task_type, status, file_name, file_path, progress, \ error_message, created_at, completed_at \ - FROM download_tasks WHERE id = ?" + FROM download_tasks WHERE id = ? AND user_id = ?" ) .bind(id) + .bind(user.user_id) .fetch_optional(&state.mysql) .await?; @@ -233,22 +240,37 @@ pub async fn download_file( } /// POST /api/downloads/cleanup — 清理过期文件 +/// +/// **范围(本次修复)**:导出任务是**按用户私有**的数据,清理也必须按 `user_id` 限定 —— +/// 历史缺陷:原先不分用户、直接删除**全平台**已完成的过期任务文件, +/// 任何持 `charge_record:export` 的客户账号都能删掉别人的导出文件(破坏性操作)。 +/// 现在:工厂(`role_level >= 2`)保留全量清理(运维需要),客户侧**只清理自己的任务**, +/// 别人的任务与文件一概不动。 pub async fn cleanup_downloads( user: CurrentUser, State(state): State, Json(req): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge_record:export")?; - // 查询过期任务(状态为完成且超过保留天数) - let expired: Vec = 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, \ error_message, created_at, completed_at \ FROM download_tasks \ WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)" - ) - .bind(req.retain_days) - .fetch_all(&state.mysql) - .await?; + }; + let mut expired_query = sqlx::query_as::<_, DownloadTaskRow>(sql).bind(req.retain_days); + if only_own { + expired_query = expired_query.bind(user.user_id); + } + let expired: Vec = expired_query.fetch_all(&state.mysql).await?; let mut cleaned = 0u32; diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index 1a59a76..170c79f 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -1987,6 +1987,73 @@ struct FeedbackRow { created_at: chrono::NaiveDateTime, } +/// 反馈列表的 WHERE 子句与**文本**绑定值 +/// +/// - 组织隔离条件(文本)在 SQL 中**先于** `status` ⇒ 调用方必须先绑文本值、 +/// 最后单独绑 `status`(整型); +/// - 工厂(`role_level >= 2`)不加组织条件(全量); +/// - 客户侧无组织 → `1=0` 空集。 +fn feedback_filter(user: &CurrentUser, has_status: bool) -> (String, Vec) { + let mut conditions: Vec = Vec::new(); + let mut binds: Vec = 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> = 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 — 反馈列表(管理端) /// /// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据, @@ -1995,6 +2062,15 @@ struct FeedbackRow { /// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近; /// 操作员即使登录也会被 403 拦住。 /// +/// **组织隔离(本次修复)**:反馈按**提交人**归属 —— +/// `user_feedback.user_id → users.organization_id`(与操作日志同一口径)。 +/// 历史缺陷:本接口只校验了 `operation_log:view` 就全量查询 ⇒ 任何**企业管理员** +/// 都能读到其它客户组织的反馈(含联系方式)并改其处理状态。 +/// 规则:工厂(`role_level >= 2`)全量;客户侧仅本组织成员提交的反馈; +/// `organization_id` 为 `NULL` → 空集(**绝不退化成全量**)。 +/// 数据范围(`user_scopes`)**不叠加**:反馈与项目/设备没有关联列,项目级范围无法映射 +/// (与操作日志完全同一口径,见 AGENTS.md「数据范围」一节的说明)。 +/// /// `status` 为可选筛选条件,落到 SQL(不影响未传参时的原有查询语义)。 pub async fn list_feedback( user: CurrentUser, @@ -2016,12 +2092,16 @@ pub async fn list_feedback( } } - // `filter` 只可能是下面这两个常量字面量之一(不含任何用户输入), - // 参数依旧全部走 bind,不拼接外部数据 - let filter = if status.is_some() { " WHERE status = ?" } else { "" }; + // 条件与绑定值一一对应:**文本条件(组织隔离)在前、`status`(整型)在后**, + // 因此绑定顺序同样是"先文本占位、后 status"(见下面的 count/list 绑定) + 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 mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); + for bind in &binds { + count_query = count_query.bind(bind); + } if let Some(s) = status { count_query = count_query.bind(s); } @@ -2033,6 +2113,9 @@ pub async fn list_feedback( filter ); 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 { list_query = list_query.bind(s); } @@ -2148,6 +2231,10 @@ pub async fn update_feedback_status( return Err(AppError::BadRequest("状态值不合法".into())); } + // 归属校验:客户侧只能处置本组织成员提交的反馈(工厂豁免)。 + // 历史缺陷:原先只校验权限码就直接 UPDATE ⇒ 企业管理员能改别的组织的反馈状态。 + verify_feedback_ownership(pool, &user, id).await?; + let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?") .bind(body.status) .bind(id) diff --git a/software/api-server/src/workers/report_worker.rs b/software/api-server/src/workers/report_worker.rs index f447b25..58072f7 100644 --- a/software/api-server/src/workers/report_worker.rs +++ b/software/api-server/src/workers/report_worker.rs @@ -19,8 +19,13 @@ //! 该通道最近一次 `bat_in` 的电池信息(`on` 建单时取电池 id 与起始 SOC) //! //! 本模块消费 / 删除的 key(由 device-server 写入,见协议 §3.5 / §3.6): -//! - `pending:{imei}`(Hash):`field = msg_id`、`value = act`、TTL 120 秒; -//! 收到设备回执(带 `suc`)时按 `msg_id` + `act` 双条件命中并 `HDEL` +//! - `pending:{imei}`(Hash):`field = msg_id`、`value = "{act}:{deadline_unix_ms}"`、 +//! 整表 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` 负责, //! 它**不复位**占用状态,故本模块额外启动周期性对账(见 [`reconcile_usage_loop`])。 @@ -319,12 +324,10 @@ async fn handle_auth_str( redis: &ConnectionManager, ) -> Result<(), Box> { - 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(()); - } + // ⚠️ **顺序很重要:登记校验必须在频率限制之前**。 + // 协议 §5.1 / §8.1 规定"未登记的 IMEI:平台**不发码、不写缓存、不回任何包**(静默丢弃)"; + // 若先跑限流,未登记设备在第 6 次请求时会收到 `rate limit exceeded` 的**应答**,与协议不符。 + // 反过来把登记校验放前面,未登记设备永远得不到任何回应,也不会白占限流额度。 // 以 DB(cabinets.auth_str)为权威存储,Redis 仅作缓存:读取已登记的安全码与下发标记 let db_row: Option<(Option, Option)> = @@ -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 已标记下发即拒绝,须管理员「重置安全码」后才能重新申领 if issued == Some(1) { tracing::warn!("[auth_str] 安全码已下发过,拒绝重复申领 dev_id={}", dev_id); @@ -563,6 +576,8 @@ async fn mark_device_online(redis: &ConnectionManager, dev_id: &str) { enum SpecSource { /// 真实拓扑现算(`cabin_boards` / `compartments`),最准 Topology, + /// 真实拓扑**超出协议量程**(>15 板 / >8 通道每板)→ 已收敛到 1–15 / 1–8(协议 §3.7) + TopologyClamped, /// 拓扑不完整 → 用 `cabinets.cell`(仓控板数)× 每板通道数 LegacyCell, /// `cell` 也为空 / 0 → 用常量默认 [`DEFAULT_BOARD_COUNT`] × [`CHANNELS_PER_BOARD`] @@ -573,7 +588,12 @@ enum SpecSource { /// /// 优先级:**真实拓扑 > `cabinets.cell` > 常量默认**;无论输入多离谱, /// **任何分支都不会返回 `"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( board_count: i64, compartment_count: i64, @@ -582,7 +602,15 @@ fn build_spec( // 1) 真实拓扑:仓控板数与每板通道数都必须 > 0 才算"拓扑完整" let per_board = if board_count > 0 { compartment_count / board_count } else { 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`(仓控板数量), @@ -648,7 +676,18 @@ async fn fetch_spec(mysql: &MySqlPool, dev_id: &str) -> Result }; 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!( "[login] 该柜拓扑未配置(cabin_boards={} compartments={} cabinets.cell={:?}),\ spec 使用兜底值 spec={} 来源={:?} dev_id={}", @@ -2165,7 +2204,7 @@ async fn reconcile_usage_loop(mysql: MySqlPool, redis: ConnectionManager) { /// 2. **"无订单却标占用"兜底复位**([`reconcile_orderless_compartments`]):`status=1` 但 /// **一笔订单都没有**的行走不到第 1 步(它要求 `EXISTS(已结束订单)`),必须单独兜底 /// 3. **"无电池"状态兜底**(**待确认第 18 项**):该通道 `batt_status` 的 **bit7 连续 -/// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §5.8 位定义:0=无电池)却还挂着活跃订单 +/// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §3.8 位定义:0=无电池)却还挂着活跃订单 /// ⇒ 判定"人把电池拔走了但设备没机会上报 `bat_out`",**提前结单**,避免订单一直挂到 10 小时超时。 /// /// 为什么放在这里而不是 `status_post` 处理路径:这里只需扫**活跃订单**(数量少), @@ -2219,7 +2258,7 @@ async fn reconcile_usage_once( /// /// 判据(先严后宽,只清"确实空闲"的行): /// 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=1 ⇒ 电池还在仓里(可能是"已插入待充电"的正常状态)⇒ **保留**; /// 3. 拿不到通道状态(键不存在 / Redis 故障 / Redis 重启后状态丢失)⇒ 退回 @@ -2293,7 +2332,7 @@ async fn reconcile_orderless_compartments( /// 读取通道 `batt_status` 的 **bit7**(有无电池):`Some(true)`=有电池 / `Some(false)`=无电池 / /// `None`=拿不到状态(键不存在或 Redis 读取失败)。 /// -/// 位定义见协议 §5.8 与 [`energy::accumulate`] 的同一判据(`batt_status & 0x80`)。 +/// 位定义见协议 §3.8 与 [`energy::accumulate`] 的同一判据(`batt_status & 0x80`)。 /// 键为无 TTL 的通道实时状态 Hash(`report_worker` 每次 `status_post` 覆盖), /// 因此"键不存在"只可能是设备长期离线或 Redis 重启 —— 调用方需自行兜底。 async fn channel_battery_present( @@ -2417,6 +2456,33 @@ async fn reconcile_no_battery_orders( /// 两处都改才算真正调整阈值。 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 = 已完成(正常结束 / 超时兜底结单) const ORDER_STATUS_DONE: i8 = 1; @@ -2439,16 +2505,12 @@ pub async fn finalize_timeout_order( redis: &ConnectionManager, compartment_id: i64, ) -> Result { - let order_id: Option = sqlx::query_scalar( - "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", - ) - .bind(compartment_id) - .bind(-ORDER_TIMEOUT_HOURS) - .fetch_optional(pool) - .await?; + // 方向由 [`FINALIZE_EXPIRED_ORDER_SQL`] 承载并被单测守门;这里绑定的是**负数** + let order_id: Option = sqlx::query_scalar(FINALIZE_EXPIRED_ORDER_SQL) + .bind(compartment_id) + .bind(-ORDER_TIMEOUT_HOURS) + .fetch_optional(pool) + .await?; match order_id { Some(order_id) => { @@ -3110,14 +3172,13 @@ async fn handle_device_on( /// 处理指令执行回执(`open` / `pow_on` / `pow_off` / `reset` / 回执形态的 `on` / `off`)。 /// /// 协议 §3.5:**回执是上行应答,平台一律不再回包**(双方都对"应答"回包会形成回环)。 -/// - **A4 配对**:device-server 下发时写挂起项 `pending:{dev_id}` → field = `msg_id`、value = `act`。 -/// 这里先 `HGET` 取挂起项的 `act`,与回执的 `act` **双条件命中**才 `HDEL` -/// (协议 §3.5:配对必须同时校验 `msg_id` 与 `act`;仅凭 `msg_id` 可能错配另一个交互)。 -/// 字段不存在 → 记 WARN「迟到的回执,已作废」,仍然不回包。 -/// - **60 秒语义**:挂起项没有入队时间,且 device-server 已完工(挂起项 TTL 120 秒兜底自动清理), -/// 因此这里**只按"字段是否存在"判定**:命中即视为 60 秒内到达;字段不存在即视为已作废。 -/// 120 秒内到达的"真迟到"回执会被记为已配对——回执本就只用于记录与配对,不触发任何业务动作, -/// 所以不影响正确性(这也正是协议 §3.6「超时后不重做、不补记业务动作」的要求)。 +/// - **A4 配对**:device-server 下发时写挂起项 `pending:{dev_id}` → +/// field = `msg_id`、value = `"{act}:{deadline_unix_ms}"`。 +/// 这里 `HGET` 取挂起项,与回执的 `act` **双条件命中**(§3.5)**且未超过 60 秒窗口**(§3.6) +/// 才 `HDEL`;`act` 不一致 / 已超窗 / 字段不存在 → 记 WARN 并**不配对**(见 [`classify_pending`])。 +/// - **60 秒语义**:窗口由挂起项值里的 `deadline`(下发时刻 + 60 秒)精确判定, +/// 不依赖 Hash 的 TTL —— 同一 IMEI 后续再有指令下发会刷新 Hash TTL。 +/// 超窗回执一律丢弃、不补配、不重传(协议 §3.6),并**记 WARN**。 /// - **A6 记录**:设备回执里的 `err`(`suc=0` 时的失败原因)写入 `device_logs.content`。 async fn handle_cmd_result( msg: &serde_json::Value, @@ -3164,22 +3225,74 @@ async fn handle_cmd_result( 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) { + match raw.rsplit_once(':') { + Some((act, ts)) => match ts.parse::() { + 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}` 命中并删除。 /// /// 挂起表由 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) { let key = pending_key(dev_id); let mut conn = redis.clone(); - // 先查 value(下发的 act),实现 §3.5 要求的 `msg_id` + `act` 双条件配对 - let pending_act: Result, redis::RedisError> = redis::cmd("HGET") + // 先查 value(下发的 act + 截止时间),实现 §3.5 的 `msg_id` + `act` 双条件 + §3.6 的 60 秒窗口 + let pending_raw: Result, redis::RedisError> = redis::cmd("HGET") .arg(&key) .arg(msg_id) .query_async(&mut conn) .await; - let pending_act = match pending_act { + let pending_raw = match pending_raw { Ok(v) => v, Err(e) => { tracing::error!("[cmd_result] 读取挂起项失败 key={} msg_id={} err={}", key, msg_id, e); @@ -3187,29 +3300,50 @@ async fn pair_pending_command(redis: &ConnectionManager, dev_id: &str, act: &str } }; - let matched = match pending_act.as_deref() { - None => false, - Some(pending) => pending == act, - }; + let lookup = classify_pending( + pending_raw.as_deref(), + act, + chrono::Utc::now().timestamp_millis(), + ); - if !matched { - match pending_act.as_deref() { - // 字段不存在:已超时作废(或无对应下发)→ 不改动挂起表,留给真正的回执 - None => tracing::warn!( - "[cmd_result] 迟到的回执,已作废 dev_id={} act={} msg_id={}", + match lookup { + PendingLookup::Missing => { + // 字段不存在:已超时被清理(或无对应下发)。§3.6:超时后一律丢弃,不补配。 + tracing::warn!( + "[cmd_result] 迟到的回执,挂起项已作废(协议 §3.6:超时后一律丢弃)dev_id={} act={} msg_id={}", dev_id, act, 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 = 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 = redis::cmd("HDEL") @@ -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 #[test] fn test_build_spec_never_produces_all_zero_spec() { @@ -3531,6 +3698,73 @@ mod tests { 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)--------------------- /// bit0 = 0(供电正常)→ 清除停电标记;bit0 = 1(仍在报警)→ 保留标记。 @@ -3727,14 +3961,94 @@ mod tests { drop(guard_a); } - /// 超时兜底的"安全线":阈值必须**往回** 10 小时(`TIMESTAMPADD` 绑定负数)。 - /// 若写成 `+10`,`start_time < NOW() + 10h` 会命中**所有**活跃订单, - /// 刚建的新单也会被当成超时单(`workers/order_timeout.rs` 现存缺陷); - /// [`finalize_timeout_order`] 正是靠这个负号自保。 + /// **纯函数孪生体(仅测试)**:与 [`FINALIZE_EXPIRED_ORDER_SQL`] 及 + /// `workers/order_timeout.rs::scan_once` 的 SQL **同义** —— `start_time + hours < now` ⇒ 已超时。 + /// + /// 为什么需要它: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] - 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!( + 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 错误**不能**被当成唯一键冲突(否则会把真错误静默吞掉)。 diff --git a/software/device-server/src/tcp/connection.rs b/software/device-server/src/tcp/connection.rs index 8192356..8d74149 100644 --- a/software/device-server/src/tcp/connection.rs +++ b/software/device-server/src/tcp/connection.rs @@ -41,10 +41,11 @@ use tokio::sync::oneshot; /// (`auth_str` 是配网第一步,`login` 是握手本身);其余任何 `act` —— 含 `status_post` /// 等应答、`on`/`off` 业务指令、`open`/`pow_on`/`pow_off`/`reset` —— **一律不投递**。 /// -/// 依据(`docs/柜控板固件对接文档.md` v1.1):§2.2「登录门控」(L80)与 §10 第 10 条 -/// (L1116:只有 `auth_str`/`login` 可以在未登录状态发送,其余 `act` 必须先 `login` 成功)、 -/// L372(上行同一规则)、L399/L401/L491(下发要求该 IMEI 存在**已登录的连接**, +/// 依据(`docs/柜控板固件对接文档.md` v1.1):§2.2「登录门控」、§10 第 10 条 +/// (只有 `auth_str`/`login` 可以在未登录状态发送,其余 `act` 必须先 `login` 成功)、 +/// §4.1(上行同一规则)、§4.2(下发要求该 IMEI 存在**已登录的连接**, /// 设备也是在收到 `suc=1` 且含 `spec` 的应答后才算已登录)。 +/// ⚠️ 引用一律用**小节号**(不要写 `L372` 这类行号:文档一改行号就漂)。 /// /// 为什么从"敏感指令清单"改成"登录前白名单":原 `SENSITIVE_ACTS` 只列了 /// `open`/`pow_on`/`pow_off`/`reset`/`auth_str`,**漏了 `on`/`off`** —— 未登录连接会被投递 @@ -54,11 +55,105 @@ use tokio::sync::oneshot; /// 天然在白名单内;`login` 成功应答投递后连接即置为已认证,后续 `act` 随之放行。 const PRE_LOGIN_ALLOWED_ACTS: &[&str] = &["auth_str", "login"]; -/// 挂起登记(`pending:{dev_id}`)的 TTL(秒)。 +/// 挂起登记(`pending:{dev_id}`)的 **TTL(秒)**,必须等于协议 §3.6 的交互窗口 **60 秒**。 /// -/// 取值 120 = 协议 60 秒等待窗口的 2 倍:既保证超时后自动作废, -/// 又留出余量避免登记项在回执到达前先过期(api-server 命中时会 `HDEL`)。 -const PENDING_TTL_SECS: u32 = 120; +/// 历史取值 120(= 协议窗口的 2 倍)与协议不符:协议 §3.6 明确"平台对每条下发的指令只等 +/// **60 秒**,超时即**丢弃该 `msg_id` 的挂起项**、不重传、**记 WARN**"。 +/// 现在 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 { + raw.rsplit_once(':').and_then(|(_, ts)| ts.parse::().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 = 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 = 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 { @@ -286,11 +381,17 @@ impl ConnectionPool { dev_id: &str, json: &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 往返不应阻塞其他连接的读写。 // 指令已投递到写通道后才登记;失败只记 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; } Ok(()) @@ -298,13 +399,14 @@ impl ConnectionPool { /// 出站报文的校验与投递:跑安全门控、按需注入 `msg_id`,最后写入设备写通道。 /// - /// 返回 `Some((msg_id, act))` 表示这是**指令**(需登记挂起项); - /// 返回 `None` 表示这是**应答**(无平台挂起项,不登记)。 + /// 返回 [`Outbound`]:`pending` 为 `Some((msg_id, act))` 表示这是**指令** + /// (需登记挂起项);`None` 表示这是**应答**(无平台挂起项,不登记)。 + /// `authenticated` = 投递后该连接是否处于**已认证**状态(`login` 成功应答投递后即为 true)。 async fn prepare_outbound( &self, dev_id: &str, json: &str, - ) -> Result, &'static str> { + ) -> Result { let mut map = self.inner.write().await; let conn = map.get_mut(dev_id).ok_or("设备不在线")?; let mut cmd: serde_json::Value = @@ -315,7 +417,7 @@ impl ConnectionPool { let is_response = cmd.get("suc").is_some(); // **登录门控(下行)**:未认证连接只允许投递 [`PRE_LOGIN_ALLOWED_ACTS`], - // 其余一律不投递(协议 §10 第 10 条 / L372:上下行同一规则)。 + // 其余一律不投递(协议 §4.2 / §10 第 10 条:上下行同一规则)。 // // 这里**不再区分应答/指令**:白名单本身已保证 `auth_str`/`login` 的应答能通过 // (它们的 `act` 就是这两个值),不会把首次配网锁死;而其它 `act` 的应答 @@ -375,7 +477,7 @@ impl ConnectionPool { // else: 应答,保持原始 msg_id 不动(透传设备发起的请求的编号) 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 只有拿到 -/// 「`msg_id` → `act`」映射,才能把设备回执配对回具体的下发指令(§3.5 要求 -/// `msg_id` + `act` 双条件配对);平台对每条指令只等 60 秒,超时即作废。 +/// 「`msg_id` → `act`(+截止时间)」映射,才能把设备回执配对回具体的下发指令(§3.5 要求 +/// `msg_id` + `act` 双条件配对);平台对每条指令只等 **60 秒**(§3.6),超时即作废。 /// /// 失败处理:Redis 异常只记 WARN —— 挂起登记是**辅助功能**, /// 绝不能让一条已成功投递到设备的指令因为登记失败而报错(那会误导上层重发)。 @@ -412,16 +514,26 @@ async fn register_pending( } 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 result: Result<(), _> = redis::cmd("HSET") .arg(&key) .arg(msg_id) - .arg(act) + .arg(&value) .query_async(&mut conn) .await; 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) => { tracing::warn!( "[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」 // ----------------------------------------------------------------------- @@ -763,7 +875,7 @@ mod tests { (r#"{"act":"reset","id":"01"}"#, "reset"), (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, Err(e) => panic!("已认证连接不得被拦: {} ({})", json, e), }; @@ -773,16 +885,95 @@ mod tests { }; let v = parse(&got); assert_eq!(v["act"], act); + assert!(outbound.authenticated, "已认证连接投递后仍应是已认证态: {}", json); if json.contains("\"suc\"") { // 应答:透传原 msg_id,不登记挂起项 assert_eq!(v["msg_id"], 9); - assert!(pending.is_none(), "应答不应登记挂起项: {}", json); + assert!(outbound.pending.is_none(), "应答不应登记挂起项: {}", json); } else { // 指令:注入自增 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"); } } } + + // ----------------------------------------------------------------------- + // 挂起项 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); + } } diff --git a/software/device-server/src/tcp/server.rs b/software/device-server/src/tcp/server.rs index 5bb3102..d8a379c 100644 --- a/software/device-server/src/tcp/server.rs +++ b/software/device-server/src/tcp/server.rs @@ -6,6 +6,7 @@ use tokio::sync::mpsc; use tokio_util::codec::{FramedRead, LinesCodec}; 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::protocol::DeviceMessage; @@ -144,6 +145,10 @@ async fn handle_connection( ); current_dev_id = Some(dev_id.clone()); 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 } => { // **连接接管**:与 MQTT 同 clientId 语义一致(新连接赢)。 @@ -162,6 +167,8 @@ async fn handle_connection( ); current_dev_id = Some(dev_id.clone()); 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 依赖它识别设备)。 // 无返回值:本服务不直接回复设备,应答由 api-server 经 device:commands 下发。 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)) => { tracing::warn!("[{}] 读取错误: {}", peer, e); @@ -293,6 +309,9 @@ async fn handle_connection( if let Some(ref dev_id) = current_dev_id { if pool.remove_if(dev_id, conn_id).await { tracing::info!("[断连] dev_id={} conn_id={} 池条目已清理", dev_id, conn_id); + // 本连接是该 IMEI 的**当前持有者**且已断开 ⇒ 连接级认证标记必须立刻删除, + // 否则 api-server 在 TTL 窗口内仍会认为"设备在线可下发"(本标记要消灭的正是这类假在线)。 + clear_device_authed(&redis, dev_id).await; } else { tracing::info!( "[断连] dev_id={} conn_id={} 池条目已易主,保留当前持有者(不清理)", diff --git a/software/h5-uniapp/src/pages/device-detail/index.vue b/software/h5-uniapp/src/pages/device-detail/index.vue index 6833b3d..689ca0b 100644 --- a/software/h5-uniapp/src/pages/device-detail/index.vue +++ b/software/h5-uniapp/src/pages/device-detail/index.vue @@ -16,7 +16,7 @@ {{ data.abstract_id }} {{ data.status === 1 ? '在线' : '离线' }} - + {{ data.rssi != null ? data.rssi : '—' }} @@ -115,6 +115,7 @@ import { powerFlagOf, cabinetDoorTag, cabinetStatusTags, + rssiBarColors, } from '../../utils/deviceStatus' const statusBarHeight = ref(47) @@ -170,12 +171,9 @@ async function fetchDetail(id: number) { finally { loading.value = false } } -function signalClass(rssi?: number): string { - if (!rssi || rssi < 30) return 'off' - if (rssi >= 55) return 'good' - if (rssi >= 40) return 'mid' - return 'low' -} +// RSSI 分档/配色/格数统一走 `utils/deviceStatus.ts::rssiBarColors()`(与 PC 端同源,5 档)。 +// ⚠️ 历史实现是本文件里的 `signalClass()`:只有 4 档(无 ≥70)、30–39 用红色、 +// 且恒渲染 5 格只改颜色 —— 2026-09 已收敛掉,不要再在页面里另写一套阈值。 /** * 通道状态 → 卡片配色 class * @@ -253,7 +251,8 @@ function openLocation() { .signal { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; } .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.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; } .pow-tags { display: flex; gap: 4px; } .pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; white-space: nowrap; } diff --git a/software/h5-uniapp/src/pages/home/index.vue b/software/h5-uniapp/src/pages/home/index.vue index 67c98b6..d6c737f 100644 --- a/software/h5-uniapp/src/pages/home/index.vue +++ b/software/h5-uniapp/src/pages/home/index.vue @@ -45,8 +45,15 @@ {{ formatCelsius(cab.temp) }} - - + + + {{ cab.rssi ?? '—' }} @@ -93,7 +100,7 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app' import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api' import { useAuthStore } from '../../store/auth' 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 navBarHeight = ref(44) @@ -111,12 +118,9 @@ setTimeout(calcNav, 600) const authStore = useAuthStore() -function signalClass(rssi?: number): string { - if (!rssi || rssi < 30) return 'off' - if (rssi >= 55) return 'good' - if (rssi >= 40) return 'mid' - return 'low' -} +// RSSI 分档/配色/格数统一走 `utils/deviceStatus.ts::rssiBarColors()`(与 PC 端同源,5 档)。 +// ⚠️ 历史实现是本文件里的 `signalClass()`:只有 4 档(无 ≥70)、30–39 用红色、 +// 且恒渲染 5 格只改颜色 —— 2026-09 已收敛掉,不要再在页面里另写一套阈值。 const stats = ref({ 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 || []) @@ -268,9 +272,10 @@ function showScanInput() { .header-right { display: flex; align-items: center; gap: 8px; } .signal-inline { display: flex; align-items: center; gap: 3px; } .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.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; } .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; } diff --git a/software/h5-uniapp/src/utils/deviceStatus.ts b/software/h5-uniapp/src/utils/deviceStatus.ts index cb51c83..41f266f 100644 --- a/software/h5-uniapp/src/utils/deviceStatus.ts +++ b/software/h5-uniapp/src/utils/deviceStatus.ts @@ -309,3 +309,91 @@ export function formatCelsius(temp?: number | null): string { const celsius = kelvinX100ToCelsius(temp) 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) +} diff --git a/software/web/src/pages/devices/CabinetGrid.tsx b/software/web/src/pages/devices/CabinetGrid.tsx index 5da03fc..390c8d9 100644 --- a/software/web/src/pages/devices/CabinetGrid.tsx +++ b/software/web/src/pages/devices/CabinetGrid.tsx @@ -30,7 +30,7 @@ import QRCode from 'qrcode'; import { cabinetApi } from '@/api/devices'; import type { Cabinet } from '@/api/devices'; import { useAuthStore } from '@/stores/auth'; -import { cabinetDoorState, formatTempK100, normalizeFlag } from '@/utils/deviceStatus'; +import { cabinetDoorState, formatTempK100, normalizeFlag, rssiSignal } from '@/utils/deviceStatus'; interface CabinetGridProps { cabinets: Cabinet[]; @@ -86,25 +86,25 @@ function QRCodeModal({ abstractId, onDownload }: { abstractId: string; onDownloa ); } -/** 信号图标 */ +/** + * 信号图标 + * + * 分档阈值与颜色**统一取自** `@/utils/deviceStatus` 的 `rssiSignal()`(与小程序端同源同名,5 档)。 + * 图标只有 4 条 ``(第 5 格是底部圆点 `M2 20h.01`),故按 `level` 决定亮几条: + * `level 0` → 全灭只剩圆点;`level 4` → 4 条全亮。 + */ function SignalIconComponent({ rssi }: { rssi?: number }) { - const color = !rssi || rssi < 30 ? '#9ca3af' - : rssi >= 70 ? '#16a34a' - : rssi >= 55 ? '#2563eb' - : rssi >= 40 ? '#f59e0b' - : '#f97316'; - const bars = !rssi || rssi < 30 ? [] as number[] - : rssi >= 70 ? [30, 40, 55, 70] - : rssi >= 55 ? [30, 40, 55] - : rssi >= 40 ? [30, 40] - : rssi >= 30 ? [30] - : []; + const signal = rssiSignal(rssi); + /** 4 条 ``,顺序即档位顺序(第 i 条对应 `level > i`) */ + const paths = [ + { key: 30, d: 'M7 20v-4' }, + { key: 40, d: 'M12 20v-8' }, + { key: 55, d: 'M17 20V8' }, + { key: 70, d: 'M22 4v16' }, + ]; return ( - - {bars.includes(70) && } - {bars.includes(55) && } - {bars.includes(40) && } - {bars.includes(30) && } + + {paths.map((p, i) => (signal.level > i ? : null))} ); @@ -344,11 +344,7 @@ export default function CabinetGrid({ cabinets, loading, onRefresh }: CabinetGri ) : (
{cabinets.map((cab) => { - const signalColor = !cab.rssi || cab.rssi < 30 ? '#9ca3af' - : cab.rssi >= 70 ? '#16a34a' - : cab.rssi >= 55 ? '#2563eb' - : cab.rssi >= 40 ? '#f59e0b' - : '#f97316'; + const signalColor = rssiSignal(cab.rssi).color; return ( `(第 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); +} diff --git a/test/rssi_signal.test.mjs b/test/rssi_signal.test.mjs new file mode 100644 index 0000000..25ee2f0 --- /dev/null +++ b/test/rssi_signal.test.mjs @@ -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)