## 起因
用户问"历史留痕?看看有没有过时的和无用的东西可以删除" ——
**不该把"归档"当免检区**。逐项查证后:确认了 3 类该删的、1 类归错类的、1 个不能删的。
## 一、删除:被"顺手 git add"误带进无关提交的产物
用 `git log --diff-filter=A` 追来历,发现它们**根本不是有意提交的**:
| 文件 | 来历 | 判据 |
|---|---|---|
| `换电柜用锂电池充电器1TN标准通信协议 - RS485 V1.0(1).pdf` | `652bde4`(nginx 路由修复) | 与 `docs/hardware/1TN协议标准_苏州翌工电源_V1.0.pdf` **MD5 完全相同**(`B723A1FC…`)—— 纯重复 |
| `package.json` + `package-lock.json` | `652bde4`(同上) | 唯一依赖 `html-docx-js`,**全仓无任何脚本引用**(当次把合同 md 转 docx 的一次性工具残留) |
| `9b0d3b8a…jpg` / `d6e6add1…png` / `ea4410ba…jpg` | `8bc7812`("23 个函数拆分")/ `999885c`(协议对齐) | **零引用**,且混在与图片毫无关系的重构提交里 —— 误提交的截图 |
## 二、归错类(**不是过时,所以不删**):`开发文档.md` → `hardware/硬件与系统设计文档.md`
原本被塞进 `archive/`,但通读目录后确认它含**硬件设计内容**,这些在 `AGENTS.md`/`docs/tech/` 里**没有**:
硬件架构、仓控/柜控主板设计、指示灯状态机、充电控制逻辑(握手/双模式/动态调整/无协议模式)、
BMS 协议适配、安全防护联动、性能指标、第三方服务配置。
⇒ 它是**归错类而非过时**:**硬件部分仍是权威参考**,只有第 7 章(软件平台)被现行文档取代。
已在 `docs/README.md` 写明"两者冲突时软件口径以 `AGENTS.md` 为准"。
## 三、删除:两份 2026-07-06 报告(**问题已全部解决**)
逐条核对了它们"发现的问题",**无一条仍然成立**:
| 报告 | 当时的问题 | 现状 |
|---|---|---|
| 深度分析报告 | 敏感信息泄露(AGENTS.md 明文密码) | 已成 `AGENTS.md` 的**明示豁免**条目 |
| | 目录名不一致(可能用 taro,需确认) | 已确认 `h5-uniapp`,无 taro |
| | `test/` 目录缺失 | `test/` 已存在且有 7 项验收脚本 |
| | 温度单位不一致(K×100 vs 0.1°C) | 已统一为 K×100 |
| 数据单位审查报告 | SOC/SOH 单位标注错误 | `API.md` 早已重写;该报告基准 `tcp-protocol.md` 已不存在 |
## 四、**不删**:`生产环境.txt`(并修掉自相矛盾的表述)
它是**唯一凭据清单**(阿里云账号/小程序 AppSecret/腾讯地图 key/SSL AccessKey 都在其中),
且被 `docs/tech/小程序用户端.md` 明确引用为"密钥记录处"。
但原文写着"仅记录在内网 Git 仓库 `生产环境.txt`,**不入代码仓库**"—— **与事实矛盾**
(`git ls-files` 实测它**已入库**)。已改为准确表述:只记录在该文件、**绝不写进任何源码/.env**、
**不要删除**(新凭据追加到那里,不要散落别处)。
> 🔐 **留给你的决定**:该文件含大量明文凭据。现策略与 `AGENTS.md`「明文密码」豁免一致(内网仓库),
> 若要收紧,建议把它加入 `.gitignore` 并另存到密码管理器 —— 但这会改变 agent 的直连方式,故未擅自改。
## 五、取消 `archive/` 目录本身
**不再设归档**。理由:被删内容在 **git 历史里完整可查**(`git log -- <原路径>`),
归档的"留痕"价值因此为零,却持续制造"这文件还算不算依据"的歧义。
已把该结论写进 `docs/README.md` 的维护约定:**过时就删;仍有价值的归到正确的类。**
## 六、其他归位
- `营业执照.jpg`(主体资质,小程序/备案用)→ `docs/contract/`
- 根目录现在只剩 5 个必要文件:`.gitignore` / `AGENTS.md` / `README.md` / `opencode.json` / `生产环境.txt`
## 校验
- docs 相对链接 **0 断链**(搬迁引入的 2 处已在上一提交修好,本次复检通过);
- 已删除文件的残留引用:**仅剩 `docs/README.md` 的处置记录/映射表**(旧名必须出现,属预期);
- `cargo check --all-targets` 两服务零 error/warning(本次只动文档与仓库清理,未碰源码逻辑)。
60 KiB
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 单元、nginxapp.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,IP10.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:
# ① 手工起隧道(前台常驻一个窗口;需先设 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"。
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—— "以为在测测试库、实际打生产"的隐患,已按默认测试库修正。)
# 列出所有表
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
结构漂移检查(只读,建议每次改动表结构/迁移后跑一次;同样需先起隧道):
# 测试库 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 卡死、
iccidNULL、SCHEMA_SQL前向引用、测试环境导出 500、生产/api/charge-orders500), 每次都是靠人肉发现。CREATE TABLE IF NOT EXISTS对已存在的表不改列定义, 漂移会长期潜伏。所有时间列必须是DATETIME(sqlx 的NaiveDateTime只与DATETIME兼容,TIMESTAMP只与DateTime<Utc>兼容,用错就是接口 500)。
端到端验收(会造临时行并自动清理,需该环境的 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
⚠️ 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 → 前端真实类型检查 → 结构漂移巡检 → 端到端)逐条跑 → 并行子代理审查代码 → 不通过则写返工任务文件
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
- 已完成的任务文件及时清理
质量约束模板
所有编码任务必须追加以下约束:
- 完成代码后执行对应栈静态校验(Rust→
cargo check;TS→npm run typecheck(即tsc -b)),零报错零警告- ⛔ 不要写
tsc --noEmit:software/web/tsconfig.json是 solution 型(files: []+references), 不带-b时 tsc 一个文件都不编译、直接 exit 0 ⇒ 假绿。详见「踩过的坑」第 2 条
- ⛔ 不要写
- 分层拆分,单函数≤80行,命名语义化,完整注释
- 所有外部IO/网络请求异常捕获,禁止裸panic
- 分支逻辑全覆盖,不遗漏兜底分支
- 常量抽离,不使用废弃API
- Rust:内存安全,合理管理所有权,禁用unsafe无合理理由
- React:函数式组件+Hooks,不写Class组件
- 时间阈值类 SQL 必须配"刚建的不命中 + 超期的命中"双向断言(见「踩过的坑」第 1 条)
验收流程
实际执行的验收项(逐条都要跑,缺一条不算通过):
# 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
- 上面 7 项全绿
- 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
- 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
- 审查通过 → 清理任务文件,更新文档
⚠️ 验收命令必须先自证能失败:写完一条"通过"的验收命令,故意造一个该命令应当抓到的缺陷,确认它真的报错, 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(
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_backwardstest_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 卡死、
iccidNULL、SCHEMA_SQL前向引用、 测试环境导出 500、生产/api/charge-orders500),每次都是人肉发现。
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:*。
- 360 个 0 基通道
- 后果:管理端把 6 台其实早已离线的柜子显示成"在线"并带 RSSI
(
GET /api/cabinets/filter的online=true/rssi=85),运维据此误判。 - 规范:
- 改 Redis key 结构或字段名时,同一次变更里必须清理/迁移旧键(并在报告里给出清理前后 keyspace 统计);
- 实时状态键一律带 TTL,"无 TTL 的
device:{imei}"可直接判定为异常/历史残留; - 排查手法:用
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已签发未复位),使后续所有观测都不可信 —— "测量工具本身改变了被测对象"。 - 规范:
- 要截取输出就先落盘再截:
python x.py *> $env:TEMP\out.txt然后Get-Content out.txt | Select-Object -First N(*> file不会掐断上游;Select-Object -Last N也不会,但统一落盘最省心); - 看到"突然变红"先自问:是不是我的取值方式改变了被测对象?(进程被杀 / 夹具被留在中间态);
- 复现任何"红"之前先把环境复位到干净态(如先
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非阻塞轮询 + 空队列时sleep200ms(生产者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 设计规范
响应格式
{
"code": 0, // 0=成功,非0=失败
"data": { ... }, // 业务数据
"message": "ok" // 提示信息
}
错误码
| code | 含义 |
|---|---|
| 0 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未认证(token缺失/无效) |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
分页约定
{
"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 拉出,完成后合并回 developfix/*— 修复分支,从 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 执行时间 |
日志格式
// 使用 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 —— 含 nginx 两份 conf.d 全文、 逐步部署命令、全部 11 条部署注意事项。本节只留"每次部署都必须先过一遍"的判据; 搬迁原因:本文件超工作区指令预算(65,536 B)会被按头部截断,尾部不加载。
SSH连接
# 外网IP(推荐)
echo y | plink -ssh root@118.31.119.206 -pw "<口令>"
# ZeroTier内网IP
echo y | plink -ssh root@10.8.0.252 -pw "<口令>"
部署前必看(致命项,逐条都栽过)
- ⛔ 服务器上禁止编译 Rust(2核/3.5G 会 OOM)—— 本地
cargo zigbuild --release --target x86_64-unknown-linux-gnu交叉编译后传二进制。 - 产物名以
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)—— 既存事实,照它传,别改名。 - 上传后必须
chmod +x(否则 systemd203/EXEC);运行中的二进制不可直接覆盖 (LinuxETXTBSY)⇒ 传xxx.new→chmod +x→systemctl stop→mv -f→systemctl start。 (deploy/deploy-prod.ps1已内置。) .env不支持行内注释 ——PORT=9000 # 注释会让服务起不来,注释必须独占一行。- 单元必须带
EnvironmentFile=—— 现行二进制强制要求环境变量,缺它起不来。 exports/uploads目录必须预置且属主为pms—— api-server 以User=pms降权运行,无法在/data/pms(root 属主)下自建目录。- 端口/库号串了就是事故 —— 生产
:9000/:9001+ db5/pms;测试:3000/:3001+ db6/pms_dev。 部署后必须核对ss -lntp与域名返回码(test域名/api/pms/应 401,502 = 测试后端没起)。 stream{}已移除,不要再加回来 —— 服务器不再监听 3306/6379(实测确认); 开发机要连库一律走 SSH 隧道(tools/dev-tunnel.ps1)。 历史事故:stream{}全配置只能有一个且只能在主配置,conf.d里再写一个 ⇒"stream" directive is duplicate⇒ 整站(含生产域)起不来。- 改 nginx 前先
nginx -t,通过后才systemctl reload nginx(不要直接restart); 主配置worker_shutdown_timeout调短会中断正在传输的响应。 - nginx 有仓库镜像,改线上前先比 md5 ——
deploy/nginx/三份(nginx.conf主配置 +app/test两份 conf.d)与线上逐字节一致,当前nginx.confmd5 =82f660d8ebd3299c89edf55483277266。改完线上必须把仓库镜像同步更新(双向一致), 否则下次比对全是噪声。为什么必须有主配置镜像:
limit_req_zone定义在主配置http{}里,而conf.d的location引用这些 zone —— 只照仓库的 conf.d 重建 nginx,zone 就没了 ⇒nginx -t失败、整站起不来。 - 新增路径要限流就三步走:① 主配置
http{}加limit_req_zone(名字要唯一); ②conf.d的对应location挂limit_req zone=...(引用的 zone 必须已在主配置存在); ③nginx -t通过后才 reload;④ 把线上改动同步回deploy/nginx/。 现有 4 个 zone:auth_login_zone10r/m、auth_challenge_zone20r/m、uploads_zone120r/m、scan_abstract_zone30r/m(只挂/api/pms/cabinets/abstract/)。💡 写"某 zone 覆盖某某路径"时必须按
location前缀语义核对并附实测 429 路径, 不要按 zone 名字推断它管谁(scan_abstract_zone这名字极易让人以为它管scan/, 而它只挂abstract/;scan/需登录、无 token 恒 401,不构成枚举信号故无限流)。 - Redis 实时键结构变更后必须清理旧键 —— 见「踩过的坑」第 6 条。
服务管理
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(由本节整段搬迁,内容逐字保留)。 改任何表结构前必须读它;本节只留改动前必须先知道的硬约束。
核心关系
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
改动前必读的硬约束
- ⛔ 建表语句的顺序即依赖顺序(
migrate.rs::SCHEMA_SQL)——compartments的父是cabin_boards、cabin_boards的父是cabinets、cabinets的父是projects。 被引用表必须先建,否则新库初始化报 MySQL 1824(外键找不到被引用表), 叠加Restart=always= 无限重启。 - ⛔ 改
SCHEMA_SQL的列定义时必须同步migrate.rs::LEGACY_COLUMN_TARGETS(有单测守门)——CREATE TABLE IF NOT EXISTS对已存在的表不改列定义,只改一处 = 新库与老库再次分叉。 - ⛔ 所有时间列必须是
DATETIME—— 本仓 Rust 侧全是NaiveDateTime,与TIMESTAMP不兼容, 一碰就 500(详见「踩过的坑」第 3 条)。 cabinets.status是启用/禁用配置态(2= 禁用 ⇒ 拦截指令链路;其余 = 启用), 不表示在线;在线判据来自 Redis(见「安全规范」的登录门控一节)。compartments.status只表示占用(0空闲 /1充电中);"充满 / 故障"不落库, 由实时数据派生。charge_orders只有0/1;charge_records.status分 3 档。- 唯一键:
energy_stats有UNIQUE(cabinet_id, stat_date);abstract_bindings.cabinet_id/batteries.battery_id/cabinets.imei唯一;charge_orders用生成列active_flag+ 唯一索引防同仓体并发建单。
TCP通讯协议
详见 docs/protocol/柜控板固件对接文档.md(v1.1,唯一权威 TCP 文档;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(act/suc/msg_id/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
📌 文档口径优先级(避免"两份文档谁对"的扯皮):
docs/protocol/柜控板固件对接文档.md—— 设备协议(帧格式/act/字段/签名)的唯一权威;- 本文件(AGENTS.md) —— 平台侧实现口径的权威:Redis 键/字段、接口行为、库结构、 验收流程、部署注意事项。凡与
docs/tech/API.md的平台存储/行为描述不一致时,以本文件为准 (本文件的每一条都按代码契约与库实测核对过);docs/tech/API.md—— HTTP 接口清单与响应结构的权威(含数据单位规范);- 代码与文档冲突时:先确认是不是代码错;确认代码对就改文档并在报告里点出。
数据单位规范
详见 docs/tech/API.md — 所有数值字段使用整数存储,禁止浮点类型。
✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的
write_numberAPI 参数为 f64(i64 as f64转换,非业务浮点)。
微信小程序用户端
📖 完整篇见 docs/tech/小程序用户端.md(由本节整段搬迁,内容逐字保留)—— 页面清单、逐页职责、状态位分层展示口径、RSSI 分档都在那里。本节只留在改动前必须知道的硬约束。
- 技术栈:uni-app + Vue 3 + Pinia,构建目标
mp-weixin(software/h5-uniapp)。 - 产物不上传服务器:
npm run build:mp-weixin产出dist/build/mp-weixin/, 用微信开发者工具导入后上传体验版/正式版(服务器上没有小程序产物)。 - 两道门禁(缺一不可):
npm run typecheck(=vue-tsc --noEmit,不是tsc—— tsc 不检查.vue; 详见「踩过的坑」第 2 条);npm run build:mp-weixin(只验语法,不验类型)。
- ⛔ 跨端口径必须三处一致(PC
web/.../cabinet.tsx、小程序device-detail/index.vue、compartment-detail/index.vue):状态位解析与 RSSI 分档已有逐值断言守门,改一处必须跑: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),设备侧不可见 |