charging-cabinet/AGENTS.md
12451 f3e733eaec docs: 电表读数落库的文档同步(AGENTS.md/数据模型 21->22 张表;tasks/002 标记已实现)
- AGENTS.md 与 docs/tech/数据模型.md:21 -> 22 张表,补 cabinet_meter_readings 说明(抄表留痕:只在读数变化时追加,cabinets.meter_wh 冗余最新值)
- tasks/002:电表项由「待确认是否实现」改为「已实现」并列出实测证据(4 次同值只留 1 行、改值新增 1 行)
- 生产已发布并核验:22 张表、结构漂移 0、0 ERROR
2026-09-20 05:55:37 +08:00

1054 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md - 充电柜项目工作手册
## 项目简介
充电柜矩阵系统(Power Matrix System / PMS)
产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。
仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
## 设备标识
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
- **设备ID**:4G SIM卡15位IMEI(10进制)
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`)
## 技术栈
- 后端:Rust + Axum
- 前端:React + Arco Design Pro(React版)
- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
- 数据库:MySQL(阿里云RDS),开发/生产共用
- 缓存:Redis(阿里云Redis),开发/生产共用
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
- 权限:RBAC,精确到按钮级别(权限码如 `charge:open_door`、`device:view`;**权威清单见 `software/api-server/src/db/migrate.rs::seed_permissions`,共 31 个**)
## 项目结构
```
D:\charging-cabinet\
├── tasks/ # 任务文件(opencode执行)
├── docs/ # 📖 **文档地图见 docs/README.md**(按用途分类,勿再平铺)
│ ├── README.md # 文档索引:分类、权威性、旧名→现名映射、维护约定
│ ├── protocol/ # 平台设备协议 → **只有 1 份**:柜控板固件对接文档.md ⭐唯一权威 TCP 协议
│ ├── tech/ # 平台技术文档 → API.md / 数据模型.md / 部署手册.md / 小程序用户端.md
│ ├── hardware/ # 硬件侧:1TN协议标准_*(RS485 总线)+ 硬件与系统设计文档.md
│ ├── contract/ # 合同·规格·验收标准·营业执照(商务/主体依据,不描述实现)
│ └── compound-notes/ # 单点经验笔记(参考性)
├── deploy/ # 部署配置与脚本
│ ├── nginx/ # Nginx 配置镜像(**三份**:nginx.conf 主配置 + app/test 两份 conf.d,均与线上逐字节一致)
│ ├── *.service # systemd 单元(pms-api / pms-device × prod·test 共 4 个)
│ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口)
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」)
├── tools/ # 开发工具
│ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道)
│ ├── dev-tunnel.ps1 # 开发机 SSH 隧道(127.0.0.1:13306/16379 → 阿里云 RDS/Redis)
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
├── software/
│ ├── api-server/ # Rust API服务(产物 pms-api)
│ ├── device-server/ # Rust TCP设备服务(产物 pms-device-server)
│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
│ └── web/ # React前端(WEB管理端)
├── hardware/ # 硬件资料(原理图/PCB等)
└── test/ # 测试/验收脚本(TCP 端到端、结构对齐、状态位与 RSSI 两端一致性)
```
> **技能库不入库** — 仓库内不存放任何 skills(`.opencode/skills`、`software/h5-uniapp/.agents/skills`、`software/h5-uniapp/agent/skills` 已于提交 `9829e9d` 移除)。开发期技能统一从全局技能目录加载:`~/.dsh/skills`(如 wechatide-skill)、`~/.agents/skills`(如 anysearch)。请勿在仓库内重新 vendor 技能副本。
## 部署架构
### 环境规划
| 环境 | 域名 | API服务 | TCP服务 | Redis DB | 部署路径 | URL路径 |
|------|------|---------|---------|----------|----------|---------|
| 正式环境 | app.anzhizhichong.com | :9000 | :9001 | db5 | /data/pms | /pms/ |
| 测试环境 | test.anzhizhichong.com | :3000 | :3001 | db6 | /data/pms_dev | /pms/ |
**服务端口分配(规划):**
- 正式环境:API 9000,TCP 9001
- 测试环境:API 3000,TCP 3001
> ✅ **实际部署现状(2026-09 已按规划完成迁移)**:
> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx
> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env`
> (**新二进制强制要求环境变量,缺它起不来**)。
> - **测试环境按本表规划独立部署在同一台服务器上**(测试就是测试,与生产完全隔离):
> `pms-api-test :3000` + `pms-device-test :3001`,路径 `/data/pms_dev`,连 `pms_dev` 库 + Redis **db6**;
> `test.anzhizhichong.com` 的 nginx `/api/pms/` 指向 `:3000`。
> - ⚠️ **绝对不要把测试域名的 API 指到生产端口 `:9000`** —— 历史事故:测试 nginx 曾指向
> `:3000`,而当时 `:3000` 正是生产 API,等于**测试后台在操作生产数据**。
> 判断方法:`test` 域名 `/api/pms/` 返回 **401**(有测试后端)是对的;若返回 **502** 说明测试后端没起。
> - 两个环境必须同时满足:生产 `:9000/:9001`(db5/pms)、测试 `:3000/:3001`(db6/pms_dev),
> 端口或库号任一串了就是事故。
**部署路径:**
- 产品介绍页:/data/landing
- 正式环境:/data/pms(api-server、device-server、web)
- 测试环境:/data/pms_dev(api-server、device-server、web)
**Nginx配置:**
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
- 测试: /etc/nginx/conf.d/test.anzhizhichong.com.conf
### 服务器信息
- **外网IP**: 118.31.119.206
- **内网IP**: 172.25.42.239
- **系统**: CentOS Stream 9(2核/3.5G/49G)
- **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`,IP `10.8.0.252`
- **本地电脑ZeroTier IP**: `10.8.0.248`
- **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat(开机自启)
- **服务**: Gitea(3080)、acme.sh
(⛔ 曾经的 `mysql-proxy(3306→RDS)`、`redis-proxy(6379→Redis)` 一律**已移除**,见「本地开发环境」)
- **不装**: Docker/Node/Rust(本地编译传二进制)
- **SSH**: plink连接,首次需 `echo y` 接受host key
### 正式环境数据库
- MySQL: `rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com`,user: root,password: Hbhyg731024,database: `pms`
- Redis: `r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com`,password: Hbhyg731024,db: 5
### 本地开发环境
**数据库连接(通过 SSH 隧道):**
服务器 nginx 的 `stream{}` 转发(把 3306/6379 转到阿里云 RDS/Redis)**已于 2026-09-20 移除**:
- `10.8.0.252:3306` / `10.8.0.252:6379`(含 `127.0.0.1` 回环监听)**现在完全不可连**(实测 ✅);
- 因此**本地/开发侧一律改走 SSH 隧道** —— `tools/dev-tunnel.ps1`:
```bash
# ① 手工起隧道(前台常驻一个窗口;需先设 PMS_SSH_PASSWORD = 服务器 SSH 口令)
$env:PMS_SSH_PASSWORD = '<服务器 SSH 口令>'
pwsh tools/dev-tunnel.ps1
# = plink -N -L 13306:<阿里云MySQL>:3306 -L 16379:<阿里云Redis>:6379 root@118.31.119.206
# ② 常驻自愈启动器(推荐日常用):断了 5 秒后自动重连
pwsh tools/dev-tunnel-run.ps1
```
**隧道两个脚本的分工(换机/重装后照这个重建):**
| 文件 | 作用 | 是否入库 |
|------|------|----------|
| `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
password: Hbhyg731024@
database: pms_dev (测试库) / pms (正式库)
Redis: 127.0.0.1:16379 (经隧道 → r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379)
password: Hbhyg731024@
db: 6 (测试环境用db6,不动db1-5) / 5 (正式)
```
所有本地脚本(`tools/db.py`、`tools/db_schema_diff.py`、`test/*.py`)**默认就指向这两个隧道端口**,
可用环境变量覆盖(无需改代码):`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / `PMS_MYSQL_DB`、
`PMS_REDIS_HOST` / `PMS_REDIS_PORT`(`db.py` 默认库 = **测试库 `pms_dev`**)。
⚠️ 隧道没起时,脚本会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 **exit 2**
(**连不上绝不会被当成"无漂移/无问题"**)。
**为什么服务侧直连阿里云(2026-09-20 已改,勿回退):**
- 走 nginx 时 `systemctl reload nginx` 会切断所有经它转发的连接 → 四个服务同时
`RPOP 错误: broken pipe`(实测每次 reload 4 条 ERROR);
- 更要紧的是**生产的库/缓存通路会依赖一个本来只给开发用的转发层**
(`stream{}` 一旦配错 nginx 起不来 → 整平台连不上库)。
- 服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连:
`DATABASE_URL=...@rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306/...`、
`REDIS_URL=...@r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379/...`。
改造后实测:reload nginx 前后生产连接数不变(5→5)、生产与测试 ERROR 均为 0。
- ⛔ **服务器不再开放 3306/6379**(ZeroTier 上也连不到了);要给开发机开库通路就用 SSH 隧道,
**不要在服务器上重新暴露库端口**(见「部署注意事项」第 6 条)。
- 设备 TCP 业务端口(`:9001` / `:3001`)**不受影响**,仍按原样可达(设备走公网连它)。
- systemd 自启,`Restart=always` 自动恢复。
### 数据库工具
⚠️ **需先起隧道**:`pwsh tools/dev-tunnel.ps1`(默认连 `127.0.0.1:13306`,见上节)。
连接参数可用环境变量覆盖:`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / **`PMS_MYSQL_DB`**;
`db_schema_diff.py` 的 `--host/--port` **优先级更高**(命令行 > 环境变量 > 默认隧道端口)。使用 `tools/db.py`:
> **`tools/db.py` 的库口径(2026-09-20 定稿)**:默认 **`pms_dev`(测试库)**,可用 `PMS_MYSQL_DB` 覆盖;
> 每次连接前都会打印 `当前目标库:xxx(可用 PMS_MYSQL_DB 覆盖)`,
> **若取 `pms`(生产库)会额外打印 `⚠️ 你正在操作正式库 pms …`**。
> (此前代码默认写死 `pms`、文档却写 `pms_dev` —— "以为在测测试库、实际打生产"的隐患,已按默认测试库修正。)
```bash
# 列出所有表
python tools/db.py tables
# 统计各表数据量
python tools/db.py count
# 执行查询
python tools/db.py query "SELECT * FROM users"
# 执行写入
python tools/db.py exec "UPDATE users SET status=1 WHERE id=1"
# 导入SQL文件
python tools/db.py import data.sql
```
**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次;同样需先起隧道)**:
```bash
# 测试库 vs 生产库 全列比对(类型/可空性/默认值/EXTRA/注释 + TIMESTAMP 巡检)
python tools/db_schema_diff.py
# 与代码权威 DDL 比对(migrate.rs 的 LEGACY_COLUMN_TARGETS)
python tools/db_schema_diff.py --vs-ddl
python tools/db_schema_diff.py --db pms --vs-ddl
```
退出码:`0`=无漂移、`1`=发现漂移(可接进部署前置检查)、`2`=参数/连接错误。
⛔ **连不上时必须是 `2`,绝不能当成"无漂移"的 `0`**(隧道没起就跑,会明确报
「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」)。
> 为什么必须有它:"测试库与生产结构不一致"已经栽了 5 次(BRPOP 卡死、`iccid` NULL、
> `SCHEMA_SQL` 前向引用、测试环境导出 500、生产 `/api/charge-orders` 500),
> 每次都是靠人肉发现。`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**,
> 漂移会长期潜伏。**所有时间列必须是 `DATETIME`**(sqlx 的 `NaiveDateTime` 只与 `DATETIME`
> 兼容,`TIMESTAMP` 只与 `DateTime<Utc>` 兼容,用错就是接口 500)。
端到端验收(会造临时行并自动清理,需该环境的 `JWT_SECRET`):
```bash
python test/schema_alignment_e2e.py --env dev --jwt-secret <JWT_SECRET>
python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-prod
```
> ⚠️ **2026-09-20 生产结构变更(行为变化,非 bug)**:`organizations`、`projects`、`cabinets`、
> `cabin_boards`、`compartments`、`batteries`、`users`、`roles` 这 **8 张表的 `updated_at` 恢复为
> `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`**
> (实测依据:`information_schema.COLUMNS` 里这 8 张表的 `updated_at` 均为
> `EXTRA = DEFAULT_GENERATED on update CURRENT_TIMESTAMP`;代码侧对应
> `migrate.rs:844-851` 的 `LEGACY_COLUMN_TARGETS`,同样是 8 条)
> —— 此前它们是"人工从 `TIMESTAMP` 改成 `DATETIME` 时漏掉了 `ON UPDATE`"的偏离状态,
> 所以**这些表的 `updated_at` 从"不会自动刷新"变成"随 `UPDATE` 自动刷新"**,
> 看到 `updated_at` 变动属预期行为。同批变更还把两库全部时间列统一成 `DATETIME`
> (详见 `software/api-server/src/db/migrate.rs` 的 `LEGACY_COLUMN_TARGETS`)。
### 证书
- 阿里云免费SSL证书(acme.sh自动续期)
- 存放路径: `/etc/nginx/ssl/{域名}.pem` + `/etc/nginx/ssl/{域名}.key`
- 已配置: app.anzhizhichong.com、test.anzhizhichong.com
### 内部服务
- **Gitea**: `https://app.anzhizhichong.com/doc/` (端口3080)
- 组织: anzhizhichong
- 仓库: charging-cabinet
- 用户: admin / skyp76
## 开发约束
### 操作规则(用户明确要求,优先级高于便利性)
- **开发/修改程序前必须先确认需求**,不可自说自话地动手;
- **生产环境服务器上的文件与数据,必须经用户授权后才可改动**;
- 产品介绍页(`deploy/landing/`)**偏好明亮/浅色风格、单屏不滚动布局**,
⛔ 不要做成暗色科技风。
### 禁止操作
- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存;服务侧一律**直连阿里云**,
开发机要连库走 **SSH 隧道**(`tools/dev-tunnel.ps1`),⛔ 不要再在服务器上做端口转发
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
### 编码任务下发
- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md)
- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json)
- 完成后由 PM(本 agent)验收:按「验收流程」的 7 项(cargo check + test → 前端真实类型检查 → 结构漂移巡检 → 端到端)逐条跑 → 并行子代理审查代码 → 不通过则写返工任务文件
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
- 已完成的任务文件及时清理
### 质量约束模板
所有编码任务必须追加以下约束:
1. 完成代码后执行对应栈静态校验(Rust→`cargo check`;TS→**`npm run typecheck`(即 `tsc -b`)**),零报错零警告
- ⛔ **不要写 `tsc --noEmit`**:`software/web/tsconfig.json` 是 **solution 型**(`files: []` + `references`),
不带 `-b` 时 tsc **一个文件都不编译、直接 exit 0** ⇒ 假绿。详见「踩过的坑」第 2 条
2. 分层拆分,单函数≤80行,命名语义化,完整注释
3. 所有外部IO/网络请求异常捕获,禁止裸panic
4. 分支逻辑全覆盖,不遗漏兜底分支
5. 常量抽离,不使用废弃API
6. Rust:内存安全,合理管理所有权,禁用unsafe无合理理由
7. React:函数式组件+Hooks,不写Class组件
8. **时间阈值类 SQL 必须配"刚建的不命中 + 超期的命中"双向断言**(见「踩过的坑」第 1 条)
### 验收流程
**实际执行的验收项(逐条都要跑,缺一条不算通过):**
```bash
# 1) 两个 Rust 服务静态校验 + 单测(零 error;警告也不放过)
cd software/api-server && cargo check --all-targets && cargo test --bin pms-api
cd software/device-server && cargo check --all-targets && cargo test --bin pms-device-server
# 2) 前端真实类型检查(⛔ 不是 tsc --noEmit:solution 型 tsconfig 下它空跑假绿)
cd software/web && npm run typecheck # = tsc -b
# 3) 结构漂移巡检(改了表结构/迁移必跑;顺手看 TIMESTAMP 巡检)
python tools/db_schema_diff.py
python tools/db_schema_diff.py --vs-ddl
python tools/db_schema_diff.py --db pms --vs-ddl
# 4) 结构对齐端到端(造临时行并自动清理,需该环境 JWT_SECRET)
python test/schema_alignment_e2e.py --env dev --jwt-secret <JWT_SECRET>
python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-prod
# 5) TCP 协议端到端(模拟柜控板,打测试环境 device-server :3001)
# 含 ⑨ 步「平台侧积分电量」断言:读测试库核对 charge_orders.energy 是否等于
# 由负载常量推导的期望值(⇒ **需要 SSH 隧道**,连不上会 exit 2「未验证」而非通过)
# ⚠️ 前置:先 python test/reset_fixture.py;且 auth_str 有「同一 IMEI 5 次/5 分钟」限流,
# 连续跑第 6 次会撞限流 —— 脚本会明确报"被频率限制/exit 2",此时
# `systemctl restart pms-api-test` 清零进程内计数器即可(**不是功能缺陷**)
python test/tcp_e2e_smoke.py
# 6) 状态位解析两端一致性(逐位遍历,断言 PC 与小程序口径一致)
node test/device_status_parse.test.mjs
# 7) RSSI 分档两端一致性(遍历 0–99 逐值断言 level/bars/color 相同)
node test/rssi_signal.test.mjs
```
1. 上面 7 项全绿
2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
4. 审查通过 → 清理任务文件,更新文档
> ⚠️ **验收命令必须先自证能失败**:写完一条"通过"的验收命令,**故意造一个该命令应当抓到的缺陷,确认它真的报错**,
> 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(`tsc --noEmit` 就是这么假绿的,见「踩过的坑」第 2 条)。
**退出码约定(所有验收/工具脚本统一,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`。
现象阈值拿不准的(如限流第几次触发)**不要写成判据**,保持观察输出 —— **误报与假绿同样有害**。
---
## 踩过的坑(历史教训,改动前必读)
> 每条都是**真实踩过**且"当时没有任何报错"的类型。新写代码/新写验收时逐条对号入座。
### 1. `TIMESTAMPADD` / `INTERVAL` 做时间阈值,方向极易写反,写反了不报错、单测也能过
- 目标语义示例:"找出 `start_time` 早于 `NOW() - 10 小时` 的订单"。
- ❌ 错误写法:`start_time < TIMESTAMPADD(HOUR, ?, NOW())`,绑 `+10` → 得到**未来时刻**,
"`start_time` < 未来"对**几乎所有订单恒真** ⇒ 刚建单几分钟的活跃订单被当超时单提前结掉、电量提前结算。
- ✅ 正确写法:把 `start_time` **往前推**再与 `NOW()` 比 ——
`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`(`order_timeout.rs:62`),
或 `start_time < TIMESTAMPADD(HOUR, ?, NOW())` 且**绑负数**(`report_worker.rs:2445-2449` 绑 `-ORDER_TIMEOUT_HOURS`)。
- **规范(已写入「质量约束模板」第 8 条)**:这类改动必须配**双向断言**——
① **刚建的记录不命中**(防止"恒真");② **超期的记录命中**(防止"恒假")。
只断言"超期的命中"是无效测试:方向写反时它照样通过。
- ✅ **现状(2026-09 已修,勿再退回)**:`report_worker.rs` 的超时选单 SQL 已抽成常量
`FINALIZE_EXPIRED_ORDER_SQL`,并新增两个守门单测:
- `test_expired_order_condition_points_backwards` —— 断言 SQL 里是
`start_time < TIMESTAMPADD(`(阈值形态固定),且**显式排除**写反的形态;
- `test_is_expired_two_way_assertions_catch_reversed_direction` —— 用纯函数孪生体 `is_expired()`
做**双向断言**(刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中),
并**反证**"写反的方向必然命中刚建单",保证方向一反**一定红**。
已实测:把 SQL 改成 `TIMESTAMPADD(HOUR, 10, NOW())` 后该用例**立即 FAILED**。
✅ **两处现已都有守门测试(2026-09-20 补齐,勿再退回)**:
- `workers/report_worker.rs`:常量 `FINALIZE_EXPIRED_ORDER_SQL` + `test_expired_order_condition_points_backwards`
+ `test_is_expired_two_way_assertions_catch_reversed_direction`;
- `workers/order_timeout.rs`:常量 `EXPIRED_ORDER_SCAN_SQL` + 同名两个守门用例
(此前只在文档里留了「遗留」标记 —— 那边再被改反也一样零报错、单测全绿,属漏网)。
**两处是等价但写法不同的判据**(前者把阈值往回绑**负数**,后者把 `start_time` **往前推**),
**改动任一处都要同时确认另一处**。
已实测(2026-09-20):把 `order_timeout.rs` 的常量反写成
`start_time < TIMESTAMPADD(HOUR, ?, NOW())` 后 → `test_expired_order_scan_sql_points_backwards`
**立即 FAILED、cargo test exit 101**;还原后 3 passed / exit 0。
### 2. 验收命令必须先自证能失败:`tsc --noEmit` 在 solution 型 tsconfig 下是空跑假绿
- `software/web/tsconfig.json` 是 **solution 文件**(`files: []` + `references`)。不带 `-b` 时
`tsc --noEmit` **一个文件都不编译、直接 exit 0**。
- **实测(2026-09)**:故意植入 `export const probe: number = "..."` 后,
`npx tsc --noEmit` → **exit 0、零输出**;`npm run typecheck`(= `tsc -b`)→
`src/probe.ts(2,14): error TS2322: Type 'string' is not assignable to type 'number'.`、**exit 2**。
- **正确命令**:`cd software/web && npm run typecheck`(`package.json` 里就是 `tsc -b`)。
`tsconfig.app.json` 已设 `noEmit: true`,`tsc -b` 不会吐 JS 产物。
- **小程序(`software/h5-uniapp`)的 typecheck 是真门禁(2026-09-20 新增)**:
`cd software/h5-uniapp && npm run typecheck`(= **`vue-tsc --noEmit -p tsconfig.json`**)。
- ⛔ **不能用 `tsc --noEmit` 代替**:`tsc` **根本不检查 `.vue` 文件**,而本工程 9 个页面全是 `.vue`
⇒ 只跑 `tsc` 等于把页面逻辑整个漏掉(与 solution-tsconfig 空跑同类的假绿)。
- 必须用 `vue-tsc`(已作为 devDependency 装入,`package.json` 里有 `typecheck` 脚本)。
- **接入时立刻抓到 17 个真实错误**(此前完全没人发现),其中两个是**真 BUG**:
① `me/index.vue` 写了 `user.value`,而 Pinia setup store 对外已解包 ⇒ `user.value` 恒 `undefined`,
**头像上传成功后写回 store 的那行是空操作**(要等冷启动才显示新头像);同时 `const user = authStore.user`
是**快照**、丢掉响应式。已改为 `computed(() => authStore.user)`;
② `CompartmentDetail` 类型**漏了后端确实会返回的 `temp`**(`routes/h5.rs` 的
`COMPARTMENT_REALTIME_FIELDS` 第 6 项,有单测守门)⇒ 页面读 `data.temp` 报 TS2339。已补。
其余 12 处是宿主全局 `wx` 未声明(`@dcloudio/types` 只声明跨端 `uni`),已在
`src/env.d.ts` 里**只声明实际用到的两个 API**(刻意不写 `[k: string]: any`,否则等于关掉这层检查)。
- 已按仓库铁律**自证能失败**:植入 `export const probe: number = 'x'` → **exit 2**;
撤销 → **exit 0**。
### 3. MySQL 的 `TIMESTAMP` 列与 sqlx `NaiveDateTime` **不兼容**(只与 `DateTime<Utc>` 兼容)
- 本仓 Rust 侧时间列全是 `NaiveDateTime` / `Option<NaiveDateTime>`;`TIMESTAMP` 列一碰就 500:
`mismatched types; Rust type Option<NaiveDateTime> (as SQL type DATETIME) is not compatible with SQL type TIMESTAMP`。
- **已实测的两处真实后果**:测试环境充电记录导出必败(`pms_dev.charge_records.start_time` 曾是 `TIMESTAMP`)、
生产 `/api/charge-orders` 一有订单就 500(`pms.charge_orders.start_time` 曾是 `TIMESTAMP`)。
- **规范**:**全库时间列必须是 `DATETIME`**;用 `python tools/db_schema_diff.py` 巡检
(它自带 TIMESTAMP 专项检查),`--vs-ddl` 再与 `migrate.rs::LEGACY_COLUMN_TARGETS` 对齐。
### 4. `CREATE TABLE IF NOT EXISTS` **不改已存在表的列定义** ⇒ 结构漂移会长期潜伏
- 它只保证"表存在"。所以 `SCHEMA_SQL` **只对全新库生效**,老库要靠启动时
`migrate.rs::align_legacy_columns` 拉齐;两边口径不一致 = 新建库与老库再次分叉。
- **改 `SCHEMA_SQL` 的列定义时,必须同步 `LEGACY_COLUMN_TARGETS`**(有单测守门)。
- 这条已经让"测试库与生产结构不一致"栽了 **5 次**(BRPOP 卡死、`iccid` NULL、`SCHEMA_SQL` 前向引用、
测试环境导出 500、生产 `/api/charge-orders` 500),每次都是人肉发现。
### 5. **不能相信"列有 DEFAULT 就不会插 NULL"** —— `INSERT ... SELECT NULL` 是**显式提供**,不退回默认值
- `column DEFAULT x` 只在**该列未出现在 INSERT 列清单里**时生效。
写成 `INSERT INTO t (a) SELECT NULL FROM ...` 时 `a` 是**显式 NULL**,MySQL 不会用默认值兜底
(`NOT NULL` 列则直接报 1364/1048)。
- 典型踩法:从别的表 `SELECT` 搬运时把某个"本该有默认值"的列显式选中(且它可为 NULL),
于是库里出现一批 NULL,接口再解码进非 `Option` 字段就 500。
- **规范**:`INSERT ... SELECT` 里的每一列都要**显式确认取值**;要么别写该列(让 DEFAULT 生效),
要么用 `COALESCE(源列, 期望默认值)`。写入后校验一次 `IS NULL` 计数。
### 6. Redis 实时键结构变更后,**必须清理旧键**(否则旧键会被当成实时值)
- **根因**:协议重构改掉了 key 格式与字段名,但**旧版本的键是"无 TTL 的永久键",没人清理也没人覆盖**,
于是它们与新版键**长期共存**,读侧只认 key 前缀就必然把旧值当实时值。
- **实测(2026-09,生产 db5)**:465 个 key 里 **462 个是旧格式残留**——
6 个 `device:{imei}`(`online=1` 且 **TTL=-1**)+ 60 个板级 `device:*:board:N`(只有 `status_hex`)
+ 360 个 0 基通道 `...:ch:M`(字段是旧版 `hex/on/full/fault`)+ 36 个死键 `compartment:status:*`。
- **后果**:管理端把 **6 台其实早已离线的柜子显示成"在线"并带 RSSI**
(`GET /api/cabinets/filter` 的 `online=true`/`rssi=85`),运维据此误判。
- **规范**:
1. **改 Redis key 结构或字段名时,同一次变更里必须清理/迁移旧键**(并在报告里给出清理前后 keyspace 统计);
2. **实时状态键一律带 TTL**,"无 TTL 的 `device:{imei}`"可直接判定为异常/历史残留;
3. 排查手法:用 `SCAN`(**不要用 `KEYS`**)按前缀分类统计 + 抽查 `TTL` 与字段名,
判据是"**字段名属于哪一版**"而不是"key 存不存在"。
- 记录:本次已清理 db5 的 462 个旧键,保留 `auth_str*` / `pending:*` / `device:reports` /
`device:commands*` / `node:*`(db6 无残留)。清理后 `/api/cabinets/filter` 的在线台数 **6 → 0**。
### 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 编码规范
### 错误处理
- 使用 `thiserror` 定义业务错误枚举,`AppError` 统一包装
- 所有 `sqlx` / `redis` 调用必须 `.map_err(AppError::Database)?`
- 网络请求/外部IO 必须 `match` 或 `if let` 处理,禁止裸 `.unwrap()`
- 关键业务路径用 `tracing::error!` 记录错误上下文
### Async 模式
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
- Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** ——
本项目 Redis 此前经 nginx stream 代理访问阿里云(**该转发已于 2026-09-20 移除,现为直连**),
客户端又是多路复用的 `ConnectionManager`,那种组合下 `BRPOP` 语义不可靠(实测 2026-09):
- `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**;
- `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。
**规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致),
延迟稳定在 200ms 内;取到消息后 `tokio::spawn` 并发处理。
### 模块划分
```
src/
├── routes/ # HTTP 路由处理器(按业务拆分)
├── middleware/ # 中间件(认证、日志、CORS、scope_guard 组织归属校验)
├── db/ # 数据库连接池、迁移
├── commands/ # 设备指令下发逻辑(含 AppState 定义)
├── nodes/ # 设备服务节点管理
├── workers/ # 后台 Worker(report/reply)
└── error.rs # 统一错误定义
```
### 命名约定
- 路由处理器:`动词 + 名词`(如 `get_cabinets`、`create_organization`)
- 数据库查询结构体:`XxxRow`(如 `CabinetBasicRow`)
- 请求体:`XxxBody` 或 `XxxQuery`(如 `CreateCabinetBody`)
- 常量:`UPPER_SNAKE_CASE`(如 `JWT_SECRET_ENV`)
---
## React 编码规范
### 组件规范
- 纯函数组件 + Hooks,禁止 Class 组件
- 组件文件与路由页面一一对应,放 `src/pages/` 下
- 复用组件放 `src/components/`,按功能命名
- Props 接口定义在组件文件顶部,用 `interface` 声明
### 状态管理
- 页面级状态用 `useState` / `useEffect`
- 全局状态用 `zustand`(如用户信息、权限)
### 样式规范
- 使用 Arco Design 组件库,优先使用组件自带的样式
- 内联 style 仅用于动态计算值,静态样式用 CSS class
- 表格列统一 `min-width` + `align="center"`,无 `fixed="right"`
- 分页:上下双分页,`page-sizes="[10,20,30,50]"`
### 图标映射
| 操作 | 图标 |
|------|------|
| 编辑 | Edit |
| 删除 | Delete |
| 查看 | ViewIcon |
| 搜索 | Search |
| 重置 | Refresh |
| 新增 | Plus(保留文字) |
---
## API 设计规范
### 响应格式
```json
{
"code": 0, // 0=成功,非0=失败
"data": { ... }, // 业务数据
"message": "ok" // 提示信息
}
```
### 错误码
| code | 含义 |
|------|------|
| 0 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未认证(token缺失/无效) |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
### 分页约定
```json
{
"code": 0,
"data": {
"list": [...],
"total": 100,
"page": 1,
"page_size": 20
}
}
```
### RESTful 约定
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/xxx | 列表查询 |
| GET | /api/xxx/:id | 详情 |
| POST | /api/xxx | 创建 |
| PUT | /api/xxx/:id | 更新 |
| DELETE | /api/xxx/:id | 删除 |
---
## Git 工作流
### 分支策略
- `main` — 正式环境,只接受 PR 合并
- `develop` — 开发分支,日常开发在此分支
- `feature/*` — 功能分支,从 develop 拉出,完成后合并回 develop
- `fix/*` — 修复分支,从 main 或 develop 拉出
### Commit 规范
格式:`<type>(<scope>): <subject>`
| type | 说明 |
|------|------|
| feat | 新功能 |
| fix | 修复 bug |
| docs | 文档变更 |
| style | 代码格式(不影响逻辑) |
| refactor | 重构(不新增功能/修复bug) |
| test | 测试相关 |
| chore | 构建/工具变更 |
示例:
- `feat(cabinets): 添加设备RSSI信号显示`
- `fix(mp): 修复登录后token未持久化问题`
- `docs(api): 更新充电记录接口文档`
---
## 测试标准
### 测试类型
| 类型 | 工具 | 要求 |
|------|------|------|
| 单元测试 | Rust `#[test]` / Vitest | 核心业务逻辑必须覆盖 |
| 集成测试 | API 请求验证 | 关键接口(登录/充电/开门)必须验证 |
| 静态校验 | `cargo check` / **`npm run typecheck`(`tsc -b`)** | 零报错零警告;⛔ TS 侧不得用 `tsc --noEmit`(solution 型 tsconfig 下空跑假绿) |
### 测试覆盖率
- 核心业务逻辑(充电控制、指令下发、权限校验):≥80%
- 数据库查询函数:≥60%
- 工具函数:≥90%
### 测试命名
```
test_<被测函数>_<场景>_<期望结果>
test_verify_password_correct_returns_true
test_verify_password_wrong_returns_false
test_check_permission_admin_returns_ok
test_check_permission_user_returns_forbidden
```
---
## 安全规范
### 认证授权
- JWT Token 有效期:24小时,过期需重新登录
- JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret
- 密码存储:argon2 哈希,禁止明文或 MD5/SHA1
- RBAC 权限码:精确到按钮级别(如 `charge:open_door`、`device:operate`;权威清单=`db/migrate.rs::seed_permissions` 的 31 条)
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
- 敏感操作(删除/禁用/开门)需二次确认
- **TCP 连接登录门控(上行 + 下行,同一规则)**:同一条 TCP 连接在 `login` 成功前**只允许 `auth_str` / `login`**;
其余任何 `act`(含 `status_post`、`bat_in`、`on`/`off`、`open`/`pow_on`/`pow_off`/`reset`、以及各类应答)
**上行一律丢弃、下行一律不投递**(设备侧收不到任何字节)。
依据:`docs/protocol/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。
**判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`,
在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位),
**不是** Redis 在线键 `device:{imei}` —— 两者回答的是**不同问题**(2026-09-20 逐行核对订正):
- `device:{imei}.online` 的写入**都是在验签之后**:`online=1` 只由 api-server 的
`report_worker.rs::handle_login` 在 `sign == expected_sign` 分支内(`mark_device_online`)
以及 `handle_status_post`(`write_device_state`,该 act 已被登录门控挡住)写入;
`handle_auth_str` **不写** `online`。(此前文档写"验签前就会被写"是**错的**。)
- 真正不能拿它判下发的原因是**另一条**:它的 TTL 语义是"最近 300 秒收到过该设备任意报文"
(协议 §2.3),**断连后仍存活最多 5 分钟** ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
- 另注:`device-server` 的 `server.rs::update_device_route` 会在 `auth_str`/`login` **到达时**
写 `device:{imei}` 的 `node_id` 并 `EXPIRE 300`——**只刷 TTL、不写 `online`**,
效果是未认证报文也可能把 `online` 的存活期往后顺延一次;这与 §2.3「收到过任意报文即算活跃」
一致,故保留,但它进一步说明该键不能当"可下发"判据。
⛔ 不要再按"某个 `act` 算不算敏感指令"来增删名单:规则是"登录前只有 `auth_str`/`login`"(常量 `PRE_LOGIN_ALLOWED_ACTS`)。
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定
- **未登录连接不予保持**:建连后 30 秒内未 `login` 成功即主动断开
### 输入校验
- 所有 API 入参必须校验(长度、格式、范围)
- SQL 查询使用参数化查询,禁止字符串拼接
- IMEI 校验:必须15位数字
- 手机号校验:11位数字,正则 `^1[3-9]\d{9}$`
### 数据安全
- 生产数据库密码不入代码,使用环境变量
- Redis 密码同上
- 日志禁止输出密码/Token/密钥
- 敏感字段(密码、auth_str)在序列化时跳过
---
## 日志规范
### 日志级别
| 级别 | 用途 | 示例 |
|------|------|------|
| `ERROR` | 系统异常、业务错误 | 数据库连接失败、指令下发超时 |
| `WARN` | 潜在问题、可恢复异常 | 设备离线、重试成功 |
| `INFO` | 关键业务事件 | 设备登录、充电开始/结束、用户操作 |
| `DEBUG` | 调试信息 | API 请求参数、SQL 执行时间 |
### 日志格式
```rust
// 使用 tracing crate
tracing::info!(imei = %imei, board_id = board_id, "设备登录成功");
tracing::error!(err = %e, "数据库查询失败");
tracing::warn!(compartment_id = id, "仓体状态异常");
```
### 关键操作日志
以下操作必须记录:
| 操作 | 日志内容 |
|------|----------|
| 用户登录/登出 | 用户ID、手机号、IP |
| 设备指令下发 | IMEI、指令类型、仓体ID |
| 充电开始/结束 | 仓体ID、时长、电量 |
| 组织/项目变更 | 操作类型、ID、名称 |
| 权限变更 | 用户ID、权限码、操作人 |
| 数据删除 | 表名、ID、操作人 |
---
## 性能规范
### 数据库
- 查询必须走索引,禁止全表扫描(`EXPLAIN` 验证)
- 分页查询:避免深分页,用游标分页(`WHERE id > ?`)
- 批量操作:用 `INSERT ... VALUES (...), (...)` 而非逐条插入
- 连接池:MySQL 最大连接数 ≤ 50,Redis 连接数 ≤ 20
### Redis
- 缓存 key 规范:`{业务}:{标识}`(如 `device:{imei}`)
- TTL 设置:在线状态 300秒,缓存数据按业务设定
- 避免大 Key:单个 Hash ≤ 500 字段,List ≤ 1000 元素
- 批量操作:用 Pipeline 减少往返次数
### API 响应
- 列表接口响应时间 ≤ 500ms
- 详情接口响应时间 ≤ 200ms
- 批量操作接口响应时间 ≤ 1s
- 响应体大小:列表 ≤ 1MB,详情 ≤ 100KB
### 前端
- 首屏加载 ≤ 3s(3G网络)
- 交互响应 ≤ 100ms
- 列表滚动:虚拟滚动(≥100条数据)
- 图片:WebP 格式,懒加载
## 部署与维护
> 📖 **完整篇见 [docs/tech/部署手册.md](docs/tech/部署手册.md)** —— 含 nginx 两份 conf.d 全文、
> 逐步部署命令、**全部 11 条部署注意事项**。本节只留"每次部署都必须先过一遍"的判据;
> 搬迁原因:本文件超工作区指令预算(65,536 B)会被按头部截断,尾部不加载。
### SSH连接
```bash
# 外网IP(推荐)
echo y | plink -ssh root@118.31.119.206 -pw "<口令>"
# ZeroTier内网IP
echo y | plink -ssh root@10.8.0.252 -pw "<口令>"
```
### 部署前必看(致命项,逐条都栽过)
1. ⛔ **服务器上禁止编译 Rust**(2核/3.5G 会 OOM)—— 本地 `cargo zigbuild --release
--target x86_64-unknown-linux-gnu` 交叉编译后传二进制。
2. **产物名以 `Cargo.toml` 的 `[package] name` 为准**:api-server → **`pms-api`**、
device-server → **`pms-device-server`**。
⚠️ 但**测试环境的 device 落地文件名是 `device-server`**(`deploy/pms-device-test.service`
的 `ExecStart=/data/pms_dev/device-server/device-server`)—— 既存事实,照它传,别改名。
3. **上传后必须 `chmod +x`**(否则 systemd `203/EXEC`);**运行中的二进制不可直接覆盖**
(Linux `ETXTBSY`)⇒ 传 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f` → `systemctl start`。
(`deploy/deploy-prod.ps1` 已内置。)
4. **`.env` 不支持行内注释** —— `PORT=9000 # 注释` 会让服务起不来,注释必须独占一行。
5. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来。
6. **`exports` / `uploads` 目录必须预置且属主为 `pms`** —— api-server 以 `User=pms`
**降权运行**,无法在 `/data/pms`(root 属主)下自建目录。
7. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。
部署后必须核对 `ss -lntp` 与域名返回码(`test` 域名 `/api/pms/` 应 **401**,502 = 测试后端没起)。
8. **`stream{}` 已移除,不要再加回来** —— 服务器不再监听 3306/6379(实测确认);
开发机要连库一律走 **SSH 隧道**(`tools/dev-tunnel.ps1`)。
历史事故:`stream{}` 全配置**只能有一个**且只能在主配置,`conf.d` 里再写一个 ⇒
`"stream" directive is duplicate` ⇒ **整站(含生产域)起不来**。
9. **改 nginx 前先 `nginx -t`,通过后才 `systemctl reload nginx`**(不要直接 `restart`);
主配置 `worker_shutdown_timeout` 调短会中断正在传输的响应。
10. **nginx 有仓库镜像,改线上前先比 md5** —— `deploy/nginx/` 三份(`nginx.conf` 主配置 +
`app`/`test` 两份 conf.d)**与线上逐字节一致**,当前 `nginx.conf` md5 =
`82f660d8ebd3299c89edf55483277266`。改完线上**必须把仓库镜像同步更新**(双向一致),
否则下次比对全是噪声。
> 为什么必须有主配置镜像:`limit_req_zone` 定义在主配置 `http{}` 里,而 `conf.d` 的
> `location` 引用这些 zone —— 只照仓库的 conf.d 重建 nginx,zone 就没了 ⇒
> `nginx -t` 失败、整站起不来。
11. **新增路径要限流就三步走**:① 主配置 `http{}` 加 `limit_req_zone`(名字要唯一);
② `conf.d` 的对应 `location` 挂 `limit_req zone=...`(**引用的 zone 必须已在主配置存在**);
③ `nginx -t` 通过后才 reload;④ 把线上改动同步回 `deploy/nginx/`。
现有 4 个 zone:`auth_login_zone` 10r/m、`auth_challenge_zone` 20r/m、`uploads_zone` 120r/m、
`scan_abstract_zone` 30r/m(**只挂 `/api/pms/cabinets/abstract/`**)。
> 💡 写"某 zone 覆盖某某路径"时**必须按 `location` 前缀语义核对并附实测 429 路径**,
> 不要按 zone 名字推断它管谁(`scan_abstract_zone` 这名字极易让人以为它管 `scan/`,
> 而它只挂 `abstract/`;`scan/` 需登录、无 token 恒 401,不构成枚举信号故无限流)。
12. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条。
### 服务管理
```bash
systemctl status pms-api pms-device pms-api-test pms-device-test nginx
journalctl -u pms-api -f # 生产 API
journalctl -u pms-api-test -f # 测试 API
tail -f /var/log/nginx/error.log
```
> ⚠️ **发布顺序硬约束**:`device-server` 与 `api-server` **必须同批发布**(`device:authed:{imei}`
> 是跨服务契约)。只发 api-server 会让**所有设备看起来离线**。顺序:device 先、api 后。
## 硬件架构
- 柜控板(ID128)×1 + 仓控板(ID1-N)×N,每仓控板管6个仓体
- 仓控板与电源:1TN RS485协议(翌工)
- 仓控板与BMS:星恒Modbus / 天能485 / 无协议(平台可配置)
- 仓控板与柜控板:自定义485协议(14+功能码)
- 柜控板与后台:4G TCP长连接(Air780E模块)
## 开发范围(当前阶段)
### 做
- 后台管理系统(Web端)
- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传)
- 设备TCP通讯服务(协议文档已就绪)
### 不做
- 支付/计费(本系统不涉及)
## 组织架构
```
组织(企业) → 项目(放置地点) → 设备(充电柜)
```
设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。
### 层级与可见性(**全系统统一口径,所有组织隔离改动一律以此为准**)
```
柜子工厂(我们,总管理员 role_level=2)
└── 客户组织(每个客户一个 organization)
└── 客户在自己组织下新建的操作员 / 组织管理员
```
| 角色 | `users.role`(role_level) | 可见组织范围 |
|------|---------------------------|-------------|
| 总管理员(工厂) | `2` | **全部组织**(跨组织视角,小程序「选择组织」弹层可切换,默认「全部」) |
| 组织管理员(客户) | `1` | **仅自己所属组织**(按 `users.organization_id`) |
| 操作员(客户) | `0` | **仅自己所属组织**(按 `users.organization_id`) |
- **归属链**:客户侧用户的组织归属只认 `users.organization_id`;`organization_id` 为 `NULL` 的客户侧账号**看不到任何组织/设备**(空集,不是报错,也**绝不退化成"全部"**)。
- **两条组织接口的分工(不可混用)**:
- `GET /api/organizations` —— **管理端**(PC)组织管理页,硬校验 `org:view`,返回**全量**组织(操作员无该权限码 → 403)。语义冻结,不得改动。
- `GET /api/my-organizations` —— **小程序**「选择组织」专用,**仅需登录**(不校验 `org:view`),服务端按上表做隔离:工厂全量、客户仅本组织。
- 设备/项目/能耗/日志等所有业务数据的组织隔离,一律按「`role_level >= 2` 放行全量,否则按 `organization_id` 过滤,无组织则空集」实现(例:`routes/h5.rs::visible_org_scope`、`middleware/auth.rs::org_condition`)。
### 数据范围(user_scopes)—— **组织隔离之上的第二道收窄**
> 权威实现:`software/api-server/src/middleware/auth.rs`(`UserScope` / `load_user_scopes` /
> `CurrentUser::effective_scope`)、`routes/scope_guard.rs`(详情/写操作)、`routes/users_perm.rs`(配置校验)。
> 前端入口:**PC 用户管理页 → 操作列「数据范围」**(`web/src/components/UserDataScopeModal.tsx`)。
**范围粒度到「项目级」**(`user_scopes.scope_type`):
| `scope_type` | 含义 | 是否参与鉴权 |
|---|---|---|
| `1` | 组织级 | ✅ 参与。**只允许配成该用户自己所属的组织**,展开为该组织下的全部项目 |
| `2` | 项目级 | ✅ 参与。**实际生效的粒度** |
| `3` | 设备/柜子级 | ❌ **不参与鉴权**(当不存在,读取时直接忽略;写入仍做存在性+归属校验后存档) |
**四种情形的行为(必须严格遵守,改动前先读这一节):**
| 情形 | 行为 |
|---|---|
| **未配置任何 scope 记录** | **不限制**:沿用组织隔离,可见范围与"没做这个功能"时**逐字一致**。⛔ 绝不能解释成"无权限" —— 现存所有客户账号都没配过,那样会一次锁死全部账号 |
| **已配置(≥1 条有效)** | 在组织隔离基础上**再收窄**:`组织隔离 ∧ 项目∈范围`(**取交集,绝不取并集、绝不放大**) |
| **总管理员(`role_level >= 2`)** | **豁免**:始终可见全量,不受 scope 限制(`CurrentUser::effective_scope` 恒返回"不限制",中间件连库都不查) |
| **有记录但一条有效范围都没有**(脏数据 / 只有设备级 / 跨组织 / 项目已删除) | 保守忽略无效行;若因此一条有效范围都不剩 ⇒ 按**未配置 = 不限制**处理,并记 `WARN`(**绝不把用户锁死**) |
**`scope_id` 校验(配置期,`PUT /api/users/{id}/scopes`)**:
- 资源必须**存在**,且**属于该用户所在组织**,否则 `BadRequest`(400);
- 组织级:`scope_id` 必须等于目标用户的 `users.organization_id`(**不允许跨组织配置**);
- 项目级:`projects.organization_id` 必须等于目标用户的 `users.organization_id`;
- 设备级:柜子所属项目的组织必须等于目标用户的 `users.organization_id`(存档但不消费)。
- 全量替换在**事务**里执行:校验全部通过前不删旧数据,避免中途失败留下"范围被清空"的中间态。
- 目标用户 `organization_id` 为 `NULL`(客户侧无组织账号)时,任何带归属的范围都不合法 ⇒ 一律 400。
**鉴权侧如何消费(改动任何列表/详情接口前必须核对):**
- 过滤统一落在**项目**这一层(`cabinets.project_id` / `projects.id`),只有一种收窄形态:
`project_id IN (...)`(柜子维度用 `cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (...))`);
- 列表接口:在**已有的组织条件之后**用 `&&` 追加范围条件(`auth::append_condition`);
- 详情/写操作:走 `routes/scope_guard.rs`(或 h5 的 `verify_cabinet_access`),组织校验通过后再校验资源是否落在范围内,不在则 `Forbidden`;
- 后台任务(Excel 导出)必须把 `scope_project_ids` 一并落库到任务参数里,否则后台任务会绕过范围;
- ⚠️ **配了 scope 的用户被漏叠加的接口 = 数据泄露**。新增任何业务列表/详情接口时,同时叠加组织条件与范围条件。
**操作日志不叠加范围**(经确认,非遗漏):`operation_logs` 按 `user_id → users.organization_id` 归属,与项目/设备没有关联列,项目级范围无法映射。
## 数据模型
> 📖 **逐列说明见 [docs/tech/数据模型.md](docs/tech/数据模型.md)**(由本节整段搬迁,内容逐字保留)。
> **改任何表结构前必须读它**;本节只留改动前必须**先知道**的硬约束。
### 核心关系
```
organization(组织/企业)
├── project(项目/放置地点)
│ └── cabinet(充电柜)
│ ├── cabin_board(仓控板×N) board_id 1–15
│ │ └── compartment(仓体×6) channel_id 1–8
│ ├── charge_order(充电订单,进行中/已结算)
│ ├── charge_record(充电记录,结算后留痕)
│ ├── energy_stat(能耗统计,按 柜×日 唯一)
│ └── device_log(设备日志)
├── user(用户) ──┬── user_roles ── role ── role_permissions ── permission
│ ├── user_scopes(数据范围,项目级生效)
│ └── operation_log / user_feedback / download_task
└── abstract_binding(平台抽象ID 10-xxxxxxxx ↔ cabinet 一对一)
battery(电池台账,按 battery_id 唯一) ── sim_change_log(换 SIM 记录,按 imei)
```
### 22 张表(实测两库表名完全一致,`information_schema.TABLES` 计数 = 22)
`organizations` `projects` `cabinets` `cabin_boards` `compartments` `batteries`
`charge_orders` `charge_records` `energy_stats` `device_logs` `abstract_bindings`
`users` `roles` `permissions` `user_roles` `role_permissions` `user_scopes`
`operation_logs` `user_feedback` `download_tasks` `sim_change_logs` `cabinet_meter_readings`
> `cabinet_meter_readings` = **柜级电表读数变化留痕**(抄表记录):`status_post.cabinet.meter`(Wh)**只在读数变化时**追加一行,`cabinets.meter_wh`/`meter_at` 冗余最新值。见 `workers/meter.rs`。
### 改动前必读的硬约束
1. ⛔ **建表语句的顺序即依赖顺序**(`migrate.rs::SCHEMA_SQL`)——
`compartments` 的父是 `cabin_boards`、`cabin_boards` 的父是 `cabinets`、`cabinets` 的父是 `projects`。
**被引用表必须先建**,否则新库初始化报 MySQL **1824**(外键找不到被引用表),
叠加 `Restart=always` = **无限重启**。
2. ⛔ **改 `SCHEMA_SQL` 的列定义时必须同步 `migrate.rs::LEGACY_COLUMN_TARGETS`**(有单测守门)——
`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**,只改一处 = 新库与老库再次分叉。
3. ⛔ **所有时间列必须是 `DATETIME`** —— 本仓 Rust 侧全是 `NaiveDateTime`,与 `TIMESTAMP` 不兼容,
一碰就 500(详见「踩过的坑」第 3 条)。
4. **`cabinets.status` 是启用/禁用配置态**(`2` = 禁用 ⇒ 拦截指令链路;其余 = 启用),
**不表示在线**;在线判据来自 Redis(见「安全规范」的登录门控一节)。
5. **`compartments.status` 只表示占用**(`0` 空闲 / `1` 充电中);"充满 / 故障"**不落库**,
由实时数据派生。**`charge_orders` 只有 `0`/`1`**;**`charge_records.status` 分 3 档**。
6. **唯一键**:`energy_stats` 有 `UNIQUE(cabinet_id, stat_date)`;
`abstract_bindings.cabinet_id` / `batteries.battery_id` / `cabinets.imei` 唯一;
`charge_orders` 用生成列 `active_flag` + 唯一索引防**同仓体并发建单**。
## TCP通讯协议
详见 **[docs/protocol/柜控板固件对接文档.md](docs/protocol/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
> 📌 **文档口径优先级(避免"两份文档谁对"的扯皮)**:
> 1. **`docs/protocol/柜控板固件对接文档.md`** —— 设备协议(帧格式/`act`/字段/签名)的**唯一权威**;
> 2. **本文件(AGENTS.md)** —— **平台侧实现口径**的权威:Redis 键/字段、接口行为、库结构、
> 验收流程、部署注意事项。凡与 `docs/tech/API.md` 的**平台存储/行为描述**不一致时,**以本文件为准**
> (本文件的每一条都按代码契约与库实测核对过);
> 3. **`docs/tech/API.md`** —— **HTTP 接口清单与响应结构**的权威(含数据单位规范);
> 4. 代码与文档冲突时:**先确认是不是代码错**;确认代码对就改文档并在报告里点出。
## 数据单位规范
详见 [docs/tech/API.md](docs/tech/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
> ✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 `write_number` API 参数为 f64(`i64 as f64` 转换,非业务浮点)。
## 微信小程序用户端
> 📖 **完整篇见 [docs/tech/小程序用户端.md](docs/tech/小程序用户端.md)**(由本节整段搬迁,内容逐字保留)——
> 页面清单、逐页职责、状态位分层展示口径、RSSI 分档都在那里。本节只留在改动前必须知道的硬约束。
- **技术栈**:uni-app + Vue 3 + Pinia,构建目标 `mp-weixin`(`software/h5-uniapp`)。
- **产物不上传服务器**:`npm run build:mp-weixin` 产出 `dist/build/mp-weixin/`,
用**微信开发者工具**导入后上传体验版/正式版(服务器上没有小程序产物)。
- **两道门禁(缺一不可)**:
1. `npm run typecheck`(= `vue-tsc --noEmit`,**不是 `tsc`** —— tsc 不检查 `.vue`;
详见「踩过的坑」第 2 条);
2. `npm run build:mp-weixin`(只验语法,不验类型)。
- ⛔ **跨端口径必须三处一致**(PC `web/.../cabinet.tsx`、小程序 `device-detail/index.vue`、
`compartment-detail/index.vue`):**状态位解析**与 **RSSI 分档**已有逐值断言守门,改一处必须跑:
```bash
node test/device_status_parse.test.mjs # 逐位遍历,断言 PC 与小程序口径一致
node test/rssi_signal.test.mjs # 遍历 0–99,断言 level/bars/color 相同
```
- **状态徽标的两个维度严格分离**:`cabinets.status` = 启用/禁用**配置态**(`2` = 禁用);
**在线/离线只看接口返回的 `is_online`**(Redis 实时值)。
⛔ **不得用 `status` 推断在线**(历史 BUG:`status` 曾被当成在线依据,在一台设备都没连时
仍显示"在线")。
- ⛔ **不显示推不出来的状态**:`cabin_boards.status` **全后端没有任何写入方**,
板级"在线/离线"在协议层面也不存在(TCP 长连接的是柜控板)—— 该徽标已删除,
**不要再加回来**(见 `src/api/index.ts` 的 `CabinBoard` 注释)。
- **温度口径**:K×100 转摄氏 `(temp / 100) - 273.15`,与 Web 管理端一致(`utils/deviceStatus.ts`)。
## 代码审查豁免
以下模式已确认无需修改:
| 审查项 | 原因 |
|--------|------|
| TCP keepalive 配置 | 我们是 TCP server,设备才是 client。设备每 60s 发 status_post 就是应用层保活,不需要在 server 侧设 keepalive。 |
| SHA256 签名只取 8 位 | MCU 硬件协议固定,不可改 |
| AGENTS.md 明文密码 | 内网 Git 仓库,agent 需要直连 |
| 小程序 token 存 Storage | 小程序无 HttpOnly cookie,纯前端场景 |
| 8-char sign 长度 | 硬件约束,设备一次只能获取一次 auth_str |
| ServerResponse / DeviceCommand 含额外内部字段(`_dev_id`、`_close`) | `_dev_id` = 目标设备 IMEI,**供 device-server 路由指令**(协议 `id` 已不含 IMEI,只表示动作目标);`_close` 指示发送后断开连接(`login` 失败时)。两者**下发前均由 device-server 摘除**(`split_internal_flags`),设备侧不可见 |