Compare commits
No commits in common. "471b61d2778a242726d0b6d4e8c76abca35fcd5e" and "9e8826e6f89b466112ca3f1f65811ecfe1f76bd2" have entirely different histories.
471b61d277
...
9e8826e6f8
1
.gitignore
vendored
1
.gitignore
vendored
@ -61,4 +61,3 @@ tmp/
|
|||||||
deploy_output.txt
|
deploy_output.txt
|
||||||
deploy_remote.sh
|
deploy_remote.sh
|
||||||
/tmp/
|
/tmp/
|
||||||
tools/dev-tunnel-run.ps1
|
|
||||||
|
|||||||
247
AGENTS.md
247
AGENTS.md
@ -35,8 +35,7 @@ D:\charging-cabinet\
|
|||||||
│ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口)
|
│ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口)
|
||||||
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」)
|
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」)
|
||||||
├── tools/ # 开发工具
|
├── tools/ # 开发工具
|
||||||
│ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道)
|
│ ├── db.py # 数据库工具(默认连测试库 pms_dev)
|
||||||
│ ├── 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)
|
||||||
@ -92,8 +91,7 @@ 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(开机自启)
|
||||||
- **服务**: Gitea(3080)、acme.sh
|
- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、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
|
||||||
|
|
||||||
@ -104,83 +102,36 @@ D:\charging-cabinet\
|
|||||||
|
|
||||||
### 本地开发环境
|
### 本地开发环境
|
||||||
|
|
||||||
**数据库连接(通过 SSH 隧道):**
|
**数据库连接(通过ZeroTier):**
|
||||||
|
|
||||||
服务器 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 (测试库) / pms (正式库)
|
database: pms_dev (新建,不动AZZCWeChat*)
|
||||||
|
|
||||||
Redis: 127.0.0.1:16379 (经隧道 → r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379)
|
Redis: 10.8.0.252:6379
|
||||||
password: Hbhyg731024@
|
password: Hbhyg731024@
|
||||||
db: 6 (测试环境用db6,不动db1-5) / 5 (正式)
|
db: 6 (测试环境用db6,不动db1-5)
|
||||||
```
|
```
|
||||||
|
|
||||||
所有本地脚本(`tools/db.py`、`tools/db_schema_diff.py`、`test/*.py`)**默认就指向这两个隧道端口**,
|
**代理说明:**
|
||||||
可用环境变量覆盖(无需改代码):`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / `PMS_MYSQL_DB`、
|
- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS ——
|
||||||
`PMS_REDIS_HOST` / `PMS_REDIS_PORT`(`db.py` 默认库 = **测试库 `pms_dev`**)。
|
⚠️ **这个转发只为「本地开发机经 ZeroTier 访问阿里云」而存在**(监听 `10.8.0.252:3306/6379` 等)。
|
||||||
⚠️ 隧道没起时,脚本会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 **exit 2**
|
**服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连阿里云**:
|
||||||
(**连不上绝不会被当成"无漂移/无问题"**)。
|
|
||||||
|
|
||||||
**为什么服务侧直连阿里云(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。
|
||||||
- ⛔ **服务器不再开放 3306/6379**(ZeroTier 上也连不到了);要给开发机开库通路就用 SSH 隧道,
|
`127.0.0.1` 的 stream 监听**保留**(无副作用,本地开发/回环排查仍可用),但**服务不再用它**
|
||||||
**不要在服务器上重新暴露库端口**(见「部署注意事项」第 6 条)。
|
- systemd自启,`Restart=always` 自动恢复
|
||||||
- 设备 TCP 业务端口(`:9001` / `:3001`)**不受影响**,仍按原样可达(设备走公网连它)。
|
- 本地直连 `10.8.0.252`,无需SSH隧道
|
||||||
- systemd 自启,`Restart=always` 自动恢复。
|
|
||||||
|
|
||||||
### 数据库工具
|
### 数据库工具
|
||||||
|
|
||||||
⚠️ **需先起隧道**:`pwsh tools/dev-tunnel.ps1`(默认连 `127.0.0.1:13306`,见上节)。
|
本地可直接访问数据库(通过ZeroTier),使用 `tools/db.py`:
|
||||||
连接参数可用环境变量覆盖:`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
|
||||||
# 列出所有表
|
# 列出所有表
|
||||||
@ -199,7 +150,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 巡检)
|
||||||
@ -210,8 +161,6 @@ 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` 对已存在的表**不改列定义**,
|
||||||
@ -255,8 +204,7 @@ 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模式** — 进程泄漏会耗尽内存;服务侧一律**直连阿里云**,
|
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
|
||||||
开发机要连库走 **SSH 隧道**(`tools/dev-tunnel.ps1`),⛔ 不要再在服务器上做端口转发
|
|
||||||
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
|
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
|
||||||
|
|
||||||
### 编码任务下发
|
### 编码任务下发
|
||||||
@ -322,20 +270,6 @@ 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`。
|
|
||||||
现象阈值拿不准的(如限流第几次触发)**不要写成判据**,保持观察输出 —— **误报与假绿同样有害**。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 踩过的坑(历史教训,改动前必读)
|
## 踩过的坑(历史教训,改动前必读)
|
||||||
@ -361,16 +295,9 @@ 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**。
|
||||||
✅ **两处现已都有守门测试(2026-09-20 补齐,勿再退回)**:
|
⚠️ 遗留:`workers/order_timeout.rs` 的扫描条件是**另一种等价写法**
|
||||||
- `workers/report_worker.rs`:常量 `FINALIZE_EXPIRED_ORDER_SQL` + `test_expired_order_condition_points_backwards`
|
(`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`),该文件不在本次改动范围内,
|
||||||
+ `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 下是空跑假绿
|
||||||
|
|
||||||
@ -381,21 +308,9 @@ 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`)的 typecheck 是真门禁(2026-09-20 新增)**:
|
- `software/h5-uniapp/tsconfig.json` 是**普通** tsconfig(有 `include`),`tsc --noEmit` 在那里
|
||||||
`cd software/h5-uniapp && npm run typecheck`(= **`vue-tsc --noEmit -p tsconfig.json`**)。
|
是有效的;但该工程**没有 typecheck 脚本**,且 `tsc` **不检查 `.vue` 文件**,小程序端的真实
|
||||||
- ⛔ **不能用 `tsc --noEmit` 代替**:`tsc` **根本不检查 `.vue` 文件**,而本工程 9 个页面全是 `.vue`
|
类型安全目前靠构建 + `node test/device_status_parse.test.mjs`。
|
||||||
⇒ 只跑 `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>` 兼容)
|
||||||
|
|
||||||
@ -441,32 +356,6 @@ 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 编码规范
|
||||||
@ -483,8 +372,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 代理访问阿里云(**该转发已于 2026-09-20 移除,现为直连**),
|
本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`,
|
||||||
客户端又是多路复用的 `ConnectionManager`,那种组合下 `BRPOP` 语义不可靠(实测 2026-09):
|
这种组合下 `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 一致),
|
||||||
@ -670,17 +559,7 @@ 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}` —— 两者回答的是**不同问题**(2026-09-20 逐行核对订正):
|
**不是** Redis 在线键 `device:{imei}` —— 该键在验签前就会被写,用它判在线必然误判。
|
||||||
- `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`**
|
||||||
@ -782,57 +661,47 @@ echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@"
|
|||||||
|
|
||||||
### 数据库连接
|
### 数据库连接
|
||||||
|
|
||||||
⛔ **不要再用 `10.8.0.252` 连库** —— 服务器 nginx `stream{}` 转发已于 2026-09-20 移除,
|
所有数据库操作通过 ZeroTier 直接连接 `10.8.0.252`,无需 SSH 隧道到服务器。
|
||||||
`10.8.0.252:3306/6379`(以及 `127.0.0.1` 回环)**都连不到了**(实测 ✅)。
|
|
||||||
**所有本地数据库操作一律先起 SSH 隧道**(`pwsh tools/dev-tunnel.ps1`),再连 `127.0.0.1:13306/16379`。
|
|
||||||
|
|
||||||
**MySQL(通过 SSH 隧道):**
|
**MySQL(通过 ZeroTier):**
|
||||||
```bash
|
```bash
|
||||||
# 先起隧道(另一个终端常驻)
|
|
||||||
pwsh tools/dev-tunnel.ps1
|
|
||||||
|
|
||||||
# 命令行
|
# 命令行
|
||||||
mysql -h 127.0.0.1 -P 13306 -u root -p
|
mysql -h 10.8.0.252 -u root -p
|
||||||
# 密码:Hbhyg731024@
|
# 密码:Hbhyg731024@
|
||||||
# 测试环境库:pms_dev
|
# 测试环境库:pms_dev
|
||||||
# 正式环境库:pms
|
# 正式环境库:pms
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
|
# Python(Agent 常用方式)
|
||||||
import os, pymysql
|
import pymysql
|
||||||
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') # 正式环境
|
||||||
port = int(os.environ.get('PMS_MYSQL_PORT', '13306'))
|
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms_dev') # 测试环境
|
||||||
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(通过 SSH 隧道):**
|
**Redis(通过 ZeroTier):**
|
||||||
```bash
|
```bash
|
||||||
# 命令行
|
# 命令行
|
||||||
redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@"
|
redis-cli -h 10.8.0.252 -a "Hbhyg731024@"
|
||||||
# 测试环境:select 6
|
# 测试环境:select 6
|
||||||
# 正式环境:select 5
|
# 正式环境:select 5
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
|
# Python(Agent 常用方式)
|
||||||
import os, redis
|
import redis
|
||||||
host = os.environ.get('PMS_REDIS_HOST', '127.0.0.1')
|
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5) # 正式环境
|
||||||
port = int(os.environ.get('PMS_REDIS_PORT', '16379'))
|
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6) # 测试环境
|
||||||
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`(需先起隧道):**
|
**数据库工具 `tools/db.py`(默认连接测试库 pms_dev):**
|
||||||
```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`**(可用 `PMS_MYSQL_DB=pms` 切生产,切换时会打印醒目警告);
|
> 注意:`tools/db.py` 默认连的是 `pms_dev`(测试库),要操作正式库请用上面的 Python 直连方式。
|
||||||
> 连接失败会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 exit 2。
|
|
||||||
|
|
||||||
### Nginx配置
|
### Nginx配置
|
||||||
|
|
||||||
@ -1028,28 +897,19 @@ 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 转发已移除(2026-09-20)—— 不要再给开发机开库端口** —— 主配置
|
6. **`stream {}` 块只能有一个** —— MySQL(3306)/Redis(6379) 的 stream 反代写在**主配置
|
||||||
`/etc/nginx/nginx.conf` 里原来那个 `stream{}`(MySQL 3306 / Redis 6379 → 阿里云 RDS/Redis)
|
`/etc/nginx/nginx.conf`** 里。⛔ **不要在 `/etc/nginx/conf.d/*.conf` 里再写一个 `stream {}`**:
|
||||||
**已整块删除**;服务器不再监听 3306/6379(`10.8.0.252:3306/6379` 实测已不可连)。
|
nginx 会以 `"stream" directive is duplicate` 直接启动失败(连带整站不可用)。
|
||||||
⛔ **不要为了给开发机开库通路而在服务器上重新加 `stream{}` / socat / 任何端口转发** ——
|
conf.d 下只放 `http` 上下文的 `server {}`。
|
||||||
开发机要连库一律走 **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、100 行、3733 字节、
|
(**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、116 行、3629 字节)。
|
||||||
md5 `82f660d8ebd3299c89edf55483277266` —— 2026-09-20 移除 `stream{}` + 订正 stream 注释后重新对齐)。
|
|
||||||
`deploy/nginx/` 下三份分别是:
|
`deploy/nginx/` 下三份分别是:
|
||||||
- `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `worker_shutdown_timeout`;
|
- `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `stream{}` / `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`
|
||||||
|
|
||||||
@ -1547,11 +1407,6 @@ 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 特定)
|
||||||
|
|
||||||
|
|||||||
@ -22,13 +22,12 @@ charging-cabinet/
|
|||||||
├── tasks/ # 任务文件(opencode执行)
|
├── tasks/ # 任务文件(opencode执行)
|
||||||
├── docs/ # 开发文档、协议文档(API.md、柜控板固件对接文档.md …)
|
├── docs/ # 开发文档、协议文档(API.md、柜控板固件对接文档.md …)
|
||||||
├── deploy/ # 部署配置与脚本
|
├── deploy/ # 部署配置与脚本
|
||||||
│ ├── nginx/ # Nginx 配置镜像(nginx.conf 主配置 + app / test,共三份,与线上逐字节一致)
|
│ ├── nginx/ # Nginx 配置(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;需先起 SSH 隧道)
|
│ ├── db.py # 数据库工具(默认连测试库 pms_dev)
|
||||||
│ ├── 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)
|
||||||
@ -108,9 +107,7 @@ 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` 注释。
|
||||||
|
|
||||||
**节点管理:**
|
**节点管理:**
|
||||||
|
|||||||
@ -17,21 +17,11 @@
|
|||||||
# 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'
|
||||||
@ -146,50 +136,18 @@ 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 os, json, time
|
import redis, json, time
|
||||||
import redis
|
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5)
|
||||||
|
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')
|
||||||
if left == 0:
|
print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}")
|
||||||
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)--------------
|
||||||
@ -255,26 +213,9 @@ 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
|
|
||||||
|
|||||||
@ -21,10 +21,8 @@ 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 转发已移除(2026-09-20):原 MySQL 3306 / Redis 6379 反代(本文件下方那个)
|
# 2. stream{} 全配置**只能有一个**(MySQL 3306 / Redis 6379 反代就在本文件下面那个),
|
||||||
# 已整块删除,服务器不再监听库端口;开发机连库走 SSH 隧道(tools/dev-tunnel.ps1)。
|
# ⛔ 不要在 conf.d/*.conf 里再写一个(报 stream directive is duplicate,整站不可用);
|
||||||
# 不要在**本文件或 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;
|
||||||
@ -98,3 +96,21 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用',
|
status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||||
@ -251,21 +251,6 @@ 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> {
|
||||||
// 逐条执行全量建表语句,避免多语句执行问题
|
// 逐条执行全量建表语句,避免多语句执行问题
|
||||||
@ -328,47 +313,25 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 更新 cabinets.status 注释(**本列是"启用/禁用"配置态,不是"在线"**)。
|
// 更新 cabinets.status 注释(**旧注释漏了禁用档**)。
|
||||||
//
|
//
|
||||||
// 全仓复核结论(grep 实测):
|
// 旧注释只有 `0离线 1在线`,但代码实际消费的是三档:
|
||||||
// - **唯一写入方** `routes/cabinets.rs::set_cabinet_status`:
|
// - `2` = 禁用 → `routes/h5.rs::ensure_cabinet_enabled` 与 `CABINET_DISABLED_SQL`
|
||||||
// `UPDATE cabinets SET status = ? WHERE id = ?`,语义是 `2` = 禁用 / 其它值 = 启用;
|
// (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)据此拒绝
|
||||||
// - **没有任何地方写 0**(0 只可能由建表默认值 `DEFAULT 0` 或该接口传入 0 时产生);
|
// 开合闸 / 开门 / 充电;`routes/cabinets.rs::set_cabinet_status` 也以 `status == 2`
|
||||||
// - 消费方只有"禁用"一档:`commands.rs::CABINET_DISABLED_SQL`
|
// 判定"禁用",其余值一律按"启用"处理;
|
||||||
// (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)与
|
// - `1` = 在线 → `routes/energy_stats.rs::count_visible_cabinets(online_only=true)`
|
||||||
// `routes/h5.rs::ensure_cabinet_enabled` 据此拦截开合闸 / 开门 / 充电。
|
// 按 `c.status = 1` 统计在线柜数。
|
||||||
//
|
// 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`)。
|
||||||
// 旧注释 `0离线 1在线 2禁用` 里的"**在线**"是**语义错配**:
|
// marker 取注释里的独有片段:老注释不含它 → 只 ALTER 一次,之后启动不再重复 DDL。
|
||||||
// 生产实测 68 台柜子 `status` 全 = 1,而当时**一台设备都没连** ——
|
ensure_column_comment(
|
||||||
// 按它统计在线(`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",
|
||||||
CABINETS_STATUS_COMMENT_ALIGN.0,
|
"2禁用",
|
||||||
CABINETS_STATUS_COMMENT_ALIGN.1,
|
r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0离线 1在线 2禁用(禁用后指令链路拦截)'"#,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"[migrate] cabinets.status 注释更新失败(服务继续启动;老库注释可能仍是旧的 `0离线 1在线`)err={}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。
|
// 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。
|
||||||
//
|
//
|
||||||
@ -1416,10 +1379,7 @@ async fn grant_permissions_by_codes(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{ColumnSpec, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS, SCHEMA_SQL};
|
||||||
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 ";
|
||||||
@ -1652,76 +1612,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// cabinets.status 注释口径(**配置态,不是在线**)守门
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 取出 `SCHEMA_SQL` 里 `cabinets` 建表语句中 `status` 那一行
|
|
||||||
fn cabinets_status_ddl_line() -> String {
|
|
||||||
let block = SCHEMA_SQL
|
|
||||||
.split("CREATE TABLE IF NOT EXISTS cabinets (")
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|rest| rest.split("ENGINE=InnoDB").next())
|
|
||||||
.expect("SCHEMA_SQL 里应有 cabinets 建表语句");
|
|
||||||
block
|
|
||||||
.lines()
|
|
||||||
.find(|l| l.trim_start().starts_with("status "))
|
|
||||||
.expect("cabinets 建表语句里应有 status 列")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **`cabinets.status` 是启用/禁用配置态,不表示在线** —— 建表注释里
|
|
||||||
/// 不得再出现 `离线` / `1在线` 这类说法。
|
|
||||||
///
|
|
||||||
/// 为什么必须守:生产实测 68 台柜子 `status` 全 = 1 而**一台设备都没连**;
|
|
||||||
/// 注释一旦写回"在线",下一个人就会照着它再写一次 `WHERE status = 1` 当在线统计。
|
|
||||||
#[test]
|
|
||||||
fn test_cabinets_status_comment_is_config_state_not_online() {
|
|
||||||
let line = cabinets_status_ddl_line();
|
|
||||||
assert!(line.contains("配置态"), "必须显式写明是配置态:{}", line);
|
|
||||||
assert!(line.contains("不表示在线"), "必须写明不表示在线:{}", line);
|
|
||||||
assert!(!line.contains("离线"), "注释不得再出现「离线」:{}", line);
|
|
||||||
assert!(!line.contains("1在线"), "注释不得再出现「1在线」:{}", line);
|
|
||||||
assert!(line.contains("2禁用"), "必须保留禁用档说明:{}", line);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 启动迁移的注释对齐**幂等且只改注释**:
|
|
||||||
/// ① `marker` 必须出现在 DDL 的 `COMMENT` 里(否则每次启动都重复 `MODIFY`);
|
|
||||||
/// ② DDL 保留 `TINYINT DEFAULT 0`(不动类型/默认值),也不改可空性;
|
|
||||||
/// ③ 与 `SCHEMA_SQL` 建表语句的注释口径**一致**(否则新库与老库再次分叉)。
|
|
||||||
#[test]
|
|
||||||
fn test_cabinets_status_comment_alignment_is_idempotent_and_comment_only() {
|
|
||||||
let (marker, ddl) = CABINETS_STATUS_COMMENT_ALIGN;
|
|
||||||
assert!(
|
|
||||||
ddl.contains(marker),
|
|
||||||
"marker `{}` 不在 DDL 注释里 → 每次启动都会重复 DDL:{}",
|
|
||||||
marker,
|
|
||||||
ddl
|
|
||||||
);
|
|
||||||
assert_eq!(ddl.matches(marker).count(), 1, "marker 在 DDL 里应只出现一次:{}", ddl);
|
|
||||||
assert!(
|
|
||||||
ddl.starts_with("ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0"),
|
|
||||||
"只允许改注释,不得改类型/默认值:{}",
|
|
||||||
ddl
|
|
||||||
);
|
|
||||||
assert!(ddl.contains("COMMENT '"), "必须显式给出新注释:{}", ddl);
|
|
||||||
assert!(!ddl.contains("NULL"), "本列不是可空性变更:{}", ddl);
|
|
||||||
|
|
||||||
// 与建表语句口径一致:新库(SCHEMA_SQL)与老库(启动 ALTER)不得分叉
|
|
||||||
let line = cabinets_status_ddl_line();
|
|
||||||
assert!(
|
|
||||||
line.contains(marker),
|
|
||||||
"建表语句与迁移 DDL 的注释口径必须一致(marker=「{}」):{}",
|
|
||||||
marker,
|
|
||||||
line
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
line.contains("不表示在线"),
|
|
||||||
"建表注释也必须写明不表示在线:{}",
|
|
||||||
line
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 建表语句里**不允许再出现 `TIMESTAMP` 列类型**
|
/// 建表语句里**不允许再出现 `TIMESTAMP` 列类型**
|
||||||
///
|
///
|
||||||
/// `TIMESTAMP` 只与 `DateTime<Utc>` 兼容,而本项目时间列在 Rust 侧全是
|
/// `TIMESTAMP` 只与 `DateTime<Utc>` 兼容,而本项目时间列在 Rust 侧全是
|
||||||
|
|||||||
@ -443,100 +443,6 @@ 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) /
|
||||||
@ -564,8 +470,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 = device_key(&row.imei);
|
let key = format!("device:{}", row.imei);
|
||||||
pipe.cmd("HGET").arg(&key).arg(DEVICE_ONLINE_FIELD);
|
pipe.cmd("HGET").arg(&key).arg("online");
|
||||||
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");
|
||||||
@ -580,9 +486,7 @@ 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;
|
||||||
// 在线判据**唯一权威纯函数**(与能耗在线柜数、H5 在线数同源;见 [`device_online_flag`])
|
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
|
||||||
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())
|
||||||
@ -1486,93 +1390,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// 设备在线判据守门(防回归:不得再拿 `cabinets.status` 当"在线")
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 在线判据**三态**:只有明确的 `"1"` 才算在线;`"0"` 是明确离线;
|
|
||||||
/// `None`(无数据)必须与"离线"区分开 —— 三者混为一谈是上一轮 `cabinets.status` 错配的根源。
|
|
||||||
#[test]
|
|
||||||
fn test_device_online_flag_is_three_state() {
|
|
||||||
assert_eq!(device_online_flag(Some("1")), Some(true), "\"1\" = 在线");
|
|
||||||
assert_eq!(device_online_flag(Some("0")), Some(false), "\"0\" = 明确离线");
|
|
||||||
assert_eq!(device_online_flag(Some("")), Some(false), "空串不是在线");
|
|
||||||
assert_eq!(device_online_flag(Some("2")), Some(false), "只有 \"1\" 算在线");
|
|
||||||
assert_eq!(device_online_flag(None), None, "无数据 ≠ 离线,必须是 None");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **语义守门:0 台设备连接 ⇒ 在线数 0**(守卫"在线数退化成柜子总数"这条真实故障)。
|
|
||||||
///
|
|
||||||
/// 纯函数级覆盖,不需要真实 Redis:设备都没连时,`device:{imei}` 要么不存在
|
|
||||||
/// (`None`)要么是历史 `"0"`,两种都必须**不计入**在线。
|
|
||||||
#[test]
|
|
||||||
fn test_count_online_flags_is_zero_when_no_device_connected() {
|
|
||||||
// 68 台柜子,一台都没连:20 台键不存在(None)、48 台是明确的 "0"
|
|
||||||
let mut raws: Vec<Option<String>> = vec![Some("0".to_string()); 48];
|
|
||||||
raws.extend(vec![None; 20]);
|
|
||||||
assert_eq!(raws.len(), 68, "样本规模与生产现场一致");
|
|
||||||
assert_eq!(
|
|
||||||
count_online_flags(&raws),
|
|
||||||
0,
|
|
||||||
"无设备连接时必须为 0,绝不能等于柜子总数"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 反向:全部 "1" ⇒ 全部在线(防止把判据写成"恒 0")
|
|
||||||
let all_online = vec![Some("1".to_string()); 68];
|
|
||||||
assert_eq!(count_online_flags(&all_online), 68);
|
|
||||||
|
|
||||||
// 混合:只有明确的 "1" 计入
|
|
||||||
let mixed = vec![
|
|
||||||
Some("1".to_string()),
|
|
||||||
Some("0".to_string()),
|
|
||||||
None,
|
|
||||||
Some(String::new()),
|
|
||||||
Some("2".to_string()),
|
|
||||||
Some("1".to_string()),
|
|
||||||
];
|
|
||||||
assert_eq!(count_online_flags(&mixed), 2);
|
|
||||||
|
|
||||||
// 空列表(无可见柜子)不 panic 且为 0
|
|
||||||
assert_eq!(count_online_flags(&[]), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **口径同源**:`is_device_online`(布尔)与 `count_online_flags`(计数)
|
|
||||||
/// 必须与 `device_online_flag`(三态)逐值一致 —— 三者是同一判据的三种形态。
|
|
||||||
#[test]
|
|
||||||
fn test_online_predicate_forms_are_mutually_consistent() {
|
|
||||||
let samples: Vec<Option<String>> = vec![
|
|
||||||
Some("1".into()),
|
|
||||||
Some("0".into()),
|
|
||||||
Some("".into()),
|
|
||||||
Some("2".into()),
|
|
||||||
Some("true".into()),
|
|
||||||
None,
|
|
||||||
];
|
|
||||||
for raw in &samples {
|
|
||||||
assert_eq!(
|
|
||||||
is_device_online(raw.as_deref()),
|
|
||||||
device_online_flag(raw.as_deref()) == Some(true),
|
|
||||||
"布尔形态与三态形态不一致:{:?}",
|
|
||||||
raw
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(count_online_flags(&samples), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在线取数的 Redis key / 字段名必须与上报写入侧(`device:{imei}` 的 `online`)**逐字一致**:
|
|
||||||
/// 取数来源写错就永远判离线(表现为"全部离线",静默无报错、无异常)。
|
|
||||||
#[test]
|
|
||||||
fn test_device_online_source_matches_report_writer_contract() {
|
|
||||||
assert_eq!(DEVICE_ONLINE_FIELD, "online", "必须与 report_worker 写入的字段名一致");
|
|
||||||
assert_eq!(DEVICE_ONLINE_VALUE, "1", "必须与 report_worker 写入的取值一致");
|
|
||||||
assert_eq!(device_key("864720562557021"), "device:864720562557021");
|
|
||||||
// 设备列表 pipeline 的**第 0 个** HGET 字段就是在线字段(`base + 0`)
|
|
||||||
assert_eq!(
|
|
||||||
DEVICE_ONLINE_FIELD, "online",
|
|
||||||
"attach_realtime_status 的 base+0 必须解析在线字段"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)-------
|
// ---- 通道级 key / 拓扑闸门(板号 1–15 / 通道号 1–8)-------
|
||||||
/// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key)
|
/// 协议量程内合法,越界一律拒绝(`board_id=0` / `channel_id=9` 拼不出有效 key)
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -176,17 +176,9 @@ 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, status)
|
r#"INSERT INTO cabinets (project_id, imei, auth_str)
|
||||||
VALUES (?, ?, ?, 1)"#,
|
VALUES (?, ?, ?)"#,
|
||||||
)
|
)
|
||||||
.bind(body.project_id)
|
.bind(body.project_id)
|
||||||
.bind(imei)
|
.bind(imei)
|
||||||
@ -263,25 +255,6 @@ 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)可以执行,客户管理员无此权限。
|
||||||
@ -299,15 +272,6 @@ 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)
|
||||||
@ -653,9 +617,6 @@ 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,
|
||||||
@ -695,14 +656,6 @@ 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 -> 通道级实时状态
|
||||||
@ -727,34 +680,17 @@ 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`。
|
||||||
///
|
///
|
||||||
/// ⚠️ **数组顺序 = pipeline 命令顺序 = 解析下标**,三者由本常量唯一决定
|
/// ⚠️ **命令顺序与下标一一对应**:本常量同时是 [`fetch_cabinet_realtime`] 下发 `HGET` 的
|
||||||
/// (原先命令顺序与下标是两处手写、靠注释对齐,容易漂移)。
|
/// 顺序(下标 0..[`CABINET_REALTIME_FIELDS`])与 [`parse_cabinet_realtime`] 的柜级下标解析依据,
|
||||||
/// **新增柜级字段只能追加在末尾**:柜级段(`0..`[`CABINET_REALTIME_FIELDS`])之后是
|
/// 还决定了 SOC 段(`CABINET_REALTIME_FIELDS + i`)与通道段(`+ comp_ids.len()`)的起始偏移。
|
||||||
/// SOC 段与通道段,两段的起始偏移都由 [`CABINET_REALTIME_FIELDS`] 派生。
|
/// **新增柜级字段必须同步改这一处,且只能追加在末尾**(改动即破坏下标对齐)。
|
||||||
const CABINET_REALTIME_FIELD_NAMES: [&str; 7] = [
|
const CABINET_REALTIME_FIELDS: usize = 6;
|
||||||
"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;
|
||||||
|
|
||||||
@ -824,8 +760,7 @@ async fn load_boards_with_compartments(
|
|||||||
|
|
||||||
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
|
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
|
||||||
///
|
///
|
||||||
/// 为什么放一起:详情接口要求低延迟,柜级 [`CABINET_REALTIME_FIELDS`] 个字段
|
/// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。
|
||||||
/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 个字段共用一次网络往返。
|
|
||||||
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
||||||
///
|
///
|
||||||
/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段:
|
/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段:
|
||||||
@ -858,11 +793,16 @@ async fn fetch_cabinet_realtime(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let key = format!("device:{}", imei);
|
let key = format!("device:{}", imei);
|
||||||
// ⚠️ 命令顺序由 [`CABINET_REALTIME_FIELD_NAMES`] 唯一决定 —— 与解析下标不可能再漂移
|
// ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及
|
||||||
|
// parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾)
|
||||||
let mut pipe = redis::pipe();
|
let mut pipe = redis::pipe();
|
||||||
for field in CABINET_REALTIME_FIELD_NAMES {
|
pipe.cmd("HGET").arg(&key).arg("rssi")
|
||||||
pipe.cmd("HGET").arg(&key).arg(field);
|
.cmd("HGET").arg(&key).arg("pow_fail_dc")
|
||||||
}
|
.cmd("HGET").arg(&key).arg("pow_fail_ac")
|
||||||
|
.cmd("HGET").arg(&key).arg("temp")
|
||||||
|
// 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析
|
||||||
|
.cmd("HGET").arg(&key).arg("cabinet_status1")
|
||||||
|
.cmd("HGET").arg(&key).arg("cabinet_status2");
|
||||||
for 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));
|
||||||
}
|
}
|
||||||
@ -919,10 +859,6 @@ 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()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1171,51 +1107,20 @@ pub async fn list_compartment_options(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// `PUT /api/cabinets/:id/status` 的入参值域守门。
|
/// pipeline 结果解析:柜级 6 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局,
|
||||||
///
|
/// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 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 / online
|
// 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2
|
||||||
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,
|
||||||
@ -1241,7 +1146,6 @@ 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), "电压应被取出");
|
||||||
@ -1326,44 +1230,15 @@ 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, 7,
|
CABINET_REALTIME_FIELDS, 6,
|
||||||
"柜级 HGET 字段数变了:必须同步 CABINET_REALTIME_FIELD_NAMES 与下标解析"
|
"柜级 HGET 字段数变了:必须同步 fetch_cabinet_realtime 的命令顺序与本文件的下标解析"
|
||||||
);
|
);
|
||||||
// 字段清单与下标常量必须逐字对上(防止只改数字不改清单)
|
assert_eq!(CABINET_STATUS1_IDX, CABINET_REALTIME_FIELDS - 2);
|
||||||
assert_eq!(CABINET_REALTIME_FIELD_NAMES[CABINET_STATUS1_IDX], "cabinet_status1");
|
assert_eq!(CABINET_STATUS2_IDX, CABINET_REALTIME_FIELDS - 1);
|
||||||
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)
|
||||||
@ -1371,7 +1246,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))];
|
||||||
// 柜级 7 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
|
// 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
|
||||||
let vals: Vec<Option<String>> = vec![
|
let vals: Vec<Option<String>> = vec![
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -1380,7 +1255,6 @@ 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()),
|
||||||
@ -1392,7 +1266,6 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(rt.temp, None);
|
assert_eq!(rt.temp, None);
|
||||||
assert_eq!(rt.rssi, None);
|
assert_eq!(rt.rssi, None);
|
||||||
assert_eq!(rt.online, None, "在线字段缺失时必须是 None(无数据),不能落成 false");
|
|
||||||
let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)");
|
let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)");
|
||||||
assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0");
|
assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0");
|
||||||
assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0");
|
assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0");
|
||||||
|
|||||||
@ -25,8 +25,6 @@ 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;
|
||||||
|
|
||||||
@ -168,121 +166,41 @@ fn charge_record_filters(user: &CurrentUser) -> (String, Vec<String>) {
|
|||||||
(cond, binds)
|
(cond, binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/// 统计当前用户可见的柜子数量(`online_only = true` 时只算在线柜子)
|
||||||
// 可见柜子 / 在线柜数(组织隔离 + 数据范围,两处口径必须完全一致)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 可见柜子(`cabinets c`)的过滤**条件后缀**:组织隔离 **∧** 数据范围(项目级)。
|
|
||||||
///
|
///
|
||||||
/// - 总管理员(`role_level >= 2`)⇒ 空串(豁免);
|
/// 口径与设备列表一致:非总管理员经 `cabinets → projects` 组织过滤,
|
||||||
/// - 客户侧账号有组织 ⇒ ` 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> {
|
||||||
let (sql, binds) = visible_cabinets_count_sql(user);
|
if user.role_level >= 2 {
|
||||||
let mut query = sqlx::query_scalar::<_, i64>(&sql);
|
let sql = if online_only {
|
||||||
for bind in &binds {
|
"SELECT COUNT(*) FROM cabinets WHERE status = 1"
|
||||||
|
} else {
|
||||||
|
"SELECT COUNT(*) FROM cabinets"
|
||||||
|
};
|
||||||
|
return Ok(sqlx::query_scalar(sql).fetch_one(pool).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(oid) = user.organization_id else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
|
||||||
|
let status_cond = if online_only { " AND c.status = 1" } else { "" };
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?{}{}",
|
||||||
|
status_cond, scope_cond
|
||||||
|
);
|
||||||
|
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(oid);
|
||||||
|
for bind in &scope_binds {
|
||||||
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 — 总览
|
||||||
///
|
///
|
||||||
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
|
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
|
||||||
@ -318,9 +236,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).await?;
|
let cabinet_count = count_visible_cabinets(pool, &user, false).await?;
|
||||||
let online_count = count_online_cabinets(&state, &user).await?;
|
let online_count = count_visible_cabinets(pool, &user, true).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@ -956,115 +874,4 @@ mod tests {
|
|||||||
let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0;
|
let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0;
|
||||||
assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small);
|
assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// 在线柜数守门(防回归:不得再拿 `cabinets.status` 当"在线")
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 构造单测用的 `CurrentUser`(不连库;`permissions` 与范围按需给)
|
|
||||||
fn user(role_level: i32, organization_id: Option<i64>, project_ids: Vec<i64>) -> CurrentUser {
|
|
||||||
CurrentUser {
|
|
||||||
user_id: 1,
|
|
||||||
phone: "13000000000".to_string(),
|
|
||||||
permissions: Default::default(),
|
|
||||||
role_level,
|
|
||||||
organization_id,
|
|
||||||
scope: crate::middleware::auth::UserScope { project_ids },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ① **在线统计不依赖 `cabinets.status`**。
|
|
||||||
///
|
|
||||||
/// `cabinets.status` 是启用/禁用**配置态**:全仓唯一写入方
|
|
||||||
/// (`routes/cabinets.rs::set_cabinet_status`)只在 `2`(禁用)与其它值(启用)之间切,
|
|
||||||
/// **没有任何地方写 0(离线)**。所以只要 SQL 里出现 `status` 条件,
|
|
||||||
/// 就必然在"一台设备都没连"时返回"全部在线"。
|
|
||||||
#[test]
|
|
||||||
fn test_visible_cabinet_sql_never_filters_by_status() {
|
|
||||||
let cases = [
|
|
||||||
user(2, None, vec![]), // 总管理员
|
|
||||||
user(1, Some(42), vec![]), // 组织管理员(未配范围)
|
|
||||||
user(1, Some(42), vec![7, 8]), // 组织管理员 + 项目级范围
|
|
||||||
user(0, None, vec![]), // 客户侧账号无组织 ⇒ 空集
|
|
||||||
user(0, Some(42), vec![]), // 操作员
|
|
||||||
];
|
|
||||||
for u in &cases {
|
|
||||||
let (count_sql, _) = visible_cabinets_count_sql(u);
|
|
||||||
let (imei_sql, _) = visible_cabinet_imeis_sql(u);
|
|
||||||
for sql in [&count_sql, &imei_sql] {
|
|
||||||
assert!(
|
|
||||||
!sql.to_lowercase().contains("status"),
|
|
||||||
"柜数/在线统计不得引用 cabinets.status:{}",
|
|
||||||
sql
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ② 组织隔离与数据范围**必须保留**(收窄只能取交集,绝不放大):
|
|
||||||
/// 总管理员豁免;非总管理员带组织条件 + 项目级范围;**无组织 ⇒ 空集**(不是"全部")。
|
|
||||||
#[test]
|
|
||||||
fn test_visible_cabinet_sql_keeps_org_isolation_and_scope() {
|
|
||||||
// 总管理员豁免:即使身上带着组织与范围,也不得出现在 SQL 里
|
|
||||||
let (sql, binds) = visible_cabinet_imeis_sql(&user(2, Some(99), vec![7]));
|
|
||||||
assert!(!sql.contains("organization_id"), "总管理员应豁免组织条件:{}", sql);
|
|
||||||
assert!(!sql.contains("IN ("), "总管理员应豁免数据范围:{}", sql);
|
|
||||||
assert!(binds.is_empty(), "总管理员不应有任何绑定值");
|
|
||||||
|
|
||||||
// 组织管理员:组织条件 + 项目级范围(绑定顺序必须与占位符顺序一致)
|
|
||||||
let (sql, binds) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![7, 8]));
|
|
||||||
assert!(sql.contains("p.organization_id = ?"), "组织隔离丢失:{}", sql);
|
|
||||||
assert!(sql.contains("c.project_id IN (?,?)"), "数据范围丢失:{}", sql);
|
|
||||||
assert_eq!(binds, vec!["42", "7", "8"], "绑定顺序必须与占位符一致");
|
|
||||||
|
|
||||||
// 客户侧账号无组织 ⇒ 空集(**绝不退化成"全部"**),两条 SQL 都必须如此
|
|
||||||
let (imei_sql, binds) = visible_cabinet_imeis_sql(&user(0, None, vec![]));
|
|
||||||
assert!(imei_sql.contains("1=0"), "无组织时在线取数必须是空集:{}", imei_sql);
|
|
||||||
assert!(binds.is_empty());
|
|
||||||
let (count_sql, _) = visible_cabinets_count_sql(&user(0, None, vec![]));
|
|
||||||
assert!(count_sql.contains("1=0"), "无组织时柜数必须是空集:{}", count_sql);
|
|
||||||
|
|
||||||
// 在线取数只取 IMEI(在线判定交给 Redis,DB 不参与)
|
|
||||||
let (sql, _) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![]));
|
|
||||||
assert!(sql.starts_with("SELECT c.imei FROM cabinets c"), "取数应只取 IMEI:{}", sql);
|
|
||||||
assert!(sql.contains("c.imei IS NOT NULL"), "必须排除 NULL 的 IMEI:{}", sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ③ **与设备列表 `is_online` 口径同源**:`energy_stats` 没有第二套在线判据 ——
|
|
||||||
/// 在线计数一律经 `cabinet_tree::count_online_flags`,其唯一依据是
|
|
||||||
/// `cabinet_tree::is_device_online` / `device_online_flag`(设备列表用的是同一对函数)。
|
|
||||||
/// 逐值断言同一批原始值在两处结论完全一致,并覆盖「0 台连接 ⇒ 0」这条语义。
|
|
||||||
#[test]
|
|
||||||
fn test_online_count_same_source_as_cabinet_tree_device_list() {
|
|
||||||
// 同一批原始值,覆盖三态边界:在线 / 明确离线 / 无数据 / 空串 / 非法值
|
|
||||||
let raws: Vec<Option<String>> = vec![
|
|
||||||
Some("1".into()),
|
|
||||||
Some("0".into()),
|
|
||||||
None,
|
|
||||||
Some(String::new()),
|
|
||||||
Some("2".into()),
|
|
||||||
];
|
|
||||||
// 期望值直接由设备列表的判据(`is_online` == `device_online_flag(..) == Some(true)`)给出
|
|
||||||
let expected = raws
|
|
||||||
.iter()
|
|
||||||
.filter(|v| cabinet_tree::device_online_flag(v.as_deref()) == Some(true))
|
|
||||||
.count() as i64;
|
|
||||||
assert_eq!(
|
|
||||||
cabinet_tree::count_online_flags(&raws),
|
|
||||||
expected,
|
|
||||||
"在线计数与设备列表 is_online 判据必须同源"
|
|
||||||
);
|
|
||||||
assert_eq!(expected, 1, "5 个样本里只有 1 个明确在线");
|
|
||||||
|
|
||||||
// **0 台设备连接 ⇒ online_count = 0**(而不是等于柜子总数)——生产实测的回归形态
|
|
||||||
let nothing_connected: Vec<Option<String>> = vec![None; 68];
|
|
||||||
assert_eq!(
|
|
||||||
cabinet_tree::count_online_flags(¬hing_connected),
|
|
||||||
0,
|
|
||||||
"无设备连接时在线数必须是 0,绝不能退化成柜子总数"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0
|
|
||||||
assert_eq!(cabinet_tree::count_online_flags(&[]), 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,8 +164,8 @@ pub async fn get_dashboard(
|
|||||||
let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope());
|
let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope());
|
||||||
let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds };
|
let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds };
|
||||||
|
|
||||||
// 统计在线柜子数(带组织隔离;**以 Redis 为准**,与 `cabinets.status` 配置态无关)
|
// 统计在线柜子数(带组织隔离)
|
||||||
let online_cabinets = count_online_cabinets(&state, is_super, org_id, &scope).await?;
|
let online_cabinets = count_online_cabinets(pool, 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,89 +217,34 @@ 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(
|
||||||
state: &AppState,
|
pool: &sqlx::MySqlPool,
|
||||||
is_super: bool,
|
is_super: bool,
|
||||||
org_id: Option<i64>,
|
org_id: Option<i64>,
|
||||||
scope: &ScopeFilter<'_>,
|
scope: &ScopeFilter<'_>,
|
||||||
) -> Result<i64, AppError> {
|
) -> Result<i64, AppError> {
|
||||||
// 总管理员:全部柜子的 IMEI,不带任何组织条件(豁免)
|
if !is_super && org_id.is_none() {
|
||||||
if is_super {
|
|
||||||
return finish_online_count(state, online_cabinet_imeis_sql(true, ""), Vec::new()).await;
|
|
||||||
}
|
|
||||||
// 客户侧账号**无组织归属 ⇒ 空集**(绝不退化成"全部"),与 `visible_org_scope` 同口径
|
|
||||||
let Some(oid) = org_id else {
|
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
};
|
|
||||||
// 组织隔离 + 数据范围(绑定顺序 = 占位符顺序:先组织,再范围)
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
let count: i64 = if is_super {
|
||||||
/// 执行在线取数 SQL,并按 Redis 判据统计在线台数(`binds` 顺序 = 占位符顺序)
|
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
|
||||||
async fn finish_online_count(
|
.fetch_one(pool)
|
||||||
state: &AppState,
|
.await?
|
||||||
sql: String,
|
} else {
|
||||||
binds: Vec<String>,
|
let sql = format!(
|
||||||
) -> Result<i64, AppError> {
|
r#"SELECT COUNT(*) FROM cabinets cab
|
||||||
let mut query = sqlx::query_scalar::<_, Option<String>>(&sql);
|
INNER JOIN projects p ON cab.project_id = p.id
|
||||||
for bind in &binds {
|
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 = query.bind(bind);
|
||||||
}
|
}
|
||||||
let imeis: Vec<String> = query
|
query.fetch_one(pool).await?
|
||||||
.fetch_all(&state.mysql)
|
};
|
||||||
.await?
|
Ok(count)
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect();
|
|
||||||
// Redis 一次 pipeline 批量判定(**零 N 次单查**),口径同设备列表 is_online
|
|
||||||
Ok(super::cabinet_tree::count_online_devices(state, &imeis).await)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计总仓体数(带组织隔离 + 数据范围)
|
/// 统计总仓体数(带组织隔离 + 数据范围)
|
||||||
@ -879,13 +824,6 @@ 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`,
|
||||||
@ -1039,8 +977,15 @@ 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(&CABINET_HMGET_FIELDS);
|
pipe.cmd("HMGET").arg(&key).arg(&[
|
||||||
|
"rssi",
|
||||||
|
"pow_fail_dc",
|
||||||
|
"pow_fail_ac",
|
||||||
|
"temp",
|
||||||
|
"cabinet_status1",
|
||||||
|
"cabinet_status2",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !rows.is_empty() {
|
if !rows.is_empty() {
|
||||||
@ -1061,15 +1006,6 @@ 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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1136,10 +1072,6 @@ 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) =
|
||||||
@ -1151,8 +1083,6 @@ 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,
|
||||||
@ -2380,7 +2310,8 @@ 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() { assert_eq!(
|
fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() {
|
||||||
|
assert_eq!(
|
||||||
pick_non_empty_hex(Some(&Some("00".into()))).as_deref(),
|
pick_non_empty_hex(Some(&Some("00".into()))).as_deref(),
|
||||||
Some("00"),
|
Some("00"),
|
||||||
"\"00\" 是「全部正常」的有效值,与「无数据」必须区分"
|
"\"00\" 是「全部正常」的有效值,与「无数据」必须区分"
|
||||||
@ -2517,68 +2448,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1));
|
assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// 实时在线(`is_online`)守门 —— 防回归:不得再拿 `cabinets.status` 当"在线"
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 小程序列表的在线取数契约:
|
|
||||||
/// ① 在线字段**恰好出现一次**且位于清单末尾(下标常量与清单同步);
|
|
||||||
/// ② 字段名与设备列表 `is_online` **逐字一致**(跨模块同源守门)。
|
|
||||||
#[test]
|
|
||||||
fn test_h5_list_online_field_matches_device_list_criterion() {
|
|
||||||
let online_field = crate::routes::cabinet_tree::DEVICE_ONLINE_FIELD;
|
|
||||||
assert_eq!(
|
|
||||||
CABINET_HMGET_ONLINE_IDX,
|
|
||||||
CABINET_HMGET_FIELDS.len() - 1,
|
|
||||||
"在线字段必须追加在字段清单末尾(前面的下标解析依赖顺序)"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
CABINET_HMGET_FIELDS[CABINET_HMGET_ONLINE_IDX], online_field,
|
|
||||||
"小程序列表在线取数字段必须与设备列表同源"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
CABINET_HMGET_FIELDS.iter().filter(|f| **f == online_field).count(),
|
|
||||||
1,
|
|
||||||
"在线字段只允许出现一次"
|
|
||||||
);
|
|
||||||
// 三态:无数据(None)不是"离线"(false),小程序据此可以区分"未知"
|
|
||||||
assert_eq!(crate::routes::cabinet_tree::device_online_flag(None), None);
|
|
||||||
assert_eq!(
|
|
||||||
crate::routes::cabinet_tree::device_online_flag(Some("1")),
|
|
||||||
Some(true)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
crate::routes::cabinet_tree::device_online_flag(Some("0")),
|
|
||||||
Some(false)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 首页「在线设备」的取数 SQL **不得依赖 `cabinets.status`**,
|
|
||||||
/// 且必须保留组织隔离:总管理员无组织条件、客户侧必须带 `p.organization_id = ?`。
|
|
||||||
#[test]
|
|
||||||
fn test_h5_dashboard_online_sql_has_no_status_and_keeps_org_isolation() {
|
|
||||||
let super_sql = online_cabinet_imeis_sql(true, "");
|
|
||||||
assert!(
|
|
||||||
!super_sql.to_lowercase().contains("status"),
|
|
||||||
"在线统计不得引用 cabinets.status:{}",
|
|
||||||
super_sql
|
|
||||||
);
|
|
||||||
assert!(super_sql.starts_with("SELECT cab.imei FROM cabinets cab"));
|
|
||||||
assert!(super_sql.contains("cab.imei IS NOT NULL"), "必须排除 NULL IMEI:{}", super_sql);
|
|
||||||
|
|
||||||
let org_sql = online_cabinet_imeis_sql(false, " AND p.id IN (?,?)");
|
|
||||||
assert!(
|
|
||||||
!org_sql.to_lowercase().contains("status"),
|
|
||||||
"在线统计不得引用 cabinets.status:{}",
|
|
||||||
org_sql
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
org_sql.contains("INNER JOIN projects p ON cab.project_id = p.id"),
|
|
||||||
"客户侧必须经 projects 做组织隔离:{}",
|
|
||||||
org_sql
|
|
||||||
);
|
|
||||||
assert!(org_sql.contains("p.organization_id = ?"), "组织隔离条件丢失:{}", org_sql);
|
|
||||||
assert!(org_sql.contains("AND p.id IN (?,?)"), "数据范围条件丢失:{}", org_sql);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,23 +23,6 @@ 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`](统一结算)—— 这样超时订单
|
||||||
@ -67,9 +50,18 @@ 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`] 的文档注释。
|
//
|
||||||
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> =
|
// ⚠️ **写法方向极易搞反,这里踩过坑**:目标是"start_time 早于 NOW() - 10 小时"。
|
||||||
sqlx::query_as(EXPIRED_ORDER_SCAN_SQL)
|
// - ❌ 错误:`start_time < TIMESTAMPADD(HOUR, ?, NOW())` —— 绑正值 10 得到的是
|
||||||
|
// **未来时刻** `NOW()+10h`,于是"start_time < 未来"对**几乎所有订单恒真**,
|
||||||
|
// 刚建单几分钟的活跃订单会被当成超时单提前结掉、电量被提前结算。
|
||||||
|
// - ✅ 正确:把 start_time 往前推 10 小时再与 NOW() 比较(下面这种写法)。
|
||||||
|
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as(
|
||||||
|
"SELECT id, compartment_id, start_time FROM charge_orders \
|
||||||
|
WHERE status = 0 AND start_time IS NOT NULL \
|
||||||
|
AND TIMESTAMPADD(HOUR, ?, start_time) < NOW() \
|
||||||
|
ORDER BY start_time ASC LIMIT ?",
|
||||||
|
)
|
||||||
.bind(TIMEOUT_HOURS)
|
.bind(TIMEOUT_HOURS)
|
||||||
.bind(BATCH_LIMIT)
|
.bind(BATCH_LIMIT)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@ -104,100 +96,3 @@ async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sq
|
|||||||
tracing::info!("[order_timeout] 本轮超时结单处理完成");
|
tracing::info!("[order_timeout] 本轮超时结单处理完成");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 单元测试:超时条件**方向**守门
|
|
||||||
//
|
|
||||||
// 为什么必须有:本文件的 SQL 与 `report_worker.rs::FINALIZE_EXPIRED_ORDER_SQL`
|
|
||||||
// 是**两种等价但写法不同**的超时判据(这里把 `start_time` 往前推,那边把阈值往回绑负数)。
|
|
||||||
// 2026-09 修复 `report_worker` 方向 BUG 时,本文件**没有**同款守门测试(只在文档里留了「遗留」标记),
|
|
||||||
// 意味着这边再被改反也一样是**零报错、零日志、单测全绿**。
|
|
||||||
// 本模块把两个方向断言都在此处补齐(依据「踩过的坑」第 1 条 +「质量约束模板」第 8 条)。
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// **纯函数孪生体(仅测试)**:与 [`EXPIRED_ORDER_SCAN_SQL`] **同义** ——
|
|
||||||
/// `start_time + hours < now` ⇒ 已超时。
|
|
||||||
///
|
|
||||||
/// 有了它才能做"刚建的不命中 + 超期的命中"双向断言,并**反证**方向写反时的后果。
|
|
||||||
fn is_expired(
|
|
||||||
start_time: chrono::NaiveDateTime,
|
|
||||||
now: chrono::NaiveDateTime,
|
|
||||||
hours: i64,
|
|
||||||
) -> bool {
|
|
||||||
start_time + chrono::Duration::hours(hours) < now
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 方向守门(SQL 文本层):阈值必须**往回**推(`start_time` 在 `<` 左侧)。
|
|
||||||
#[test]
|
|
||||||
fn test_expired_order_scan_sql_points_backwards() {
|
|
||||||
assert!(
|
|
||||||
EXPIRED_ORDER_SCAN_SQL.contains("TIMESTAMPADD(HOUR, ?, start_time) < NOW()"),
|
|
||||||
"超时条件必须是「start_time 早于过去时刻」。当前:{EXPIRED_ORDER_SCAN_SQL}"
|
|
||||||
);
|
|
||||||
// 反证:以下是**写反**的形态(绑正数小时 ⇒ 未来时刻 ⇒ 对活跃订单恒真)
|
|
||||||
assert!(
|
|
||||||
!EXPIRED_ORDER_SCAN_SQL.contains("start_time < TIMESTAMPADD(HOUR, ?, NOW())")
|
|
||||||
&& !EXPIRED_ORDER_SCAN_SQL.contains("NOW() + INTERVAL")
|
|
||||||
&& !EXPIRED_ORDER_SCAN_SQL.contains("TIMESTAMPADD(HOUR, 10, NOW())"),
|
|
||||||
"方向写反:刚建单几分钟的活跃订单会被当超时单提前结掉、电量提前结算。\
|
|
||||||
当前:{EXPIRED_ORDER_SCAN_SQL}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **双向语义断言**:刚建的不命中 / 超期的命中 / 边界 1 分钟不算,
|
|
||||||
/// 并**反证**写反方向时"刚建的单"必然被命中 —— 即"方向反了一定红"。
|
|
||||||
#[test]
|
|
||||||
fn test_is_expired_two_way_assertions_catch_reversed_direction() {
|
|
||||||
let now = chrono::NaiveDate::from_ymd_opt(2026, 1, 1)
|
|
||||||
.unwrap()
|
|
||||||
.and_hms_opt(12, 0, 0)
|
|
||||||
.unwrap();
|
|
||||||
let fresh = now - chrono::Duration::minutes(5); // 刚建 5 分钟
|
|
||||||
let just_over = now - chrono::Duration::hours(TIMEOUT_HOURS) - chrono::Duration::minutes(1);
|
|
||||||
let just_under = now - chrono::Duration::hours(TIMEOUT_HOURS) + chrono::Duration::minutes(1);
|
|
||||||
|
|
||||||
// ① 刚建的不命中(防"恒真")
|
|
||||||
assert!(
|
|
||||||
!is_expired(fresh, now, TIMEOUT_HOURS),
|
|
||||||
"刚建 5 分钟的订单绝不能被判超时(提早结单会提前结算电量)"
|
|
||||||
);
|
|
||||||
// ② 超期的命中(防"恒假")
|
|
||||||
assert!(
|
|
||||||
is_expired(just_over, now, TIMEOUT_HOURS),
|
|
||||||
"已过 {} 小时 1 分钟的订单必须判超时",
|
|
||||||
TIMEOUT_HOURS
|
|
||||||
);
|
|
||||||
// ③ 边界:距超时还差 1 分钟 ⇒ 不命中
|
|
||||||
assert!(
|
|
||||||
!is_expired(just_under, now, TIMEOUT_HOURS),
|
|
||||||
"还差 1 分钟不算超时(阈值是严格小于)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// ④ 反证:把阈值加到 now 上(等价于 SQL 里绑正数)⇒ 刚建的单也"命中"
|
|
||||||
let reversed_hits_fresh = fresh < now + chrono::Duration::hours(TIMEOUT_HOURS);
|
|
||||||
assert!(
|
|
||||||
reversed_hits_fresh,
|
|
||||||
"反证失败:写反的方向本应命中刚建单;若这里不成立,说明反证用例本身失效"
|
|
||||||
);
|
|
||||||
assert_ne!(
|
|
||||||
is_expired(fresh, now, TIMEOUT_HOURS),
|
|
||||||
reversed_hits_fresh,
|
|
||||||
"正确方向与写反方向对「刚建的单」的判定必须相反 —— 否则测试抓不到方向写反"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 常量自身合理性(防阈值被改小到"把正常充电判超时")
|
|
||||||
#[test]
|
|
||||||
fn test_timeout_constants_are_sane() {
|
|
||||||
assert_eq!(TIMEOUT_HOURS, 10, "协议/业务约定为 10 小时");
|
|
||||||
assert!(
|
|
||||||
TIMEOUT_HOURS > 0,
|
|
||||||
"阈值必须为正 —— 负数会让条件恒假、超时单永不结单"
|
|
||||||
);
|
|
||||||
assert!(BATCH_LIMIT > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -69,24 +69,11 @@ 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)。
|
||||||
/// 在线键 `device:{imei}` 判不了这件事,因为它表达的是**另一件事**:
|
/// 过去用 Redis 在线键 `device:{imei}` 判断,但该键在**验签前**就会被写(`auth_str`/`login`
|
||||||
/// - `online` 字段由 api-server 在 `login` **验签成功后**(`report_worker.rs::handle_login`
|
/// 到达即写)、断连后还能存活 300 秒 ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
|
||||||
/// 的 `sign == expected_sign` 分支内,`mark_device_online`)以及 `status_post`
|
/// 该标记只在 **`login` 验签成功**(投递含 `spec`+`suc=1` 的应答)时写入、
|
||||||
/// (`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 读)。
|
||||||
|
|||||||
@ -3,8 +3,7 @@
|
|||||||
"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",
|
||||||
@ -22,7 +21,6 @@
|
|||||||
"@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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,21 +116,7 @@ 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
|
||||||
@ -178,16 +164,6 @@ 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[]
|
||||||
}
|
}
|
||||||
@ -198,19 +174,7 @@ 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
|
||||||
@ -250,15 +214,6 @@ 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 {
|
||||||
|
|||||||
28
software/h5-uniapp/src/env.d.ts
vendored
28
software/h5-uniapp/src/env.d.ts
vendored
@ -5,31 +5,3 @@ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -15,13 +15,7 @@
|
|||||||
<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">
|
||||||
<!-- 状态徽标:`status === 2` = 禁用(配置态);其余按**接口的实时在线字段** `is_online`
|
<view class="status-badge" :class="data.status === 1 ? 'online' : 'offline'"><text>{{ data.status === 1 ? '在线' : '离线' }}</text></view>
|
||||||
(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">
|
||||||
@ -77,16 +71,7 @@
|
|||||||
<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)">
|
||||||
@ -262,9 +247,7 @@ 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; }
|
||||||
/* 配置态「禁用」(`cabinets.status === 2`),与"离线"区分 */
|
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||||
.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; }
|
||||||
@ -304,8 +287,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` 样式已随"板级在线徽标"一并删除:该状态无数据来源(见模板注释),
|
.board-status { font-size: 11px; color: #94a3b8; }
|
||||||
留着只会诱使后来者再加回那个恒定显示"离线"的徽标。 */
|
.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); }
|
||||||
|
|||||||
105
software/h5-uniapp/src/pages/devices/index.vue
Normal file
105
software/h5-uniapp/src/pages/devices/index.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<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>
|
||||||
@ -40,16 +40,7 @@
|
|||||||
<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>
|
||||||
@ -276,9 +267,7 @@ 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; }
|
||||||
/* 配置态「禁用」(`cabinets.status === 2`):与"离线"区分开的橙色徽标 */
|
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||||
.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; }
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { 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,12 +46,7 @@ setTimeout(calcNav, 300)
|
|||||||
setTimeout(calcNav, 600)
|
setTimeout(calcNav, 600)
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
// ⚠️ 必须用 `computed` 包一层,不能写 `const user = authStore.user`:
|
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' }) } } })
|
||||||
|
|||||||
@ -54,16 +54,10 @@ 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 }> = {
|
||||||
1: { label: '启用', color: 'green' },
|
0: { label: '离线', color: 'red' },
|
||||||
|
1: { label: '在线', color: 'green' },
|
||||||
2: { label: '禁用', color: 'orange' },
|
2: { label: '禁用', color: 'orange' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -489,20 +483,7 @@ export default function CabinetDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '启用', color: 'green' };
|
const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '未知', color: 'gray' };
|
||||||
/**
|
|
||||||
* **实时在线**标签:`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;无数据 → 「未知」(绝不显示成"关闭") */
|
||||||
@ -519,9 +500,6 @@ 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' }}>
|
||||||
|
|||||||
@ -6,44 +6,20 @@
|
|||||||
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
|
||||||
|
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6, decode_responses=True)
|
||||||
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}']
|
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))
|
||||||
|
|
||||||
try:
|
c = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@',
|
||||||
c = pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user='root', password='Hbhyg731024@',
|
|
||||||
database='pms_dev', autocommit=True)
|
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)
|
||||||
|
|||||||
@ -66,8 +66,8 @@ ENVS = {
|
|||||||
"admin_phone": "13912391234",
|
"admin_phone": "13912391234",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
DEFAULT_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
|
DEFAULT_HOST = "10.8.0.252"
|
||||||
DEFAULT_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
|
DEFAULT_PORT = 3306
|
||||||
DEFAULT_USER = "root"
|
DEFAULT_USER = "root"
|
||||||
DEFAULT_PASSWORD = "Hbhyg731024@"
|
DEFAULT_PASSWORD = "Hbhyg731024@"
|
||||||
|
|
||||||
@ -166,9 +166,7 @@ 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\n"
|
die("数据库连接失败:%s" % e)
|
||||||
"连接被拒:请先运行 `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")
|
||||||
|
|
||||||
|
|||||||
@ -10,54 +10,11 @@
|
|||||||
| `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,无组织) | 本地(经 SSH 隧道 `127.0.0.1:13306`,先跑 `pwsh tools/dev-tunnel.ps1`;⛔ 不再直连 `10.8.0.252`) |
|
| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(直连 `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 内容复制到仓库**。
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""在 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
|
||||||
@ -10,11 +9,8 @@ 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=os.environ.get("PMS_MYSQL_HOST", "127.0.0.1"),
|
host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev", autocommit=True
|
||||||
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:
|
||||||
|
|||||||
@ -15,8 +15,6 @@ 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"
|
||||||
@ -103,8 +101,6 @@ 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)
|
||||||
@ -113,36 +109,23 @@ 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]}")
|
||||||
c_after_fail = counters(redis, "10.9.9.4")
|
print(f" 计数(2 次失败后): {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]}")
|
||||||
v.check("B1b 正确密码应登录成功", st == 200, f"status={st}")
|
print(f" 计数(登录成功后应为 None): {counters(redis, '10.9.9.4')}")
|
||||||
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()
|
||||||
@ -163,13 +146,12 @@ 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 v.exit_code() # 0=判据全过,1=有判据失败
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -2,22 +2,8 @@
|
|||||||
"""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
|
||||||
@ -27,9 +13,8 @@ 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"
|
||||||
# 允许用环境变量换成"确有 charge_record:export 权限"的账号(默认沿用安全测试账号)
|
PHONE = "13900000009"
|
||||||
PHONE = os.environ.get("PMS_PROBE_PHONE", "13900000009")
|
PASSWORD = "SecTest#2026"
|
||||||
PASSWORD = os.environ.get("PMS_PROBE_PASSWORD", "SecTest#2026")
|
|
||||||
|
|
||||||
|
|
||||||
class Redis:
|
class Redis:
|
||||||
@ -92,60 +77,32 @@ def main():
|
|||||||
)
|
)
|
||||||
if st != 200:
|
if st != 200:
|
||||||
print(f"[login] {st} {body.decode()[:200]}")
|
print(f"[login] {st} {body.decode()[:200]}")
|
||||||
print(" ⚠️ 未验证(exit 2):登录失败属环境/夹具问题,不是导出链路缺陷")
|
return 1
|
||||||
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(20):
|
for _ in range(15):
|
||||||
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"]
|
||||||
cur = info.get("status")
|
print(f"[export] task={task_id} status={info.get('status')} progress={info.get('progress')} file_path={info.get('file_path')}")
|
||||||
print(f"[export] task={task_id} status={cur} progress={info.get('progress')} file_path={info.get('file_path')}")
|
if info.get("status") == 1:
|
||||||
if cur == STATUS_DONE:
|
|
||||||
file_path = info.get("file_path")
|
file_path = info.get("file_path")
|
||||||
break
|
break
|
||||||
if cur == STATUS_FAILED:
|
if info.get("status") == 2:
|
||||||
print(f" ❌ exit 1:导出任务失败(status=3)err={info.get('error_message')!r}")
|
print("导出任务失败")
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
print(" ❌ exit 1:120 秒内任务未进入终态(既没完成也没失败)—— 导出链路可能卡住")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not file_path:
|
|
||||||
print(" ❌ exit 1:任务标记完成但没有 file_path —— 落盘环节有问题")
|
|
||||||
return 1
|
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}")
|
||||||
if st == 200 and blob[:2] == b"PK":
|
return 0 if (st == 200 and blob[:2] == b"PK") else 1
|
||||||
print(" ✅ exit 0:导出链路可用(降权成 pms 后仍能往 exports/ 落盘并下载)")
|
|
||||||
return 0
|
|
||||||
print(" ❌ exit 1:下载失败或不是 xlsx(xlsx 是 zip,魔数应为 PK)")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -16,8 +16,6 @@ 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"
|
||||||
@ -77,8 +75,6 @@ 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}"}
|
||||||
|
|
||||||
@ -97,7 +93,6 @@ 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
|
||||||
@ -109,8 +104,6 @@ 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"]
|
||||||
|
|
||||||
@ -126,8 +119,6 @@ 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",
|
||||||
@ -140,7 +131,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 v.exit_code() # 0=判据全过,1=有判据失败
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -13,8 +13,6 @@ 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
|
||||||
@ -126,28 +124,20 @@ 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 =====")
|
||||||
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.svg", SVG, "image/svg+xml", "A1 svg")
|
||||||
v.check("A1 头像:svg(image/svg+xml)应被拒", st != 200, f"status={st}")
|
try_upload(token, "/api/auth/avatar/upload", "file", "xss.png", SVG, "image/png", "A2 svg-as-png")
|
||||||
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 =====")
|
||||||
st, _ = try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg")
|
try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg")
|
||||||
v.check("A4 反馈图:svg 应被拒", st != 200, f"status={st}")
|
try_upload(token, "/api/feedback/upload", "file", "xss2.png", SVG, "image/png", "A5 svg-as-png")
|
||||||
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]):
|
||||||
@ -158,7 +148,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 v.exit_code() # 0=判据全过,1=有判据失败
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -6,8 +6,6 @@ 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"
|
||||||
@ -59,11 +57,8 @@ 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"):
|
||||||
@ -72,13 +67,9 @@ 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",
|
||||||
@ -87,13 +78,12 @@ 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 v.exit_code() # 0=判据全过,1=有判据失败
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
"""安全探针统一的「判据 + 退出码」小工具(供 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
|
|
||||||
@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
每一步都打印平台的应答,便于人工核对协议实现。
|
每一步都打印平台的应答,便于人工核对协议实现。
|
||||||
"""
|
"""
|
||||||
import sys
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
@ -155,12 +154,3 @@ 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)
|
|
||||||
|
|||||||
@ -1,32 +1,16 @@
|
|||||||
"""测量端到端真实延迟:发一条 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"
|
||||||
|
|
||||||
# 复位夹具,保证能重新申领
|
# 复位夹具,保证能重新申领
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
conn = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@",
|
||||||
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)
|
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")
|
||||||
@ -37,15 +21,9 @@ cur.execute("UPDATE compartments SET status=0")
|
|||||||
conn.close()
|
conn.close()
|
||||||
print(" 夹具已复位")
|
print(" 夹具已复位")
|
||||||
|
|
||||||
try:
|
|
||||||
s = socket.create_connection((HOST, PORT), timeout=5)
|
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):
|
||||||
@ -77,12 +55,9 @@ 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 延迟实测 ===")
|
||||||
@ -91,8 +66,6 @@ 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,
|
||||||
@ -101,16 +74,5 @@ 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)
|
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
import os
|
|
||||||
import pymysql
|
import pymysql
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||||
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
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
import pymysql
|
import pymysql
|
||||||
|
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||||
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字段
|
||||||
|
|||||||
38
tools/db.py
38
tools/db.py
@ -2,11 +2,6 @@
|
|||||||
"""
|
"""
|
||||||
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语句> # 执行写入
|
||||||
@ -14,47 +9,20 @@ 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': os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
|
'host': '10.8.0.252',
|
||||||
'port': int(os.environ.get('PMS_MYSQL_PORT', '13306')),
|
|
||||||
'user': 'root',
|
'user': 'root',
|
||||||
'password': 'Hbhyg731024@',
|
'password': 'Hbhyg731024@',
|
||||||
'database': os.environ.get('PMS_MYSQL_DB', 'pms_dev'),
|
'database': 'pms',
|
||||||
'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():
|
||||||
"""建立连接;连不上时打印可读提示并退出(不抛栈回溯)"""
|
|
||||||
announce_target()
|
|
||||||
try:
|
|
||||||
return pymysql.connect(**DB_CONFIG)
|
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文件"""
|
||||||
|
|||||||
@ -24,9 +24,6 @@ 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 # 显式指定参照库
|
||||||
@ -58,11 +55,9 @@ try:
|
|||||||
except ImportError: # pragma: no cover - 环境缺依赖时给出可执行的提示
|
except ImportError: # pragma: no cover - 环境缺依赖时给出可执行的提示
|
||||||
die("缺少依赖:pip install pymysql")
|
die("缺少依赖:pip install pymysql")
|
||||||
|
|
||||||
# ---- 默认连接参数 ----------------------------------------------------------
|
# ---- 默认连接参数(ZeroTier 直连,见 AGENTS.md「数据库连接」)------------------
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发。
|
DEFAULT_HOST = "10.8.0.252"
|
||||||
# 优先级:命令行 --host/--port > 环境变量 PMS_MYSQL_HOST/PMS_MYSQL_PORT > 下面的默认值。
|
DEFAULT_PORT = 3306
|
||||||
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@"
|
||||||
|
|
||||||
@ -277,10 +272,7 @@ def main():
|
|||||||
conn_a.close()
|
conn_a.close()
|
||||||
conn_b.close()
|
conn_b.close()
|
||||||
except pymysql.MySQLError as e:
|
except pymysql.MySQLError as e:
|
||||||
# 连不上时必须是 exit 2(连接错误),绝不能落成"无漂移"的 exit 0
|
die("数据库连接/查询失败:%s" % e)
|
||||||
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)
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
# 开发机 → 生产服务器(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"
|
|
||||||
@ -9,23 +9,21 @@ 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 = os.environ.get("PMS_REDIS_HOST", "127.0.0.1")
|
REDIS_HOST = "10.8.0.252"
|
||||||
REDIS_PORT = int(os.environ.get("PMS_REDIS_PORT", "16379"))
|
REDIS_PORT = 6379
|
||||||
REDIS_PASSWORD = "Hbhyg731024@"
|
REDIS_PASSWORD = "Hbhyg731024@"
|
||||||
REDIS_DB = 5
|
REDIS_DB = 5
|
||||||
|
|
||||||
# MySQL 配置
|
# MySQL 配置
|
||||||
MYSQL_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
|
MYSQL_HOST = "10.8.0.252"
|
||||||
MYSQL_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
|
MYSQL_PORT = 3306
|
||||||
MYSQL_USER = "root"
|
MYSQL_USER = "root"
|
||||||
MYSQL_PASSWORD = "Hbhyg731024@"
|
MYSQL_PASSWORD = "Hbhyg731024@"
|
||||||
MYSQL_DB = "pms"
|
MYSQL_DB = "pms"
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
import os
|
|
||||||
import pymysql
|
import pymysql
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||||
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字段
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
import os
|
|
||||||
import pymysql
|
import pymysql
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||||
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规则:
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
import os
|
|
||||||
import pymysql
|
import pymysql
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||||
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字段
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user