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

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

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

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

发布验收(本次统一发布,按 device→api 顺序):
- 测试环境与生产环境四服务 active,端口 9000/9001/3000/3001 正确,ERROR 均为 0;
- 生产迁移按判据 grep '更新列注释' 实测对齐 cabinets.status 列注释;
- 结构漂移 python tools/db_schema_diff.py = 0 处漂移 / exit 0(--vs-ddl 两库同样 0);
- schema_alignment_e2e --env dev 全部通过 exit 0;
- 生产域名 /api/pms/cabinets -> 401、/pms/ -> 200;消费链路探测通过。
2026-09-20 04:31:42 +08:00

88 KiB
Raw Blame History

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/               # 开发文档、协议文档
├── 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:
# ① 手工起隧道(前台常驻一个窗口;需先设 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 卡死、iccid NULL、 SCHEMA_SQL 前向引用、测试环境导出 500、生产 /api/charge-orders 500), 每次都是靠人肉发现。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 → 前端真实类型检查 → 结构漂移巡检 → 端到端)逐条跑 → 并行子代理审查代码 → 不通过则写返工任务文件
  • 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
  • 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
  • 已完成的任务文件及时清理

质量约束模板

所有编码任务必须追加以下约束:

  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 条)

验收流程

实际执行的验收项(逐条都要跑,缺一条不算通过):

# 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 设计规范

响应格式

{
  "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 拉出,完成后合并回 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/柜控板固件对接文档.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 格式,懒加载

部署与维护

SSH连接

# 外网IP(推荐)
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"

# ZeroTier内网IP
echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@"

数据库连接

⛔ 不要再用 10.8.0.252 连库 —— 服务器 nginx stream{} 转发已于 2026-09-20 移除, 10.8.0.252:3306/6379(以及 127.0.0.1 回环)都连不到了(实测 ✅)。 所有本地数据库操作一律先起 SSH 隧道(pwsh tools/dev-tunnel.ps1),再连 127.0.0.1:13306/16379。

MySQL(通过 SSH 隧道):

# 先起隧道(另一个终端常驻)
pwsh tools/dev-tunnel.ps1

# 命令行
mysql -h 127.0.0.1 -P 13306 -u root -p
# 密码:Hbhyg731024@
# 测试环境库:pms_dev
# 正式环境库:pms
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
import os, pymysql
host = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1')
port = int(os.environ.get('PMS_MYSQL_PORT', '13306'))
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms')      # 正式库
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms_dev')  # 测试库

Redis(通过 SSH 隧道):

# 命令行
redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@"
# 测试环境:select 6
# 正式环境:select 5
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
import os, redis
host = os.environ.get('PMS_REDIS_HOST', '127.0.0.1')
port = int(os.environ.get('PMS_REDIS_PORT', '16379'))
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=5)  # 正式环境
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=6)  # 测试环境

数据库工具 tools/db.py(需先起隧道):

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 ..."   # 写入

tools/db.py 默认连测试库 pms_dev(可用 PMS_MYSQL_DB=pms 切生产,切换时会打印醒目警告); 连接失败会打印「连接被拒:请先运行 pwsh tools/dev-tunnel.ps1 …」并 exit 2。

Nginx配置

⚠️ 下面是"骨架节选",不是线上全文 —— 线上配置还包含(2026-09 实测): 安全响应头(Strict-Transport-Security / X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Content-Security-Policy-Report-Only)、 限流 location(= /api/pms/auth/login、= /api/pms/auth/challenge、/api/pms/cabinets/abstract/,配合 /etc/nginx/nginx.conf 里的 limit_req_zone auth_login_zone / auth_challenge_zone / uploads_zone / scan_abstract_zone)、 location /uploads/(免认证同源直出 + 限流 + expires 30d)、生产侧 location = /pms { return 301 /pms/; }、 测试侧 location /h5/。 改动前先拉线上真实文件为准: plink -ssh root@118.31.119.206 "cat /etc/nginx/conf.d/app.anzhizhichong.com.conf"。 仓库内 deploy/nginx/ 是三份配置的镜像(nginx.conf 主配置 + app/test 两份 conf.d, 均与线上逐字节一致)—— 改线上前先比对,见「部署注意事项」第 10 条。

正式环境 (/etc/nginx/conf.d/app.anzhizhichong.com.conf):

server {
    listen 80;
    server_name app.anzhizhichong.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name app.anzhizhichong.com;

    ssl_certificate /etc/nginx/ssl/app.anzhizhichong.com.pem;
    ssl_certificate_key /etc/nginx/ssl/app.anzhizhichong.com.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # 产品介绍页
    root /data/landing;
    index index.html;

    location /doc/ {
        proxy_pass http://127.0.0.1:3080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        client_max_body_size 500m;
    }

    # PMS API 入口(微服务统一前缀 /api/{服务})
    location /api/pms/ {
        proxy_pass http://127.0.0.1:9000/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # PMS管理前端
    location /pms/ {
        alias /data/pms/web/;
        try_files $uri $uri/ /pms/index.html;
    }

    # 微信回调(免认证)
    location /api/wechat/ {
        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

测试环境 (/etc/nginx/conf.d/test.anzhizhichong.com.conf):

server {
    listen 80;
    server_name test.anzhizhichong.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name test.anzhizhichong.com;

    ssl_certificate /etc/nginx/ssl/test.anzhizhichong.com.pem;
    ssl_certificate_key /etc/nginx/ssl/test.anzhizhichong.com.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    root /data/pms_dev;
    index index.html;

    # PMS API 入口(微服务统一前缀 /api/{服务})
    location /api/pms/ {
        proxy_pass http://127.0.0.1:3000/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /pms/ {
        try_files $uri $uri/ /pms/index.html;
    }

    # 微信回调(免认证)
    location /api/wechat/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

测试环境SSL证书与正式环境共用(test是app证书的SAN域名),部署时复制pem+key即可。

部署步骤

✅ 推荐用一键脚本(在本地开发机执行):

pwsh deploy/deploy-prod.ps1              # 完整部署:构建 → 校验产物 → 服务器备份 → 传 .new → 原子替换 → 重启 → 验收
pwsh deploy/deploy-prod.ps1 -SkipBuild   # 跳过构建,只重发现有产物
pwsh deploy/deploy-prod.ps1 -DryRun      # 演练,不做任何改动
pwsh deploy/deploy-prod.ps1 -SkipWeb     # 只发后端

脚本会自动做:产物路径校验、上传后 chmod +x、传 .new 再原子 mv(避开 ETXTBSY)、 nginx -t 后才 reload、验收(服务/端口/域名/ERROR 数)、以及用不存在的 IMEI 探测上报消费链路 (不碰真实设备数据),最后打印回滚方法。

⛔ deploy/deploy-prod.sh 与 deploy/deploy-test.sh 已废弃(执行即报错退出): 它们在 target/release/ 找产物、会安装重复的 pms-api-prod 单元、并会用本地 .env 覆盖线上 .env(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。

1. 本地编译

# 后端(Rust)— Windows 交叉编译 Linux 目标
cd software/api-server
cargo zigbuild --release --target x86_64-unknown-linux-gnu

cd software/device-server
cargo zigbuild --release --target x86_64-unknown-linux-gnu

# 前端(React 管理端)
cd software/web
npm run build

# 微信小程序用户端(uni-app)
cd software/h5-uniapp
npm run build:mp-weixin
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传

2. 传输到服务器

产物名以 Cargo.toml 的 [package] name 为准:api-server → pms-api (software/api-server/Cargo.toml:2)、device-server → pms-device-server (software/device-server/Cargo.toml:2)。⛔ 旧文档写的 device-server 是产物名写错, 在 target/.../release/ 下根本没有这个文件。

# 传输后端二进制(测试环境)
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
# ⚠️ 测试环境的落地文件名目前是 device-server(见 deploy/pms-device-test.service:11
#    的 ExecStart=/data/pms_dev/device-server/device-server),与 Cargo.toml 的
#    pms-device-server 不一致 —— 这是既存事实,照它传,别改名(改名要同步改单元)
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms_dev/device-server/device-server

# 传输后端二进制(正式环境)—— 生产落地名就是 Cargo.toml 产物名
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms/device-server/

# 传输前端静态文件
scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/  # 测试环境
scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/  # 正式环境

服务器实测(2026-09,ls -la + ss -lntp): /data/pms/api-server/pms-api(:9000)、/data/pms/device-server/pms-device-server(:9001)、 /data/pms_dev/api-server/pms-api(:3000)、/data/pms_dev/device-server/device-server(:3001)。

部署注意事项(最容易踩的坑,逐条都栽过)

  1. 上传后必须 chmod +x —— 否则 systemd 报 203/EXEC,服务起不来。

  2. 运行中的二进制不可直接覆盖(Linux ETXTBSY)—— 正确姿势: 传到 xxx.new → chmod +x → systemctl stop → mv -f xxx.new xxx → systemctl start。 (deploy-prod.ps1 已内置这套流程。)

  3. .env 不支持行内注释 —— PORT=9000 # 注释 会让配置解析失败、服务起不来; 注释必须独占一行。(历史上用带行内注释的本地 .env 覆盖线上,导致生产起不来。)

  4. exports 目录必须预置、且属主为 pms —— api-server 以 User=pms 降权运行 (deploy/pms-api-*.service 的 User=pms/Group=pms),它无法在 /data/pms(root 属主) 下自建目录;exports(异步导出落盘)缺失或属主不对 → 导出任务直接失败。 实测正确属主:drwxr-xr-x pms pms /data/pms/api-server/exports(uploads 同理)。

  5. worker_shutdown_timeout 30s(/etc/nginx/nginx.conf 第 2 行)—— 调短会让正在传输的 响应被中断;改 nginx 配置前先 nginx -t,通过后再 systemctl reload nginx。

  6. stream 转发已移除(2026-09-20)—— 不要再给开发机开库端口 —— 主配置 /etc/nginx/nginx.conf 里原来那个 stream{}(MySQL 3306 / Redis 6379 → 阿里云 RDS/Redis) 已整块删除;服务器不再监听 3306/6379(10.8.0.252:3306/6379 实测已不可连)。 ⛔ 不要为了给开发机开库通路而在服务器上重新加 stream{} / socat / 任何端口转发 —— 开发机要连库一律走 SSH 隧道(pwsh tools/dev-tunnel.ps1,见「本地开发环境」); 服务侧(/data/pms、/data/pms_dev)本来就直连阿里云,也不需要转发层。 历史原因(务必记住):stream{} 全配置只能有一个、且只能在主配置里 —— 历史上曾在 conf.d/*.conf 里又写了一个,nginx 以 "stream" directive is duplicate 直接启动失败、整站不可用(含生产域)。 所以:conf.d 下只放 http 上下文的 server {};主配置里现在应当完全没有 stream{} (若哪天又看到它,先查清是谁加的)。

  7. 单元必须带 EnvironmentFile= —— 现行二进制强制要求环境变量,缺它起不来: EnvironmentFile=/data/pms/api-server/.env / /data/pms/device-server/.env(测试环境同理)。

  8. 端口/库号串了就是事故 —— 生产 :9000/:9001 + db5/pms;测试 :3000/:3001 + db6/pms_dev。 部署后务必核对 ss -lntp 与域名返回码(见「部署架构」的判断方法)。

  9. Redis 实时键结构变更后必须清理旧键 —— 见「踩过的坑」第 6 条(无 TTL 的旧键会被当成实时值)。

  10. nginx 主配置也有仓库镜像,改线上前必须先比对 —— deploy/nginx/nginx.conf (与线上 /etc/nginx/nginx.conf 逐字节一致,纯 LF、100 行、3733 字节、 md5 82f660d8ebd3299c89edf55483277266 —— 2026-09-20 移除 stream{} + 订正 stream 注释后重新对齐)。 deploy/nginx/ 下三份分别是:

    • nginx.conf ←→ 线上 /etc/nginx/nginx.conf(主配置:limit_req_zone / worker_shutdown_timeout; ⚠️ 已无 stream{} —— 见第 6 条,别再加回去)
    • app.anzhizhichong.com.conf ←→ 线上 /etc/nginx/conf.d/app.anzhizhichong.com.conf
    • test.anzhizhichong.com.conf ←→ 线上 /etc/nginx/conf.d/test.anzhizhichong.com.conf

    为什么必须有主配置镜像:limit_req_zone 定义在主配置的 http{} 里,而 conf.d/*.conf 的 location 引用了这些 zone。只照仓库的 conf.d 重建 nginx,zone 就没了 ⇒ nginx -t 直接失败、整站起不来。 (历史已踩过 stream{} 重复、行内注释等同类问题。)

    # 改线上前:先确认线上与仓库镜像一致(不一致就先搞清是谁改的)
    plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "md5sum /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf"
    md5sum deploy/nginx/nginx.conf deploy/nginx/*.conf     # 纯 LF,可直接比对
    # 逐行比对(推荐,能看出差在哪)
    diff <(plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "cat /etc/nginx/nginx.conf") deploy/nginx/nginx.conf
    

    改完线上后把仓库镜像同步更新(双向一致),否则下次比对全是噪声。

  11. 新增域名/新增路径要限流,按这个顺序落(三步缺一不可):

    1. 主配置 http{} 加 zone(deploy/nginx/nginx.conf + 线上 /etc/nginx/nginx.conf):
      limit_req_zone $binary_remote_addr zone=<name>_zone:10m rate=<N>r/m;
      
      ⛔ zone 名要唯一;limit_req_status 429; 只需在主配置写一次(已在)。

      现有 zone(2026-09 实测 4 个):auth_login_zone 10r/m、auth_challenge_zone 20r/m、 uploads_zone 120r/m、scan_abstract_zone 30r/m —— 只挂在 location /api/pms/cabinets/abstract/(免认证的扫码查设备入口,无 token 可枚举 10-%08d, 是真实攻击面)。 ⚠️ /api/cabinets/scan/{abstract_id} 不在这个 zone 里(前缀 abstract/ 匹配不到 scan/)—— 它需登录、无 token 恒 401(与 id 是否存在无关),不构成枚举信号,故无需限流。 实测:scan 同 IP 60 连发 = 401×60 / 429×0;abstract 同法 = 404×23 / 429×37。

    2. 对应的 conf.d/*.conf 里给那个 location 挂 limit_req:
      location = /api/pms/<path> {
          limit_req zone=<name>_zone burst=N nodelay;
          proxy_pass http://127.0.0.1:<port>/api/<path>;
          ...
      }
      
      ⛔ limit_req 引用的 zone 必须在主配置里存在,否则 nginx -t 报 unknown limit_req_zone "<name>_zone" 而拒绝启动。
    3. 先 nginx -t,通过后才 reload:nginx -t && systemctl reload nginx。 ⛔ 不要跳过 -t 直接 restart —— 配置错会让整站(含生产域)起不来。
    4. 把线上改动同步回 deploy/nginx/ 的对应文件(见第 10 条),并在报告里注明。

    💡 经验(写限流文档时必守):写"某 zone 覆盖某某路径"时,必须按 location 的前缀语义核对, 并附实测 429 的路径清单 —— 不要按 zone 的名字推断它管谁 (scan_abstract_zone 这名字本身就极易让人以为它管 scan,而它的 location 只有 /api/pms/cabinets/abstract/;scan/ 落在无 limit_req 的通用 location /api/pms/)。

2.1 微信小程序上传

小程序构建产物 dist/build/mp-weixin/ 不上传到服务器,而是通过微信开发者工具上传:

  1. 打开微信开发者工具
  2. 导入项目目录 software/h5-uniapp/dist/build/mp-weixin
  3. 填入 AppID(已配置在 manifest.json)
  4. 预览/真机调试 → 上传体验版/正式版

3. 服务器重启服务

# SSH到服务器
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"

# 正式环境
systemctl restart pms-api
systemctl restart pms-device

# 重启Nginx
systemctl restart nginx

服务管理

# 查看服务状态
systemctl status pms-api      # 正式环境API
systemctl status pms-device   # 正式环境TCP
systemctl status nginx

# 查看日志
journalctl -u pms-api -f
journalctl -u pms-device -f
tail -f /var/log/nginx/error.log

硬件架构

  • 柜控板(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 归属,与项目/设备没有关联列,项目级范围无法映射。

数据模型

核心关系

organization(组织/企业)
├── project(项目/放置地点)
│   └── cabinet(充电柜)
│       ├── cabin_board(仓控板×N)          board_id 1–15
│       │   └── compartment(仓体×6)        channel_id 1–8
│       ├── charge_order(充电订单,进行中/已结算;active_flag 唯一键防并发) 
│       ├── charge_record(充电记录,结算后留痕)
│       ├── energy_stat(能耗统计,按 柜×日 唯一)
│       └── device_log(设备日志)
├── user(用户) ──┬── user_roles ── role ── role_permissions ── permission
│                ├── user_scopes(数据范围,项目级生效)
│                └── operation_log(操作日志) / user_feedback(问题反馈) / download_tasks(下载中心)
└── abstract_binding(平台抽象ID 10-xxxxxxxx ↔ cabinet 一对一)

battery(电池台账,按 battery_id 唯一) ── sim_change_log(换 SIM 记录,按 imei)

外键与唯一键要点(改动前必读)

  • compartments 的父是 cabin_boards,cabin_boards 的父是 cabinets,cabinets 的父是 projects; 建表语句顺序即依赖顺序(migrate.rs::SCHEMA_SQL,被引用表必须先建,否则 MySQL 报 1824)。
  • charge_orders / charge_records 同时外键到 compartments 与 cabinets。
  • energy_stats 有 UNIQUE(cabinet_id, stat_date);abstract_bindings / batteries / cabinets.imei(唯一) 同理。

数据库表

实测:测试库 pms_dev 与生产库 pms 各 21 张表(依据: SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=...,两库表名完全一致)。

表名 说明
organizations 组织/企业(parent_id 支持父组织,NULL=顶级)
projects 项目/放置地点(隶属组织)
cabinets 充电柜(imei 唯一、abstract_id、auth_str+auth_str_issued、cell、work_mode、status、经纬度、address)
cabin_boards 仓控板(board_id 1–15)
compartments 仓体/通道(channel_id 1–8;status 只表示占用 0 空闲/1 充电中,充满与故障不落库)
batteries 电池台账(battery_id 唯一、SOC‰、厂家/型号/材料代码原值、额定电压容量、状态)
charge_orders 充电订单(进行中/已结算;reason normal/user_stop/forced/timeout;active_flag 唯一键防并发建单)
charge_records 充电记录(结算后留痕;energy 单位 Wh、max_temperature K×100)
device_logs 设备日志(log_type 1 状态上报 / 2 故障 / 3 告警 / 4 停电)
energy_stats 能耗统计(按 cabinet_id + stat_date 唯一,total_energy 单位 Wh)
operation_logs 操作日志(按 user_id 归属组织;不叠加项目级数据范围)
users 用户(phone 唯一、argon2 密码、role 0/1/2、organization_id)
roles 角色(内置 super_admin / org_admin / normal_user)
permissions 权限码字典(31 条,见 migrate.rs::seed_permissions)
role_permissions 角色↔权限 关联(复合主键)
user_roles 用户↔角色 关联(复合主键;为空时按 users.role 回退内置角色权限,见 auth.rs::load_user_permissions)
user_scopes 数据范围(scope_type 1 组织 / 2 项目 / 3 设备;只有 1、2 参与鉴权)
download_tasks 下载中心/异步导出任务(task_type、status 0排队~3失败、file_path)
abstract_bindings 平台抽象ID ↔ 柜子 绑定(abstract_id / cabinet_id 均唯一)
sim_change_logs SIM 卡(ICCID)变更记录(login 时检测到变更即写)
user_feedback 用户问题反馈(内容/联系方式/处理状态)

表清单的权威来源是 software/api-server/src/db/migrate.rs::SCHEMA_SQL(代码建表 DDL) 与库实测两处,两者必须一致;不一致用 python tools/db_schema_diff.py --vs-ddl 查。

Redis 实时状态存储

权威依据:software/api-server/src/workers/report_worker.rs 的模块头注释 + write_device_state / write_channel_state / handle_pow_fail / save_channel_battery(写入侧), workers/energy.rs::order_energy_key(累加器),device-server/src/tcp/server.rs::update_device_route(路由)。 下表是逐字段核对代码后的实际写入契约,不是设计稿。

层级结构(实测:测试库 db6 的真实 key/hash 与下表完全一致):

device:{imei}                        # 设备级 Hash(TTL 300 秒,写它的每一条路径都会 EXPIRE 300)
├─ online            = "1"           # 在线标记(login 成功 / status_post 均会置)
├─ rssi              = "85"          # 信号强度(协议口径 0–99 等级,非 dBm)
├─ temp              = "29800"       # 柜温 K×100(status_post 的 cabinet.temp)
├─ meter             = "123456"      # 电表读数(status_post 的 cabinet.meter)
├─ cabinet_status1   = "01"          # 柜级状态位 byte0(2 位 hex,**取不到不写**)
├─ cabinet_status2   = "00"          # 柜级状态位 byte1(2 位 hex,**取不到不写**)
├─ pow_fail_dc       = "0/1"         # 直流停电(接触器分闸)
├─ pow_fail_ac       = "0/1"         # 交流停电(柜控板自身断电)
└─ node_id           = "node-1"      # 该设备 TCP 连接所在节点(device-server 写,用于多节点路由)

device:{imei}:board:{板号}:ch:{通道号}          # 通道级 Hash(**唯一的状态载体,无 TTL**)
├─ batt_status = "82"              # byte0 电池状态(2 位小写 hex,位定义见协议 §3.8)
├─ pow_status  = "82"              # byte1 电源状态(2 位小写 hex,与电池状态独立)
├─ soc         = "850"             # SOC ‰(硬件 1% → ×10)
├─ voltage     = "54000"           # 电压 mV(硬件 0.01V → ×10)
├─ current     = "8000"            # 电流 mA(硬件 0.01A → ×10)
└─ temp        = "29800"           # 电芯最高温 K×100(硬件 1K → ×100)

device:{imei}:board:{板号}:ch:{通道号}:bat      # 通道电池台账(内部键,TTL 48h,bat_in 登记)
├─ battery_id / soc / info / brand_code / model_code / material_code / cell_count
└─ (rated_voltage / rated_capacity)

device:{imei}:board:{板号}:ch:{通道号}:order    # 通道订单电量累加器(内部键,Lua 原子累加)
├─ mwh / maxtemp / ts / nobat
└─ (由 workers/energy.rs 维护,供结单取电量)

⚠️ 关键事实(旧文档写错了,逐条纠正):

  1. 板级 hash device:{imei}:board:{板号} 根本不存在 —— 现协议板级没有状态字节, 写入侧只写通道级 key。/api/cabinets/realtime-status 响应里那个 boards[].status_hex 是接口现拼的(各通道 batt_status + pow_status 按通道号升序拼接),不是一个 Redis 键。 生产 Redis 里若还能搜到 device:*:board:N 这种 hash(内容只有 status_hex),那是旧版本残留。
  2. 通道号是 1 基(1–8),不是 0 基;老数据里出现过 ...:ch:0,也是旧版本残留。 板号同样从 1 开始,量程 1–15。
  3. 通道级字段是 batt_status / pow_status / soc / voltage / current / temp, 没有 on / full / fault / hex 这四个字段(那是旧格式;on/full/fault/hex 是接口 由 batt_status/pow_status 派生出来的响应字段,不是存储字段)。
  4. 板号和通道号十进制不补零(board:1:ch:3,不是 board:01:ch:03)。
  5. 设备级 key 的字段可能是残缺的(如只有 online+rssi):cabinet_status1/2 只在 status_post 带合法 cabinet.status1/2 时才写;读不到就是"无数据",绝不落 "00"。
  6. device:{imei} 的 TTL 就是在线判据(300 秒内无任意上行即判离线), 所以任何写 device:{imei} 的代码都必须跟一条 EXPIRE 300。
  7. hex 编码格式已定稿(协议 §3.8 的位定义表),不再有"待硬件团队确认"的悬置项。

写入时机:

事件 写入内容
login(验签通过) device:{imei}:online=1 + EXPIRE 300;另写 node_id(device-server 侧)
status_post device:{imei}:online/rssi/temp/meter(+cabinet_status1/2) + EXPIRE 300;
逐板逐通道 device:{imei}:board:{b}:ch:{c} 的 6 个字段;
cabinet.status1 bit0=0 时顺带把 pow_fail_dc/pow_fail_ac 清 0(供电恢复);
并累加 ...:order 的电量
pow_fail_dc device:{imei}.pow_fail_dc = 1
pow_fail_ac device:{imei}.pow_fail_ac = 1
bat_in device:{imei}:board:{b}:ch:{c}:bat(电池台账,TTL 48h)
任意上行报文 device-server 刷新 device:{imei} 的 TTL/node_id(路由用)

⛔ 没有 pow_fail 这条 act:停电是两条独立指令 pow_fail_dc(直流,接触器分闸→仓控板下电) 与 pow_fail_ac(交流,柜控板自身断电),各写同名字段(协议 §5.4 / §10 第 11 条)。 report_worker.rs:237 的 "pow_fail_dc" | "pow_fail_ac" => handle_pow_fail(...) 是唯一入口。

通道状态位(batt_status / pow_status)—— 按协议 §3.8 位定义,不按"五态枚举":

batt_status(byte0):bit0 充满|bit1 正在充电|bit2–3 保留(不展示)|bit4 过欠压|bit5 过流| bit6 热失控|bit7 有电池(决定"空仓/在位")。 pow_status(byte1):bit0 电源故障|bit1–2 电源状态位|bit3–6 保留(不展示)|bit7 电源有效。

全仓唯一权威推导规则(routes/cabinet_tree.rs::derive_channel_flags,前端 web/src/utils/deviceStatus.ts 与 h5-uniapp/src/utils/deviceStatus.ts 同源):

展示状态 = fault > full > charging > idle
  fault    = batt_status bit4|bit5|bit6  或  pow_status bit0
  full     = batt_status bit0
  charging = batt_status bit1
  无数据(batt_status / pow_status 都取不到)= has_data:false
             → 列表计数按 0 计、详情保留 DB 占用值,**绝不猜测**

旧文档那张"空闲/插入/充电中/充满/故障"五态表已废弃:它既不是协议口径, 也无法表达"电池在位但未充电"(batt_status bit7=1、bit0/bit1=0)这类真实状态。

API 接口(接口总表以 docs/API.md 为准,这里只列与 Redis 存储强相关的两个):

接口 说明 Redis 查询方式
GET /api/cabinets/filter 设备列表(设备级状态 + 充满/故障计数) ① Pipeline HGET 每柜 6 个设备级字段(online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2,1 次往返);② 通道计数:按 DB 的板号/通道号拼 key(fetch_channel_keys,SQL 取拓扑)+ pipeline HMGET(batt_status, pow_status) —— 零 KEYS 扫描
GET /api/cabinets/realtime-status?imei=xxx 单设备详情(板级+通道级) HMGET 取设备级 4 字段;板/通道:按 DB 拓扑(cabinets→cabin_boards→compartments)拼候选 key + 一次 pipeline HGETALL,空 map = 键不存在(等价于只返回存在的键)。
✅ 2026-09 已修:旧实现用 KEYS device:{imei}:board:*:ch:*,在与既有充电站平台共用的 Redis 实例上会阻塞单线程(实测每次请求 cmdstat_keys +1),已彻底移除

⛔ 本项目禁止在任何请求路径上用 KEYS —— Redis 实例是共用的,KEYS 会阻塞单线程遍历整个 keyspace。要枚举就用「按 DB 拓扑拼 key」或 SCAN 游标。查是否有人用了: grep -rn '"KEYS"' software/*/src/。

TCP通讯协议

详见 docs/柜控板固件对接文档.md(v1.1,唯一权威 TCP 文档;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(act/suc/msg_id/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。

📌 文档口径优先级(避免"两份文档谁对"的扯皮):

  1. docs/柜控板固件对接文档.md —— 设备协议(帧格式/act/字段/签名)的唯一权威;
  2. 本文件(AGENTS.md) —— 平台侧实现口径的权威:Redis 键/字段、接口行为、库结构、 验收流程、部署注意事项。凡与 docs/API.md 的平台存储/行为描述不一致时,以本文件为准 (本文件的每一条都按代码契约与库实测核对过);
  3. docs/API.md —— HTTP 接口清单与响应结构的权威(含数据单位规范);
  4. 代码与文档冲突时:先确认是不是代码错;确认代码对就改文档并在报告里点出。

数据单位规范

详见 docs/API.md — 所有数值字段使用整数存储,禁止浮点类型。

✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 write_number API 参数为 f64(i64 as f64 转换,非业务浮点)。

微信小程序用户端

小程序配置

参数 值
AppID wx226061c550c52e85
AppSecret f11dfee9dc73265b75bf54277ebb1047
账号 13951516272@163.com
消息加解密方式 明文模式
数据格式 XML
服务器地址(URL) https://app.anzhizhichong.com/api/wechat/callback
Token(令牌) 7GW16XdnFyak0mE32rc8ZPij
EncodingAESKey kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu

⚠️ 以上密钥仅记录在内网 Git 仓库 生产环境.txt,不入代码仓库。

构建命令

cd software/h5-uniapp
npm run build:mp-weixin
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传

API 域名配置

小程序请求后端 API 必须配置合法域名(HTTPS):

  • 后台「开发管理 → 开发设置 → 服务器域名」
  • request 合法域名:https://app.anzhizhichong.com
  • 开发阶段可在 manifest.json 设 urlCheck: false 跳过校验

API base URL

小程序用全 URL(api/index.ts 条件编译):

// #ifdef MP-WEIXIN
const BASE_URL = 'https://app.anzhizhichong.com/api/pms'
// #endif
// #ifndef MP-WEIXIN
const BASE_URL = '/api/pms'
// #endif

信号强度显示(RSSI)

设备卡片右上角显示 RSSI 信号强度。

✅ 2026-09 已收敛为「两端同源同名、统一 5 档」(此前两端不一致,见下方"行为变化")。 唯一权威实现:web/src/utils/deviceStatus.ts 与 h5-uniapp/src/utils/deviceStatus.ts 的 rssiSignal() / rssiBarColors() / RSSI_TIERS(两端逐字一致,改一边必须同步另一边)。 一致性用例:node test/rssi_signal.test.mjs(遍历 0–99 逐值断言两端 level/bars/color 相同)。 ⛔ 页面里不得再自己写一套阈值(历史上两端各写一套,就是不一致的根因)。

统一分档表(协议口径:rssi 是 0–99 的信号等级,不是 dBm)

阈值 level 颜色 5 格制格数 PC 图标亮的 <path>
≥ 70 4 strong #16a34a 绿 5 4 条全亮
≥ 55 3 good #2563eb 蓝 4 3 条
≥ 40 2 fair #f59e0b 黄 3 2 条
≥ 30 1 weak #f97316 橙 2 1 条
< 30 或 null 0 none #9ca3af 灰 1(底格) 0 条(只剩底部圆点 M2 20h.01)

两端如何消费同一份纯函数:

  • PC(web/src/pages/devices/CabinetGrid.tsx::SignalIconComponent):rssiSignal(rssi) 取 color 与 level, 4 条 <path> 按 level > i 决定亮几条;卡片头部数值颜色用同一个 color。
  • 小程序(home/index.vue、device-detail/index.vue): v-for="(color, i) in rssiBarColors(cab.rssi)" 渲染 bars 根 <view class="signal-bar">, 颜色内联 :style="{ background: color }";格数真的随档位变化。
  • 颜色只在 TS 里定义一次(不再有 .signal.good/.mid/.low/.off 这类 CSS 颜色规则; 页面 CSS 只负责尺寸/间距)。

后端(两端共用): 设备列表接口用 pipeline 批量 HGET device:{imei} rssi (routes/cabinet_tree.rs::attach_realtime_status,字段下标见 DEVICE_LIST_FIELDS); 小程序侧 routes/h5.rs::get_cabinets 同样带 Redis。

相关文件:

  • 纯函数(权威): software/web/src/utils/deviceStatus.ts、software/h5-uniapp/src/utils/deviceStatus.ts
  • PC 消费点: software/web/src/pages/devices/CabinetGrid.tsx(SignalIconComponent + 卡片头部)
  • 小程序消费点: software/h5-uniapp/src/pages/home/index.vue、pages/device-detail/index.vue
  • 类型: software/h5-uniapp/src/api/index.ts — CabinetItem.rssi
  • 一致性用例: test/rssi_signal.test.mjs

⚠️ 行为变化(2026-09,小程序端显示会变 —— 这是有意为之) 收敛前:PC 5 档(≥70 绿/≥55 蓝/≥40 黄/≥30 橙/<30 灰,格数随档位增删); 小程序只有 4 档(无 ≥70)、30–39 用红色、且恒渲染 5 格只改颜色。 收敛后小程序:新增 ≥70 档(绿/满格)、30–39 由红改橙、≥55 由绿改蓝、 离线灰由 #94a3b8 改为 #9ca3af,且格数随档位变化(不再恒 5 格)。 影响页面:小程序首页设备卡片、小程序设备详情页头部。

近期 UI 改动

  1. 设备卡片头部:PC 管理端(web/src/pages/devices/CabinetGrid.tsx)去掉"在线/离线"文字 badge, 改为信号图标 + RSSI 数值。 ✅ 小程序保留"在线/离线"文字 badge —— 这是有意差异,不是缺陷,不要当 bug 改掉: h5-uniapp/src/pages/home/index.vue:43(设备卡片,cab.status===1 ? '在线' : '离线')与 pages/device-detail/index.vue:18(设备详情;同文件 :74 还有板级的在线/离线 badge)。 理由(产品决定):PC 用"信号图标 + RSSI 数值"表达;小程序屏幕小、文字 badge 更直观。

    PC 卡片:图标 + 数值;小程序:文字 badge(有意保留)。 另外 PC 设备详情页(web/src/pages/devices/cabinet.tsx:503)也仍用 Tag 渲染 CABINET_STATUS_MAP(0 离线 / 1 在线 / 2 禁用)—— 只是卡片去掉了,别理解成"全平台都去掉了"。

  2. 通道统计:图标+数字一行排列,图标用 <svg> 内联 Lucide(不加容器,flex 居中)
  3. 右上角扫码:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐

状态位展示约定(状态位按归属层级展示)

以下三条是协议 §3.8 / §4.2 / §5.7 的既定结论(产品已确认),后续不要再反复确认。

  1. 柜级状态位 → 显示在"柜子"层面
    • 数据:⚠️ 这两个名字不是 DB 列(实测 cabinets 表没有 status1/status2 列, 它的列是 id/project_id/abstract_id/imei/iccid/auth_str/name/address/status/created_at/updated_at/ latitude/longitude/work_mode/auth_str_issued/cell)。它们是: Redis 字段 device:{imei} 的 cabinet_status1 / cabinet_status2(设备上报原样落,2 位 hex) → 接口原样透出为响应字段 status1 / status2。 写:report_worker.rs::write_device_state;读:cabinet_tree.rs::attach_realtime_status(列表)、 cabinets.rs::fetch_cabinet_realtime(详情)。
    • 位定义(§3.8):status1 bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|bit4 柜门打开| bit5 加热风扇|bit6 接触器断开|bit7 喷淋;status2 bit0 声光报警|bit1 柜门打开超时 (阈值硬件定义,平台只呈现、不自行判定);status2 bit2–7 为保留位,不展示。
    • 页面:PC web/src/pages/devices/cabinet.tsx、小程序 h5-uniapp/src/pages/device-detail/index.vue。
    • 取不到返回 null(未知),绝不落 "00" —— "00" 是"全部正常"的有效值。
  2. 仓体级状态位 → 显示在"仓体"层面
    • 数据:通道 18 字符 hex 的 batt_status(byte0) / pow_status(byte1);保留位 (batt_status bit2–3、pow_status bit3–6)不展示。
    • 页面:PC 柜子详情里的通道卡片、小程序 pages/compartment-detail/index.vue。
    • 仓体层面不显示任何"门"状态:协议没有"按仓体(舱门)开门"的指令(§4.2 L403)。
  3. 柜门是柜级独立动作,不与业务联动,全平台只有一个开门按钮
    • open 是柜级指令(不带 id),语义是**「柜门解锁,下发后由人拉开,门不会自动开」**; 没有"关门"指令(关门也是人为动作)。全平台只有一扇柜门,不按仓体区分。
    • 柜门状态永远来自 status_post(约 60 秒一次),不是实时量 —— 界面上必须标注 "来自最近一次上报(约 60 秒一次),非实时"(§4.2 L428)。
    • 开门是独立流程,与电池插入、充电开始/结束没有因果关系也没有先后要求(§5.7、§7.2): 不得出现在"开始充电"流程里、不得改任何订单状态、不得要求存在订单。
    • 入口只有一个:PC 详情页按钮(<Permission code="charge:open_door"> 包裹)+ 小程序设备详情页按钮;仓体(通道)详情页不放开门按钮。
    • 权限码 charge:open_door(后端 check_permission 硬校验;device:door:unlock 是历史误用的 不存在权限码,禁止再用)。
  4. 解析函数两端必须同源同名:web/src/utils/deviceStatus.ts 与 h5-uniapp/src/utils/deviceStatus.ts 各有一组 parseCabinetStatus1 / parseCabinetStatus2 / cabinetDoorState / cabinetStatusTags(注释互相指认对方文件,改一边务必同步另一边)。 端到端一致性用例见 test/device_status_parse.test.mjs(node test/device_status_parse.test.mjs, 逐位遍历 0x00–0xFF 断言两端一致)。
  5. cabinets.status 是「启用/禁用」配置态,不表示在线 —— 1 启用 / 2 禁用(0 是建表默认值 产生的取值,代码不显式写它,语义等同启用);实时在线一律以 Redis device:{imei}.online 为准 (唯一权威判据 routes/cabinet_tree.rs::device_online_flag,接口字段 is_online), energy_stats 的 online_count、H5 首页 online_cabinets、PC/小程序的「在线/离线」全部消费它 (CABINET_STATUS_MAP 只保留启用/禁用两档,不要再出现"离线"档)。

开发约束(uni-app 特定)

  1. <text> 不支持 display: flex — uni-app 的 <text> 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 <view>
  2. SVG 图标居中:.stat-chip svg { display: block; flex-shrink: 0 } 用 CSS 统一控制尺寸,比内联属性更可靠
  3. 内联 SVG + v-if:Vue 3 支持在 <path> 上用 v-if 条件渲染,适用于动态图标
  4. CSS 覆盖 SVG 尺寸:用 .xxx svg { width: Xpx; height: Xpx } 统一控制图标大小,不在每个 <svg> 上写死

代码审查豁免

以下模式已确认无需修改:

审查项 原因
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),设备侧不可见