## 为什么要做
`AGENTS.md` 曾达 **90,370 B**,而工作区指令预算是 **65,536 B** —— 系统按**保留头部**截断,
于是**尾部约 25 KB 永远不会被加载**:组织架构、数据模型、TCP 协议、数据单位规范、
微信小程序用户端**全部处于"写了但读不到"的状态**。手册实际上部分失效。
## 一、AGENTS.md 瘦身:90,370 → 60,227 B(余量 5,309 B,尾部不再被截断)
三个**纯参考型大段**整段搬迁到 `docs/tech/`(**内容逐字保留,未删改**),
原位置换成「判据 + 链接」:
| 原 AGENTS.md 章节 | 大小 | 现位置 |
|---|---|---|
| `## 部署与维护` | 17,611 B | `docs/tech/部署手册.md` |
| `## 数据模型` | 12,100 B | `docs/tech/数据模型.md` |
| `## 微信小程序用户端` | 10,262 B | `docs/tech/小程序用户端.md` |
替换后的章节**只留改动前必须先知道的硬约束**(例如:产物名与测试环境落地名不一致、
ETXTBSY 的 `.new`→`stop`→`mv`→`start` 流程、`stream{}` 不可重加、发布顺序 device 先 api 后、
建表顺序即依赖顺序且写错报 MySQL 1824、时间列必须 DATETIME、`cabinets.status` 是配置态不表示在线……),
其余指向详细篇。**判据留在常驻加载的 AGENTS.md,逐项参考移出**。
## 二、docs/ 分类(原 21 个文件平铺 → 五个用途目录)
```
docs/
├── README.md 新增:文档地图(分类/权威性/旧名→现名映射/维护约定)
├── protocol/ 设备协议(冻结,对硬件交付)★唯一权威
│ ├── 柜控板固件对接文档.md
│ └── 1TN协议标准_*.md / *.pdf
├── tech/ 平台技术文档(开发/运维必读)
│ ├── API.md · 数据模型.md · 部署手册.md · 小程序用户端.md
├── contract/ 合同·规格·验收标准(商务依据,不描述实现)
├── compound-notes/ 单点经验笔记(原有)
└── archive/ ⛔ 历史留痕(只读,不代表现状,不可作依据)
├── 开发文档.md(早于现行实现)
└── 审查报告/深度分析报告-2026-07-06
```
- 19 个文件用 `git mv` 迁移(保留历史),3 个未跟踪文件用普通移动;
- **内容零改动**(唯一例外是引用替换),已核验 `docs/tech/API.md` 的 `git diff` 为空。
## 三、全仓引用同步:40 处 / 10 个文件
路径不只出现在文档里,还散落在**源码注释**(`db/migrate.rs`、`routes/cabinets.rs`、
`workers/report_worker.rs`、`tcp/connection.rs`、`h5-uniapp/utils/format.ts`)、
`test/tcp_e2e_smoke.py`、`tasks/001-*.md`、`README.md`、`AGENTS.md` 中,全部同步。
**并已实测校验命令能失败**:往 `docs/tech/API.md` 植入 `<!-- probe: docs/API.md -->`
→ 命中;删除 → 无输出。
> ⚠️ 过程中踩了一个坑并已修:这些 `.md` 多为 **CRLF**,我第一次用 `\n` 正则删除时
> **没匹配上 `\r\n`**,以为删干净了其实残留了一行 —— 靠 `git diff` 非空才发现。
> 已把「CRLF 必须按 `\r?\n` 处理」写进 `docs/README.md` 的维护约定。
## 四、顺带查实的失效引用(归档保留原文,不改写历史)
- `docs/tcp-protocol.md`:仅出现在 2026-07-06 的两份历史报告里(当时的事实);
- `docs/柜控板-平台TCP接口文档.md`:仅出现在协议文档 **v1.1 变更记录**里
(记录"已删除该内部重复文档"这件事),**不是活链接**,无需修复。
两者已在 `docs/README.md` 的「旧名 → 现名映射」表中给出对应关系。
1040 lines
60 KiB
Markdown
1040 lines
60 KiB
Markdown
# 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/ # 设备协议(冻结,对硬件交付)→ 柜控板固件对接文档.md ⭐唯一权威
|
||
│ ├── tech/ # 平台技术文档 → API.md / 数据模型.md / 部署手册.md / 小程序用户端.md
|
||
│ ├── contract/ # 合同·规格·验收标准(商务依据,不描述实现)
|
||
│ ├── compound-notes/ # 单点经验笔记(参考性)
|
||
│ └── archive/ # ⛔ 历史留痕(只读,不代表现状,不可作依据)
|
||
├── 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
|
||
|
||
## 开发约束
|
||
|
||
### 禁止操作
|
||
|
||
- **禁止修改设备与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)
|
||
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)
|
||
```
|
||
|
||
### 21 张表(实测两库表名完全一致,`information_schema.TABLES` 计数 = 21)
|
||
|
||
`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`
|
||
|
||
### 改动前必读的硬约束
|
||
|
||
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`),设备侧不可见 |
|