Compare commits

..

3 Commits

Author SHA1 Message Date
12451
471b61d277 fix(test)+fix(deploy)+docs: 探针状态语义反转修复、退出码补齐、文档校订
1) probe_export.py:任务状态语义**搞反了**(导出成功也会被判失败)
   - download_tasks.status 的权威语义是 0 排队 / 1 处理中 / **2 完成** / 3 失败
     (db/migrate.rs:225 列注释;写入侧 charge_records.rs::mark_task_completed 写的就是 2)。
   - 该脚本此前**等 status == 1(处理中)当成功、把 status == 2(完成)判成"导出失败"**
     —— 典型假红。已按权威语义订正,并与另外两处**本来就正确**的口径对齐:
     web/src/pages/charge-records/DownloadCenter.tsx::statusText、
     test/schema_alignment_e2e.py(等 status in (2,3) 再断言 == 2)。
   - 同批修正退出码分类:账号缺 charge_record:export(403)由 exit 1 改为
     **exit 2「未验证」** —— 本项要验证的是"降权成 pms 后能否往 exports/ 落盘",
     没权限根本走不到那一步,报 1 是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。
   - 允许用 PMS_PROBE_PHONE / PMS_PROBE_PASSWORD 换成有权限的账号。
   - **C 项结论(由落盘物证直接判定,不依赖该脚本)**:测试环境
     exports/充电记录__-.xlsx 5559 B、生产同类产物 5457 B,属主均为 pms:pms;
     两环境 exports/uploads 目录均 pms:pms,两 api-server 单元均 User=pms/Group=pms
     ⇒ 降权进程确实能落盘。已在 README 写明并给出取证依据。

2) deploy-prod.ps1 的退出码(此前失败的探针只打印 ❌ 就继续,脚本仍以 0 退出)
   - 消费链路探测失败(exit 1)现在**记入失败清单**并以 exit 1 收尾;
     "未验证"(隧道没起)默认仍不计失败(环境问题会误报),
     但新增 **-StrictVerify** 开关供 CI/无人值守使用,打开即 exit 1。
   - 好处:把这条从"打印了 ❌ 却 exit 0"(仓库第 8 条假绿)里摘出来。

3) 统一退出码 0/1/2 落地到本批脚本,并对每个失败路径实测取证:
   - test/tcp_e2e_smoke.py:13/13 通过(含未登录业务报文被静默丢弃);先复位夹具再跑。
   - test/security-hardening/ 新增 verdict.py 判据容器(未跟踪 → 本次入库);
     4 个探针全部按失败→exit 1 改造,并各实测过失败路径。
   - 实测结果(2026-09-20 发布后回归):smoke_prod 4/4 exit 0、
     probe_prod 5/5 exit 0、probe_upload 6/6 exit 0。

4) 工具与文档
   - tools/db.py 默认库由**生产 pms 改为测试 pms_dev**(可用 PMS_MYSQL_DB 覆盖,
     切生产时打印醒目警告)—— "以为在测测试库、实际打生产"的隐患。
   - tools/db_schema_diff.py:连不上必须是 exit 2,绝不伪装成"无漂移"的 0。
   - 新增 tools/dev-tunnel.ps1(入库,SSH 隧道;口令走 PMS_SSH_PASSWORD 环境变量),
     配套 tools/dev-tunnel-run.ps1 自愈启动器**保持 gitignore**(内含口令)。
   - deploy/nginx/nginx.conf 镜像随线上同步(移除 stream 转发;与线上逐字节一致)。
   - AGENTS.md / README.md:补「踩过的坑」第 7/8 条(掐断上游管道会杀被测进程、
     打印 ❌ 却 exit 0 是假绿)、退出码约定、本地开发改 SSH 隧道、
     在线判据口径订正、order_timeout 守门补齐、小程序 vue-tsc 门禁与其抓到的两个真 BUG。

发布验收(本次统一发布,按 device→api 顺序):
- 测试环境与生产环境四服务 active,端口 9000/9001/3000/3001 正确,ERROR 均为 0;
- 生产迁移按判据 grep '更新列注释' 实测对齐 cabinets.status 列注释;
- 结构漂移 python tools/db_schema_diff.py = 0 处漂移 / exit 0(--vs-ddl 两库同样 0);
- schema_alignment_e2e --env dev 全部通过 exit 0;
- 生产域名 /api/pms/cabinets -> 401、/pms/ -> 200;消费链路探测通过。
2026-09-20 04:31:42 +08:00
12451
aa02cd4e58 fix(mp): 接入 vue-tsc 真类型检查并修掉它抓出的两个真实缺陷
小程序端此前**没有任何类型门禁**:tsc 不检查 .vue,而 9 个页面全是 .vue,
npm run build:mp-weixin 只验语法不验类型。接入 vue-tsc 后立刻抓到 17 个错误,
其中两个是真 BUG:

1. me/index.vue:写 const user = authStore.user 之后又写 user.value ——
   Pinia setup store 对外已把 ref 解包,user.value 恒为 undefined
   ⇒ **头像上传成功后写回 store 的那行是空操作**(要等冷启动才显示新头像);
   且直接取值是快照、丢掉响应式。改为 computed(() => authStore.user),模板无需改动。
2. api/index.ts:CompartmentDetail 漏了后端确实会返回的 temp
   (routes/h5.rs 的 COMPARTMENT_REALTIME_FIELDS 第 6 项,有单测守门)
   ⇒ compartment-detail 页读 data.temp 报 TS2339。已按后端契约补上。

其余 12 处是宿主全局 wx 未声明(@dcloudio/types 只声明跨端 uni),
在 env.d.ts 里**只声明实际用到的两个 API**(刻意不写 [k:string]:any,
否则等于把这一层检查关掉)。

顺带清理:
- 删除死页面 pages/devices/index.vue(不在 pages.json、无任何跳转引用,grep 确认);
- 删除仓控板「在线/离线」徽标:cabin_boards.status **全后端没有任何写入方**
  (生产库实测取值只有 0),该徽标恒定显示"离线",属对用户的谎报;
  协议层面也没有"仓控板在线"这个概念(TCP 长连接的是柜控板,按 IMEI 标识)。
  按「柜的就显示到柜子」+「不显示推不出来的状态」去掉,并在类型上加禁止说明。

已按仓库铁律自证门禁能失败:植入 export const probe: number = 'x' → exit 2;撤销 → exit 0。
2026-09-20 04:31:42 +08:00
12451
c3a3ea7c3a fix(api)+fix(device): 状态取值收敛、超时方向守门补齐、在线判据口径订正
1) cabinets.status 写入侧收敛(此前 body.status 是 i8,0/99/-1 都能落库)
   - create_cabinet 的 INSERT **显式写 status = 1**,不再依赖列默认值 0:
     冻结口径是「2 = 禁用,其余 = 启用」,但列默认会产生第二种"启用"表示,
     任何按 status = 1 做的统计都会悄悄漏掉新建柜子(见「踩过的坑」第 5 条)。
   - PUT /api/cabinets/:id/status 增加白名单 {1,2},抽成纯函数 is_valid_cabinet_status
     并配守门单测(i8 全量遍历断言,杜绝"只挡住我知道的那几个")。
     白名单校验放在**鉴权之后**:越权恒 403,不会因参数非法而先返回 400
     (否则 400/403 的差异会变成"该柜子是否存在"的探测信号)。
   - 已实测自证:把白名单放宽成 [0,1,2] → 用例 FAILED / exit 101;还原 → 通过。
   - 生产库实测 68 台**全部已是 status=1**,故无需数据迁移。

2) order_timeout.rs 的超时方向守门(此前只在文档里留了「遗留」标记)
   - 选单 SQL 抽成常量 EXPIRED_ORDER_SCAN_SQL;
   - 补 3 个单测:SQL 文本方向断言(含**反证**写反形态)+ 纯函数孪生体双向断言
     (刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中 + 反证)+ 常量合理性。
   - Why:本文件与 report_worker.rs 是**等价但写法不同**的超时判据
     (那边绑负数、这边把 start_time 往前推),修好一边而另一边没有守门 = 漏网;
     方向写反**零报错、零日志、单测全绿**,只会静默提前结单、提前结算电量。
   - 已实测自证:反写成 start_time < TIMESTAMPADD(HOUR, ?, NOW())
     → test_expired_order_scan_sql_points_backwards FAILED / exit 101;还原 → 3 passed。

3) device-server 的在线判据注释订正(原文与代码事实不符)
   - 原文称在线键 device:{imei} "在验签前就会被写" —— **逐行核对后确认是错的**:
     online=1 只由 api-server 的 report_worker 在 handle_login 的
     sign == expected_sign 分支内(mark_device_online)以及 handle_status_post
     (write_device_state,该 act 已被登录门控挡住)写入;handle_auth_str 不写 online。
   - 真正不能拿它判下发的原因是另一条:它的 TTL 语义是"最近 300 秒收到过任意报文",
     断连后仍存活最多 5 分钟 ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
   - 另注:server.rs::update_device_route 在 auth_str/login **到达时**写 node_id 并
     EXPIRE 300 —— 只刷 TTL、不写 online;与协议 §2.3 一致,故保留,但同样说明
     该键不能当"可下发"判据。

验收:api cargo check --all-targets 零 error/零 warning,单测 182 passed;
device 30 passed;两产物交叉编译通过,已按 device→api 顺序发布到测试与生产。
2026-09-20 04:31:42 +08:00
41 changed files with 1841 additions and 379 deletions

1
.gitignore vendored
View File

@ -61,3 +61,4 @@ tmp/
deploy_output.txt deploy_output.txt
deploy_remote.sh deploy_remote.sh
/tmp/ /tmp/
tools/dev-tunnel-run.ps1

247
AGENTS.md
View File

@ -35,7 +35,8 @@ D:\charging-cabinet\
│ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口) │ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口)
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」) │ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」)
├── tools/ # 开发工具 ├── tools/ # 开发工具
│ ├── db.py # 数据库工具(默认连测试库 pms_dev) │ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道)
│ ├── dev-tunnel.ps1 # 开发机 SSH 隧道(127.0.0.1:13306/16379 → 阿里云 RDS/Redis)
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL) │ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
├── software/ ├── software/
│ ├── api-server/ # Rust API服务(产物 pms-api) │ ├── api-server/ # Rust API服务(产物 pms-api)
@ -91,7 +92,8 @@ D:\charging-cabinet\
- **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`,IP `10.8.0.252` - **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`,IP `10.8.0.252`
- **本地电脑ZeroTier IP**: `10.8.0.248` - **本地电脑ZeroTier IP**: `10.8.0.248`
- **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat(开机自启) - **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat(开机自启)
- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、Gitea(3080)、acme.sh - **服务**: Gitea(3080)、acme.sh
(⛔ 曾经的 `mysql-proxy(3306→RDS)`、`redis-proxy(6379→Redis)` 一律**已移除**,见「本地开发环境」)
- **不装**: Docker/Node/Rust(本地编译传二进制) - **不装**: Docker/Node/Rust(本地编译传二进制)
- **SSH**: plink连接,首次需 `echo y` 接受host key - **SSH**: plink连接,首次需 `echo y` 接受host key
@ -102,36 +104,83 @@ D:\charging-cabinet\
### 本地开发环境 ### 本地开发环境
**数据库连接(通过ZeroTier):** **数据库连接(通过 SSH 隧道):**
服务器 nginx 的 `stream{}` 转发(把 3306/6379 转到阿里云 RDS/Redis)**已于 2026-09-20 移除**:
- `10.8.0.252:3306` / `10.8.0.252:6379`(含 `127.0.0.1` 回环监听)**现在完全不可连**(实测 ✅);
- 因此**本地/开发侧一律改走 SSH 隧道** —— `tools/dev-tunnel.ps1`:
```bash
# ① 手工起隧道(前台常驻一个窗口;需先设 PMS_SSH_PASSWORD = 服务器 SSH 口令)
$env:PMS_SSH_PASSWORD = '<服务器 SSH 口令>'
pwsh tools/dev-tunnel.ps1
# = plink -N -L 13306:<阿里云MySQL>:3306 -L 16379:<阿里云Redis>:6379 root@118.31.119.206
# ② 常驻自愈启动器(推荐日常用):断了 5 秒后自动重连
pwsh tools/dev-tunnel-run.ps1
``` ```
MySQL: 10.8.0.252:3306
**隧道两个脚本的分工(换机/重装后照这个重建):**
| 文件 | 作用 | 是否入库 |
|------|------|----------|
| `tools/dev-tunnel.ps1` | 建隧道本体(`plink -N -L …`);默认 `127.0.0.1:13306`(MySQL) / `127.0.0.1:16379`(Redis),口令从 `$env:PMS_SSH_PASSWORD` 读 | ✅ 入库 |
| `tools/dev-tunnel-run.ps1` | **自愈启动器**:`while($true){ 跑 dev-tunnel.ps1; sleep 5 }`,**内含口令** | ⛔ **已 gitignore,勿入库** |
- 日常已把 `dev-tunnel-run.ps1` 的快捷方式放进 **Windows 启动文件夹**
(`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\PMS-DEV-TUNNEL.lnk`)→ **登录即起、断线自愈**;
它的输出追加到 `%TEMP%\pms-dev-tunnel.log`(**排查时先看这个日志**)。
- **换机 / 重装后**:设好 `PMS_SSH_PASSWORD` 跑一次 `pwsh tools/dev-tunnel.ps1` 即可;
要恢复自愈+开机自启,就在启动文件夹放一个指向 `tools/dev-tunnel-run.ps1`
(`pwsh -NoProfile -WindowStyle Hidden -File <仓库>\tools\dev-tunnel-run.ps1`)的快捷方式。
- 端口/主机名**只在 `dev-tunnel.ps1` 的 `param` 里定义一份**(默认与各脚本的环境变量默认值一致),
别在别处再写第二套默认值。
- ⚠️ 该脚本历史上有个"**日志骗人**"的坑(已修):`"... -> $MySqlHost:3306"` 被 PowerShell 当成
`$作用域:名字` 的变量,主机名渲染成空串(隧道其实正常)。变量后紧跟 `:` 必须写 `"${MySqlHost}:3306"`。
```text
MySQL: 127.0.0.1:13306 (经隧道 → rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306)
user: root user: root
password: Hbhyg731024@ password: Hbhyg731024@
database: pms_dev (新建,不动AZZCWeChat*) database: pms_dev (测试库) / pms (正式库)
Redis: 10.8.0.252:6379 Redis: 127.0.0.1:16379 (经隧道 → r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379)
password: Hbhyg731024@ password: Hbhyg731024@
db: 6 (测试环境用db6,不动db1-5) db: 6 (测试环境用db6,不动db1-5) / 5 (正式)
``` ```
**代理说明:** 所有本地脚本(`tools/db.py`、`tools/db_schema_diff.py`、`test/*.py`)**默认就指向这两个隧道端口**,
- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS —— 可用环境变量覆盖(无需改代码):`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / `PMS_MYSQL_DB`、
⚠️ **这个转发只为「本地开发机经 ZeroTier 访问阿里云」而存在**(监听 `10.8.0.252:3306/6379` 等)。 `PMS_REDIS_HOST` / `PMS_REDIS_PORT`(`db.py` 默认库 = **测试库 `pms_dev`**)。
**服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连阿里云**: ⚠️ 隧道没起时,脚本会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 **exit 2**
(**连不上绝不会被当成"无漂移/无问题"**)。
**为什么服务侧直连阿里云(2026-09-20 已改,勿回退):**
- 走 nginx 时 `systemctl reload nginx` 会切断所有经它转发的连接 → 四个服务同时
`RPOP 错误: broken pipe`(实测每次 reload 4 条 ERROR);
- 更要紧的是**生产的库/缓存通路会依赖一个本来只给开发用的转发层**
(`stream{}` 一旦配错 nginx 起不来 → 整平台连不上库)。
- 服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连:
`DATABASE_URL=...@rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306/...`、 `DATABASE_URL=...@rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306/...`、
`REDIS_URL=...@r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379/...` `REDIS_URL=...@r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379/...`。
(2026-09-20 已改;改前生产走的是 `127.0.0.1:6379/3306` —— 那两个 **nginx 监听**的回环端口)。
**为什么必须直连**:走 nginx 时 `systemctl reload nginx` 会切断所有经它转发的连接 → 四个服务同时
`RPOP 错误: broken pipe`(实测每次 reload 4 条 ERROR);更要紧的是**生产的库/缓存通路会依赖一个
本来只给开发用的转发层**(`stream{}` 一旦配错 nginx 起不来 → 整平台连不上库)。
改造后实测:reload nginx 前后生产连接数不变(5→5)、生产与测试 ERROR 均为 0。 改造后实测:reload nginx 前后生产连接数不变(5→5)、生产与测试 ERROR 均为 0。
`127.0.0.1` 的 stream 监听**保留**(无副作用,本地开发/回环排查仍可用),但**服务不再用它** - ⛔ **服务器不再开放 3306/6379**(ZeroTier 上也连不到了);要给开发机开库通路就用 SSH 隧道,
- systemd自启,`Restart=always` 自动恢复 **不要在服务器上重新暴露库端口**(见「部署注意事项」第 6 条)。
- 本地直连 `10.8.0.252`,无需SSH隧道 - 设备 TCP 业务端口(`:9001` / `:3001`)**不受影响**,仍按原样可达(设备走公网连它)。
- systemd 自启,`Restart=always` 自动恢复。
### 数据库工具 ### 数据库工具
本地可直接访问数据库(通过ZeroTier),使用 `tools/db.py`: ⚠️ **需先起隧道**:`pwsh tools/dev-tunnel.ps1`(默认连 `127.0.0.1:13306`,见上节)。
连接参数可用环境变量覆盖:`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / **`PMS_MYSQL_DB`**;
`db_schema_diff.py` 的 `--host/--port` **优先级更高**(命令行 > 环境变量 > 默认隧道端口)。使用 `tools/db.py`:
> **`tools/db.py` 的库口径(2026-09-20 定稿)**:默认 **`pms_dev`(测试库)**,可用 `PMS_MYSQL_DB` 覆盖;
> 每次连接前都会打印 `当前目标库:xxx(可用 PMS_MYSQL_DB 覆盖)`,
> **若取 `pms`(生产库)会额外打印 `⚠️ 你正在操作正式库 pms …`**。
> (此前代码默认写死 `pms`、文档却写 `pms_dev` —— "以为在测测试库、实际打生产"的隐患,已按默认测试库修正。)
```bash ```bash
# 列出所有表 # 列出所有表
@ -150,7 +199,7 @@ python tools/db.py exec "UPDATE users SET status=1 WHERE id=1"
python tools/db.py import data.sql python tools/db.py import data.sql
``` ```
**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次)**: **结构漂移检查(只读,建议每次改动表结构/迁移后跑一次;同样需先起隧道)**:
```bash ```bash
# 测试库 vs 生产库 全列比对(类型/可空性/默认值/EXTRA/注释 + TIMESTAMP 巡检) # 测试库 vs 生产库 全列比对(类型/可空性/默认值/EXTRA/注释 + TIMESTAMP 巡检)
@ -161,6 +210,8 @@ python tools/db_schema_diff.py --vs-ddl
python tools/db_schema_diff.py --db pms --vs-ddl python tools/db_schema_diff.py --db pms --vs-ddl
``` ```
退出码:`0`=无漂移、`1`=发现漂移(可接进部署前置检查)、`2`=参数/连接错误。 退出码:`0`=无漂移、`1`=发现漂移(可接进部署前置检查)、`2`=参数/连接错误。
⛔ **连不上时必须是 `2`,绝不能当成"无漂移"的 `0`**(隧道没起就跑,会明确报
「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」)。
> 为什么必须有它:"测试库与生产结构不一致"已经栽了 5 次(BRPOP 卡死、`iccid` NULL、 > 为什么必须有它:"测试库与生产结构不一致"已经栽了 5 次(BRPOP 卡死、`iccid` NULL、
> `SCHEMA_SQL` 前向引用、测试环境导出 500、生产 `/api/charge-orders` 500), > `SCHEMA_SQL` 前向引用、测试环境导出 500、生产 `/api/charge-orders` 500),
> 每次都是靠人肉发现。`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**, > 每次都是靠人肉发现。`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**,
@ -204,7 +255,8 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-p
- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知 - **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制 - **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理 - **禁止使用socat fork模式** — 进程泄漏会耗尽内存;服务侧一律**直连阿里云**,
开发机要连库走 **SSH 隧道**(`tools/dev-tunnel.ps1`),⛔ 不要再在服务器上做端口转发
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore) - **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
### 编码任务下发 ### 编码任务下发
@ -270,6 +322,20 @@ node test/rssi_signal.test.mjs
> ⚠️ **验收命令必须先自证能失败**:写完一条"通过"的验收命令,**故意造一个该命令应当抓到的缺陷,确认它真的报错**, > ⚠️ **验收命令必须先自证能失败**:写完一条"通过"的验收命令,**故意造一个该命令应当抓到的缺陷,确认它真的报错**,
> 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(`tsc --noEmit` 就是这么假绿的,见「踩过的坑」第 2 条)。 > 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(`tsc --noEmit` 就是这么假绿的,见「踩过的坑」第 2 条)。
**退出码约定(所有验收/工具脚本统一,2026-09-20 定稿)**:
| 退出码 | 含义 |
|--------|------|
| `0` | 全部通过 |
| `1` | **断言/判据失败**(必须把失败项额外打到 stderr) |
| `2` | **环境/连接问题**(隧道没起、DB/Redis/后端连不上、缺 `.env`) |
⛔ **适用于本仓每一个会被自动化调用的脚本**(`tools/*.py`、`test/*.py`、`test/*.mjs`、`deploy/*.ps1`、`*.sh`):
**"失败时退出码非 0"是硬性验收项 —— 上线前必须像上面那样自证一遍**(故意造缺陷 → 确认非 0 → 撤销)。
只打印 `❌`/`合计 N/M` 而不设退出码 = **假绿**(`test/tcp_e2e_smoke.py` 曾长期如此,见「踩过的坑」第 7/8 条);
探针脚本若无明确判据,必须**显式声明"纯取证、不可作判据"**,不许默默 `exit 0`。
现象阈值拿不准的(如限流第几次触发)**不要写成判据**,保持观察输出 —— **误报与假绿同样有害**。
--- ---
## 踩过的坑(历史教训,改动前必读) ## 踩过的坑(历史教训,改动前必读)
@ -295,9 +361,16 @@ node test/rssi_signal.test.mjs
做**双向断言**(刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中), 做**双向断言**(刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中),
并**反证**"写反的方向必然命中刚建单",保证方向一反**一定红**。 并**反证**"写反的方向必然命中刚建单",保证方向一反**一定红**。
已实测:把 SQL 改成 `TIMESTAMPADD(HOUR, 10, NOW())` 后该用例**立即 FAILED**。 已实测:把 SQL 改成 `TIMESTAMPADD(HOUR, 10, NOW())` 后该用例**立即 FAILED**。
⚠️ 遗留:`workers/order_timeout.rs` 的扫描条件是**另一种等价写法** ✅ **两处现已都有守门测试(2026-09-20 补齐,勿再退回)**:
(`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`),该文件不在本次改动范围内, - `workers/report_worker.rs`:常量 `FINALIZE_EXPIRED_ORDER_SQL` + `test_expired_order_condition_points_backwards`
+ `test_is_expired_two_way_assertions_catch_reversed_direction`;
- `workers/order_timeout.rs`:常量 `EXPIRED_ORDER_SCAN_SQL` + 同名两个守门用例
(此前只在文档里留了「遗留」标记 —— 那边再被改反也一样零报错、单测全绿,属漏网)。
**两处是等价但写法不同的判据**(前者把阈值往回绑**负数**,后者把 `start_time` **往前推**),
**改动任一处都要同时确认另一处**。 **改动任一处都要同时确认另一处**。
已实测(2026-09-20):把 `order_timeout.rs` 的常量反写成
`start_time < TIMESTAMPADD(HOUR, ?, NOW())` 后 → `test_expired_order_scan_sql_points_backwards`
**立即 FAILED、cargo test exit 101**;还原后 3 passed / exit 0。
### 2. 验收命令必须先自证能失败:`tsc --noEmit` 在 solution 型 tsconfig 下是空跑假绿 ### 2. 验收命令必须先自证能失败:`tsc --noEmit` 在 solution 型 tsconfig 下是空跑假绿
@ -308,9 +381,21 @@ node test/rssi_signal.test.mjs
`src/probe.ts(2,14): error TS2322: Type 'string' is not assignable to type 'number'.`、**exit 2**。 `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`)。 - **正确命令**:`cd software/web && npm run typecheck`(`package.json` 里就是 `tsc -b`)。
`tsconfig.app.json` 已设 `noEmit: true`,`tsc -b` 不会吐 JS 产物。 `tsconfig.app.json` 已设 `noEmit: true`,`tsc -b` 不会吐 JS 产物。
- `software/h5-uniapp/tsconfig.json` 是**普通** tsconfig(有 `include`),`tsc --noEmit` 在那里 - **小程序(`software/h5-uniapp`)的 typecheck 是真门禁(2026-09-20 新增)**:
是有效的;但该工程**没有 typecheck 脚本**,且 `tsc` **不检查 `.vue` 文件**,小程序端的真实 `cd software/h5-uniapp && npm run typecheck`(= **`vue-tsc --noEmit -p tsconfig.json`**)。
类型安全目前靠构建 + `node test/device_status_parse.test.mjs`。 - ⛔ **不能用 `tsc --noEmit` 代替**:`tsc` **根本不检查 `.vue` 文件**,而本工程 9 个页面全是 `.vue`
⇒ 只跑 `tsc` 等于把页面逻辑整个漏掉(与 solution-tsconfig 空跑同类的假绿)。
- 必须用 `vue-tsc`(已作为 devDependency 装入,`package.json` 里有 `typecheck` 脚本)。
- **接入时立刻抓到 17 个真实错误**(此前完全没人发现),其中两个是**真 BUG**:
① `me/index.vue` 写了 `user.value`,而 Pinia setup store 对外已解包 ⇒ `user.value` 恒 `undefined`,
**头像上传成功后写回 store 的那行是空操作**(要等冷启动才显示新头像);同时 `const user = authStore.user`
是**快照**、丢掉响应式。已改为 `computed(() => authStore.user)`;
② `CompartmentDetail` 类型**漏了后端确实会返回的 `temp`**(`routes/h5.rs` 的
`COMPARTMENT_REALTIME_FIELDS` 第 6 项,有单测守门)⇒ 页面读 `data.temp` 报 TS2339。已补。
其余 12 处是宿主全局 `wx` 未声明(`@dcloudio/types` 只声明跨端 `uni`),已在
`src/env.d.ts` 里**只声明实际用到的两个 API**(刻意不写 `[k: string]: any`,否则等于关掉这层检查)。
- 已按仓库铁律**自证能失败**:植入 `export const probe: number = 'x'` → **exit 2**;
撤销 → **exit 0**。
### 3. MySQL 的 `TIMESTAMP` 列与 sqlx `NaiveDateTime` **不兼容**(只与 `DateTime<Utc>` 兼容) ### 3. MySQL 的 `TIMESTAMP` 列与 sqlx `NaiveDateTime` **不兼容**(只与 `DateTime<Utc>` 兼容)
@ -356,6 +441,32 @@ node test/rssi_signal.test.mjs
- 记录:本次已清理 db5 的 462 个旧键,保留 `auth_str*` / `pending:*` / `device:reports` / - 记录:本次已清理 db5 的 462 个旧键,保留 `auth_str*` / `pending:*` / `device:reports` /
`device:commands*` / `node:*`(db6 无残留)。清理后 `/api/cabinets/filter` 的在线台数 **6 → 0**。 `device:commands*` / `node:*`(db6 无残留)。清理后 `/api/cabinets/filter` 的在线台数 **6 → 0**。
### 7. **验收取值别用会掐断上游管道的方式** —— `| Select-Object -First N` 会杀掉被测进程
- **实测(2026-09-20,我踩的)**:为看 `test/tcp_e2e_smoke.py` 的开头几行,写成
`python test/tcp_e2e_smoke.py | Select-Object -First 8`。PowerShell 拿满 8 个对象后**立即停止上游管道**
⇒ **python 被中途掐死**:脚本只跑到第 ② 步,既没跑完断言,也**没走清理/复位**,夹具被留在半路状态。
紧接着我又跑了一次"取最后 3 行"的命令,于是看到 **2/13**,差点当成"隧道改造引入了回归"。
- **为什么危险**:这类假信号**看起来像被测对象坏了**,会把人引向完全错误的排查方向;
更糟的是它**污染了夹具**(本次:`auth_str` 已签发未复位),使后续所有观测都不可信 ——
"测量工具本身改变了被测对象"。
- **规范**:
1. 要截取输出就**先落盘再截**:`python x.py *> $env:TEMP\out.txt` 然后 `Get-Content out.txt | Select-Object -First N`
(`*> file` 不会掐断上游;`Select-Object -Last N` 也不会,但统一落盘最省心);
2. 看到"突然变红"先自问:**是不是我的取值方式改变了被测对象?**(进程被杀 / 夹具被留在中间态);
3. 复现任何"红"之前先**把环境复位到干净态**(如先 `python test/reset_fixture.py`)再跑 ——
`test/tcp_e2e_smoke.py` 有前置条件:**必须先复位夹具**,否则第 ① 步 `auth_str already issued` 会连锁失败(2/13)。
### 8. **"打印了 ❌ 却 exit 0" 是假绿** —— 脚本不设退出码,自动化就把失败当通过
- **实测(2026-09-20)**:`test/tcp_e2e_smoke.py` 结尾只 `print("合计 N/13 通过")`,**没有 `sys.exit`**
⇒ 断言 2/13 失败时 **退出码仍是 0**;`test/tcp_latency_probe.py` 打印 `❌ 75s 内无应答` 也是 exit 0;
`test/security-hardening/` 下 4 个探针(`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod`)
结尾写死 `return 0`。这些与 `tsc --noEmit` 空跑**同属一类假绿**,且**一直被当成"✅ 通过"写进验收报告**。
- **规范**:见「验收流程」的**退出码约定(0/1/2)**——每个脚本都要"故意造缺陷 → 确认非 0 → 撤销"。
探针类脚本共用 `test/security-hardening/verdict.py`(判据容器 + 统一退出码),
判据只写明确可复现的期望("应被拒" = `status != 200`),拿不准的保持观察输出。
--- ---
## Rust 编码规范 ## Rust 编码规范
@ -372,8 +483,8 @@ node test/rssi_signal.test.mjs
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await` - 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环 - 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
- Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** —— - Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** ——
本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`, 本项目 Redis 此前经 nginx stream 代理访问阿里云(**该转发已于 2026-09-20 移除,现为直连**),
这种组合下 `BRPOP` 语义不可靠(实测 2026-09): 客户端又是多路复用的 `ConnectionManager`,那种组合下 `BRPOP` 语义不可靠(实测 2026-09):
- `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**; - `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**;
- `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。 - `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。
**规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致), **规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致),
@ -559,7 +670,17 @@ test_check_permission_user_returns_forbidden
依据:`docs/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。 依据:`docs/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。
**判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`, **判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`,
在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位), 在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位),
**不是** Redis 在线键 `device:{imei}` —— 该键在验签前就会被写,用它判在线必然误判。 **不是** Redis 在线键 `device:{imei}` —— 两者回答的是**不同问题**(2026-09-20 逐行核对订正):
- `device:{imei}.online` 的写入**都是在验签之后**:`online=1` 只由 api-server 的
`report_worker.rs::handle_login` 在 `sign == expected_sign` 分支内(`mark_device_online`)
以及 `handle_status_post`(`write_device_state`,该 act 已被登录门控挡住)写入;
`handle_auth_str` **不写** `online`。(此前文档写"验签前就会被写"是**错的**。)
- 真正不能拿它判下发的原因是**另一条**:它的 TTL 语义是"最近 300 秒收到过该设备任意报文"
(协议 §2.3),**断连后仍存活最多 5 分钟** ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
- 另注:`device-server` 的 `server.rs::update_device_route` 会在 `auth_str`/`login` **到达时**
写 `device:{imei}` 的 `node_id` 并 `EXPIRE 300`——**只刷 TTL、不写 `online`**,
效果是未认证报文也可能把 `online` 的存活期往后顺延一次;这与 §2.3「收到过任意报文即算活跃」
一致,故保留,但它进一步说明该键不能当"可下发"判据。
⛔ 不要再按"某个 `act` 算不算敏感指令"来增删名单:规则是"登录前只有 `auth_str`/`login`"(常量 `PRE_LOGIN_ALLOWED_ACTS`)。 ⛔ 不要再按"某个 `act` 算不算敏感指令"来增删名单:规则是"登录前只有 `auth_str`/`login`"(常量 `PRE_LOGIN_ALLOWED_ACTS`)。
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`) - 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`** - **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
@ -661,47 +782,57 @@ echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@"
### 数据库连接 ### 数据库连接
所有数据库操作通过 ZeroTier 直接连接 `10.8.0.252`,无需 SSH 隧道到服务器。 ⛔ **不要再用 `10.8.0.252` 连库** —— 服务器 nginx `stream{}` 转发已于 2026-09-20 移除,
`10.8.0.252:3306/6379`(以及 `127.0.0.1` 回环)**都连不到了**(实测 ✅)。
**所有本地数据库操作一律先起 SSH 隧道**(`pwsh tools/dev-tunnel.ps1`),再连 `127.0.0.1:13306/16379`。
**MySQL(通过 ZeroTier):** **MySQL(通过 SSH 隧道):**
```bash ```bash
# 先起隧道(另一个终端常驻)
pwsh tools/dev-tunnel.ps1
# 命令行 # 命令行
mysql -h 10.8.0.252 -u root -p mysql -h 127.0.0.1 -P 13306 -u root -p
# 密码:Hbhyg731024@ # 密码:Hbhyg731024@
# 测试环境库:pms_dev # 测试环境库:pms_dev
# 正式环境库:pms # 正式环境库:pms
``` ```
```python ```python
# Python(Agent 常用方式) # Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
import pymysql import os, pymysql
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 正式环境 host = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1')
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms_dev') # 测试环境 port = int(os.environ.get('PMS_MYSQL_PORT', '13306'))
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms') # 正式库
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms_dev') # 测试库
``` ```
**Redis(通过 ZeroTier):** **Redis(通过 SSH 隧道):**
```bash ```bash
# 命令行 # 命令行
redis-cli -h 10.8.0.252 -a "Hbhyg731024@" redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@"
# 测试环境:select 6 # 测试环境:select 6
# 正式环境:select 5 # 正式环境:select 5
``` ```
```python ```python
# Python(Agent 常用方式) # Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
import redis import os, redis
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5) # 正式环境 host = os.environ.get('PMS_REDIS_HOST', '127.0.0.1')
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6) # 测试环境 port = int(os.environ.get('PMS_REDIS_PORT', '16379'))
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=5) # 正式环境
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=6) # 测试环境
``` ```
**数据库工具 `tools/db.py`(默认连接测试库 pms_dev):** **数据库工具 `tools/db.py`(需先起隧道):**
```bash ```bash
python tools/db.py tables # 列出所有表 python tools/db.py tables # 列出所有表
python tools/db.py count # 统计各表数据量 python tools/db.py count # 统计各表数据量
python tools/db.py query "SELECT * FROM users" # 查询 python tools/db.py query "SELECT * FROM users" # 查询
python tools/db.py exec "UPDATE users SET ..." # 写入 python tools/db.py exec "UPDATE users SET ..." # 写入
``` ```
> 注意:`tools/db.py` 默认连的是 `pms_dev`(测试库),要操作正式库请用上面的 Python 直连方式。 > **`tools/db.py` 默认连测试库 `pms_dev`**(可用 `PMS_MYSQL_DB=pms` 切生产,切换时会打印醒目警告);
> 连接失败会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 exit 2。
### Nginx配置 ### Nginx配置
@ -897,19 +1028,28 @@ scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境
实测正确属主:`drwxr-xr-x pms pms /data/pms/api-server/exports`(`uploads` 同理)。 实测正确属主:`drwxr-xr-x pms pms /data/pms/api-server/exports`(`uploads` 同理)。
5. **`worker_shutdown_timeout 30s`**(`/etc/nginx/nginx.conf` 第 2 行)—— 调短会让正在传输的 5. **`worker_shutdown_timeout 30s`**(`/etc/nginx/nginx.conf` 第 2 行)—— 调短会让正在传输的
响应被中断;**改 nginx 配置前先 `nginx -t`**,通过后再 `systemctl reload nginx`。 响应被中断;**改 nginx 配置前先 `nginx -t`**,通过后再 `systemctl reload nginx`。
6. **`stream {}` 块只能有一个** —— MySQL(3306)/Redis(6379) 的 stream 反代写在**主配置 6. **stream 转发已移除(2026-09-20)—— 不要再给开发机开库端口** —— 主配置
`/etc/nginx/nginx.conf`** 里。⛔ **不要在 `/etc/nginx/conf.d/*.conf` 里再写一个 `stream {}`**: `/etc/nginx/nginx.conf` 里原来那个 `stream{}`(MySQL 3306 / Redis 6379 → 阿里云 RDS/Redis)
nginx 会以 `"stream" directive is duplicate` 直接启动失败(连带整站不可用)。 **已整块删除**;服务器不再监听 3306/6379(`10.8.0.252:3306/6379` 实测已不可连)。
conf.d 下只放 `http` 上下文的 `server {}`。 ⛔ **不要为了给开发机开库通路而在服务器上重新加 `stream{}` / socat / 任何端口转发** ——
开发机要连库一律走 **SSH 隧道**(`pwsh tools/dev-tunnel.ps1`,见「本地开发环境」);
服务侧(`/data/pms`、`/data/pms_dev`)本来就直连阿里云,也不需要转发层。
**历史原因(务必记住)**:`stream{}` 全配置**只能有一个**、且只能在主配置里 ——
历史上曾在 `conf.d/*.conf` 里又写了一个,nginx 以
`"stream" directive is duplicate` **直接启动失败、整站不可用**(含生产域)。
所以:conf.d 下只放 `http` 上下文的 `server {}`;**主配置里现在应当完全没有 `stream{}`**
(若哪天又看到它,先查清是谁加的)。
7. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来: 7. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来:
`EnvironmentFile=/data/pms/api-server/.env` / `/data/pms/device-server/.env`(测试环境同理)。 `EnvironmentFile=/data/pms/api-server/.env` / `/data/pms/device-server/.env`(测试环境同理)。
8. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。 8. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。
部署后务必核对 `ss -lntp` 与域名返回码(见「部署架构」的判断方法)。 部署后务必核对 `ss -lntp` 与域名返回码(见「部署架构」的判断方法)。
9. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条(无 TTL 的旧键会被当成实时值)。 9. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条(无 TTL 的旧键会被当成实时值)。
10. **nginx 主配置也有仓库镜像,改线上前必须先比对** —— `deploy/nginx/nginx.conf` 10. **nginx 主配置也有仓库镜像,改线上前必须先比对** —— `deploy/nginx/nginx.conf`
(**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、116 行、3629 字节)。 (**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、100 行、3733 字节、
md5 `82f660d8ebd3299c89edf55483277266` —— 2026-09-20 移除 `stream{}` + 订正 stream 注释后重新对齐)。
`deploy/nginx/` 下三份分别是: `deploy/nginx/` 下三份分别是:
- `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `stream{}` / `worker_shutdown_timeout`) - `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `worker_shutdown_timeout`;
⚠️ 已无 `stream{}` —— 见第 6 条,别再加回去)
- `app.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/app.anzhizhichong.com.conf` - `app.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/app.anzhizhichong.com.conf`
- `test.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/test.anzhizhichong.com.conf` - `test.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/test.anzhizhichong.com.conf`
@ -1407,6 +1547,11 @@ const BASE_URL = '/api/pms'
`cabinetDoorState` / `cabinetStatusTags`(注释互相指认对方文件,改一边务必同步另一边)。 `cabinetDoorState` / `cabinetStatusTags`(注释互相指认对方文件,改一边务必同步另一边)。
端到端一致性用例见 `test/device_status_parse.test.mjs`(`node test/device_status_parse.test.mjs`, 端到端一致性用例见 `test/device_status_parse.test.mjs`(`node test/device_status_parse.test.mjs`,
逐位遍历 0x00–0xFF 断言两端一致)。 逐位遍历 0x00–0xFF 断言两端一致)。
5. **`cabinets.status` 是「启用/禁用」配置态,不表示在线** —— `1` 启用 / `2` 禁用(`0` 是建表默认值
产生的取值,代码不显式写它,语义等同启用);实时在线一律以 Redis `device:{imei}.online` 为准
(唯一权威判据 `routes/cabinet_tree.rs::device_online_flag`,接口字段 `is_online`),
`energy_stats` 的 `online_count`、H5 首页 `online_cabinets`、PC/小程序的「在线/离线」全部消费它
(`CABINET_STATUS_MAP` 只保留启用/禁用两档,**不要再出现"离线"档**)。
### 开发约束(uni-app 特定) ### 开发约束(uni-app 特定)

View File

@ -22,12 +22,13 @@ charging-cabinet/
├── tasks/ # 任务文件(opencode执行) ├── tasks/ # 任务文件(opencode执行)
├── docs/ # 开发文档、协议文档(API.md、柜控板固件对接文档.md …) ├── docs/ # 开发文档、协议文档(API.md、柜控板固件对接文档.md …)
├── deploy/ # 部署配置与脚本 ├── deploy/ # 部署配置与脚本
│ ├── nginx/ # Nginx 配置(app / test 两份,与线上一致) │ ├── nginx/ # Nginx 配置镜像(nginx.conf 主配置 + app / test,共三份,与线上逐字节一致)
│ ├── *.service # systemd 单元(pms-api / pms-device × prod·test) │ ├── *.service # systemd 单元(pms-api / pms-device × prod·test)
│ ├── deploy-prod.ps1 # 正式环境一键部署(推荐入口) │ ├── deploy-prod.ps1 # 正式环境一键部署(推荐入口)
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出) │ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出)
├── tools/ # 开发工具 ├── tools/ # 开发工具
│ ├── db.py # 数据库工具(默认连测试库 pms_dev) │ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道)
│ ├── dev-tunnel.ps1 # 开发机 SSH 隧道(127.0.0.1:13306/16379 → 阿里云 RDS/Redis)
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL) │ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
├── software/ ├── software/
│ ├── api-server/ # Rust API服务(产物 pms-api) │ ├── api-server/ # Rust API服务(产物 pms-api)
@ -107,7 +108,9 @@ charging-cabinet/
- **死信**:投递失败的指令进 `device:commands:dead` - **死信**:投递失败的指令进 `device:commands:dead`
> ⚠️ **队列消费一律用 `RPOP` 非阻塞轮询(200ms)+ 空队列 `sleep`,禁止 `BRPOP`** —— > ⚠️ **队列消费一律用 `RPOP` 非阻塞轮询(200ms)+ 空队列 `sleep`,禁止 `BRPOP`** ——
> Redis 经 nginx stream 代理 + 多路复用连接时 `BRPOP` 语义不可靠(`BRPOP 0` 会永久卡死且无日志)。 > Redis 走 nginx stream 代理 + 多路复用连接时 `BRPOP` 语义不可靠(`BRPOP 0` 会永久卡死且无日志);
> 该 stream 转发已于 **2026-09-20 移除**(服务侧现直连阿里云 Redis,开发机走 `tools/dev-tunnel.ps1` 隧道),
> 但**轮询约定不变**。
> 依据:`workers/report_worker.rs` 的 `REDIS_POLL_INTERVAL_MS` 注释。 > 依据:`workers/report_worker.rs` 的 `REDIS_POLL_INTERVAL_MS` 注释。
**节点管理:** **节点管理:**

View File

@ -17,11 +17,21 @@
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署 # pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署
# pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动 # pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动
# pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端 # pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端
# pwsh deploy/deploy-prod.ps1 -StrictVerify # 【CI/无人值守请加】"未验证"也当作失败(退出码 1)
#
# 退出码(遵循仓库统一约定):
# 0 = 部署且全部验收通过
# 1 = 有验收项失败(含 -StrictVerify 下的"未验证")
# 2 = 环境/连接问题(构建产物缺失、SSH/上传失败)
# ============================================================================= # =============================================================================
param( param(
[switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build [switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build
[switch]$SkipWeb, # 跳过前端 [switch]$SkipWeb, # 跳过前端
[switch]$DryRun # 只演练,不执行 [switch]$DryRun, # 只演练,不执行
# 把"未验证(通常是本地 SSH 隧道没起)"也当成失败:**默认关闭**是因为
# "未验证"确实是环境问题而非部署问题,误报会让人习惯性忽略;但 CI/无人值守
# 必须打开 —— 否则"探测根本没跑"会被当成绿灯(这正是仓库第 7/8 条假绿的形态)。
[switch]$StrictVerify
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@ -136,18 +146,50 @@ journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^
"@ "@
# ---- 7. 消费链路探测(无副作用)------------------------------------------- # ---- 7. 消费链路探测(无副作用)-------------------------------------------
# ⚠️ 本步骤的 probe.py 在**本地**执行,Redis 走 SSH 隧道(tools/dev-tunnel.ps1 的
# 127.0.0.1:16379)—— 服务器已不再开放 3306/6379(nginx stream 转发已移除)。
# 连不上时**必须显示为"未验证",绝不伪装成通过**(否则部署会带着未验证的链路报成功)。
$Unverified = @()
$Failures = @()
Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)' Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
if (-not $DryRun) { if (-not $DryRun) {
$probe = @' $probe = @'
import redis, json, time import os, json, time
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5) import redis
r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1}))
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
host = os.environ.get("PMS_REDIS_HOST", "127.0.0.1")
port = int(os.environ.get("PMS_REDIS_PORT", "16379"))
try:
r = redis.Redis(host=host, port=port, password="Hbhyg731024@", db=5, socket_timeout=5)
r.ping()
except Exception as e:
# 连不上 ≠ 通过:退出码 3 = "本步骤未验证",与 1 = "消费未工作" 明确区分
print(" ⚠️ 未验证:连不上 Redis %s:%s(%s)" % (host, port, e))
print(" 连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道"
"(或设置 PMS_REDIS_HOST/PMS_REDIS_PORT 指向其它可达地址)")
raise SystemExit(3)
r.lpush("device:reports", json.dumps({"act": "auth_str", "dev_id": "899999999999999", "msg_id": 1}))
time.sleep(3) time.sleep(3)
left = r.llen('device:reports') left = r.llen("device:reports")
print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}") if left == 0:
print(" 队列剩余=0 -> ✅ 消费正常")
raise SystemExit(0)
print(" 队列剩余=%d -> ❌ 消费未工作,请立即回滚" % left)
raise SystemExit(1)
'@ '@
$probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py" $probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py"
python "$env:TEMP\pms_probe.py" python "$env:TEMP\pms_probe.py"
switch ($LASTEXITCODE) {
0 { }
3 { $Unverified += '上报消费链路探测(Redis 不可达 —— 见上方 ⚠️,通常是本地 SSH 隧道未建立)' }
default {
Say "❌ 上报消费链路探测失败(exit $LASTEXITCODE)—— 请立即回滚(见上方输出)"
# ⚠️ 这里必须**记入失败清单**:此前只打印 ❌ 就继续往下走,脚本最终以 0 退出,
# 于是"消费链路已经断了"会被 CI/人当成"部署成功"(仓库第 7/8 条假绿)。
$Failures += "上报消费链路探测失败(exit $LASTEXITCODE)—— 消费已停摆,需回滚"
}
}
} }
# ---- 8. 回滚点保留策略(只保留最近 N 次 backup-YYYYMMDD-HHMMSS)-------------- # ---- 8. 回滚点保留策略(只保留最近 N 次 backup-YYYYMMDD-HHMMSS)--------------
@ -213,9 +255,26 @@ echo " $Dst 占用: `$(du -sh $Dst | cut -f1)"
"@ "@
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
if ($Unverified.Count -gt 0) {
Write-Host "⚠️ 有步骤【未验证】(不是通过,请勿当作绿灯):" -ForegroundColor Yellow
foreach ($u in $Unverified) { Say " - $u" }
Say ' 处理:先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道,再重跑本步确认。'
if ($StrictVerify) {
Write-Host " ❌ -StrictVerify 已开启:未验证视为失败(退出码 1)" -ForegroundColor Red
}
}
if ($Failures.Count -gt 0) {
Write-Host "❌ 以下验收项**失败**:" -ForegroundColor Red
foreach ($f in $Failures) { Say " - $f" }
}
Say "正式环境:https://$Domain/pms/" Say "正式环境:https://$Domain/pms/"
Say "API :$ApiPort TCP :$TcpPort" Say "API :$ApiPort TCP :$TcpPort"
Say '' Say ''
Say '回滚方法(如需):' Say '回滚方法(如需):'
Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`"" Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`""
Say " 备份目录见 $Dst/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 $KeepBackups 次)" Say " 备份目录见 $Dst/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 $KeepBackups 次)"
# 退出码:失败优先;-StrictVerify 下"未验证"也算失败。
if ($Failures.Count -gt 0) { exit 1 }
if ($StrictVerify -and $Unverified.Count -gt 0) { exit 1 }
exit 0

View File

@ -21,8 +21,10 @@ http {
# ⛔ 三条硬性约束(任一条违反都会让整站起不来或行为异常,历史已各踩过一次): # ⛔ 三条硬性约束(任一条违反都会让整站起不来或行为异常,历史已各踩过一次):
# 1. limit_req_zone 必须写在 http{} 内 —— conf.d/*.conf 的 location 引用了这些 zone, # 1. limit_req_zone 必须写在 http{} 内 —— conf.d/*.conf 的 location 引用了这些 zone,
# 引用未定义 zone 会让 nginx -t 直接失败; # 引用未定义 zone 会让 nginx -t 直接失败;
# 2. stream{} 全配置**只能有一个**(MySQL 3306 / Redis 6379 反代就在本文件下面那个), # 2. ⛔ stream 转发已移除(2026-09-20):原 MySQL 3306 / Redis 6379 反代(本文件下方那个)
# ⛔ 不要在 conf.d/*.conf 里再写一个(报 stream directive is duplicate,整站不可用); # 已整块删除,服务器不再监听库端口;开发机连库走 SSH 隧道(tools/dev-tunnel.ps1)。
# 不要在**本文件或 conf.d/*.conf** 里重新加 stream{}(全配置只能有一个,
# 写重复会报 stream directive is duplicate,整站不可用)。
# 3. worker_shutdown_timeout 30s 不要调短 —— 调短会中断正在传输的响应。 # 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_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=auth_challenge_zone:10m rate=20r/m;
@ -96,21 +98,3 @@ http {
# } # }
} }
stream {
server {
listen 127.0.0.1:3306;
listen 10.8.0.252:3306;
proxy_pass rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306;
proxy_timeout 10m;
proxy_connect_timeout 10s;
}
server {
listen 127.0.0.1:6379;
listen 10.8.0.252:6379;
proxy_pass r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379;
proxy_timeout 10m;
proxy_connect_timeout 10s;
}
}

View File

@ -46,7 +46,7 @@ const SCHEMA_SQL: &str = r#"
cell TINYINT DEFAULT 6 COMMENT '仓控板数量', cell TINYINT DEFAULT 6 COMMENT '仓控板数量',
work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式', work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式',
name VARCHAR(100) COMMENT '柜子名称', name VARCHAR(100) COMMENT '柜子名称',
status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)', status TINYINT DEFAULT 0 COMMENT '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) FOREIGN KEY (project_id) REFERENCES projects(id)
@ -251,6 +251,21 @@ const SCHEMA_SQL: &str = r#"
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"#; "#;
/// `cabinets.status` 列注释的**对齐目标**:`(marker, DDL)`。
///
/// 抽成常量是为了让单测能断言"marker 确实出现在 DDL 的 `COMMENT` 里" ——
/// 否则 marker 匹配不上,`ensure_column_comment` 会**每次启动都跑一次 `MODIFY`**(整表重建)。
///
/// `marker` 取新注释里的独有片段:旧注释 `0离线 1在线 2禁用(禁用后指令链路拦截)` 不含它
/// ⇒ 只 ALTER 一次,之后启动不再重复 DDL。
///
/// ⚠️ DDL **只改注释**:保留 `TINYINT DEFAULT 0`(不动类型与默认值),
/// 新注释与 `SCHEMA_SQL` 建表语句里的注释**逐字一致**(有单测守门,防止新库/老库分叉)。
const CABINETS_STATUS_COMMENT_ALIGN: (&str, &str) = (
"启用/禁用配置态",
r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用'"#,
);
/// 执行数据库迁移,创建所有必要的数据表 /// 执行数据库迁移,创建所有必要的数据表
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 逐条执行全量建表语句,避免多语句执行问题 // 逐条执行全量建表语句,避免多语句执行问题
@ -313,25 +328,47 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
) )
.await?; .await?;
// 更新 cabinets.status 注释(**旧注释漏了禁用档**)。 // 更新 cabinets.status 注释(**本列是"启用/禁用"配置态,不是"在线"**)。
// //
// 旧注释只有 `0离线 1在线`,但代码实际消费的是三档: // 全仓复核结论(grep 实测):
// - `2` = 禁用 → `routes/h5.rs::ensure_cabinet_enabled` 与 `CABINET_DISABLED_SQL` // - **唯一写入方** `routes/cabinets.rs::set_cabinet_status`:
// (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)据此拒绝 // `UPDATE cabinets SET status = ? WHERE id = ?`,语义是 `2` = 禁用 / 其它值 = 启用;
// 开合闸 / 开门 / 充电;`routes/cabinets.rs::set_cabinet_status` 也以 `status == 2` // - **没有任何地方写 0**(0 只可能由建表默认值 `DEFAULT 0` 或该接口传入 0 时产生);
// 判定"禁用",其余值一律按"启用"处理; // - 消费方只有"禁用"一档:`commands.rs::CABINET_DISABLED_SQL`
// - `1` = 在线 → `routes/energy_stats.rs::count_visible_cabinets(online_only=true)` // (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)与
// 按 `c.status = 1` 统计在线柜数。 // `routes/h5.rs::ensure_cabinet_enabled` 据此拦截开合闸 / 开门 / 充电。
// 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`)。 //
// marker 取注释里的独有片段:老注释不含它 → 只 ALTER 一次,之后启动不再重复 DDL。 // 旧注释 `0离线 1在线 2禁用` 里的"**在线**"是**语义错配**:
ensure_column_comment( // 生产实测 68 台柜子 `status` 全 = 1,而当时**一台设备都没连** ——
// 按它统计在线(`energy_stats` 的 `online_count`、H5 首页 `online_cabinets`)
// 会恒定显示"全部在线"。在线已全部改为 **Redis 判据**
// (`routes/cabinet_tree.rs::device_online_flag` / `count_online_devices`,
// 与设备列表 `is_online` 同源),本列不再参与任何在线判定。
//
// 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`);
// `0` 是建表默认值产生的取值(`create_cabinet` 的 INSERT 未显式给 status),
// 语义等同"启用"(除 `2` 外一律按启用处理),代码不显式写它。
//
// marker 取新注释里的独有片段(旧注释 `0离线 1在线 2禁用(禁用后指令链路拦截)` 不含它)
// → 只 ALTER 一次,之后启动不再重复 DDL。
//
// ⚠️ 失败只记 ERROR、**绝不阻断启动**:注释只是元信息,
// 不配享有"迁移失败 → main panic → systemd 崩溃循环"的杀伤力
// (与上面 `cabinets.abstract_id` 可空化、下面 `align_legacy_columns` 同口径)。
if let Err(e) = ensure_column_comment(
pool, pool,
"cabinets", "cabinets",
"status", "status",
"2禁用", CABINETS_STATUS_COMMENT_ALIGN.0,
r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)'"#, CABINETS_STATUS_COMMENT_ALIGN.1,
) )
.await?; .await
{
tracing::error!(
"[migrate] cabinets.status 注释更新失败(服务继续启动;老库注释可能仍是旧的 `0离线 1在线`)err={}",
e
);
}
// 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。 // 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。
// //
@ -1379,7 +1416,10 @@ async fn grant_permissions_by_codes(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ColumnSpec, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS, SCHEMA_SQL}; use super::{
ColumnSpec, CABINETS_STATUS_COMMENT_ALIGN, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS,
SCHEMA_SQL,
};
/// `SCHEMA_SQL` 中每条建表语句的固定前缀 /// `SCHEMA_SQL` 中每条建表语句的固定前缀
const CREATE_PREFIX: &str = "CREATE TABLE IF NOT EXISTS "; const CREATE_PREFIX: &str = "CREATE TABLE IF NOT EXISTS ";
@ -1612,6 +1652,76 @@ mod tests {
} }
} }
// -----------------------------------------------------------------------
// cabinets.status 注释口径(**配置态,不是在线**)守门
// -----------------------------------------------------------------------
/// 取出 `SCHEMA_SQL` 里 `cabinets` 建表语句中 `status` 那一行
fn cabinets_status_ddl_line() -> String {
let block = SCHEMA_SQL
.split("CREATE TABLE IF NOT EXISTS cabinets (")
.nth(1)
.and_then(|rest| rest.split("ENGINE=InnoDB").next())
.expect("SCHEMA_SQL 里应有 cabinets 建表语句");
block
.lines()
.find(|l| l.trim_start().starts_with("status "))
.expect("cabinets 建表语句里应有 status 列")
.to_string()
}
/// **`cabinets.status` 是启用/禁用配置态,不表示在线** —— 建表注释里
/// 不得再出现 `离线` / `1在线` 这类说法。
///
/// 为什么必须守:生产实测 68 台柜子 `status` 全 = 1 而**一台设备都没连**;
/// 注释一旦写回"在线",下一个人就会照着它再写一次 `WHERE status = 1` 当在线统计。
#[test]
fn test_cabinets_status_comment_is_config_state_not_online() {
let line = cabinets_status_ddl_line();
assert!(line.contains("配置态"), "必须显式写明是配置态:{}", line);
assert!(line.contains("不表示在线"), "必须写明不表示在线:{}", line);
assert!(!line.contains("离线"), "注释不得再出现「离线」:{}", line);
assert!(!line.contains("1在线"), "注释不得再出现「1在线」:{}", line);
assert!(line.contains("2禁用"), "必须保留禁用档说明:{}", line);
}
/// 启动迁移的注释对齐**幂等且只改注释**:
/// ① `marker` 必须出现在 DDL 的 `COMMENT` 里(否则每次启动都重复 `MODIFY`);
/// ② DDL 保留 `TINYINT DEFAULT 0`(不动类型/默认值),也不改可空性;
/// ③ 与 `SCHEMA_SQL` 建表语句的注释口径**一致**(否则新库与老库再次分叉)。
#[test]
fn test_cabinets_status_comment_alignment_is_idempotent_and_comment_only() {
let (marker, ddl) = CABINETS_STATUS_COMMENT_ALIGN;
assert!(
ddl.contains(marker),
"marker `{}` 不在 DDL 注释里 → 每次启动都会重复 DDL:{}",
marker,
ddl
);
assert_eq!(ddl.matches(marker).count(), 1, "marker 在 DDL 里应只出现一次:{}", ddl);
assert!(
ddl.starts_with("ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0"),
"只允许改注释,不得改类型/默认值:{}",
ddl
);
assert!(ddl.contains("COMMENT '"), "必须显式给出新注释:{}", ddl);
assert!(!ddl.contains("NULL"), "本列不是可空性变更:{}", ddl);
// 与建表语句口径一致:新库(SCHEMA_SQL)与老库(启动 ALTER)不得分叉
let line = cabinets_status_ddl_line();
assert!(
line.contains(marker),
"建表语句与迁移 DDL 的注释口径必须一致(marker=「{}」):{}",
marker,
line
);
assert!(
line.contains("不表示在线"),
"建表注释也必须写明不表示在线:{}",
line
);
}
/// 建表语句里**不允许再出现 `TIMESTAMP` 列类型** /// 建表语句里**不允许再出现 `TIMESTAMP` 列类型**
/// ///
/// `TIMESTAMP` 只与 `DateTime<Utc>` 兼容,而本项目时间列在 Rust 侧全是 /// `TIMESTAMP` 只与 `DateTime<Utc>` 兼容,而本项目时间列在 Rust 侧全是

View File

@ -443,6 +443,100 @@ async fn fetch_filter_page(
Ok(rows) Ok(rows)
} }
// ---------------------------------------------------------------------------
// 设备在线判据 —— **全仓唯一权威实现**(纯函数 + 批量计数)
// ---------------------------------------------------------------------------
/// 设备级实时状态的 Redis key 前缀:`device:{imei}`(Hash,TTL 300 秒)。
const DEVICE_KEY_PREFIX: &str = "device:";
/// 设备级在线标记在 `device:{imei}` 里的字段名(`"1"` = 在线)。
///
/// **写入侧**:`workers/report_worker.rs::write_device_state`(`login` / `status_post`),
/// 每条写 `device:{imei}` 的路径都会跟一条 `EXPIRE 300`
/// ⇒ 「300 秒内无任意上行」即整键过期 ⇒ 判离线。
pub(crate) const DEVICE_ONLINE_FIELD: &str = "online";
/// 在线标记取值(协议约定 `"1"` = 在线;其余一律不算在线)。
const DEVICE_ONLINE_VALUE: &str = "1";
/// `device:{imei}` 的 key(通道级 key 见 [`channel_key`])。
pub(crate) fn device_key(imei: &str) -> String {
format!("{}{}", DEVICE_KEY_PREFIX, imei)
}
/// **设备在线判据的唯一权威纯函数**(全仓共用,禁止各处自写一套)。
///
/// # 为什么是它,而不是 `cabinets.status`
/// `cabinets.status` 是**启用/禁用配置态**(`2` = 禁用,其余 = 启用/false 语义),
/// 全仓**没有任何写入方会把它写成 0(离线)**(`routes/cabinets.rs::set_cabinet_status`
/// 只在 `2` / 其它值之间切)⇒ 拿它当"在线"会让管理端在**一台设备都没连**时
/// 依然恒定显示"全部在线"。实时在线一律以 Redis 为准(`AGENTS.md`「状态位展示约定」)。
///
/// # 三态("无数据"与"离线"必须区分)
/// - `Some("1")` → `Some(true)`:在线;
/// - `Some(其它)`(含 `"0"`)→ `Some(false)`:离线;
/// - `None`(字段缺失 / key 不存在 / Redis 不可用)→ `None`:**无数据**。
///
/// 计数场景请用 [`is_device_online`](无数据按"不在线"计,**绝不猜测在线**)。
pub(crate) fn device_online_flag(raw: Option<&str>) -> Option<bool> {
raw.map(|v| v == DEVICE_ONLINE_VALUE)
}
/// 在线判据的**布尔形态**(供计数使用):只有明确的 `online = "1"` 才算在线。
///
/// 与 [`device_online_flag`] **同源** —— 内部直接调用它,保证
/// 「设备列表 `is_online`」「能耗报表在线柜数」「H5 在线数/详情在线」四处
/// 永远同一口径、不可能分叉。
pub(crate) fn is_device_online(raw: Option<&str>) -> bool {
device_online_flag(raw) == Some(true)
}
/// **纯函数形态的在线计数**:把一批 `device:{imei}.online` 原始值折算成在线台数。
///
/// 抽成纯函数的目的:让"**0 台设备连接 ⇒ 在线数 0**"这条语义可以在单测里覆盖,
/// 且不需要连真实 Redis。所有在线计数(能耗总览 / H5 首页)最终都走它。
pub(crate) fn count_online_flags(raws: &[Option<String>]) -> i64 {
raws.iter()
.filter(|v| is_device_online(v.as_deref()))
.count() as i64
}
/// 批量统计一批 IMEI 里**明确在线**的台数(**一次 pipeline,零 N 次单查**)。
///
/// 判据与设备列表 `is_online` 完全同源([`is_device_online`] → [`count_online_flags`])。
/// Redis 连不上 / pipeline 失败 → 记 `ERROR` 并按 0 计(等价于"全部无数据",
/// **绝不猜测在线**,与 `derive_counts_by_cabinet` 的降级口径一致)。
pub(crate) async fn count_online_devices(state: &AppState, imeis: &[String]) -> i64 {
if imeis.is_empty() {
return 0;
}
let mut conn = match open_redis_conn(state).await {
Ok(c) => c,
Err(e) => {
tracing::error!("[online] Redis 连接失败,在线数按 0 计 err={}", e);
return 0;
}
};
// 每设备 1 次 `HGET device:{imei} online`,全部走一次 pipeline(1 次网络往返)
let mut pipe = redis::pipe();
for imei in imeis {
pipe.cmd("HGET")
.arg(device_key(imei))
.arg(DEVICE_ONLINE_FIELD);
}
let queried: Result<Vec<Option<String>>, redis::RedisError> = pipe.query_async(&mut conn).await;
match queried {
Ok(vals) => count_online_flags(&vals),
Err(e) => {
tracing::error!("[online] 在线状态 pipeline 读取失败,在线数按 0 计 err={}", e);
0
}
}
}
/// 设备列表 pipeline 中**每个设备**下发的 `HGET` 字段个数。 /// 设备列表 pipeline 中**每个设备**下发的 `HGET` 字段个数。
/// ///
/// ⚠️ **命令顺序与下标一一对应**:`online`(0) / `rssi`(1) / `pow_fail_dc`(2) / /// ⚠️ **命令顺序与下标一一对应**:`online`(0) / `rssi`(1) / `pow_fail_dc`(2) /
@ -470,8 +564,8 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re
// ⚠️ 命令顺序与下面解析的下标一一对应:online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2 // ⚠️ 命令顺序与下面解析的下标一一对应:online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2
let mut pipe = redis::pipe(); let mut pipe = redis::pipe();
for row in rows.iter() { for row in rows.iter() {
let key = format!("device:{}", row.imei); let key = device_key(&row.imei);
pipe.cmd("HGET").arg(&key).arg("online"); pipe.cmd("HGET").arg(&key).arg(DEVICE_ONLINE_FIELD);
pipe.cmd("HGET").arg(&key).arg("rssi"); pipe.cmd("HGET").arg(&key).arg("rssi");
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc"); pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac"); pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
@ -486,7 +580,9 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re
// 每6个一组解析 // 每6个一组解析
for (i, row) in rows.iter_mut().enumerate() { for (i, row) in rows.iter_mut().enumerate() {
let base = i * DEVICE_LIST_FIELDS; let base = i * DEVICE_LIST_FIELDS;
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1"); // 在线判据**唯一权威纯函数**(与能耗在线柜数、H5 在线数同源;见 [`device_online_flag`])
row.is_online =
device_online_flag(results.get(base).and_then(|v| v.as_ref()).map(String::as_str));
row.rssi = results row.rssi = results
.get(base + 1) .get(base + 1)
.and_then(|v| v.as_ref()) .and_then(|v| v.as_ref())
@ -1390,6 +1486,93 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// 设备在线判据守门(防回归:不得再拿 `cabinets.status` 当"在线")
// -----------------------------------------------------------------------
/// 在线判据**三态**:只有明确的 `"1"` 才算在线;`"0"` 是明确离线;
/// `None`(无数据)必须与"离线"区分开 —— 三者混为一谈是上一轮 `cabinets.status` 错配的根源。
#[test]
fn test_device_online_flag_is_three_state() {
assert_eq!(device_online_flag(Some("1")), Some(true), "\"1\" = 在线");
assert_eq!(device_online_flag(Some("0")), Some(false), "\"0\" = 明确离线");
assert_eq!(device_online_flag(Some("")), Some(false), "空串不是在线");
assert_eq!(device_online_flag(Some("2")), Some(false), "只有 \"1\" 算在线");
assert_eq!(device_online_flag(None), None, "无数据 ≠ 离线,必须是 None");
}
/// **语义守门:0 台设备连接 ⇒ 在线数 0**(守卫"在线数退化成柜子总数"这条真实故障)。
///
/// 纯函数级覆盖,不需要真实 Redis:设备都没连时,`device:{imei}` 要么不存在
/// (`None`)要么是历史 `"0"`,两种都必须**不计入**在线。
#[test]
fn test_count_online_flags_is_zero_when_no_device_connected() {
// 68 台柜子,一台都没连:20 台键不存在(None)、48 台是明确的 "0"
let mut raws: Vec<Option<String>> = vec![Some("0".to_string()); 48];
raws.extend(vec![None; 20]);
assert_eq!(raws.len(), 68, "样本规模与生产现场一致");
assert_eq!(
count_online_flags(&raws),
0,
"无设备连接时必须为 0,绝不能等于柜子总数"
);
// 反向:全部 "1" ⇒ 全部在线(防止把判据写成"恒 0")
let all_online = vec![Some("1".to_string()); 68];
assert_eq!(count_online_flags(&all_online), 68);
// 混合:只有明确的 "1" 计入
let mixed = vec![
Some("1".to_string()),
Some("0".to_string()),
None,
Some(String::new()),
Some("2".to_string()),
Some("1".to_string()),
];
assert_eq!(count_online_flags(&mixed), 2);
// 空列表(无可见柜子)不 panic 且为 0
assert_eq!(count_online_flags(&[]), 0);
}
/// **口径同源**:`is_device_online`(布尔)与 `count_online_flags`(计数)
/// 必须与 `device_online_flag`(三态)逐值一致 —— 三者是同一判据的三种形态。
#[test]
fn test_online_predicate_forms_are_mutually_consistent() {
let samples: Vec<Option<String>> = vec![
Some("1".into()),
Some("0".into()),
Some("".into()),
Some("2".into()),
Some("true".into()),
None,
];
for raw in &samples {
assert_eq!(
is_device_online(raw.as_deref()),
device_online_flag(raw.as_deref()) == Some(true),
"布尔形态与三态形态不一致:{:?}",
raw
);
}
assert_eq!(count_online_flags(&samples), 1);
}
/// 在线取数的 Redis key / 字段名必须与上报写入侧(`device:{imei}` 的 `online`)**逐字一致**:
/// 取数来源写错就永远判离线(表现为"全部离线",静默无报错、无异常)。
#[test]
fn test_device_online_source_matches_report_writer_contract() {
assert_eq!(DEVICE_ONLINE_FIELD, "online", "必须与 report_worker 写入的字段名一致");
assert_eq!(DEVICE_ONLINE_VALUE, "1", "必须与 report_worker 写入的取值一致");
assert_eq!(device_key("864720562557021"), "device:864720562557021");
// 设备列表 pipeline 的**第 0 个** HGET 字段就是在线字段(`base + 0`)
assert_eq!(
DEVICE_ONLINE_FIELD, "online",
"attach_realtime_status 的 base+0 必须解析在线字段"
);
}
// ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)------- // ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)-------
/// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key) /// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key)
#[test] #[test]

View File

@ -176,9 +176,17 @@ pub async fn create_cabinet(
let mut tx = db.begin().await.map_err(AppError::Database)?; let mut tx = db.begin().await.map_err(AppError::Database)?;
// ⚠️ `status` 必须**显式写入**,不能依赖列默认值:
// 冻结口径是「`2` = 禁用(拦截指令链路),**其余取值 = 启用**」
// (见 `migrate.rs` 的列注释与 `routes/h5.rs` / `energy_stats.rs` / `cabinet_tree.rs` 的读法)。
// 列默认是 `0` —— 语义上等同启用,但**多出一个取值维度**:
// 生产库实测 68 台全是 `1`,若新建柜子落进 `0`,同一个"启用"就有两种表示,
// 后续任何按 `status = 1` 做的判断/统计都会漏掉新柜子。
// (另见「踩过的坑」第 5 条:`DEFAULT` 只在列**未出现在 INSERT 列清单**时生效,
// 把它当"总会兜底"是错的;此处主动写死,语义不依赖 DDL 演变。)
let result = sqlx::query( let result = sqlx::query(
r#"INSERT INTO cabinets (project_id, imei, auth_str) r#"INSERT INTO cabinets (project_id, imei, auth_str, status)
VALUES (?, ?, ?)"#, VALUES (?, ?, ?, 1)"#,
) )
.bind(body.project_id) .bind(body.project_id)
.bind(imei) .bind(imei)
@ -255,6 +263,25 @@ pub struct SetCabinetStatusBody {
pub status: i8, pub status: i8,
} }
/// 允许通过本接口写入 `cabinets.status` 的取值白名单:`1`=启用 / `2`=禁用。
///
/// 为什么必须有白名单:`body.status` 是 `i8`,此前直接绑进 `UPDATE`,
/// 于是 `0`、`99`、`-1` 都能落库 —— 而全系统(PC 列表徽标、小程序、
/// 指令链路 `status = 2` ⇒ 拒绝下发)的冻结口径是「**`2` = 禁用,其余 = 启用**」,
/// 落进其它值就是"改也改了、但显示与行为都说不清"的静默脏数据。
///
/// ⚠️ 白名单**只收 `1`/`2`**(不是 `0`/`1`/`2`):`0` 是建表默认值的历史遗留,
/// 语义上等同启用;本接口是"启用/禁用"开关,**只写确定的 `1`**,
/// 这样"启用"在库里只有一种表示(生产库实测 68 台全是 `1`,保持一致)。
const ALLOWED_CABINET_STATUS: [i8; 2] = [1, 2];
/// 入参判据(抽成纯函数以便单测守门:**值域是硬约束,不是"顺手写个 if"**)。
///
/// 依据见 [`ALLOWED_CABINET_STATUS`]。单测见本文件底部 `test_...`。
fn is_valid_cabinet_status(status: i8) -> bool {
ALLOWED_CABINET_STATUS.contains(&status)
}
/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用) /// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用)
/// ///
/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。 /// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。
@ -272,6 +299,15 @@ pub async fn set_cabinet_status(
return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into())); return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into()));
} }
// 入参白名单放在**鉴权之后**:越权调用恒 403,不会因为参数非法而先拿到 400
// (否则 400/403 的差异会变成一个"该柜子是否存在"的探测信号)。
if !is_valid_cabinet_status(body.status) {
return Err(AppError::BadRequest(format!(
"status 只允许 1(启用)或 2(禁用),收到 {}",
body.status
)));
}
let db = &state.mysql; let db = &state.mysql;
let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?") let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?")
.bind(body.status) .bind(body.status)
@ -617,6 +653,9 @@ pub async fn get_cabinet_detail(
// 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00" // 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00"
"status1": realtime.status1, "status1": realtime.status1,
"status2": realtime.status2, "status2": realtime.status2,
// **实时在线**(Redis `device:{imei}.online`,与 PC 设备列表字段名一致):
// true 在线 / false 明确离线 / null 无数据。⚠️ 与下面的 `status`(启用/禁用配置态)无关。
"is_online": realtime.online,
"address": cabinet.address, "address": cabinet.address,
"latitude": cabinet.latitude, "latitude": cabinet.latitude,
"longitude": cabinet.longitude, "longitude": cabinet.longitude,
@ -656,6 +695,14 @@ struct CabinetRealtime {
/// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。 /// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。
/// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。 /// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。
status2: Option<String>, status2: Option<String>,
/// **实时在线**(Redis `device:{imei}.online`)—— 三态,与"无数据"严格区分:
/// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = 无数据(键或字段不存在)。
///
/// ⚠️ **与 `cabinets.status` 无关**:后者是启用/禁用**配置态**
/// (`2` = 禁用,其余 = 启用),全仓没有任何写入方会把它写成 0(离线),
/// 拿它当"在线"会让详情页在设备一台未连时依然显示"在线"。
/// 判据来自 `cabinet_tree::device_online_flag`(与设备列表 `is_online` 同源)。
online: Option<bool>,
/// 仓体 id -> (SOC, 电池ID) /// 仓体 id -> (SOC, 电池ID)
soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>, soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>,
/// 仓体 id -> 通道级实时状态 /// 仓体 id -> 通道级实时状态
@ -680,17 +727,34 @@ struct ChannelRealtime {
current: Option<i64>, current: Option<i64>,
} }
/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数: /// pipeline 中柜级(`device:{imei}`)HGET 的**字段清单**。
/// `rssi` / `pow_fail_dc` / `pow_fail_ac` / `temp` / `cabinet_status1` / `cabinet_status2`。
/// ///
/// ⚠️ **命令顺序与下标一一对应**:本常量同时是 [`fetch_cabinet_realtime`] 下发 `HGET` 的 /// ⚠️ **数组顺序 = pipeline 命令顺序 = 解析下标**,三者由本常量唯一决定
/// 顺序(下标 0..[`CABINET_REALTIME_FIELDS`])与 [`parse_cabinet_realtime`] 的柜级下标解析依据, /// (原先命令顺序与下标是两处手写、靠注释对齐,容易漂移)。
/// 还决定了 SOC 段(`CABINET_REALTIME_FIELDS + i`)与通道段(`+ comp_ids.len()`)的起始偏移。 /// **新增柜级字段只能追加在末尾**:柜级段(`0..`[`CABINET_REALTIME_FIELDS`])之后是
/// **新增柜级字段必须同步改这一处,且只能追加在末尾**(改动即破坏下标对齐)。 /// SOC 段与通道段,两段的起始偏移都由 [`CABINET_REALTIME_FIELDS`] 派生。
const CABINET_REALTIME_FIELDS: usize = 6; const CABINET_REALTIME_FIELD_NAMES: [&str; 7] = [
"rssi",
"pow_fail_dc",
"pow_fail_ac",
"temp",
// 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析
"cabinet_status1",
"cabinet_status2",
// 追加在末尾:**实时在线**(判据与设备列表 `is_online` 同源,见 CABINET_ONLINE_IDX)
super::cabinet_tree::DEVICE_ONLINE_FIELD,
];
/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数(由字段清单派生,不再手写数字)
const CABINET_REALTIME_FIELDS: usize = CABINET_REALTIME_FIELD_NAMES.len();
/// 柜级 hex 位域字段在 pipeline 结果中的下标(协议 §3.8,**柜级**:`cabinet.status1` / `status2`) /// 柜级 hex 位域字段在 pipeline 结果中的下标(协议 §3.8,**柜级**:`cabinet.status1` / `status2`)
const CABINET_STATUS1_IDX: usize = 4; const CABINET_STATUS1_IDX: usize = 4;
const CABINET_STATUS2_IDX: usize = 5; const CABINET_STATUS2_IDX: usize = 5;
/// 柜级**在线**字段下标(`device:{imei}.online`,追加在末尾)
///
/// 判据与设备列表 `is_online`、能耗在线柜数**完全同源**
/// (`cabinet_tree::device_online_flag`);与 `cabinets.status`(启用/禁用配置态)无关。
const CABINET_ONLINE_IDX: usize = 6;
/// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current /// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current
const CHANNEL_REALTIME_FIELDS: usize = 5; const CHANNEL_REALTIME_FIELDS: usize = 5;
@ -760,7 +824,8 @@ async fn load_boards_with_compartments(
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态 /// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
/// ///
/// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。 /// 为什么放一起:详情接口要求低延迟,柜级 [`CABINET_REALTIME_FIELDS`] 个字段
/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 个字段共用一次网络往返。
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
/// ///
/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段: /// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段:
@ -793,16 +858,11 @@ async fn fetch_cabinet_realtime(
}; };
let key = format!("device:{}", imei); let key = format!("device:{}", imei);
// ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及 // ⚠️ 命令顺序由 [`CABINET_REALTIME_FIELD_NAMES`] 唯一决定 —— 与解析下标不可能再漂移
// parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾)
let mut pipe = redis::pipe(); let mut pipe = redis::pipe();
pipe.cmd("HGET").arg(&key).arg("rssi") for field in CABINET_REALTIME_FIELD_NAMES {
.cmd("HGET").arg(&key).arg("pow_fail_dc") pipe.cmd("HGET").arg(&key).arg(field);
.cmd("HGET").arg(&key).arg("pow_fail_ac") }
.cmd("HGET").arg(&key).arg("temp")
// 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析
.cmd("HGET").arg(&key).arg("cabinet_status1")
.cmd("HGET").arg(&key).arg("cabinet_status2");
for cid in comp_ids { for cid in comp_ids {
pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); pipe.cmd("GET").arg(format!("compartment:status:{}", cid));
} }
@ -859,6 +919,10 @@ fn parse_cabinet_realtime(
// 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值) // 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值)
status1: pick_non_empty(vals, CABINET_STATUS1_IDX), status1: pick_non_empty(vals, CABINET_STATUS1_IDX),
status2: pick_non_empty(vals, CABINET_STATUS2_IDX), status2: pick_non_empty(vals, CABINET_STATUS2_IDX),
// 实时在线:**唯一权威纯函数**(与设备列表 `is_online` 同源;无数据 → None)
online: super::cabinet_tree::device_online_flag(
pick(vals, CABINET_ONLINE_IDX).as_deref(),
),
..Default::default() ..Default::default()
}; };
@ -1107,20 +1171,51 @@ pub async fn list_compartment_options(
mod tests { mod tests {
use super::*; use super::*;
/// pipeline 结果解析:柜级 6 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局, /// `PUT /api/cabinets/:id/status` 的入参值域守门。
/// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取) ///
/// 为什么必须测:`body.status` 是 `i8`,而此前**没有**任何值域校验,
/// `0` / `99` / `-1` 都能落库。落进去的后果是**静默**的:
/// 系统各处按「`2` = 禁用,其余 = 启用」读,于是 `99` 被当"启用"、`0` 也被当"启用" ——
/// 看上去一切正常,但库里多出若干"谁也没定义过"的取值,
/// 任何按 `status = 1` 做的统计/判断都会悄悄漏掉它们。
/// 这类"不报错、只在对账时才发现"的脏数据,正是「踩过的坑」反复出现的一类。
#[test]
fn test_cabinet_status_whitelist_accepts_only_1_and_2() {
// ① 合法:启用 / 禁用
assert!(is_valid_cabinet_status(1), "1 = 启用,必须放行");
assert!(is_valid_cabinet_status(2), "2 = 禁用,必须放行");
// ② 非法:0(建表默认值遗留态,语义等同启用,但**不允许经本接口写回**)
assert!(
!is_valid_cabinet_status(0),
"0 是列默认值遗留态,不允许经状态接口写回(否则'启用'有两种表示)"
);
// ③ 非法:值域外的任意取值(i8 全量遍历,杜绝"只挡住了我知道的那几个")
for v in i8::MIN..=i8::MAX {
let expected = v == 1 || v == 2;
assert_eq!(
is_valid_cabinet_status(v),
expected,
"status={v} 的判定不符预期(只允许 1/2)"
);
}
}
/// pipeline 结果解析:柜级 7 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局,
/// 并确认电压/电流/柜温/在线被正确取出(回归 ③:数据在 Redis 但接口不取)
#[test] #[test]
fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() { fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() {
let comp_ids = vec![11_i64, 12_i64]; let comp_ids = vec![11_i64, 12_i64];
let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))]; let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))];
let vals: Vec<Option<String>> = vec![ let vals: Vec<Option<String>> = vec![
// 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2 // 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2 / online
Some("85".into()), Some("85".into()),
Some("1".into()), Some("1".into()),
Some("0".into()), Some("0".into()),
Some("29815".into()), Some("29815".into()),
Some("10".into()), Some("10".into()),
Some("01".into()), Some("01".into()),
Some("1".into()),
// 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None) // 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None)
None, None,
None, None,
@ -1146,6 +1241,7 @@ mod tests {
assert_eq!(rt.temp, Some(29815), "柜温应被取出"); assert_eq!(rt.temp, Some(29815), "柜温应被取出");
assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 hex"); assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 hex");
assert_eq!(rt.status2.as_deref(), Some("01"), "柜级 status2 应原样透出 hex"); assert_eq!(rt.status2.as_deref(), Some("01"), "柜级 status2 应原样透出 hex");
assert_eq!(rt.online, Some(true), "在线应被取出(Redis online=\"1\")");
let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态"); let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态");
assert_eq!(ch11.voltage, Some(53200), "电压应被取出"); assert_eq!(ch11.voltage, Some(53200), "电压应被取出");
@ -1230,15 +1326,44 @@ mod tests {
} }
/// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步 /// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步
/// (命令顺序与下标一一对应;新增柜级字段只能追加在末尾) /// (命令顺序由字段清单唯一决定;新增柜级字段只能追加在末尾)
#[test] #[test]
fn test_cabinet_realtime_field_indices_are_in_sync() { fn test_cabinet_realtime_field_indices_are_in_sync() {
assert_eq!( assert_eq!(
CABINET_REALTIME_FIELDS, 6, CABINET_REALTIME_FIELDS, 7,
"柜级 HGET 字段数变了:必须同步 fetch_cabinet_realtime 的命令顺序与本文件的下标解析" "柜级 HGET 字段数变了:必须同步 CABINET_REALTIME_FIELD_NAMES 与下标解析"
); );
assert_eq!(CABINET_STATUS1_IDX, CABINET_REALTIME_FIELDS - 2); // 字段清单与下标常量必须逐字对上(防止只改数字不改清单)
assert_eq!(CABINET_STATUS2_IDX, CABINET_REALTIME_FIELDS - 1); assert_eq!(CABINET_REALTIME_FIELD_NAMES[CABINET_STATUS1_IDX], "cabinet_status1");
assert_eq!(CABINET_REALTIME_FIELD_NAMES[CABINET_STATUS2_IDX], "cabinet_status2");
// 在线字段与设备列表 `is_online` 的取数字段**必须逐字一致**(跨模块同源守门)
assert_eq!(
CABINET_REALTIME_FIELD_NAMES[CABINET_ONLINE_IDX],
super::super::cabinet_tree::DEVICE_ONLINE_FIELD,
"详情页在线取数字段必须与设备列表同源"
);
assert_eq!(CABINET_ONLINE_IDX, CABINET_REALTIME_FIELDS - 1, "在线字段必须追加在柜级段末尾");
}
/// 柜级**在线**字段三态:`"1"`→Some(true) / `"0"`→Some(false) / 缺失→**None(无数据)**。
/// 绝不把"无数据"落成 false(那是"明确离线"),也绝不落成"在线"。
#[test]
fn test_parse_cabinet_realtime_cabinet_online_is_three_state() {
let comp_ids: Vec<i64> = vec![];
let comp_to_board: Vec<(i64, (i8, i8))> = vec![];
let with_online = |raw: Option<String>| -> Vec<Option<String>> {
vec![None, None, None, None, None, None, raw]
};
let rt = parse_cabinet_realtime(&with_online(Some("1".into())), &comp_ids, &comp_to_board);
assert_eq!(rt.online, Some(true), "\"1\" = 在线");
let rt = parse_cabinet_realtime(&with_online(Some("0".into())), &comp_ids, &comp_to_board);
assert_eq!(rt.online, Some(false), "\"0\" = 明确离线");
let rt = parse_cabinet_realtime(&with_online(None), &comp_ids, &comp_to_board);
assert_eq!(rt.online, None, "缺失必须是 None(无数据),不能伪装成离线");
// 长度不足 / 空切片(Redis 整体不可用)也不能 panic
let rt = parse_cabinet_realtime(&[], &comp_ids, &comp_to_board);
assert_eq!(rt.online, None);
} }
/// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0) /// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0)
@ -1246,7 +1371,7 @@ mod tests {
fn test_parse_cabinet_realtime_missing_fields_stay_none() { fn test_parse_cabinet_realtime_missing_fields_stay_none() {
let comp_ids = vec![7_i64]; let comp_ids = vec![7_i64];
let comp_to_board = vec![(7_i64, (1_i8, 1_i8))]; let comp_to_board = vec![(7_i64, (1_i8, 1_i8))];
// 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 // 柜级 7 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
let vals: Vec<Option<String>> = vec![ let vals: Vec<Option<String>> = vec![
None, None,
None, None,
@ -1255,6 +1380,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
Some("29815".into()), Some("29815".into()),
Some("82".into()), Some("82".into()),
Some("03".into()), Some("03".into()),
@ -1266,6 +1392,7 @@ mod tests {
assert_eq!(rt.temp, None); assert_eq!(rt.temp, None);
assert_eq!(rt.rssi, None); assert_eq!(rt.rssi, None);
assert_eq!(rt.online, None, "在线字段缺失时必须是 None(无数据),不能落成 false");
let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)"); let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)");
assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0"); assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0");
assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0"); assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0");

View File

@ -25,6 +25,8 @@ use serde_json::{json, Value};
use crate::error::AppError; use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser}; use crate::middleware::auth::{self, CurrentUser};
use super::cabinet_tree;
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh /// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
const COST_PER_WH_CENT: i64 = 80; const COST_PER_WH_CENT: i64 = 80;
@ -166,41 +168,121 @@ fn charge_record_filters(user: &CurrentUser) -> (String, Vec<String>) {
(cond, binds) (cond, binds)
} }
/// 统计当前用户可见的柜子数量(`online_only = true` 时只算在线柜子) // ---------------------------------------------------------------------------
// 可见柜子 / 在线柜数(组织隔离 + 数据范围,两处口径必须完全一致)
// ---------------------------------------------------------------------------
/// 可见柜子(`cabinets c`)的过滤**条件后缀**:组织隔离 **∧** 数据范围(项目级)。
/// ///
/// 口径与设备列表一致:非总管理员经 `cabinets → projects` 组织过滤, /// - 总管理员(`role_level >= 2`)⇒ 空串(豁免);
/// 再叠加数据范围的项目收窄。 /// - 客户侧账号有组织 ⇒ ` AND p.organization_id = ?` + 项目级范围收窄;
/// - 客户侧账号**无组织** ⇒ ` AND 1=0`(空集,**绝不退化成"全部"**)。
///
/// ⚠️ 返回的是**条件后缀**(调用方自己起 `WHERE`),因此片段一律以 ` AND ` 开头;
/// 用 `1=0` 表达"空集"可以避免在 Rust 里为"无组织"再分叉一条分支。
fn visible_cabinet_filters(user: &CurrentUser) -> (String, Vec<String>) {
if user.role_level >= 2 {
return (String::new(), vec![]);
}
let Some(oid) = user.organization_id else {
return (" AND 1=0".to_string(), vec![]);
};
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
// 绑定顺序必须与占位符出现顺序一致:先组织,再项目级范围
(
format!(" AND p.organization_id = ?{}", scope_cond),
std::iter::once(oid.to_string()).chain(scope_binds).collect(),
)
}
/// `cabinet_count`(**全部可见柜数**)的 SQL —— 语义冻结:只数柜子。
///
/// **不含任何 `status` 条件**:`cabinets.status` 是启用/禁用**配置态**,
/// 既不表示在线,也不该影响"柜子总数"(禁用中的柜子依然是柜子)。
fn visible_cabinets_count_sql(user: &CurrentUser) -> (String, Vec<String>) {
let (cond, binds) = visible_cabinet_filters(user);
let sql = if user.role_level >= 2 {
format!("SELECT COUNT(*) FROM cabinets c WHERE 1=1{}", cond)
} else {
format!(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE 1=1{}",
cond
)
};
(sql, binds)
}
/// 在线统计的**取数 SQL**:只取可见柜的 IMEI(`imei IS NOT NULL`)。
///
/// # 为什么 DB 不参与"在线"判定
/// `cabinets.status` 是**启用/禁用配置态**(全仓唯一写入方
/// `routes/cabinets.rs::set_cabinet_status` 只在 `2`(禁用)与其它值(启用)之间切,
/// **没有任何地方写 0(离线)**)⇒ 按 `status = 1` 统计在线,会在**一台设备都没连**时
/// 依然返回"全部在线"。
///
/// 因此这里只负责按**组织隔离 + 数据范围**取出一批 IMEI,在线与否交给
/// [`cabinet_tree::count_online_devices`](Redis 一次 pipeline,判据与设备列表
/// `is_online` 同源)。
fn visible_cabinet_imeis_sql(user: &CurrentUser) -> (String, Vec<String>) {
let (cond, binds) = visible_cabinet_filters(user);
let sql = if user.role_level >= 2 {
format!(
"SELECT c.imei FROM cabinets c WHERE c.imei IS NOT NULL{}",
cond
)
} else {
format!(
"SELECT c.imei FROM cabinets c JOIN projects p ON c.project_id = p.id \
WHERE c.imei IS NOT NULL{}",
cond
)
};
(sql, binds)
}
/// 执行「可见柜计数」SQL(组织隔离 + 数据范围)
async fn count_visible_cabinets( async fn count_visible_cabinets(
pool: &sqlx::MySqlPool, pool: &sqlx::MySqlPool,
user: &CurrentUser, user: &CurrentUser,
online_only: bool,
) -> Result<i64, AppError> { ) -> Result<i64, AppError> {
if user.role_level >= 2 { let (sql, binds) = visible_cabinets_count_sql(user);
let sql = if online_only { let mut query = sqlx::query_scalar::<_, i64>(&sql);
"SELECT COUNT(*) FROM cabinets WHERE status = 1" for bind in &binds {
} else {
"SELECT COUNT(*) FROM cabinets"
};
return Ok(sqlx::query_scalar(sql).fetch_one(pool).await?);
}
let Some(oid) = user.organization_id else {
return Ok(0);
};
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
let status_cond = if online_only { " AND c.status = 1" } else { "" };
let sql = format!(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?{}{}",
status_cond, scope_cond
);
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(oid);
for bind in &scope_binds {
query = query.bind(bind); query = query.bind(bind);
} }
Ok(query.fetch_one(pool).await?) Ok(query.fetch_one(pool).await?)
} }
/// 取当前用户**可见柜子的 IMEI 列表**(组织隔离 + 数据范围,顺序无关)
///
/// `imei` 列可空(老数据/联调库有 NULL),按 `Option<String>` 解码后过滤,
/// 避免一行 NULL 让整个在线统计解码失败(接口 500)。
async fn visible_cabinet_imeis(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
) -> Result<Vec<String>, AppError> {
let (sql, binds) = visible_cabinet_imeis_sql(user);
let mut query = sqlx::query_scalar::<_, Option<String>>(&sql);
for bind in &binds {
query = query.bind(bind);
}
Ok(query.fetch_all(pool).await?.into_iter().flatten().collect())
}
/// `online_count`:**在线柜数 = 可见柜里 Redis 明确在线的台数**。
///
/// 先按组织隔离 + 数据范围取出可见柜的 IMEI,再用**一次 pipeline** 批量查
/// `device:{imei}.online`(**零 N 次单查**);判据是
/// [`cabinet_tree::is_device_online`] —— 与设备列表 `is_online` 同一纯函数,
/// 且**完全不看 `cabinets.status`**。
async fn count_online_cabinets(
state: &super::AppState,
user: &CurrentUser,
) -> Result<i64, AppError> {
let imeis = visible_cabinet_imeis(&state.mysql, user).await?;
Ok(cabinet_tree::count_online_devices(state, &imeis).await)
}
/// GET /api/energy-stats/summary — 总览 /// GET /api/energy-stats/summary — 总览
/// ///
/// 返回今日/本月能耗、电费估算、柜子/在线数量。 /// 返回今日/本月能耗、电费估算、柜子/在线数量。
@ -236,9 +318,9 @@ pub async fn get_summary(
for v in &org_binds { month_q = month_q.bind(v); } for v in &org_binds { month_q = month_q.bind(v); }
let month_energy: i64 = month_q.fetch_one(pool).await?; let month_energy: i64 = month_q.fetch_one(pool).await?;
// 柜子总数 / 在线柜子数(含组织过滤 + 数据范围) // 柜子总数(全部可见柜,语义不变)/ 在线柜子数(**以 Redis 为准**,与 `cabinets.status` 无关)
let cabinet_count = count_visible_cabinets(pool, &user, false).await?; let cabinet_count = count_visible_cabinets(pool, &user).await?;
let online_count = count_visible_cabinets(pool, &user, true).await?; let online_count = count_online_cabinets(&state, &user).await?;
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,
@ -874,4 +956,115 @@ mod tests {
let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0; let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0;
assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small); assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small);
} }
// -----------------------------------------------------------------------
// 在线柜数守门(防回归:不得再拿 `cabinets.status` 当"在线")
// -----------------------------------------------------------------------
/// 构造单测用的 `CurrentUser`(不连库;`permissions` 与范围按需给)
fn user(role_level: i32, organization_id: Option<i64>, project_ids: Vec<i64>) -> CurrentUser {
CurrentUser {
user_id: 1,
phone: "13000000000".to_string(),
permissions: Default::default(),
role_level,
organization_id,
scope: crate::middleware::auth::UserScope { project_ids },
}
}
/// ① **在线统计不依赖 `cabinets.status`**。
///
/// `cabinets.status` 是启用/禁用**配置态**:全仓唯一写入方
/// (`routes/cabinets.rs::set_cabinet_status`)只在 `2`(禁用)与其它值(启用)之间切,
/// **没有任何地方写 0(离线)**。所以只要 SQL 里出现 `status` 条件,
/// 就必然在"一台设备都没连"时返回"全部在线"。
#[test]
fn test_visible_cabinet_sql_never_filters_by_status() {
let cases = [
user(2, None, vec![]), // 总管理员
user(1, Some(42), vec![]), // 组织管理员(未配范围)
user(1, Some(42), vec![7, 8]), // 组织管理员 + 项目级范围
user(0, None, vec![]), // 客户侧账号无组织 ⇒ 空集
user(0, Some(42), vec![]), // 操作员
];
for u in &cases {
let (count_sql, _) = visible_cabinets_count_sql(u);
let (imei_sql, _) = visible_cabinet_imeis_sql(u);
for sql in [&count_sql, &imei_sql] {
assert!(
!sql.to_lowercase().contains("status"),
"柜数/在线统计不得引用 cabinets.status:{}",
sql
);
}
}
}
/// ② 组织隔离与数据范围**必须保留**(收窄只能取交集,绝不放大):
/// 总管理员豁免;非总管理员带组织条件 + 项目级范围;**无组织 ⇒ 空集**(不是"全部")。
#[test]
fn test_visible_cabinet_sql_keeps_org_isolation_and_scope() {
// 总管理员豁免:即使身上带着组织与范围,也不得出现在 SQL 里
let (sql, binds) = visible_cabinet_imeis_sql(&user(2, Some(99), vec![7]));
assert!(!sql.contains("organization_id"), "总管理员应豁免组织条件:{}", sql);
assert!(!sql.contains("IN ("), "总管理员应豁免数据范围:{}", sql);
assert!(binds.is_empty(), "总管理员不应有任何绑定值");
// 组织管理员:组织条件 + 项目级范围(绑定顺序必须与占位符顺序一致)
let (sql, binds) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![7, 8]));
assert!(sql.contains("p.organization_id = ?"), "组织隔离丢失:{}", sql);
assert!(sql.contains("c.project_id IN (?,?)"), "数据范围丢失:{}", sql);
assert_eq!(binds, vec!["42", "7", "8"], "绑定顺序必须与占位符一致");
// 客户侧账号无组织 ⇒ 空集(**绝不退化成"全部"**),两条 SQL 都必须如此
let (imei_sql, binds) = visible_cabinet_imeis_sql(&user(0, None, vec![]));
assert!(imei_sql.contains("1=0"), "无组织时在线取数必须是空集:{}", imei_sql);
assert!(binds.is_empty());
let (count_sql, _) = visible_cabinets_count_sql(&user(0, None, vec![]));
assert!(count_sql.contains("1=0"), "无组织时柜数必须是空集:{}", count_sql);
// 在线取数只取 IMEI(在线判定交给 Redis,DB 不参与)
let (sql, _) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![]));
assert!(sql.starts_with("SELECT c.imei FROM cabinets c"), "取数应只取 IMEI:{}", sql);
assert!(sql.contains("c.imei IS NOT NULL"), "必须排除 NULL 的 IMEI:{}", sql);
}
/// ③ **与设备列表 `is_online` 口径同源**:`energy_stats` 没有第二套在线判据 ——
/// 在线计数一律经 `cabinet_tree::count_online_flags`,其唯一依据是
/// `cabinet_tree::is_device_online` / `device_online_flag`(设备列表用的是同一对函数)。
/// 逐值断言同一批原始值在两处结论完全一致,并覆盖「0 台连接 ⇒ 0」这条语义。
#[test]
fn test_online_count_same_source_as_cabinet_tree_device_list() {
// 同一批原始值,覆盖三态边界:在线 / 明确离线 / 无数据 / 空串 / 非法值
let raws: Vec<Option<String>> = vec![
Some("1".into()),
Some("0".into()),
None,
Some(String::new()),
Some("2".into()),
];
// 期望值直接由设备列表的判据(`is_online` == `device_online_flag(..) == Some(true)`)给出
let expected = raws
.iter()
.filter(|v| cabinet_tree::device_online_flag(v.as_deref()) == Some(true))
.count() as i64;
assert_eq!(
cabinet_tree::count_online_flags(&raws),
expected,
"在线计数与设备列表 is_online 判据必须同源"
);
assert_eq!(expected, 1, "5 个样本里只有 1 个明确在线");
// **0 台设备连接 ⇒ online_count = 0**(而不是等于柜子总数)——生产实测的回归形态
let nothing_connected: Vec<Option<String>> = vec![None; 68];
assert_eq!(
cabinet_tree::count_online_flags(&nothing_connected),
0,
"无设备连接时在线数必须是 0,绝不能退化成柜子总数"
);
// 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0
assert_eq!(cabinet_tree::count_online_flags(&[]), 0);
}
} }

View File

@ -164,8 +164,8 @@ pub async fn get_dashboard(
let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope()); let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope());
let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds }; let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds };
// 统计在线柜子数(带组织隔离) // 统计在线柜子数(带组织隔离;**以 Redis 为准**,与 `cabinets.status` 配置态无关)
let online_cabinets = count_online_cabinets(pool, is_super, org_id, &scope).await?; let online_cabinets = count_online_cabinets(&state, is_super, org_id, &scope).await?;
// 统计总通道数(带组织隔离) // 统计总通道数(带组织隔离)
let total_channels = count_total_compartments(pool, is_super, org_id, &scope).await?; let total_channels = count_total_compartments(pool, is_super, org_id, &scope).await?;
@ -217,34 +217,89 @@ pub async fn get_dashboard(
}))) })))
} }
/// 统计在线柜子数(带组织隔离 + 数据范围) /// 设备级 HMGET 的**字段清单**(顺序 = pipeline 命令顺序 = 解析下标)。
///
/// 下标:`rssi`(0) / `pow_fail_dc`(1) / `pow_fail_ac`(2) / `temp`(3) /
/// `cabinet_status1`(4) / `cabinet_status2`(5) / **`online`(6,追加在末尾)**。
/// ⚠️ 新增字段**只能追加在末尾**(前面的下标被下面的解析逐一对齐)。
const CABINET_HMGET_FIELDS: [&str; 7] = [
"rssi",
"pow_fail_dc",
"pow_fail_ac",
"temp",
"cabinet_status1",
"cabinet_status2",
// 实时在线:字段名直接取自 `cabinet_tree`(与设备列表 `is_online` 同一常量,跨模块同源)
super::cabinet_tree::DEVICE_ONLINE_FIELD,
];
/// 在线字段在 [`CABINET_HMGET_FIELDS`] 中的下标(追加在末尾)
const CABINET_HMGET_ONLINE_IDX: usize = 6;
/// 首页「在线设备」的**取数 SQL**:只取可见柜的 IMEI,在线与否交给 Redis 判定。
///
/// **不含任何 `status` 条件** —— `cabinets.status` 是启用/禁用**配置态**,
/// 全仓没有任何写入方会把它写成 0(离线),拿它统计会在"一台设备都没连"时
/// 返回"全部在线"。此处只负责按组织隔离 + 数据范围取出 IMEI。
fn online_cabinet_imeis_sql(is_super: bool, scope_cond: &str) -> String {
if is_super {
"SELECT cab.imei FROM cabinets cab WHERE cab.imei IS NOT NULL".to_string()
} else {
format!(
r#"SELECT cab.imei FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.imei IS NOT NULL AND p.organization_id = ?{}"#,
scope_cond
)
}
}
/// 统计在线柜子数(**以 Redis 为准**,带组织隔离 + 数据范围)
///
/// # 为什么不再用 `cabinets.status = 1`
/// `cabinets.status` 是启用/禁用**配置态**(`2` = 禁用,其余 = 启用),
/// 全仓**没有任何写入方会把它写成 0(离线)** ⇒ 按它统计会在"一台设备都没连"时
/// 依然返回"全部在线"。现在改为:先按组织隔离 + 范围取出可见柜的 IMEI,
/// 再用一次 pipeline 查 `device:{imei}.online`(判据与设备列表 `is_online` 同一纯函数
/// [`cabinet_tree::is_device_online`])。
async fn count_online_cabinets( async fn count_online_cabinets(
pool: &sqlx::MySqlPool, state: &AppState,
is_super: bool, is_super: bool,
org_id: Option<i64>, org_id: Option<i64>,
scope: &ScopeFilter<'_>, scope: &ScopeFilter<'_>,
) -> Result<i64, AppError> { ) -> Result<i64, AppError> {
if !is_super && org_id.is_none() { // 总管理员:全部柜子的 IMEI,不带任何组织条件(豁免)
return Ok(0); if is_super {
return finish_online_count(state, online_cabinet_imeis_sql(true, ""), Vec::new()).await;
} }
let count: i64 = if is_super { // 客户侧账号**无组织归属 ⇒ 空集**(绝不退化成"全部"),与 `visible_org_scope` 同口径
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1") let Some(oid) = org_id else {
.fetch_one(pool) return Ok(0);
.await?
} else {
let sql = format!(
r#"SELECT COUNT(*) FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.status = 1 AND p.organization_id = ?{}"#,
scope.cond
);
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(org_id.unwrap());
for bind in scope.binds {
query = query.bind(bind);
}
query.fetch_one(pool).await?
}; };
Ok(count) // 组织隔离 + 数据范围(绑定顺序 = 占位符顺序:先组织,再范围)
let mut binds: Vec<String> = vec![oid.to_string()];
binds.extend(scope.binds.iter().cloned());
finish_online_count(state, online_cabinet_imeis_sql(false, scope.cond), binds).await
}
/// 执行在线取数 SQL,并按 Redis 判据统计在线台数(`binds` 顺序 = 占位符顺序)
async fn finish_online_count(
state: &AppState,
sql: String,
binds: Vec<String>,
) -> Result<i64, AppError> {
let mut query = sqlx::query_scalar::<_, Option<String>>(&sql);
for bind in &binds {
query = query.bind(bind);
}
let imeis: Vec<String> = query
.fetch_all(&state.mysql)
.await?
.into_iter()
.flatten()
.collect();
// Redis 一次 pipeline 批量判定(**零 N 次单查**),口径同设备列表 is_online
Ok(super::cabinet_tree::count_online_devices(state, &imeis).await)
} }
/// 统计总仓体数(带组织隔离 + 数据范围) /// 统计总仓体数(带组织隔离 + 数据范围)
@ -824,6 +879,13 @@ struct CabinetRealtimeMaps {
dc: HashMap<String, Option<bool>>, dc: HashMap<String, Option<bool>>,
ac: HashMap<String, Option<bool>>, ac: HashMap<String, Option<bool>>,
temp: HashMap<String, Option<i32>>, temp: HashMap<String, Option<i32>>,
/// 设备在线(Redis `device:{imei}` 的 `online` 字段)
///
/// 判据与 PC 设备列表的 `is_online` **同源**(`cabinet_tree::device_online_flag`):
/// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = **无数据**(键或字段不存在)。
/// ⚠️ 与 DB 的 `cabinets.status`(启用/禁用**配置态**)**没有任何关系** ——
/// 小程序卡片不得再用 `cab.status` 推断在线。
online: HashMap<String, Option<bool>>,
/// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开) /// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开)
/// ///
/// 取不到(Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`, /// 取不到(Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`,
@ -977,15 +1039,8 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin
for r in rows { for r in rows {
if let Some(ref imei) = r.imei { if let Some(ref imei) = r.imei {
let key = format!("device:{imei}"); let key = format!("device:{imei}");
// ⚠️ 字段顺序与下面的下标解析一一对应(新增字段只能追加在末尾) // ⚠️ 字段顺序由 CABINET_HMGET_FIELDS 唯一决定,与下面的下标解析一一对应
pipe.cmd("HMGET").arg(&key).arg(&[ pipe.cmd("HMGET").arg(&key).arg(&CABINET_HMGET_FIELDS);
"rssi",
"pow_fail_dc",
"pow_fail_ac",
"temp",
"cabinet_status1",
"cabinet_status2",
]);
} }
} }
if !rows.is_empty() { if !rows.is_empty() {
@ -1006,6 +1061,15 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin
// 柜级状态位:原样透出 hex;空串/缺失 → None(响应 null,不落 "00") // 柜级状态位:原样透出 hex;空串/缺失 → None(响应 null,不落 "00")
maps.status1.insert(imei.clone(), pick_non_empty_hex(vals.get(4))); maps.status1.insert(imei.clone(), pick_non_empty_hex(vals.get(4)));
maps.status2.insert(imei.clone(), pick_non_empty_hex(vals.get(5))); maps.status2.insert(imei.clone(), pick_non_empty_hex(vals.get(5)));
// 在线(下标 CABINET_HMGET_ONLINE_IDX):判据**唯一权威纯函数**,与设备列表同源
maps.online.insert(
imei.clone(),
super::cabinet_tree::device_online_flag(
vals.get(CABINET_HMGET_ONLINE_IDX)
.and_then(|v| v.as_ref())
.map(String::as_str),
),
);
} }
} }
} }
@ -1072,6 +1136,10 @@ fn build_cabinet_list(
// 柜级状态位(协议 §3.8):原样透出 2 位 hex;取不到 → null(不落 "00") // 柜级状态位(协议 §3.8):原样透出 2 位 hex;取不到 → null(不落 "00")
let status1 = imei.as_ref().and_then(|i| realtime.status1.get(i).cloned()).flatten(); let status1 = imei.as_ref().and_then(|i| realtime.status1.get(i).cloned()).flatten();
let status2 = imei.as_ref().and_then(|i| realtime.status2.get(i).cloned()).flatten(); let status2 = imei.as_ref().and_then(|i| realtime.status2.get(i).cloned()).flatten();
// **实时在线**(Redis `device:{imei}.online`):`true` 在线 / `false` 明确离线 /
// `null` 无数据。字段名与 PC 端一致(`is_online`),小程序两端都消费它。
// ⚠️ 与 `status`(启用/禁用配置态)严格分离:`status === 2` 仍单独渲染「禁用」。
let is_online = imei.as_ref().and_then(|i| realtime.online.get(i).copied()).flatten();
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false); let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
// `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障 // `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障
let (_, _, full_count, fault_count) = let (_, _, full_count, fault_count) =
@ -1083,6 +1151,8 @@ fn build_cabinet_list(
"imei": r.imei, "imei": r.imei,
"iccid": r.iccid, "iccid": r.iccid,
"status": r.status, "status": r.status,
// 实时在线(Redis,判据与设备列表 `is_online` 同源);`status` 仍是配置态
"is_online": is_online,
"project_id": r.project_id, "project_id": r.project_id,
"address": r.address, "address": r.address,
"latitude": r.latitude, "latitude": r.latitude,
@ -2310,8 +2380,7 @@ mod tests {
/// 小程序设备列表的柜级 hex 位域取值(协议 §3.8): /// 小程序设备列表的柜级 hex 位域取值(协议 §3.8):
/// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。 /// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。
#[test] #[test]
fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() { fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() { assert_eq!(
assert_eq!(
pick_non_empty_hex(Some(&Some("00".into()))).as_deref(), pick_non_empty_hex(Some(&Some("00".into()))).as_deref(),
Some("00"), Some("00"),
"\"00\" 是「全部正常」的有效值,与「无数据」必须区分" "\"00\" 是「全部正常」的有效值,与「无数据」必须区分"
@ -2448,4 +2517,68 @@ mod tests {
); );
assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1)); assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1));
} }
// -----------------------------------------------------------------------
// 实时在线(`is_online`)守门 —— 防回归:不得再拿 `cabinets.status` 当"在线"
// -----------------------------------------------------------------------
/// 小程序列表的在线取数契约:
/// ① 在线字段**恰好出现一次**且位于清单末尾(下标常量与清单同步);
/// ② 字段名与设备列表 `is_online` **逐字一致**(跨模块同源守门)。
#[test]
fn test_h5_list_online_field_matches_device_list_criterion() {
let online_field = crate::routes::cabinet_tree::DEVICE_ONLINE_FIELD;
assert_eq!(
CABINET_HMGET_ONLINE_IDX,
CABINET_HMGET_FIELDS.len() - 1,
"在线字段必须追加在字段清单末尾(前面的下标解析依赖顺序)"
);
assert_eq!(
CABINET_HMGET_FIELDS[CABINET_HMGET_ONLINE_IDX], online_field,
"小程序列表在线取数字段必须与设备列表同源"
);
assert_eq!(
CABINET_HMGET_FIELDS.iter().filter(|f| **f == online_field).count(),
1,
"在线字段只允许出现一次"
);
// 三态:无数据(None)不是"离线"(false),小程序据此可以区分"未知"
assert_eq!(crate::routes::cabinet_tree::device_online_flag(None), None);
assert_eq!(
crate::routes::cabinet_tree::device_online_flag(Some("1")),
Some(true)
);
assert_eq!(
crate::routes::cabinet_tree::device_online_flag(Some("0")),
Some(false)
);
}
/// 首页「在线设备」的取数 SQL **不得依赖 `cabinets.status`**,
/// 且必须保留组织隔离:总管理员无组织条件、客户侧必须带 `p.organization_id = ?`。
#[test]
fn test_h5_dashboard_online_sql_has_no_status_and_keeps_org_isolation() {
let super_sql = online_cabinet_imeis_sql(true, "");
assert!(
!super_sql.to_lowercase().contains("status"),
"在线统计不得引用 cabinets.status:{}",
super_sql
);
assert!(super_sql.starts_with("SELECT cab.imei FROM cabinets cab"));
assert!(super_sql.contains("cab.imei IS NOT NULL"), "必须排除 NULL IMEI:{}", super_sql);
let org_sql = online_cabinet_imeis_sql(false, " AND p.id IN (?,?)");
assert!(
!org_sql.to_lowercase().contains("status"),
"在线统计不得引用 cabinets.status:{}",
org_sql
);
assert!(
org_sql.contains("INNER JOIN projects p ON cab.project_id = p.id"),
"客户侧必须经 projects 做组织隔离:{}",
org_sql
);
assert!(org_sql.contains("p.organization_id = ?"), "组织隔离条件丢失:{}", org_sql);
assert!(org_sql.contains("AND p.id IN (?,?)"), "数据范围条件丢失:{}", org_sql);
}
} }

View File

@ -23,6 +23,23 @@ const SCAN_INTERVAL_SECS: u64 = 300;
/// 单轮最多处理订单数,避免一次扫描锁过多行 /// 单轮最多处理订单数,避免一次扫描锁过多行
const BATCH_LIMIT: i64 = 200; const BATCH_LIMIT: i64 = 200;
/// 超时选单 SQL(抽成常量是为了让**方向**可被单测文本断言守住)。
///
/// ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"`start_time` 早于 `NOW() - 10 小时`"。
/// - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是
/// **未来时刻** `NOW()+10h`,于是"`start_time` < 未来"对**几乎所有订单恒真**,
/// 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算;
/// - ✅ 正确(本常量):把 `start_time` **往前推** 10 小时再与 `NOW()` 比较。
///
/// 方向写反**不报错、不打日志、跑起来一切正常**,只会在业务上悄悄提前结单,
/// 因此必须由单测(见文件底部 `test_...`)把方向钉死 —— 这是「踩过的坑」第 1 条的规范要求。
/// 同义的另一处写法在 [`crate::workers::report_worker`] 的 `FINALIZE_EXPIRED_ORDER_SQL`
/// (那边绑的是**负数**),**改动任一处都要同时确认另一处**。
const EXPIRED_ORDER_SCAN_SQL: &str = "SELECT id, compartment_id, start_time FROM charge_orders \
WHERE status = 0 AND start_time IS NOT NULL \
AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \
ORDER BY start_time ASC LIMIT ?";
/// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行) /// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
/// ///
/// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单 /// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单
@ -50,22 +67,13 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq
// 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的, // 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的,
// 交给数据库算差值可避免本地时区换算偏差; // 交给数据库算差值可避免本地时区换算偏差;
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。 // 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
// // ⚠️ 方向说明与踩坑记录见 [`EXPIRED_ORDER_SCAN_SQL`] 的文档注释。
// ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"start_time 早于 NOW() - 10 小时"。 let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> =
// - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是 sqlx::query_as(EXPIRED_ORDER_SCAN_SQL)
// **未来时刻** `NOW()+10h`,于是"start_time < 未来"对**几乎所有订单恒真**, .bind(TIMEOUT_HOURS)
// 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算。 .bind(BATCH_LIMIT)
// - ✅ 正确:把 start_time 往前推 10 小时再与 NOW() 比较(下面这种写法)。 .fetch_all(pool)
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as( .await?;
"SELECT id, compartment_id, start_time FROM charge_orders \
WHERE status = 0 AND start_time IS NOT NULL \
AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \
ORDER BY start_time ASC LIMIT ?",
)
.bind(TIMEOUT_HOURS)
.bind(BATCH_LIMIT)
.fetch_all(pool)
.await?;
if expired.is_empty() { if expired.is_empty() {
return Ok(()); return Ok(());
@ -96,3 +104,100 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq
tracing::info!("[order_timeout] 本轮超时结单处理完成"); tracing::info!("[order_timeout] 本轮超时结单处理完成");
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// 单元测试:超时条件**方向**守门
//
// 为什么必须有:本文件的 SQL 与 `report_worker.rs::FINALIZE_EXPIRED_ORDER_SQL`
// 是**两种等价但写法不同**的超时判据(这里把 `start_time` 往前推,那边把阈值往回绑负数)。
// 2026-09 修复 `report_worker` 方向 BUG 时,本文件**没有**同款守门测试(只在文档里留了「遗留」标记),
// 意味着这边再被改反也一样是**零报错、零日志、单测全绿**。
// 本模块把两个方向断言都在此处补齐(依据「踩过的坑」第 1 条 +「质量约束模板」第 8 条)。
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// **纯函数孪生体(仅测试)**:与 [`EXPIRED_ORDER_SCAN_SQL`] **同义** ——
/// `start_time + hours < now` ⇒ 已超时。
///
/// 有了它才能做"刚建的不命中 + 超期的命中"双向断言,并**反证**方向写反时的后果。
fn is_expired(
start_time: chrono::NaiveDateTime,
now: chrono::NaiveDateTime,
hours: i64,
) -> bool {
start_time + chrono::Duration::hours(hours) < now
}
/// 方向守门(SQL 文本层):阈值必须**往回**推(`start_time` 在 `<` 左侧)。
#[test]
fn test_expired_order_scan_sql_points_backwards() {
assert!(
EXPIRED_ORDER_SCAN_SQL.contains("TIMESTAMPADD(HOUR, ?, start_time) < NOW()"),
"超时条件必须是「start_time 早于过去时刻」。当前:{EXPIRED_ORDER_SCAN_SQL}"
);
// 反证:以下是**写反**的形态(绑正数小时 ⇒ 未来时刻 ⇒ 对活跃订单恒真)
assert!(
!EXPIRED_ORDER_SCAN_SQL.contains("start_time < TIMESTAMPADD(HOUR, ?, NOW())")
&& !EXPIRED_ORDER_SCAN_SQL.contains("NOW() + INTERVAL")
&& !EXPIRED_ORDER_SCAN_SQL.contains("TIMESTAMPADD(HOUR, 10, NOW())"),
"方向写反:刚建单几分钟的活跃订单会被当超时单提前结掉、电量提前结算。\
当前:{EXPIRED_ORDER_SCAN_SQL}"
);
}
/// **双向语义断言**:刚建的不命中 / 超期的命中 / 边界 1 分钟不算,
/// 并**反证**写反方向时"刚建的单"必然被命中 —— 即"方向反了一定红"。
#[test]
fn test_is_expired_two_way_assertions_catch_reversed_direction() {
let now = chrono::NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let fresh = now - chrono::Duration::minutes(5); // 刚建 5 分钟
let just_over = now - chrono::Duration::hours(TIMEOUT_HOURS) - chrono::Duration::minutes(1);
let just_under = now - chrono::Duration::hours(TIMEOUT_HOURS) + chrono::Duration::minutes(1);
// ① 刚建的不命中(防"恒真")
assert!(
!is_expired(fresh, now, TIMEOUT_HOURS),
"刚建 5 分钟的订单绝不能被判超时(提早结单会提前结算电量)"
);
// ② 超期的命中(防"恒假")
assert!(
is_expired(just_over, now, TIMEOUT_HOURS),
"已过 {} 小时 1 分钟的订单必须判超时",
TIMEOUT_HOURS
);
// ③ 边界:距超时还差 1 分钟 ⇒ 不命中
assert!(
!is_expired(just_under, now, TIMEOUT_HOURS),
"还差 1 分钟不算超时(阈值是严格小于)"
);
// ④ 反证:把阈值加到 now 上(等价于 SQL 里绑正数)⇒ 刚建的单也"命中"
let reversed_hits_fresh = fresh < now + chrono::Duration::hours(TIMEOUT_HOURS);
assert!(
reversed_hits_fresh,
"反证失败:写反的方向本应命中刚建单;若这里不成立,说明反证用例本身失效"
);
assert_ne!(
is_expired(fresh, now, TIMEOUT_HOURS),
reversed_hits_fresh,
"正确方向与写反方向对「刚建的单」的判定必须相反 —— 否则测试抓不到方向写反"
);
}
/// 常量自身合理性(防阈值被改小到"把正常充电判超时")
#[test]
fn test_timeout_constants_are_sane() {
assert_eq!(TIMEOUT_HOURS, 10, "协议/业务约定为 10 小时");
assert!(
TIMEOUT_HOURS > 0,
"阈值必须为正 —— 负数会让条件恒假、超时单永不结单"
);
assert!(BATCH_LIMIT > 0);
}
}

View File

@ -69,11 +69,24 @@ const PENDING_WINDOW_MS: i64 = 60_000;
/// **连接级认证标记**(`device:authed:{imei}`)的 TTL(秒),与设备在线 TTL 一致。 /// **连接级认证标记**(`device:authed:{imei}`)的 TTL(秒),与设备在线 TTL 一致。
/// ///
/// 为什么需要它:api-server 下发指令前要判断"该 IMEI **是否存在已登录的连接**"(协议 §4.2)。 /// **为什么需要它**:api-server 下发指令前要判断"该 IMEI **是否存在已登录的连接**"(协议 §4.2)。
/// 过去用 Redis 在线键 `device:{imei}` 判断,但该键在**验签前**就会被写(`auth_str`/`login` /// 在线键 `device:{imei}` 判不了这件事,因为它表达的是**另一件事**:
/// 到达即写)、断连后还能存活 300 秒 ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。 /// - `online` 字段由 api-server 在 `login` **验签成功后**(`report_worker.rs::handle_login`
/// 该标记只在 **`login` 验签成功**(投递含 `spec`+`suc=1` 的应答)时写入、 /// 的 `sign == expected_sign` 分支内,`mark_device_online`)以及 `status_post`
/// 该连接每次上行/下行时刷新、断连或被接管时删除。 /// (`write_device_state`,该 act 已被本服务的登录门控挡住,必然来自已认证连接)写入;
/// - 但它的 **TTL 是"最近 300 秒收到过该设备任意报文"**(协议 §2.3),断连后仍会存活最多 5 分钟。
///
/// 也就是说:`device:{imei}` 回答的是"**该设备最近活跃吗**",而不是"**现在有没有一条能收指令的
/// 已登录连接**"。两者在"设备刚断开"这个窗口里必然不一致 —— 若用前者判可否下发,就会出现
/// "管理端显示已下发成功、设备实际收不到"。
///
/// 因此本标记的判据是**严格的连接级事实**:只在 `login` 验签成功(投递含 `spec`+`suc=1`
/// 的应答)时写入、该连接每次上行/下行时刷新、断连或被接管时**立即删除**。
/// ⚠️ 另需注意 `device-server` 自己也会写 `device:{imei}`(`server.rs::update_device_route`
/// 写 `node_id` + `EXPIRE 300`),那是在 `auth_str`/`login` **到达时**(验签之前)发生的 ——
/// 它只刷新 TTL、不写 `online`,效果是"未认证报文也可能把 `online` 的存活期往后顺延一次"。
/// 该行为与协议 §2.3「收到过该设备任意报文即算活跃」一致,**不是**"未验签就算在线",
/// 故保留;但它进一步说明**不能**用 `device:{imei}` 当"可下发"的判据。
const AUTH_MARKER_TTL_SECS: u32 = 300; const AUTH_MARKER_TTL_SECS: u32 = 300;
/// 连接级认证标记的 Redis 键(**跨服务契约**:device-server 写、api-server 读)。 /// 连接级认证标记的 Redis 键(**跨服务契约**:device-server 写、api-server 读)。

View File

@ -3,7 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build:mp-weixin": "uni build --platform mp-weixin" "build:mp-weixin": "uni build --platform mp-weixin",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001", "@dcloudio/uni-app": "3.0.0-4060620250520001",
@ -21,6 +22,7 @@
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001", "@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001", "@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.8" "vite": "^5.2.8",
"vue-tsc": "^2.2.12"
} }
} }

View File

@ -116,7 +116,21 @@ export interface CabinetItem {
id: number id: number
abstract_id: string abstract_id: string
name?: string name?: string
/**
* **配置态**:`1` = 启用(默认)/ `2` = 禁用(后端据此拦截开合闸、开门、充电)。
*
* ⛔ **不表示在线** —— 全仓没有任何写入方会把它写成 `0`(离线)。
* 展示"在线/离线"一律用 [`CabinetItem.is_online`]。
*/
status: number status: number
/**
* **实时在线**(`GET /api/h5/cabinets` 返回,值来自 Redis `device:{imei}.online`)
*
* `true` 在线 / `false` 明确离线 / `null`、缺失 = 无数据(键或字段不存在)。
* 口径与 PC 设备列表 `is_online` **同源**(后端同一纯函数)。
* ⚠️ 与 `status`(启用/禁用配置态)严格分离,不得互相推断。
*/
is_online?: boolean | null
project_id?: number project_id?: number
rssi?: number rssi?: number
imei?: string imei?: string
@ -164,6 +178,16 @@ export interface CompartmentInfo {
export interface CabinBoard { export interface CabinBoard {
id: number id: number
board_id: number board_id: number
/**
* ⛔ **不要用它推断"仓控板在线"** —— `cabin_boards.status` 在**整个后端没有任何写入方**
* (全仓只有建表默认值 0,`INSERT INTO cabin_boards (cabinet_id, board_id)` 没写这一列;
* 生产库实测取值分布只有 `0`)。协议里也**没有"仓控板在线"的概念**:
* 与平台保持 TCP 长连接的是**柜控板**(按 IMEI 标识),仓控板挂在柜内 485 总线上,
* 没有独立上行通路 ⇒ 这个状态**推不出来**。
* 该列保留仅为兼容后端响应形状;**界面不得据此显示"在线/离线"**
* (此前小程序展示的板级徽标恒定显示"离线",属谎报,已删除)。
* 柜级在线状态请看 `CabinetDetail.is_online`(Redis 实时值)。
*/
status: number status: number
compartments: CompartmentInfo[] compartments: CompartmentInfo[]
} }
@ -174,7 +198,19 @@ export interface CabinetDetail {
name?: string name?: string
imei?: string imei?: string
iccid?: string iccid?: string
/**
* **配置态**:`1` = 启用 / `2` = 禁用(后端据此拦截指令链路)。
*
* ⛔ **不表示在线**;展示"在线/离线"请用 [`CabinetDetail.is_online`]。
*/
status: number status: number
/**
* **实时在线**(`GET /api/cabinets/{id}` 返回,值来自 Redis `device:{imei}.online`)
*
* `true` 在线 / `false` 明确离线 / `null`、缺失 = 无数据。
* 判据与设备列表 `is_online` **同源**(后端同一纯函数)。
*/
is_online?: boolean | null
rssi?: number rssi?: number
/** 直流停电(协议 §5.4);true=停电 / false=正常 / null=无数据 */ /** 直流停电(协议 §5.4);true=停电 / false=正常 / null=无数据 */
pow_fail_dc?: boolean | null pow_fail_dc?: boolean | null
@ -214,6 +250,15 @@ export interface CompartmentDetail {
batt_status?: string | null batt_status?: string | null
/** 电源状态(协议 §3.8,2 位 hex);与电池状态相互独立,null/缺失 = 展示 "-" */ /** 电源状态(协议 §3.8,2 位 hex);与电池状态相互独立,null/缺失 = 展示 "-" */
pow_status?: string | null pow_status?: string | null
/**
* **仓体温度**(协议附 A,K×100)
*
* 后端 `GET /api/h5/compartments/{id}` 会返回该字段
* (`routes/h5.rs` 的 `COMPARTMENT_REALTIME_FIELDS` 第 6 项 `"temp"`,有单测守门),
* 展示走 `utils/deviceStatus.ts::formatCelsius`。
* 此前类型里漏了它 ⇒ 页面 `data.temp` 报 TS2339(类型契约与后端不一致,已补)。
*/
temp?: number | null
} }
export interface ChargeRecord { export interface ChargeRecord {

View File

@ -5,3 +5,31 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any> const component: DefineComponent<{}, {}, any>
export default component export default component
} }
/**
* 微信小程序宿主全局 `wx`。
*
* **为什么需要手工声明**:`@dcloudio/types` 只声明跨端的 `uni`,**不声明宿主特有的 `wx`**;
* 而自定义导航栏的安全区计算必须用宿主 API(胶囊按钮位置没有等价的跨端取法),
* 所以 6 个页面里直接用了 `wx.*`。不声明时 `vue-tsc` 会报 12 处
* `TS2304: Cannot find name 'wx'` —— 噪声会把**真正的类型错误淹掉**
* (与「打印 ❌ 却 exit 0」是同一类问题:判据不再指向真实缺陷)。
*
* ⚠️ **刻意只声明实际用到的两个 API**,不写 `[k: string]: any`:
* 后者会让 `wx.anything()` 全部通过,等于把这一层检查关掉。需要新宿主 API 时,
* 按同样方式在下面补一条(签名照官方文档抄)。
*
* 运行期说明:`wx` 只存在于 `mp-weixin` 产物中,而本工程只构建该目标(`build:mp-weixin`)。
*/
declare const wx: {
/** 取宿主系统信息(本工程只消费 `statusBarHeight`) */
getSystemInfoSync(): {
/** 状态栏高度(px) */
statusBarHeight?: number
}
/** 取右上角胶囊按钮布局,用于按"胶囊高度 + 上下间距"反推自定义导航栏高度 */
getMenuButtonBoundingClientRect(): {
top: number
height: number
}
}

View File

@ -15,7 +15,13 @@
<view class="info-top"> <view class="info-top">
<view><text class="info-id">{{ data.abstract_id }}</text></view> <view><text class="info-id">{{ data.abstract_id }}</text></view>
<view class="info-status-row"> <view class="info-status-row">
<view class="status-badge" :class="data.status === 1 ? 'online' : 'offline'"><text>{{ data.status === 1 ? '在线' : '离线' }}</text></view> <!-- 状态徽标:`status === 2` = 禁用(配置态);其余按**接口的实时在线字段** `is_online`
(Redis `device:{imei}.online`,与 PC 设备列表 `is_online` 同源)。
⛔ 不得用 `data.status` 推断在线。 -->
<view
class="status-badge"
:class="data.status === 2 ? 'disabled' : (data.is_online === true ? 'online' : 'offline')"
><text>{{ data.status === 2 ? '禁用' : (data.is_online === true ? '在线' : '离线') }}</text></view>
<view class="signal"><view v-for="(color, i) in rssiBarColors(data.rssi)" :key="i" class="signal-bar" :class="'l' + (i + 1)" :style="{ background: color }"></view></view> <view class="signal"><view v-for="(color, i) in rssiBarColors(data.rssi)" :key="i" class="signal-bar" :class="'l' + (i + 1)" :style="{ background: color }"></view></view>
<text class="rssi-val">{{ data.rssi != null ? data.rssi : '—' }}</text> <text class="rssi-val">{{ data.rssi != null ? data.rssi : '—' }}</text>
<view class="pow-tags"> <view class="pow-tags">
@ -71,7 +77,16 @@
<view v-for="board in data.cabin_boards" :key="board.id" class="board-section"> <view v-for="board in data.cabin_boards" :key="board.id" class="board-section">
<view class="board-header"> <view class="board-header">
<text class="board-title">{{ data.imei }}-{{ board.board_id }}</text> <text class="board-title">{{ data.imei }}-{{ board.board_id }}</text>
<view class="board-status" :class="board.status === 1 ? 'online' : 'offline'"><text>{{ board.status === 1 ? '在线' : '离线' }}</text></view> <!-- ⛔ 这里**不再显示"在线/离线"徽标**。
原因(实测 2026-09-20):`cabin_boards.status` 在**整个后端没有任何写入方**
—— 全仓只有建表默认值,`INSERT INTO cabin_boards (cabinet_id, board_id)` 根本没写它;
生产库实测取值分布只有 `0`(2/2 行),接口也只是把该列读出来。
所以此前那句 `board.status === 1 ? '在线' : '离线'` 是**恒定显示"离线"**,
属对用户的**谎报**(比不显示更糟:运维会据此以为板子坏了)。
协议层面也**没有"仓控板在线"这个概念**:与平台保持 TCP 连接的是柜控板(按 IMEI 标识),
仓控板挂在柜内 485 总线上,没有独立的上行通路 ⇒ 该项状态**推不出来**。
按既定口径「柜的就显示到柜子,仓体的就显示到仓上」+「不显示推不出来的状态」,
这里直接去掉;柜级在线状态在同页顶部按 Redis 实时值显示。 -->
</view> </view>
<view class="comp-grid"> <view class="comp-grid">
<view v-for="comp in board.compartments" :key="comp.id" class="comp" :class="compClass(comp.status)" @click="goCompartment(comp.id)"> <view v-for="comp in board.compartments" :key="comp.id" class="comp" :class="compClass(comp.status)" @click="goCompartment(comp.id)">
@ -247,7 +262,9 @@ function openLocation() {
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; } .status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; }
.status-badge.online { background: #f0fdf4; color: #22c55e; } .status-badge.online { background: #f0fdf4; color: #22c55e; }
.status-badge.offline { background: #f1f5f9; color: #94a3b8; } .status-badge.offline { background: #f1f5f9; color: #94a3b8; }
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } /* 配置态「禁用」(`cabinets.status === 2`),与"离线"区分 */
.status-badge.disabled { background: #fff7ed; color: #f97316; }
.status-badge.online::before, .status-badge.offline::before, .status-badge.disabled::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.signal { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; } .signal { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; }
.signal-bar { width: 2.5px; border-radius: 1px; } .signal-bar { width: 2.5px; border-radius: 1px; }
.signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 14px; } .signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 14px; }
@ -287,8 +304,8 @@ function openLocation() {
.board-section { margin: 12px 16px; background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); } .board-section { margin: 12px 16px; background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.board-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .board-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.board-title { font-size: 14px; font-weight: 600; color: #111827; font-variant-numeric: tabular-nums; } .board-title { font-size: 14px; font-weight: 600; color: #111827; font-variant-numeric: tabular-nums; }
.board-status { font-size: 11px; color: #94a3b8; } /* `.board-status` 样式已随"板级在线徽标"一并删除:该状态无数据来源(见模板注释),
.board-status.online { color: #22c55e; } 留着只会诱使后来者再加回那个恒定显示"离线"的徽标。 */
.comp-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } .comp-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.comp { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1.5px solid transparent; } .comp { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1.5px solid transparent; }
.comp:active { transform: scale(0.97); } .comp:active { transform: scale(0.97); }

View File

@ -1,105 +0,0 @@
<template>
<view class="devices-page">
<!-- List -->
<view v-if="list.length === 0 && !loading" class="empty">
<text class="empty-text">暂无设备</text>
</view>
<view v-else class="device-list">
<view
v-for="item in list"
:key="item.id"
class="device-card"
@click="goDetail(item.id)"
>
<view class="device-header">
<text class="device-name">{{ item.name || item.abstract_id }}</text>
<text class="device-status" :class="item.status === 1 ? 'online' : 'offline'">
{{ item.status === 1 ? '在线' : '离线' }}
</text>
</view>
<text class="device-id">ID: {{ item.abstract_id }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { h5Api, type CabinetItem } from '../../api'
const list = ref<CabinetItem[]>([])
const loading = ref(true)
onShow(() => {
fetchList()
})
async function fetchList() {
loading.value = true
try {
const res = await h5Api.getCabinets()
list.value = res.data || []
} catch {
// 静默失败
} finally {
loading.value = false
}
}
function goDetail(id: number) {
uni.navigateTo({ url: `/pages/device-detail/index?id=${id}` })
}
</script>
<style scoped>
.devices-page {
padding: 24px;
}
.empty {
padding: 80px 0;
text-align: center;
}
.empty-text {
font-size: 28px;
color: #9ca3af;
}
.device-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.device-card {
background-color: #fff;
border-radius: 16px;
padding: 24px;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.device-name {
font-size: 30px;
font-weight: 600;
color: #111;
}
.device-status {
font-size: 24px;
padding: 4px 12px;
border-radius: 8px;
}
.device-status.online {
color: #16a34a;
background-color: #f0fdf4;
}
.device-status.offline {
color: #9ca3af;
background-color: #f9fafb;
}
.device-id {
font-size: 24px;
color: #9ca3af;
}
</style>

View File

@ -40,7 +40,16 @@
<view class="device-header"> <view class="device-header">
<view class="device-id-row"> <view class="device-id-row">
<text class="device-id">{{ cab.abstract_id }}</text> <text class="device-id">{{ cab.abstract_id }}</text>
<view class="status-badge" :class="cab.status === 1 ? 'online' : 'offline'"><text>{{ cab.status === 1 ? '在线' : '离线' }}</text></view> <!-- 状态徽标:**配置态与实时态分开**
- `status === 2` = 禁用(`cabinets.status` 配置态,后端据此拦截指令);
- 否则按**接口返回的实时在线字段** `is_online` 显示「在线/离线」
(值来自 Redis `device:{imei}.online`,与 PC 设备列表 `is_online` 同源)。
⛔ 不得再用 `cab.status` 推断在线 —— 那是启用/禁用配置态,全仓没有写入方会把
它写成 0(离线),拿它判在线会在一台设备都没连时依然显示「在线」。 -->
<view
class="status-badge"
:class="cab.status === 2 ? 'disabled' : (cab.is_online === true ? 'online' : 'offline')"
><text>{{ cab.status === 2 ? '禁用' : (cab.is_online === true ? '在线' : '离线') }}</text></view>
</view> </view>
<view class="header-right"> <view class="header-right">
<text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text> <text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text>
@ -267,7 +276,9 @@ function showScanInput() {
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; } .status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; }
.status-badge.online { background: #f0fdf4; color: #22c55e; } .status-badge.online { background: #f0fdf4; color: #22c55e; }
.status-badge.offline { background: #f1f5f9; color: #94a3b8; } .status-badge.offline { background: #f1f5f9; color: #94a3b8; }
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } /* 配置态「禁用」(`cabinets.status === 2`):与"离线"区分开的橙色徽标 */
.status-badge.disabled { background: #fff7ed; color: #f97316; }
.status-badge.online::before, .status-badge.offline::before, .status-badge.disabled::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.temp-badge { font-size: 12px; color: #475569; font-variant-numeric: tabular-nums; flex-shrink: 0; } .temp-badge { font-size: 12px; color: #475569; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: 8px; } .header-right { display: flex; align-items: center; gap: 8px; }
.signal-inline { display: flex; align-items: center; gap: 3px; } .signal-inline { display: flex; align-items: center; gap: 3px; }

View File

@ -27,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
@ -46,7 +46,12 @@ setTimeout(calcNav, 300)
setTimeout(calcNav, 600) setTimeout(calcNav, 600)
const authStore = useAuthStore() const authStore = useAuthStore()
const user = authStore.user // ⚠️ 必须用 `computed` 包一层,不能写 `const user = authStore.user`:
// Pinia 的 **setup store** 对外已把 `ref` 解包,`authStore.user` 是**值**而不是 Ref,
// 于是 ① `user.value` 恒为 `undefined`(头像上传成功后写回 store 的那行成了**空操作**,
// 页面要等下次冷启动才显示新头像);② 直接取值是**快照**,store 之后被 `restore()`/
// 重新登录改写时,页面仍指向旧对象(响应式丢失)。两处都是 vue-tsc 报出来的真实缺陷。
const user = computed(() => authStore.user)
function goFeedback() { uni.navigateTo({ url: '/pages/feedback/index' }) } function goFeedback() { uni.navigateTo({ url: '/pages/feedback/index' }) }
function confirmLogout() { function confirmLogout() {
uni.showModal({ title: '退出登录', content: '确定要退出当前账号?', success: (res) => { if (res.confirm) { authStore.logout(); uni.reLaunch({ url: '/pages/login/index' }) } } }) uni.showModal({ title: '退出登录', content: '确定要退出当前账号?', success: (res) => { if (res.confirm) { authStore.logout(); uni.reLaunch({ url: '/pages/login/index' }) } } })

View File

@ -54,10 +54,16 @@ const STATUS_MAP: Record<number, { label: string; color: string }> = {
3: { label: '故障', color: 'red' }, 3: { label: '故障', color: 'red' },
}; };
/** 柜子状态映射 */ /**
* 柜子**配置态**映射(`cabinets.status`):只有「启用 / 禁用」两档。
*
* ⚠️ 这里**不含"在线/离线"**:`cabinets.status` 是启用/禁用配置态,
* 全仓没有任何写入方会把它写成 `0`(离线),拿它判在线会让"一台设备都没连"时
* 依然显示"在线"。实时在线一律看接口的 `is_online`(Redis,见 `onlineTag`)。
* ⛔ 不要再往本表里加"离线"档。
*/
const CABINET_STATUS_MAP: Record<number, { label: string; color: string }> = { const CABINET_STATUS_MAP: Record<number, { label: string; color: string }> = {
0: { label: '离线', color: 'red' }, 1: { label: '启用', color: 'green' },
1: { label: '在线', color: 'green' },
2: { label: '禁用', color: 'orange' }, 2: { label: '禁用', color: 'orange' },
}; };
@ -483,7 +489,20 @@ export default function CabinetDetail() {
); );
} }
const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '未知', color: 'gray' }; const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '启用', color: 'green' };
/**
* **实时在线**标签:`is_online` 来自接口(Redis `device:{imei}.online`,
* 与设备列表 `is_online` 同源)。三态 —— `true` 在线 / `false` 离线 / `null`、缺失 = 无数据。
*
* ⚠️ 与 `detail.status`(启用/禁用配置态)**无关**:后者永不表示在线,不得再用来推断。
* 无数据时显示"在线未知"而不是"离线"("没收到上报" 与 "明确离线" 必须区分)。
*/
const onlineTag: { label: string; color: string } =
detail.is_online === true
? { label: '在线', color: 'green' }
: detail.is_online === false
? { label: '离线', color: 'red' }
: { label: '在线未知', color: 'gray' };
/** 充电工作模式:后端未返回时为 null(界面上按默认「平台指令模式」展示并标注) */ /** 充电工作模式:后端未返回时为 null(界面上按默认「平台指令模式」展示并标注) */
const workMode: '0' | '1' | null = detail.work_mode === '1' ? '1' : detail.work_mode === '0' ? '0' : null; const workMode: '0' | '1' | null = detail.work_mode === '1' ? '1' : detail.work_mode === '0' ? '0' : null;
/** 柜级柜门三态(协议 §4.2):`status1` bit4;无数据 → 「未知」(绝不显示成"关闭") */ /** 柜级柜门三态(协议 §4.2):`status1` bit4;无数据 → 「未知」(绝不显示成"关闭") */
@ -500,6 +519,9 @@ export default function CabinetDetail() {
<Button type="text" icon={<IconLeft />} onClick={() => navigate('/devices')} style={{ padding: '0 4px' }} /> <Button type="text" icon={<IconLeft />} onClick={() => navigate('/devices')} style={{ padding: '0 4px' }} />
<IconDesktop style={{ fontSize: 20, color: 'var(--color-text-2)' }} /> <IconDesktop style={{ fontSize: 20, color: 'var(--color-text-2)' }} />
<Typography.Text style={{ fontSize: 16, fontWeight: 600 }}>柜子 {detail.abstract_id}</Typography.Text> <Typography.Text style={{ fontSize: 16, fontWeight: 600 }}>柜子 {detail.abstract_id}</Typography.Text>
{/* 实时在线(Redis `device:{imei}.online`,接口 `is_online`):三态,无数据 → 「在线未知」 */}
<Tag color={onlineTag.color} size="small">{onlineTag.label}</Tag>
{/* 配置态(启用/禁用):与上面"是否在线"是两个维度,同时展示 */}
<Tag color={cabStatus.color} size="small">{cabStatus.label}</Tag> <Tag color={cabStatus.color} size="small">{cabStatus.label}</Tag>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<span style={{ fontSize: 12, color: 'var(--color-text-2)', background: 'var(--color-fill-1)', borderRadius: 4, padding: '2px 8px' }}> <span style={{ fontSize: 12, color: 'var(--color-text-2)', background: 'var(--color-fill-1)', borderRadius: 4, padding: '2px 8px' }}>

View File

@ -6,20 +6,44 @@
3. 结束该柜未结束的充电订单(`active_flag` 是生成列,由 `end_time` 派生,不能直接写), 3. 结束该柜未结束的充电订单(`active_flag` 是生成列,由 `end_time` 派生,不能直接写),
避免 ⑤ `on` 撞上 `active_flag` 唯一键 避免 ⑤ `on` 撞上 `active_flag` 唯一键
""" """
import os
import sys
import pymysql import pymysql
import redis import redis
IMEI = '860000000000001' IMEI = '860000000000001'
CABINET_ID = 1 CABINET_ID = 1
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6, decode_responses=True) # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
keys = r.keys(f'device:{IMEI}*') + [f'auth_str:issued:{IMEI}'] REDIS_HOST = os.environ.get('PMS_REDIS_HOST', '127.0.0.1')
REDIS_PORT = int(os.environ.get('PMS_REDIS_PORT', '16379'))
MYSQL_HOST = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1')
MYSQL_PORT = int(os.environ.get('PMS_MYSQL_PORT', '13306'))
# 连不上时给可读提示并 exit 2(隧道没起 ≠ 复位成功,绝不能静默继续)
HINT = ('连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道'
'(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT / PMS_REDIS_HOST/PMS_REDIS_PORT 指向其它可达地址)')
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT,
password='Hbhyg731024@', db=6, decode_responses=True)
try:
keys = r.keys(f'device:{IMEI}*') + [f'auth_str:issued:{IMEI}']
except redis.RedisError as e:
print(f'✗ Redis 连接失败 {REDIS_HOST}:{REDIS_PORT}:{e}', file=sys.stderr)
print(HINT, file=sys.stderr)
sys.exit(2)
print('Redis 待删除 key 数:', len(keys)) print('Redis 待删除 key 数:', len(keys))
if keys: if keys:
print('Redis deleted:', r.delete(*keys)) print('Redis deleted:', r.delete(*keys))
c = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', try:
database='pms_dev', autocommit=True) c = pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user='root', password='Hbhyg731024@',
database='pms_dev', autocommit=True)
except pymysql.MySQLError as e:
print(f'✗ MySQL 连接失败 {MYSQL_HOST}:{MYSQL_PORT}:{e}', file=sys.stderr)
print(HINT, file=sys.stderr)
sys.exit(2)
cur = c.cursor() cur = c.cursor()
cur.execute("UPDATE cabinets SET auth_str_issued = 0 WHERE imei = %s", (IMEI,)) cur.execute("UPDATE cabinets SET auth_str_issued = 0 WHERE imei = %s", (IMEI,))
print('auth_str_issued 置 0 行数:', cur.rowcount) print('auth_str_issued 置 0 行数:', cur.rowcount)

View File

@ -66,8 +66,8 @@ ENVS = {
"admin_phone": "13912391234", "admin_phone": "13912391234",
}, },
} }
DEFAULT_HOST = "10.8.0.252" DEFAULT_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
DEFAULT_PORT = 3306 DEFAULT_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
DEFAULT_USER = "root" DEFAULT_USER = "root"
DEFAULT_PASSWORD = "Hbhyg731024@" DEFAULT_PASSWORD = "Hbhyg731024@"
@ -166,7 +166,9 @@ def main():
password=args.password, database=env["db"], password=args.password, database=env["db"],
charset="utf8mb4", autocommit=True) charset="utf8mb4", autocommit=True)
except pymysql.MySQLError as e: except pymysql.MySQLError as e:
die("数据库连接失败:%s" % e) die("数据库连接失败:%s\n"
"连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道"
"(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)" % e)
cur = conn.cursor() cur = conn.cursor()
phone = env.get("temp_phone") phone = env.get("temp_phone")

View File

@ -10,11 +10,54 @@
| `probe_prod.py` | A/D 项(生产):用服务端 `JWT_SECRET` 临时签一个 token,只打"应被拒绝"的路径 + 反馈图测试文件(末尾自删),并打印 CORS 白名单内外行为 | 服务器(打 `127.0.0.1:9000` + 走域名) | | `probe_prod.py` | A/D 项(生产):用服务端 `JWT_SECRET` 临时签一个 token,只打"应被拒绝"的路径 + 反馈图测试文件(末尾自删),并打印 CORS 白名单内外行为 | 服务器(打 `127.0.0.1:9000` + 走域名) |
| `probe_export.py` | C 项:Excel 导出(写 `./exports`)在降权后是否可用(测试环境) | 服务器(打 `127.0.0.1:3000`) | | `probe_export.py` | C 项:Excel 导出(写 `./exports`)在降权后是否可用(测试环境) | 服务器(打 `127.0.0.1:3000`) |
| `smoke_prod.py` | 生产冒烟:SPA 资源、challenge、登录参数校验,并清理测试产生的 Redis 计数键 | 服务器 | | `smoke_prod.py` | 生产冒烟:SPA 资源、challenge、登录参数校验,并清理测试产生的 Redis 计数键 | 服务器 |
| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(直连 `10.8.0.252`) | | `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(经 SSH 隧道 `127.0.0.1:13306`,先跑 `pwsh tools/dev-tunnel.ps1`;⛔ 不再直连 `10.8.0.252`) |
| `nginx-hardening.sh` | D 项:把两份 conf 落到 `/etc/nginx/conf.d/`,在 `nginx.conf` 的 `http{}` 内插入 `limit_req_zone`,`nginx -t` 通过才 reload,失败回滚 | 服务器(先 `pscp` conf 到 `/tmp`) | | `nginx-hardening.sh` | D 项:把两份 conf 落到 `/etc/nginx/conf.d/`,在 `nginx.conf` 的 `http{}` 内插入 `limit_req_zone`,`nginx -t` 通过才 reload,失败回滚 | 服务器(先 `pscp` conf 到 `/tmp`) |
| `switch-user-test.sh` | C 项:测试环境两单元 `User=root → User=pms`,失败立即回滚 | 服务器 | | `switch-user-test.sh` | C 项:测试环境两单元 `User=root → User=pms`,失败立即回滚 | 服务器 |
| `deploy-prod-sec.sh` | 生产:二进制原子替换 + 两单元降权,失败回滚(单元 + 二进制) | 服务器 | | `deploy-prod-sec.sh` | 生产:二进制原子替换 + 两单元降权,失败回滚(单元 + 二进制) | 服务器 |
## 退出码约定(2026-09-20 定稿,全仓统一)
| 退出码 | 含义 |
|--------|------|
| `0` | 全部判据通过 |
| `1` | **有判据失败**(失败项额外打到 stderr) |
| `2` | 环境/连接问题(读不到 `.env`、连不上 Redis/后端 —— 各脚本用 `raise SystemExit(...)` 报出) |
- 探针的判据与退出码统一由**同目录的 `verdict.py`** 提供(`Verdict.check(label, passed, detail)` + `exit_code()`)。
- ⛔ **"打印了 ❌ 却 exit 0"是假绿**:`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod`
早期结尾写死 `return 0`,**无论安全控制是否生效都 exit 0**,与 `tsc --noEmit` 空跑同类。
已改为"失败 → exit 1",并按仓库铁律**实测过失败路径**:
把 `probe_upload.py` 的 A1 判据翻成 `st == 200` 后在服务器上跑 → **exit 1**;
恢复后 → **6/6 判据通过、exit 0**(`verdict.py` 本身另有 0/1/空判据 三种自测)。
- ⚠️ **拿不准阈值的现象不写成判据**(如 challenge 限流"第几次触发 429"、并发耗时)——只作观察输出,
否则会误报;误报与假绿同样有害。
- ✅ **C 项(降权后 Excel 导出落盘)结论:可用 —— 由落盘物证直接判定(2026-09-20)**
- 测试环境:`/data/pms_dev/api-server/exports/充电记录__-.xlsx`,**5559 B,属主 `pms:pms`**,
时间 04:29(与 `test/schema_alignment_e2e.py` 的成功导出同一时刻);
- 生产环境:同类产物 5457 B、属主同为 `pms:pms`;
- 两环境 `exports/` 与 `uploads/` 目录均为 `drwxr-xr-x pms pms`,两个 api-server 单元均为
`User=pms` / `Group=pms`(`systemctl show -p User -p Group` 实测)。
- ⇒ 降权成 `pms` 的进程**确实能往 `exports/` 落盘**(这正是 C 项要验证的事)。
- ⚠️ 上述结论**不依赖 `probe_export.py`**:该脚本因测试库唯一账号没有任何 `user_roles`(无
`charge_record:export`)仍返回 `exit 2`「未验证」(见下条)。想让它也给出判决,二选一补齐夹具。
- ⚠️ **`probe_export.py` 当前是"未验证"(exit 2),不是"失败"** —— 实测测试库 `pms_dev`
**只有 1 个账号(`13900000009`)且 `user_roles` 表为空**,即它**一个权限码都没有**:
创建导出任务恒 `403 缺少权限: charge_record:export`。该探针要回答的是
"**降权成 `pms` 后还能不能往 `exports/` 写文件**",而没权限根本走不到那一步 ——
早期把这种情形报成 `exit 1` 属**误报**(把夹具缺陷说成产品缺陷)。
现已改为 `exit 2`(环境/夹具问题,"未验证")。**补齐夹具后必须重跑**,二选一:
① 给该账号配含 `charge_record:export` 的角色;
② 用 `PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD` 环境变量给一个超管账号。
- 🐞 **`probe_export.py` 曾把任务状态语义搞反(已修,值得记住)**:
`download_tasks.status` 的**权威语义是 `0 排队 / 1 处理中 / 2 完成 / 3 失败`**
(`db/migrate.rs:225` 的列注释;写入侧 `routes/charge_records.rs::mark_task_completed` 写的就是 `2`)。
该脚本此前**等 `status == 1`(处理中)当成功、把 `status == 2`(完成)当"导出任务失败"** ——
即**导出成功也会被判失败**(假红)。已按权威语义订正,并与另外两处正确口径对齐:
`web/src/pages/charge-records/DownloadCenter.tsx::statusText`、
`test/schema_alignment_e2e.py`(等 `status in (2,3)` 再断言 `== 2`)。
> 教训:**状态码这种"看着像布尔"的字段,一定要回权威 DDL 的列注释核对,别照字面猜**;
> 三处读同一个字段就更要三处对齐(本次就是两处对一个错)。
## 前置说明 ## 前置说明
- 脚本内有 `ENV_FILE` 常量指向线上 `.env`(读 `REDIS_URL` / `JWT_SECRET`),**不要把 .env 内容复制到仓库**。 - 脚本内有 `ENV_FILE` 常量指向线上 `.env`(读 `REDIS_URL` / `JWT_SECRET`),**不要把 .env 内容复制到仓库**。

View File

@ -1,6 +1,7 @@
"""在 pms_dev(测试库)创建/重置一个安全测试账号,用于实测登录锁定与上传。 """在 pms_dev(测试库)创建/重置一个安全测试账号,用于实测登录锁定与上传。
账号:13900000009 / SecTest#2026(role=0 操作员,status=1 启用,无组织) 账号:13900000009 / SecTest#2026(role=0 操作员,status=1 启用,无组织)
""" """
import os
import sys import sys
import pymysql import pymysql
@ -9,8 +10,11 @@ from argon2 import PasswordHasher
PHONE = "13900000009" PHONE = "13900000009"
PASSWORD = "SecTest#2026" PASSWORD = "SecTest#2026"
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
conn = pymysql.connect( conn = pymysql.connect(
host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev", autocommit=True host=os.environ.get("PMS_MYSQL_HOST", "127.0.0.1"),
port=int(os.environ.get("PMS_MYSQL_PORT", "13306")),
user="root", password="Hbhyg731024@", database="pms_dev", autocommit=True,
) )
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:

View File

@ -15,6 +15,8 @@ import urllib.error
import urllib.request import urllib.request
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
BASE = "http://127.0.0.1:3000" BASE = "http://127.0.0.1:3000"
ENV_FILE = "/data/pms_dev/api-server/.env" ENV_FILE = "/data/pms_dev/api-server/.env"
PHONE = "13900000009" PHONE = "13900000009"
@ -101,6 +103,8 @@ def counters(redis, ip):
def main(): def main():
# 判据容器:失败 → exit 1(此前写死 return 0,属假绿)
v = Verdict("probe_bruteforce(登录锁定 / challenge 限流)")
redis = Redis(read_env("REDIS_URL")) redis = Redis(read_env("REDIS_URL"))
for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.6"): for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.6"):
redis.cmd("DEL", k) redis.cmd("DEL", k)
@ -109,23 +113,36 @@ def main():
for i in (1, 2): for i in (1, 2):
st, body = login_attempt(redis, "10.9.9.4", BAD) st, body = login_attempt(redis, "10.9.9.4", BAD)
print(f" 错密码#{i} status={st} body={body.decode()[:120]}") print(f" 错密码#{i} status={st} body={body.decode()[:120]}")
print(f" 计数(2 次失败后): {counters(redis, '10.9.9.4')}") c_after_fail = counters(redis, "10.9.9.4")
print(f" 计数(2 次失败后): {c_after_fail}")
v.check("B1a 2 次错密码应记入失败计数", c_after_fail["phone"] is not None,
f'phone={c_after_fail["phone"]}')
st, body = login_attempt(redis, "10.9.9.4", GOOD) st, body = login_attempt(redis, "10.9.9.4", GOOD)
print(f" 正确密码 status={st} body={body.decode()[:120]}") print(f" 正确密码 status={st} body={body.decode()[:120]}")
print(f" 计数(登录成功后应为 None): {counters(redis, '10.9.9.4')}") v.check("B1b 正确密码应登录成功", st == 200, f"status={st}")
c_after_ok = counters(redis, "10.9.9.4")
print(f" 计数(登录成功后应为 None): {c_after_ok}")
v.check("B1c 登录成功后失败计数应被清零", c_after_ok["phone"] is None,
f'phone={c_after_ok["phone"]}')
print("\n===== B2 连续失败锁定(阈值 5 次 / 15 分钟)=====") print("\n===== B2 连续失败锁定(阈值 5 次 / 15 分钟)=====")
last_st = None
for i in range(1, 7): for i in range(1, 7):
st, body = login_attempt(redis, "10.9.9.5", BAD) st, body = login_attempt(redis, "10.9.9.5", BAD)
last_st = st
print(f" 错密码#{i} status={st} body={body.decode()[:120]} counters={counters(redis, '10.9.9.5')}") print(f" 错密码#{i} status={st} body={body.decode()[:120]} counters={counters(redis, '10.9.9.5')}")
v.check("B2a 第 6 次错密码应被拒(非 200)", last_st != 200, f"status={last_st}")
print(" 锁定后再用【正确密码】尝试(应仍被拒,且文案与密码错误完全一致,不泄露锁定状态):") print(" 锁定后再用【正确密码】尝试(应仍被拒,且文案与密码错误完全一致,不泄露锁定状态):")
st, body = login_attempt(redis, "10.9.9.7", GOOD) st, body = login_attempt(redis, "10.9.9.7", GOOD)
print(f" 正确密码 status={st} body={body.decode()[:120]}") print(f" 正确密码 status={st} body={body.decode()[:120]}")
v.check("B2b 锁定后用正确密码也应被拒(文案不泄露锁定状态)", st != 200, f"status={st}")
print("\n===== B3 challenge 限流(同一 IP 12 次)=====") print("\n===== B3 challenge 限流(同一 IP 12 次)=====")
for i in range(1, 13): for i in range(1, 13):
st, body = http("GET", "/api/auth/challenge", headers={"X-Real-IP": "10.9.9.6"}) st, body = http("GET", "/api/auth/challenge", headers={"X-Real-IP": "10.9.9.6"})
print(f" challenge#{i} status={st} body={body.decode()[:80]}") print(f" challenge#{i} status={st} body={body.decode()[:80]}")
# ⚠️ 不加硬判据:触发点取决于 nginx `auth_challenge_zone` 的 rate(AGENTS.md 记 20r/m,
# 而本脚本注释写"第 11 次起 429")—— 口径不一致时写死判据会误报,故保持观察输出。
print("\n===== B4 并发闸门(Semaphore=2,不同 IP 并发请求 challenge)=====") print("\n===== B4 并发闸门(Semaphore=2,不同 IP 并发请求 challenge)=====")
t0 = time.time() t0 = time.time()
@ -146,12 +163,13 @@ def main():
for r in results: for r in results:
codes[r] = codes.get(r, 0) + 1 codes[r] = codes.get(r, 0) + 1
print(f" {n} 并发(不同 IP)耗时 {wall:.2f}s,状态码分布={codes}") print(f" {n} 并发(不同 IP)耗时 {wall:.2f}s,状态码分布={codes}")
# ⚠️ 同上:并发耗时/状态码只作观察,不设判据。
print("\n===== 清理测试计数键 =====") print("\n===== 清理测试计数键 =====")
for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.7"): for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.7"):
redis.cmd("DEL", k) redis.cmd("DEL", k)
print(f" 清理后 phone 计数 = {redis.cmd('GET', FAIL_KEY)}") print(f" 清理后 phone 计数 = {redis.cmd('GET', FAIL_KEY)}")
return 0 return v.exit_code() # 0=判据全过,1=有判据失败
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -2,8 +2,22 @@
"""C 项补充实测:降权后(uid=pms)Excel 导出(写 ./exports)仍可用 """C 项补充实测:降权后(uid=pms)Excel 导出(写 ./exports)仍可用
流程:登录 → 创建导出任务 → 轮询任务状态 → 下载文件 → 打印文件落盘信息 流程:登录 → 创建导出任务 → 轮询任务状态 → 下载文件 → 打印文件落盘信息
退出码(仓库统一约定 0/1/2):
- `0` = 导出链路可用(任务成功 + 下载到 xlsx)
- `1` = **判据失败**:导出链路真的坏了(任务 status=2 / 下载非 xlsx)
- `2` = **环境或夹具问题,本项"未验证"** —— 登录失败、或账号**缺少 `charge_record:export`
权限码**(403)。⚠️ 这一档必须与 1 分开:本探针要回答的是"**降权后能不能往 exports 写文件**",
账号没权限根本走不到那一步,报 1 就是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。
实测(2026-09-20)测试库 `pms_dev` **只有 1 个账号且没有任何 user_roles**,
因此这一项在补齐夹具前**恒为"未验证"**,不能算"导出不可用"。
夹具要求(两条路任选):
1. 给测试账号配含 `charge_record:export` 的角色;或
2. 用环境变量给一个超管账号:`PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD`。
""" """
import json import json
import os
import socket import socket
import sys import sys
import time import time
@ -13,8 +27,9 @@ from urllib.parse import unquote, urlparse
BASE = "http://127.0.0.1:3000" BASE = "http://127.0.0.1:3000"
ENV_FILE = "/data/pms_dev/api-server/.env" ENV_FILE = "/data/pms_dev/api-server/.env"
PHONE = "13900000009" # 允许用环境变量换成"确有 charge_record:export 权限"的账号(默认沿用安全测试账号)
PASSWORD = "SecTest#2026" PHONE = os.environ.get("PMS_PROBE_PHONE", "13900000009")
PASSWORD = os.environ.get("PMS_PROBE_PASSWORD", "SecTest#2026")
class Redis: class Redis:
@ -77,32 +92,60 @@ def main():
) )
if st != 200: if st != 200:
print(f"[login] {st} {body.decode()[:200]}") print(f"[login] {st} {body.decode()[:200]}")
return 1 print(" ⚠️ 未验证(exit 2):登录失败属环境/夹具问题,不是导出链路缺陷")
return 2
token = json.loads(body)["data"]["token"] token = json.loads(body)["data"]["token"]
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
st, body = http("GET", "/api/charge-records/export?start_time=2020-01-01%2000:00:00&end_time=2030-01-01%2000:00:00", headers=auth) st, body = http("GET", "/api/charge-records/export?start_time=2020-01-01%2000:00:00&end_time=2030-01-01%2000:00:00", headers=auth)
print(f"[export] create -> {st} {body.decode()[:200]}") print(f"[export] create -> {st} {body.decode()[:200]}")
if st == 403:
# 关键区分:403 说明**账号没有权限码**,根本没走到"往 exports 写文件"那一步。
# 把它算成失败(退出码 1)会让人以为"降权后导出不可用",是误报。
print(" ⚠️ 未验证(exit 2):账号缺少 charge_record:export 权限,本项无法判定")
print(" 夹具要求见本文件顶部 docstring(配权限 或 设 PMS_PROBE_PHONE/PASSWORD)")
return 2
if st != 200: if st != 200:
print(f" ❌ exit 1:创建导出任务失败(HTTP {st}),非权限问题")
return 1 return 1
task_id = json.loads(body)["data"]["task_id"] task_id = json.loads(body)["data"]["task_id"]
# ⚠️ `download_tasks.status` 的**权威语义**(`db/migrate.rs:225` 列注释 +
# `routes/charge_records.rs:435` 的 `mark_task_completed` 写的就是 2):
# 0=排队 1=处理中 2=**完成** 3=失败
# 本脚本此前**把语义搞反了**(等 `status == 1`、把 `status == 2` 判成"导出任务失败"),
# 于是"导出成功"会被报成失败(假红),与 console 端 `DownloadCenter.tsx::statusText`
# 和 `test/schema_alignment_e2e.py` 的口径都不一致。已按权威语义订正。
STATUS_DONE, STATUS_FAILED = 2, 3
file_path = None file_path = None
for _ in range(15): for _ in range(20):
time.sleep(1) time.sleep(1)
st, body = http("GET", f"/api/downloads/{task_id}", headers=auth) st, body = http("GET", f"/api/downloads/{task_id}", headers=auth)
info = json.loads(body)["data"] info = json.loads(body)["data"]
print(f"[export] task={task_id} status={info.get('status')} progress={info.get('progress')} file_path={info.get('file_path')}") cur = info.get("status")
if info.get("status") == 1: print(f"[export] task={task_id} status={cur} progress={info.get('progress')} file_path={info.get('file_path')}")
if cur == STATUS_DONE:
file_path = info.get("file_path") file_path = info.get("file_path")
break break
if info.get("status") == 2: if cur == STATUS_FAILED:
print("导出任务失败") print(f" ❌ exit 1:导出任务失败(status=3)err={info.get('error_message')!r}")
return 1 return 1
else:
print(" ❌ exit 1:120 秒内任务未进入终态(既没完成也没失败)—— 导出链路可能卡住")
return 1
if not file_path:
print(" ❌ exit 1:任务标记完成但没有 file_path —— 落盘环节有问题")
return 1
st, blob = http("GET", f"/api/downloads/{task_id}/file", headers=auth) st, blob = http("GET", f"/api/downloads/{task_id}/file", headers=auth)
print(f"[download] GET /api/downloads/{task_id}/file -> {st} bytes={len(blob)} head={blob[:4]!r}") print(f"[download] GET /api/downloads/{task_id}/file -> {st} bytes={len(blob)} head={blob[:4]!r}")
return 0 if (st == 200 and blob[:2] == b"PK") else 1 if st == 200 and blob[:2] == b"PK":
print(" ✅ exit 0:导出链路可用(降权成 pms 后仍能往 exports/ 落盘并下载)")
return 0
print(" ❌ exit 1:下载失败或不是 xlsx(xlsx 是 zip,魔数应为 PK)")
return 1
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -16,6 +16,8 @@ import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
BASE = "http://127.0.0.1:9000" # 直连后端(应用层行为) BASE = "http://127.0.0.1:9000" # 直连后端(应用层行为)
DOMAIN = "https://app.anzhizhichong.com" # 走 nginx(看响应头/CORS) DOMAIN = "https://app.anzhizhichong.com" # 走 nginx(看响应头/CORS)
ENV_FILE = "/data/pms/api-server/.env" ENV_FILE = "/data/pms/api-server/.env"
@ -75,6 +77,8 @@ def multipart(field, filename, content, ctype):
def main(): def main():
# 判据容器:失败 → exit 1(此前写死 return 0,属假绿)
v = Verdict("probe_prod(生产上传拒绝路径 / CORS)")
token = mint_token(env("JWT_SECRET"), UID, PHONE) token = mint_token(env("JWT_SECRET"), UID, PHONE)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@ -93,6 +97,7 @@ def main():
"POST", BASE + "/api/auth/avatar/upload", data, {**auth, "Content-Type": ct} "POST", BASE + "/api/auth/avatar/upload", data, {**auth, "Content-Type": ct}
) )
print(f" avatar filename={fn} -> {st} {body.decode()[:140]}") print(f" avatar filename={fn} -> {st} {body.decode()[:140]}")
v.check(f"生产头像:{fn} 应被拒", st != 200, f"status={st}")
print("\n===== 生产 反馈图上传(拒绝路径 + 一个真实 PNG 测试文件)=====") print("\n===== 生产 反馈图上传(拒绝路径 + 一个真实 PNG 测试文件)=====")
url = None url = None
@ -104,6 +109,8 @@ def main():
data, ct = multipart("file", fn, content, ctype) data, ct = multipart("file", fn, content, ctype)
st, _, body = http("POST", BASE + "/api/feedback/upload", data, {**auth, "Content-Type": ct}) st, _, body = http("POST", BASE + "/api/feedback/upload", data, {**auth, "Content-Type": ct})
print(f" feedback filename={fn}(应{expect})-> {st} {body.decode()[:140]}") print(f" feedback filename={fn}(应{expect})-> {st} {body.decode()[:140]}")
v.check(f"生产反馈图:{fn} 应{expect}", (st != 200) if expect == "拒" else (st == 200),
f"status={st}")
if st == 200: if st == 200:
url = json.loads(body)["data"]["url"] url = json.loads(body)["data"]["url"]
@ -119,6 +126,8 @@ def main():
print(f"[cleanup] 已删除我自己的测试文件 {path}") print(f"[cleanup] 已删除我自己的测试文件 {path}")
print("\n===== 生产 CORS 白名单 =====") print("\n===== 生产 CORS 白名单 =====")
# ⚠️ CORS 只作观察:是否回 `access-control-allow-origin` 还取决于浏览器语义与后端实现口径,
# 写成硬判据容易误报 ⇒ 保持观察输出(误报与假绿同样有害)。
for origin in ( for origin in (
"https://app.anzhizhichong.com", "https://app.anzhizhichong.com",
"https://test.anzhizhichong.com", "https://test.anzhizhichong.com",
@ -131,7 +140,7 @@ def main():
f" Origin={origin:<32} -> {st} allow-origin=" f" Origin={origin:<32} -> {st} allow-origin="
f"{hdrs.get('access-control-allow-origin', '(无)')}" f"{hdrs.get('access-control-allow-origin', '(无)')}"
) )
return 0 return v.exit_code() # 0=判据全过,1=有判据失败
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -13,6 +13,8 @@ import os
import re import re
import socket import socket
import sys import sys
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
import urllib.error import urllib.error
import urllib.request import urllib.request
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
@ -124,20 +126,28 @@ def try_upload(token, path, field, filename, content, ctype, label):
def main(): def main():
# 判据容器:失败 → exit 1(见 verdict.py;此前本脚本写死 return 0,属假绿)
v = Verdict("probe_upload(测试环境 uploads 安全)")
redis = Redis(read_env("REDIS_URL")) redis = Redis(read_env("REDIS_URL"))
token = login(redis) token = login(redis)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
print("\n===== 头像上传 /api/auth/avatar/upload =====") print("\n===== 头像上传 /api/auth/avatar/upload =====")
try_upload(token, "/api/auth/avatar/upload", "file", "xss.svg", SVG, "image/svg+xml", "A1 svg") st, _ = try_upload(token, "/api/auth/avatar/upload", "file", "xss.svg", SVG, "image/svg+xml", "A1 svg")
try_upload(token, "/api/auth/avatar/upload", "file", "xss.png", SVG, "image/png", "A2 svg-as-png") v.check("A1 头像:svg(image/svg+xml)应被拒", st != 200, f"status={st}")
st, _ = try_upload(token, "/api/auth/avatar/upload", "file", "xss.png", SVG, "image/png", "A2 svg-as-png")
v.check("A2 头像:改名 .png 的 SVG(魔数兜底)应被拒", st != 200, f"status={st}")
st, resp = try_upload(token, "/api/auth/avatar/upload", "file", "ok.png", PNG, "image/png", "A3 real-png") st, resp = try_upload(token, "/api/auth/avatar/upload", "file", "ok.png", PNG, "image/png", "A3 real-png")
v.check("A3 头像:真 PNG 应通过", st == 200, f"status={st}")
ok_url = json.loads(resp)["data"]["avatar"] if st == 200 else None ok_url = json.loads(resp)["data"]["avatar"] if st == 200 else None
print("\n===== 反馈图上传 /api/feedback/upload =====") print("\n===== 反馈图上传 /api/feedback/upload =====")
try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg") st, _ = try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg")
try_upload(token, "/api/feedback/upload", "file", "xss2.png", SVG, "image/png", "A5 svg-as-png") v.check("A4 反馈图:svg 应被拒", st != 200, f"status={st}")
st, _ = try_upload(token, "/api/feedback/upload", "file", "xss2.png", SVG, "image/png", "A5 svg-as-png")
v.check("A5 反馈图:改名 .png 的 SVG 应被拒", st != 200, f"status={st}")
fb_st, fb_resp = try_upload(token, "/api/feedback/upload", "file", "ok.png", PNG, "image/png", "A6 real-png") fb_st, fb_resp = try_upload(token, "/api/feedback/upload", "file", "ok.png", PNG, "image/png", "A6 real-png")
v.check("A6 反馈图:真 PNG 应通过", fb_st == 200, f"status={fb_st}")
print("\n===== 上传后静态访问(直连 :3000,验证 nosniff 由 nginx 加,这里看后端 Content-Type)=====") print("\n===== 上传后静态访问(直连 :3000,验证 nosniff 由 nginx 加,这里看后端 Content-Type)=====")
for url in filter(None, [ok_url, json.loads(fb_resp)["data"]["url"] if fb_st == 200 else None]): for url in filter(None, [ok_url, json.loads(fb_resp)["data"]["url"] if fb_st == 200 else None]):
@ -148,7 +158,7 @@ def main():
for d in ("/data/pms_dev/api-server/uploads/avatars", "/data/pms_dev/api-server/uploads/feedback"): for d in ("/data/pms_dev/api-server/uploads/avatars", "/data/pms_dev/api-server/uploads/feedback"):
if os.path.isdir(d): if os.path.isdir(d):
print(f"{d}: {sorted(os.listdir(d))}") print(f"{d}: {sorted(os.listdir(d))}")
return 0 return v.exit_code() # 0=判据全过,1=有判据失败
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -6,6 +6,8 @@ import urllib.error
import urllib.request import urllib.request
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
DOMAIN = "https://app.anzhizhichong.com" DOMAIN = "https://app.anzhizhichong.com"
ENV_FILE = "/data/pms/api-server/.env" ENV_FILE = "/data/pms/api-server/.env"
FAKE_PHONE = "00000000000" FAKE_PHONE = "00000000000"
@ -57,8 +59,11 @@ def http(method, path, data=None, headers=None):
def main(): def main():
# 判据容器:失败 → exit 1(此前写死 return 0,属假绿)
v = Verdict("smoke_prod(生产冒烟:SPA / challenge / 登录参数校验)")
st, _, body = http("GET", "/pms/") st, _, body = http("GET", "/pms/")
print(f"[SPA] GET /pms/ -> {st} bytes={len(body)}") print(f"[SPA] GET /pms/ -> {st} bytes={len(body)}")
v.check("生产 SPA /pms/ 应 200", st == 200, f"status={st}")
asset = None asset = None
for token in body.decode(errors="ignore").split('"'): for token in body.decode(errors="ignore").split('"'):
if token.startswith("/pms/assets/") and token.endswith(".js"): if token.startswith("/pms/assets/") and token.endswith(".js"):
@ -67,9 +72,13 @@ def main():
if asset: if asset:
st, _, blob = http("GET", asset) st, _, blob = http("GET", asset)
print(f"[SPA] GET {asset} -> {st} bytes={len(blob)}") print(f"[SPA] GET {asset} -> {st} bytes={len(blob)}")
v.check(f"生产 SPA 资源 {asset} 应 200", st == 200, f"status={st}")
else:
v.check("生产 SPA 页面应引用 /pms/assets/*.js", False, "未在 HTML 中找到资源引用")
st, _, body = http("GET", "/api/pms/auth/challenge") st, _, body = http("GET", "/api/pms/auth/challenge")
print(f"[login] GET /api/pms/auth/challenge -> {st} (JSON 长度 {len(body)})") print(f"[login] GET /api/pms/auth/challenge -> {st} (JSON 长度 {len(body)})")
v.check("challenge 接口应 200", st == 200, f"status={st}")
st, _, body = http( st, _, body = http(
"POST", "POST",
@ -78,12 +87,13 @@ def main():
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
print(f"[login] POST /api/pms/auth/login(无 challenge)-> {st} {body.decode()[:120]}") print(f"[login] POST /api/pms/auth/login(无 challenge)-> {st} {body.decode()[:120]}")
v.check("无 challenge 的登录必须被拒(非 200)", st != 200, f"status={st}")
redis = Redis(env("REDIS_URL")) redis = Redis(env("REDIS_URL"))
for key in (f"auth:fail:phone:{FAKE_PHONE}", "auth:fail:ip:unknown"): for key in (f"auth:fail:phone:{FAKE_PHONE}", "auth:fail:ip:unknown"):
redis.cmd("DEL", key) redis.cmd("DEL", key)
print("[cleanup] 清理测试产生的 Redis 计数键完成") print("[cleanup] 清理测试产生的 Redis 计数键完成")
return 0 return v.exit_code() # 0=判据全过,1=有判据失败
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -0,0 +1,69 @@
"""安全探针统一的「判据 + 退出码」小工具(供 test/security-hardening/*.py 使用)。
为什么需要它
------------
这些探针早期只 `print` 观察结果、结尾写死 `return 0` ⇒ **无论安全控制是否生效都 exit 0**:
人眼看到的是 ❌/异常现象,自动化(CI、部署前置检查、agent 验收)看到的却是绿灯。
这与 `tsc --noEmit` 空跑、`tcp_e2e_smoke.py` 永远 exit 0 属**同一类假绿**。
统一口径(与 AGENTS.md「验收流程」一致)
--------------------------------------
0 = 全部判据通过
1 = 有判据失败(失败项额外打到 stderr,便于只看 stderr 定位)
2 = 环境/连接问题(调用方自行 `raise SystemExit(2)`,例如读不到 .env、连不上 Redis/后端)
用法
----
import sys
from verdict import Verdict
v = Verdict("probe_upload(测试环境 uploads 安全)")
v.check("A1 头像上传 svg 应被拒", st != 200, f"status={st}")
...
sys.exit(v.exit_code())
约定
----
- 判据只写**明确、可复现**的期望("应被拒" = status != 200;"应通过" = status == 200);
拿不准阈值/口径的现象(如限流具体第几次触发)**不要**写成判据,保持为观察输出,
否则会制造误报 —— 误报与假绿同样有害。
- 判据数可能为 0(纯取证脚本):此时 exit_code() 返回 0,但会打印"未设置判据"提醒。
"""
import sys
class Verdict:
def __init__(self, title):
self.title = title
self.checks = [] # [(label, passed, detail)]
def check(self, label, passed, detail=""):
"""记录一条判据;返回 bool 便于就地分支"""
passed = bool(passed)
self.checks.append((label, passed, detail))
return passed
@property
def failed(self):
return [(l, d) for l, p, d in self.checks if not p]
def exit_code(self):
"""打印结论并返回退出码(0=全通过;1=有失败)"""
n_pass = len(self.checks) - len(self.failed)
print(f"\n===== 判据结论:{self.title} =====")
for label, passed, detail in self.checks:
suffix = f" ({detail})" if detail else ""
print(f" {'✅' if passed else '❌'} {label}{suffix}")
print(f" 合计 {n_pass}/{len(self.checks)} 判据通过")
if not self.checks:
print(" ⚠️ 本脚本未设置任何判据(纯取证脚本)—— 不要拿它的退出码当验收判据")
return 0
if self.failed:
print("\n未通过判据(stderr):", file=sys.stderr)
for label, detail in self.failed:
suffix = f" ({detail})" if detail else ""
print(f" ❌ {label}{suffix}", file=sys.stderr)
print(f"合计 {n_pass}/{len(self.checks)} 判据通过,{len(self.failed)} 项失败", file=sys.stderr)
return 1
return 0

View File

@ -5,6 +5,7 @@
每一步都打印平台的应答,便于人工核对协议实现。 每一步都打印平台的应答,便于人工核对协议实现。
""" """
import sys
import hashlib import hashlib
import json import json
import socket import socket
@ -154,3 +155,12 @@ print("\n========== 联调结果 ==========")
for name, passed in ok: for name, passed in ok:
print(f" {'✅' if passed else '❌'} {name}") print(f" {'✅' if passed else '❌'} {name}")
print(f"\n 合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过") print(f"\n 合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过")
# ⛔ 退出码必须反映结果:0=全部通过,1=有失败(此前本脚本永远 exit 0,属假绿);
# 失败项额外打到 stderr,便于 CI/人只看 stderr 就能定位(口径见 AGENTS.md「验收流程」)。
failed = [n for n, p in ok if not p]
if failed:
print("未通过项(stderr):", file=sys.stderr)
for n in failed:
print(f" ❌ {n}", file=sys.stderr)
print(f"合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过,{len(failed)} 项失败", file=sys.stderr)
sys.exit(0 if not failed else 1)

View File

@ -1,16 +1,32 @@
"""测量端到端真实延迟:发一条 auth_str,看应答多久到达(最长等 75 秒)。""" """测量端到端真实延迟:发一条 auth_str,看应答多久到达(最长等 75 秒)。
退出码(全仓统一口径):0 = 三步都有应答;1 = 有步骤无应答/断言失败;
2 = 连接或环境问题(DB 连不上、TCP 建连失败)。
⛔ 以前它只打印 ✅/❌ 却永远 exit 0 —— 无应答也算"通过",属假绿。
"""
import hashlib import hashlib
import json import json
import os
import socket import socket
import sys
import time import time
import pymysql import pymysql
# 3001 是 device-server 的 TCP 业务端口(设备经公网/ZeroTier 直连),与数据库通路无关,保持不变
HOST, PORT = "10.8.0.252", 3001 HOST, PORT = "10.8.0.252", 3001
IMEI = "860000000000001" IMEI = "860000000000001"
# 复位夹具,保证能重新申领 # 复位夹具,保证能重新申领
conn = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
database="pms_dev", connect_timeout=8, autocommit=True) try:
conn = pymysql.connect(host=os.environ.get("PMS_MYSQL_HOST", "127.0.0.1"),
port=int(os.environ.get("PMS_MYSQL_PORT", "13306")),
user="root", password="Hbhyg731024@",
database="pms_dev", connect_timeout=8, autocommit=True)
except pymysql.MySQLError as e:
print(f"✗ MySQL 连接失败:{e}", file=sys.stderr)
print("连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道", file=sys.stderr)
sys.exit(2)
cur = conn.cursor() cur = conn.cursor()
cur.execute("UPDATE cabinets SET auth_str=NULL, auth_str_issued=0 WHERE imei=%s", (IMEI,)) cur.execute("UPDATE cabinets SET auth_str=NULL, auth_str_issued=0 WHERE imei=%s", (IMEI,))
cur.execute("DELETE FROM charge_records WHERE cabinet_id=1") cur.execute("DELETE FROM charge_records WHERE cabinet_id=1")
@ -21,9 +37,15 @@ cur.execute("UPDATE compartments SET status=0")
conn.close() conn.close()
print(" 夹具已复位") print(" 夹具已复位")
s = socket.create_connection((HOST, PORT), timeout=5) try:
s = socket.create_connection((HOST, PORT), timeout=5)
except OSError as e:
print(f"✗ TCP 建连失败 {HOST}:{PORT}:{e}", file=sys.stderr)
print("(该端口是 device-server 业务端口;测试环境为 :3001)", file=sys.stderr)
sys.exit(2)
s.settimeout(1.0) s.settimeout(1.0)
buf = b"" buf = b""
fails = [] # 收集失败项,决定退出码(非 0 = 失败)
def send(obj): def send(obj):
@ -55,9 +77,12 @@ dt, resp = wait_reply(t0)
if resp: if resp:
print(f" ✅ 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False)}") print(f" ✅ 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False)}")
auth_str = resp.get("auth_str") auth_str = resp.get("auth_str")
if not auth_str:
fails.append("① auth_str 有应答但未带 auth_str")
else: else:
print(f" ❌ {dt if dt else '>75'}s 内无应答") print(f" ❌ {dt if dt else '>75'}s 内无应答")
auth_str = None auth_str = None
fails.append(f"① auth_str 无应答(等待 {dt if dt else '>75'}s)")
if auth_str: if auth_str:
print("\n=== ② login 延迟实测 ===") print("\n=== ② login 延迟实测 ===")
@ -66,6 +91,8 @@ if auth_str:
t0 = send({"act": "login", "msg_id": 2, "dev_id": IMEI, "sign": sign, "timestamp": ts}) t0 = send({"act": "login", "msg_id": 2, "dev_id": IMEI, "sign": sign, "timestamp": ts})
dt, resp = wait_reply(t0) dt, resp = wait_reply(t0)
print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}") print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}")
if not resp:
fails.append(f"② login 无应答(等待 {dt if dt else '>75'}s)")
print("\n=== ③ status_post 延迟实测 ===") print("\n=== ③ status_post 延迟实测 ===")
t0 = send({"act": "status_post", "msg_id": 3, "rssi": 85, t0 = send({"act": "status_post", "msg_id": 3, "rssi": 85,
@ -74,5 +101,16 @@ if auth_str:
{"board": 2, "channels": ["008200000000000000", "008200000000000000"]}]}) {"board": 2, "channels": ["008200000000000000", "008200000000000000"]}]})
dt, resp = wait_reply(t0) dt, resp = wait_reply(t0)
print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}") print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}")
if not resp:
fails.append(f"③ status_post 无应答(等待 {dt if dt else '>75'}s)")
s.close() s.close()
# ⛔ 退出码必须反映结果(口径:0=通过,1=断言失败,2=环境/连接问题)
if fails:
print("\n未通过项(stderr):", file=sys.stderr)
for f in fails:
print(f" ❌ {f}", file=sys.stderr)
sys.exit(1)
print("\n✅ 三步均有应答")
sys.exit(0)

View File

@ -1,7 +1,13 @@
import os
import pymysql import pymysql
import random import random
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
conn = pymysql.connect(
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
user='root', password='Hbhyg731024@', database='pms',
)
cur = conn.cursor() cur = conn.cursor()
# 获取所有项目ID # 获取所有项目ID

View File

@ -1,6 +1,13 @@
import os
import pymysql import pymysql
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
conn = pymysql.connect(
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
user='root', password='Hbhyg731024@', database='pms',
)
cur = conn.cursor() cur = conn.cursor()
# 添加parent_id字段 # 添加parent_id字段

View File

@ -2,6 +2,11 @@
""" """
PMS 数据库工具 PMS 数据库工具
用法: 用法:
需先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道(默认连 127.0.0.1:13306);
可用环境变量 PMS_MYSQL_HOST / PMS_MYSQL_PORT 指向其它可达地址。
⚠️ 默认目标是**测试库 `pms_dev`**(可用 PMS_MYSQL_DB 覆盖;取 `pms` = 生产库,
脚本会额外打印一行醒目警告)。
python tools/db.py import <sql文件> # 导入SQL文件 python tools/db.py import <sql文件> # 导入SQL文件
python tools/db.py query <SQL语句> # 执行查询 python tools/db.py query <SQL语句> # 执行查询
python tools/db.py exec <SQL语句> # 执行写入 python tools/db.py exec <SQL语句> # 执行写入
@ -9,20 +14,47 @@ PMS 数据库工具
python tools/db.py count # 统计各表数据量 python tools/db.py count # 统计各表数据量
""" """
import os
import sys import sys
import pymysql import pymysql
# 数据库配置 # 数据库配置:默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发。
# 可用环境变量 PMS_MYSQL_HOST / PMS_MYSQL_PORT / PMS_MYSQL_DB 覆盖。
# ⚠️ 默认库是**测试库 `pms_dev`** —— 本工具能执行写操作(`exec`/`import`),
# 默认绝不能落在生产库上(历史上文档写 pms_dev、代码却是 pms:以为在测测试库、实际打生产)。
PROD_DB = 'pms'
DB_CONFIG = { DB_CONFIG = {
'host': '10.8.0.252', 'host': os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
'port': int(os.environ.get('PMS_MYSQL_PORT', '13306')),
'user': 'root', 'user': 'root',
'password': 'Hbhyg731024@', 'password': 'Hbhyg731024@',
'database': 'pms', 'database': os.environ.get('PMS_MYSQL_DB', 'pms_dev'),
'charset': 'utf8mb4', 'charset': 'utf8mb4',
} }
# 连不上时的统一提示(隧道没起是最常见原因,绝不能让它变成一段栈回溯)
TUNNEL_HINT = ('连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道'
'(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)')
def announce_target():
"""每次连接前醒目提示当前目标库(防止"以为在测试库、实际打生产")"""
db = DB_CONFIG['database']
print(f"当前目标库:{db}(可用 PMS_MYSQL_DB 覆盖)")
if db == PROD_DB:
print(f"⚠️ 你正在操作正式库 {PROD_DB} —— 写操作(exec/import)前请再次确认;"
f"测试库请用 PMS_MYSQL_DB=pms_dev")
def get_conn(): def get_conn():
return pymysql.connect(**DB_CONFIG) """建立连接;连不上时打印可读提示并退出(不抛栈回溯)"""
announce_target()
try:
return pymysql.connect(**DB_CONFIG)
except pymysql.MySQLError as e:
print(f"✗ MySQL 连接失败 {DB_CONFIG['host']}:{DB_CONFIG['port']}:{e}", file=sys.stderr)
print(TUNNEL_HINT, file=sys.stderr)
sys.exit(2)
def import_sql(filepath): def import_sql(filepath):
"""导入SQL文件""" """导入SQL文件"""

View File

@ -24,6 +24,9 @@ BRPOP 卡死、`iccid` NULL、`SCHEMA_SQL` 前向引用、测试环境导出 500
用法 用法
---- ----
需先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道(默认连 127.0.0.1:13306);
`--host/--port` 优先于环境变量 `PMS_MYSQL_HOST/PMS_MYSQL_PORT`。
python tools/db_schema_diff.py # 默认:pms_dev 与 pms 全列比对 python tools/db_schema_diff.py # 默认:pms_dev 与 pms 全列比对
python tools/db_schema_diff.py --db pms # 被检查库换成 pms(参照库自动取另一个) python tools/db_schema_diff.py --db pms # 被检查库换成 pms(参照库自动取另一个)
python tools/db_schema_diff.py --reference-db pms # 显式指定参照库 python tools/db_schema_diff.py --reference-db pms # 显式指定参照库
@ -55,9 +58,11 @@ try:
except ImportError: # pragma: no cover - 环境缺依赖时给出可执行的提示 except ImportError: # pragma: no cover - 环境缺依赖时给出可执行的提示
die("缺少依赖:pip install pymysql") die("缺少依赖:pip install pymysql")
# ---- 默认连接参数(ZeroTier 直连,见 AGENTS.md「数据库连接」)------------------ # ---- 默认连接参数 ----------------------------------------------------------
DEFAULT_HOST = "10.8.0.252" # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发。
DEFAULT_PORT = 3306 # 优先级:命令行 --host/--port > 环境变量 PMS_MYSQL_HOST/PMS_MYSQL_PORT > 下面的默认值。
DEFAULT_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
DEFAULT_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
DEFAULT_USER = "root" DEFAULT_USER = "root"
DEFAULT_PASSWORD = "Hbhyg731024@" DEFAULT_PASSWORD = "Hbhyg731024@"
@ -272,7 +277,10 @@ def main():
conn_a.close() conn_a.close()
conn_b.close() conn_b.close()
except pymysql.MySQLError as e: except pymysql.MySQLError as e:
die("数据库连接/查询失败:%s" % e) # 连不上时必须是 exit 2(连接错误),绝不能落成"无漂移"的 exit 0
die("数据库连接/查询失败:%s\n"
"连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道"
"(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)" % e)
# ---- TIMESTAMP 巡检(两种模式都做)---- # ---- TIMESTAMP 巡检(两种模式都做)----
ts_columns = scan_timestamp_columns(snapshot) ts_columns = scan_timestamp_columns(snapshot)

28
tools/dev-tunnel.ps1 Normal file
View File

@ -0,0 +1,28 @@
# 开发机 → 生产服务器(SSH) → 阿里云 RDS/Redis
# 用途:本地脚本 / DBX / 客户端通过 127.0.0.1:13306(MySQL)、127.0.0.1:16379(Redis) 访问阿里云
# 说明:不需要在服务器上开放任何数据库端口(已移除 nginx stream 转发)
#
# 用法:$env:PMS_SSH_PASSWORD = '<服务器 SSH 口令>'; pwsh tools/dev-tunnel.ps1
# 常驻自愈:tools/dev-tunnel-run.ps1(含口令、已 gitignore、登录即起的快捷方式 PMS-DEV-TUNNEL.lnk)
# 换机/重装后重建:把上面两行跑一次即可;要开机自启就在启动文件夹放一个指向 dev-tunnel-run.ps1 的快捷方式。
param(
[string]$Server = '118.31.119.206',
[string]$User = 'root',
[string]$MySqlHost = 'rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com',
[string]$RedisHost = 'r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com',
[int]$LocalMySqlPort = 13306,
[int]$LocalRedisPort = 16379
)
# ⚠️ 日志骗人的历史坑(2026-09-20 已修,勿退回):
# 原日志行写成 `"... -> $MySqlHost:3306 ..."`,而 PowerShell 会把 `$MySqlHost:3306`
# 解析成**带作用域限定的变量名**(形如 `$env:PATH` 的 `$作用域:名字`)—— 该变量不存在,
# 于是**主机名连端口一起渲染成空串**(日志出现 `127.0.0.1:13306 -> ,`),
# 而隧道本身完全正常(下面 plink 的参数本来就写了花括号)⇒ 属于"日志与事实不符"。
# ✅ 规则:变量后面紧跟 `:` 时必须写成 `"${MySqlHost}:3306"`。
# ❌ 这不是 param 默认值的问题(默认值一直生效,已实测 plink 命令行带真实主机名);
# 默认值**只在本 param 块定义一份**,别在别处再写第二套(两套默认值必然漂移)。
$pw = $env:PMS_SSH_PASSWORD
if (-not $pw) { Write-Error '请先设置环境变量 PMS_SSH_PASSWORD(服务器 SSH 口令)'; exit 2 }
Write-Host "建立隧道:127.0.0.1:${LocalMySqlPort} -> ${MySqlHost}:3306 , 127.0.0.1:${LocalRedisPort} -> ${RedisHost}:6379 (经 ${User}@${Server})"
plink -batch -pw $pw -N -L "${LocalMySqlPort}:${MySqlHost}:3306" -L "${LocalRedisPort}:${RedisHost}:6379" "$User@$Server"

View File

@ -9,21 +9,23 @@ Redis 模拟数据填充工具
python tools/mock_redis.py # 填充模拟数据 python tools/mock_redis.py # 填充模拟数据
python tools/mock_redis.py --clear # 清除所有模拟数据 python tools/mock_redis.py --clear # 清除所有模拟数据
""" """
import os
import sys import sys
import json import json
import random import random
import redis import redis
import pymysql import pymysql
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
# Redis 配置 # Redis 配置
REDIS_HOST = "10.8.0.252" REDIS_HOST = os.environ.get("PMS_REDIS_HOST", "127.0.0.1")
REDIS_PORT = 6379 REDIS_PORT = int(os.environ.get("PMS_REDIS_PORT", "16379"))
REDIS_PASSWORD = "Hbhyg731024@" REDIS_PASSWORD = "Hbhyg731024@"
REDIS_DB = 5 REDIS_DB = 5
# MySQL 配置 # MySQL 配置
MYSQL_HOST = "10.8.0.252" MYSQL_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
MYSQL_PORT = 3306 MYSQL_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
MYSQL_USER = "root" MYSQL_USER = "root"
MYSQL_PASSWORD = "Hbhyg731024@" MYSQL_PASSWORD = "Hbhyg731024@"
MYSQL_DB = "pms" MYSQL_DB = "pms"

View File

@ -1,7 +1,13 @@
import os
import pymysql import pymysql
import random import random
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
conn = pymysql.connect(
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
user='root', password='Hbhyg731024@', database='pms',
)
cur = conn.cursor() cur = conn.cursor()
# 1. 添加address字段 # 1. 添加address字段

View File

@ -1,7 +1,13 @@
import os
import pymysql import pymysql
import random import random
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
conn = pymysql.connect(
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
user='root', password='Hbhyg731024@', database='pms',
)
cur = conn.cursor() cur = conn.cursor()
# ICCID规则: # ICCID规则:

View File

@ -1,7 +1,13 @@
import os
import pymysql import pymysql
import random import random
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
conn = pymysql.connect(
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
user='root', password='Hbhyg731024@', database='pms',
)
cur = conn.cursor() cur = conn.cursor()
# 添加ICCID字段 # 添加ICCID字段