charging-cabinet/AGENTS.md
12451 61adcdeaf9 fix(security)+fix(api)+docs: 越权面修复、登录门控与状态语义对齐、文档全面校对
- 越权面(全部 before/after 实测可复现,10 条):cabinets/filter 的 project_id/organization_id
  参数分支越权(客户可拿对方向柜全字段)、organization-tree 返回全量组织树、
  cabin-boards/compartments/cabinets 三个 options 无归属校验、batteries 无归属(改为经
  charge_orders→cabinets→projects 间接归属)、feedback 跨组织读且能改他人状态、
  GET /downloads/{id} 漏 user_id 过滤(跨用户元数据泄露)、
  POST /downloads/cleanup 全局删除所有组织导出(改客户侧只清自己)、
  /api/nodes/* 仅需登录(客户可读内网 IP 并注销节点)→ 收紧 role_level>=2;
  /api/devices/stats 同样收紧;新增共享闸门 auth::require_super_admin
- TCP 登录门控:下行投递改为"登录前白名单"(PRE_LOGIN_ALLOWED_ACTS),与上行同一规则
- 60 秒超时对齐协议:挂起项 TTL 120→60 + 值内嵌 deadline 精确判窗 + 超时 WARN(兼容老格式)
- 下发前在线判据改连接级认证标记 device:authed:{imei}(消除"管理端显示成功、设备收不到")
  ⚠️ device-server 与 api-server 必须同批发布
- 协议对齐:auth_str 登记校验提到限流之前、build_spec Topology 分支 clamp(15/8)、
  行号引用改小节引用、修 3 处错引小节
- 状态语义:compartments.status 只表占用,充满/故障由通道状态派生;柜级 status1/status2
  与开门按钮(柜级独立动作不联动);列注释对齐(cabinets.status 补 2=禁用、
  charge_orders.status 标注 2 保留未使用)
- 假绿单测修复:超时 SQL 抽常量 + 纯函数孪生体双向断言(方向写反必红)
- RSSI 两端统一为同源纯函数 5 档(小程序显示随之变化)+ test/rssi_signal.test.mjs
- nginx:扫码入口 abstract 限流(zone=scan_abstract_zone 30r/m burst=20)+ 主配置仓库镜像
- 文档:AGENTS.md 新增「踩过的坑」7 条教训 + 部署注意事项 11 条 + 文档口径优先级;
  docs/API.md 接口总表 80 条与 mod.rs 逐条一致、Redis 契约按代码重写、9 处事实错误修正;
  权威协议文档 v1.1.1(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
2026-09-20 03:23:00 +08:00

1411 lines
74 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

# AGENTS.md - 充电柜项目工作手册
## 项目简介
充电柜矩阵系统(Power Matrix System / PMS)
产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。
仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
## 设备标识
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
- **设备ID**:4G SIM卡15位IMEI(10进制)
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`)
## 技术栈
- 后端:Rust + Axum
- 前端:React + Arco Design Pro(React版)
- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
- 数据库:MySQL(阿里云RDS),开发/生产共用
- 缓存:Redis(阿里云Redis),开发/生产共用
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
- 权限:RBAC,精确到按钮级别(权限码如 `charge:open_door`、`device:view`;**权威清单见 `software/api-server/src/db/migrate.rs::seed_permissions`,共 31 个**)
## 项目结构
```
D:\charging-cabinet\
├── tasks/ # 任务文件(opencode执行)
├── docs/ # 开发文档、协议文档
├── 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)
│ └── 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(开机自启)
- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、Gitea(3080)、acme.sh
- **不装**: 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
### 本地开发环境
**数据库连接(通过ZeroTier):**
```
MySQL: 10.8.0.252:3306
user: root
password: Hbhyg731024@
database: pms_dev (新建,不动AZZCWeChat*)
Redis: 10.8.0.252:6379
password: Hbhyg731024@
db: 6 (测试环境用db6,不动db1-5)
```
**代理说明:**
- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS
- systemd自启,`Restart=always` 自动恢复
- 本地直连 `10.8.0.252`,无需SSH隧道
### 数据库工具
本地可直接访问数据库(通过ZeroTier),使用 `tools/db.py`:
```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`=参数/连接错误。
> 为什么必须有它:"测试库与生产结构不一致"已经栽了 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模式** — 进程泄漏会耗尽内存,用nginx stream代理
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已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 条)。
---
## 踩过的坑(历史教训,改动前必读)
> 每条都是**真实踩过**且"当时没有任何报错"的类型。新写代码/新写验收时逐条对号入座。
### 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**。
⚠️ 遗留:`workers/order_timeout.rs` 的扫描条件是**另一种等价写法**
(`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`),该文件不在本次改动范围内,
**改动任一处都要同时确认另一处**。
### 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/tsconfig.json` 是**普通** tsconfig(有 `include`),`tsc --noEmit` 在那里
是有效的;但该工程**没有 typecheck 脚本**,且 `tsc` **不检查 `.vue` 文件**,小程序端的真实
类型安全目前靠构建 + `node test/device_status_parse.test.mjs`。
### 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**。
---
## 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 代理访问阿里云 RDS,客户端又是多路复用的 `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/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。
**判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`,
在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位),
**不是** Redis 在线键 `device:{imei}` —— 该键在验签前就会被写,用它判在线必然误判。
⛔ 不要再按"某个 `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 格式,懒加载
## 部署与维护
### SSH连接
```bash
# 外网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@"
```
### 数据库连接
所有数据库操作通过 ZeroTier 直接连接 `10.8.0.252`,无需 SSH 隧道到服务器。
**MySQL(通过 ZeroTier):**
```bash
# 命令行
mysql -h 10.8.0.252 -u root -p
# 密码:Hbhyg731024@
# 测试环境库:pms_dev
# 正式环境库:pms
```
```python
# Python(Agent 常用方式)
import pymysql
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 正式环境
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms_dev') # 测试环境
```
**Redis(通过 ZeroTier):**
```bash
# 命令行
redis-cli -h 10.8.0.252 -a "Hbhyg731024@"
# 测试环境:select 6
# 正式环境:select 5
```
```python
# Python(Agent 常用方式)
import redis
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5) # 正式环境
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6) # 测试环境
```
**数据库工具 `tools/db.py`(默认连接测试库 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 ..." # 写入
```
> 注意:`tools/db.py` 默认连的是 `pms_dev`(测试库),要操作正式库请用上面的 Python 直连方式。
### 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`,配合
> `/etc/nginx/nginx.conf` 里的 `limit_req_zone auth_login_zone / auth_challenge_zone / uploads_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`):
```nginx
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`):
```nginx
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即可。
### 部署步骤
> ✅ **推荐用一键脚本(在本地开发机执行)**:
> ```bash
> 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. 本地编译**
```bash
# 后端(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/` 下根本没有这个文件。
```bash
# 传输后端二进制(测试环境)
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 {}` 块只能有一个** —— MySQL(3306)/Redis(6379) 的 stream 反代写在**主配置
`/etc/nginx/nginx.conf`** 里。⛔ **不要在 `/etc/nginx/conf.d/*.conf` 里再写一个 `stream {}`**:
nginx 会以 `"stream" directive is duplicate` 直接启动失败(连带整站不可用)。
conf.d 下只放 `http` 上下文的 `server {}`。
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、116 行、3629 字节)。
`deploy/nginx/` 下三份分别是:
- `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `stream{}` / `worker_shutdown_timeout`)
- `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{}` 重复、行内注释等同类问题。)
```bash
# 改线上前:先确认线上与仓库镜像一致(不一致就先搞清是谁改的)
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`):
```nginx
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**(扫码查设备 `/api/cabinets/scan/{abstract_id}`
> 与 `/api/cabinets/abstract/{abstract_id}` 这类**免认证**入口,是枚举攻击面)。
2. **对应的 `conf.d/*.conf` 里给那个 location 挂 `limit_req`**:
```nginx
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 条),并在报告里注明。
**2.1 微信小程序上传**
小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传:
1. 打开微信开发者工具
2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin`
3. 填入 AppID(已配置在 `manifest.json`)
4. 预览/真机调试 → 上传体验版/正式版
**3. 服务器重启服务**
```bash
# SSH到服务器
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
# 正式环境
systemctl restart pms-api
systemctl restart pms-device
# 重启Nginx
systemctl restart nginx
```
### 服务管理
```bash
# 查看服务状态
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`;<br>逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;<br>`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);<br>并累加 `...: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](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 = 键不存在(等价于只返回存在的键)。<br>✅ **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](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](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`,不入代码仓库。
### 构建命令
```bash
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` 条件编译):
```typescript
// #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 断言两端一致)。
### 开发约束(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`),设备侧不可见 |