2026-07-02 05:38:01 +08:00
|
|
|
|
# AGENTS.md - 充电柜项目工作手册
|
|
|
|
|
|
|
|
|
|
|
|
## 项目简介
|
|
|
|
|
|
|
|
|
|
|
|
充电柜矩阵系统(Power Matrix System / PMS)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。
|
2026-07-02 05:38:01 +08:00
|
|
|
|
仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
|
|
|
|
|
|
|
|
|
|
|
|
## 设备标识
|
|
|
|
|
|
|
|
|
|
|
|
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
|
|
|
|
|
|
- **设备ID**:4G SIM卡15位IMEI(10进制)
|
|
|
|
|
|
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
## 技术栈
|
|
|
|
|
|
|
|
|
|
|
|
- 后端:Rust + Axum
|
|
|
|
|
|
- 前端:React + Arco Design Pro(React版)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
- 数据库:MySQL(阿里云RDS),开发/生产共用
|
|
|
|
|
|
- 缓存:Redis(阿里云Redis),开发/生产共用
|
|
|
|
|
|
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
2026-09-20 03:23:00 +08:00
|
|
|
|
- 权限:RBAC,精确到按钮级别(权限码如 `charge:open_door`、`device:view`;**权威清单见 `software/api-server/src/db/migrate.rs::seed_permissions`,共 31 个**)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
## 项目结构
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
D:\charging-cabinet\
|
|
|
|
|
|
├── tasks/ # 任务文件(opencode执行)
|
|
|
|
|
|
├── docs/ # 开发文档、协议文档
|
2026-09-20 03:23:00 +08:00
|
|
|
|
├── deploy/ # 部署配置与脚本
|
|
|
|
|
|
│ ├── nginx/ # Nginx 配置镜像(**三份**:nginx.conf 主配置 + app/test 两份 conf.d,均与线上逐字节一致)
|
|
|
|
|
|
│ ├── *.service # systemd 单元(pms-api / pms-device × prod·test 共 4 个)
|
|
|
|
|
|
│ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口)
|
|
|
|
|
|
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」)
|
2026-07-02 19:44:23 +08:00
|
|
|
|
├── tools/ # 开发工具
|
2026-09-20 03:23:00 +08:00
|
|
|
|
│ ├── db.py # 数据库工具(默认连测试库 pms_dev)
|
|
|
|
|
|
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
├── software/
|
2026-09-20 03:23:00 +08:00
|
|
|
|
│ ├── api-server/ # Rust API服务(产物 pms-api)
|
|
|
|
|
|
│ ├── device-server/ # Rust TCP设备服务(产物 pms-device-server)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
|
2026-07-02 19:44:23 +08:00
|
|
|
|
│ └── web/ # React前端(WEB管理端)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
├── hardware/ # 硬件资料(原理图/PCB等)
|
2026-09-20 03:23:00 +08:00
|
|
|
|
└── test/ # 测试/验收脚本(TCP 端到端、结构对齐、状态位与 RSSI 两端一致性)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
> **技能库不入库** — 仓库内不存放任何 skills(`.opencode/skills`、`software/h5-uniapp/.agents/skills`、`software/h5-uniapp/agent/skills` 已于提交 `9829e9d` 移除)。开发期技能统一从全局技能目录加载:`~/.dsh/skills`(如 wechatide-skill)、`~/.agents/skills`(如 anysearch)。请勿在仓库内重新 vendor 技能副本。
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
## 部署架构
|
|
|
|
|
|
|
|
|
|
|
|
### 环境规划
|
|
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
| 环境 | 域名 | API服务 | TCP服务 | Redis DB | 部署路径 | URL路径 |
|
|
|
|
|
|
|------|------|---------|---------|----------|----------|---------|
|
|
|
|
|
|
| 正式环境 | app.anzhizhichong.com | :9000 | :9001 | db5 | /data/pms | /pms/ |
|
|
|
|
|
|
| 测试环境 | test.anzhizhichong.com | :3000 | :3001 | db6 | /data/pms_dev | /pms/ |
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
**服务端口分配(规划):**
|
2026-07-02 14:58:47 +08:00
|
|
|
|
- 正式环境:API 9000,TCP 9001
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 测试环境: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`
|
|
|
|
|
|
> (**新二进制强制要求环境变量,缺它起不来**)。
|
2026-09-20 00:01:01 +08:00
|
|
|
|
> - **测试环境按本表规划独立部署在同一台服务器上**(测试就是测试,与生产完全隔离):
|
|
|
|
|
|
> `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),
|
|
|
|
|
|
> 端口或库号任一串了就是事故。
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
|
|
|
|
|
**部署路径:**
|
2026-07-02 15:43:59 +08:00
|
|
|
|
- 产品介绍页:/data/landing
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 正式环境:/data/pms(api-server、device-server、web)
|
|
|
|
|
|
- 测试环境:/data/pms_dev(api-server、device-server、web)
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
|
|
|
|
|
**Nginx配置:**
|
|
|
|
|
|
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
|
|
|
|
|
|
- 测试: /etc/nginx/conf.d/test.anzhizhichong.com.conf
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
### 服务器信息
|
|
|
|
|
|
|
|
|
|
|
|
- **外网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
|
|
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
### 正式环境数据库
|
|
|
|
|
|
|
|
|
|
|
|
- MySQL: `rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com`,user: root,password: Hbhyg731024,database: `pms`
|
|
|
|
|
|
- Redis: `r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com`,password: Hbhyg731024,db: 5
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
### 本地开发环境
|
|
|
|
|
|
|
|
|
|
|
|
**数据库连接(通过ZeroTier):**
|
|
|
|
|
|
```
|
|
|
|
|
|
MySQL: 10.8.0.252:3306
|
|
|
|
|
|
user: root
|
|
|
|
|
|
password: Hbhyg731024@
|
|
|
|
|
|
database: pms_dev (新建,不动AZZCWeChat*)
|
|
|
|
|
|
|
|
|
|
|
|
Redis: 10.8.0.252:6379
|
|
|
|
|
|
password: Hbhyg731024@
|
2026-07-02 14:58:47 +08:00
|
|
|
|
db: 6 (测试环境用db6,不动db1-5)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**代理说明:**
|
|
|
|
|
|
- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS
|
|
|
|
|
|
- systemd自启,`Restart=always` 自动恢复
|
|
|
|
|
|
- 本地直连 `10.8.0.252`,无需SSH隧道
|
|
|
|
|
|
|
2026-07-02 19:44:23 +08:00
|
|
|
|
### 数据库工具
|
|
|
|
|
|
|
|
|
|
|
|
本地可直接访问数据库(通过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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-20 02:44:51 +08:00
|
|
|
|
**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次)**:
|
|
|
|
|
|
|
|
|
|
|
|
```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`、
|
2026-09-20 03:23:00 +08:00
|
|
|
|
> `cabin_boards`、`compartments`、`batteries`、`users`、`roles` 这 **8 张表的 `updated_at` 恢复为
|
2026-09-20 02:44:51 +08:00
|
|
|
|
> `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`**
|
2026-09-20 03:23:00 +08:00
|
|
|
|
> (实测依据:`information_schema.COLUMNS` 里这 8 张表的 `updated_at` 均为
|
|
|
|
|
|
> `EXTRA = DEFAULT_GENERATED on update CURRENT_TIMESTAMP`;代码侧对应
|
|
|
|
|
|
> `migrate.rs:844-851` 的 `LEGACY_COLUMN_TARGETS`,同样是 8 条)
|
2026-09-20 02:44:51 +08:00
|
|
|
|
> —— 此前它们是"人工从 `TIMESTAMP` 改成 `DATETIME` 时漏掉了 `ON UPDATE`"的偏离状态,
|
|
|
|
|
|
> 所以**这些表的 `updated_at` 从"不会自动刷新"变成"随 `UPDATE` 自动刷新"**,
|
|
|
|
|
|
> 看到 `updated_at` 变动属预期行为。同批变更还把两库全部时间列统一成 `DATETIME`
|
|
|
|
|
|
> (详见 `software/api-server/src/db/migrate.rs` 的 `LEGACY_COLUMN_TARGETS`)。
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
### 证书
|
|
|
|
|
|
|
|
|
|
|
|
- 阿里云免费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
|
|
|
|
|
|
|
|
|
|
|
|
## 开发约束
|
|
|
|
|
|
|
|
|
|
|
|
### 禁止操作
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
|
2026-07-02 05:38:01 +08:00
|
|
|
|
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
|
|
|
|
|
|
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
|
2026-07-04 04:03:02 +08:00
|
|
|
|
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
### 编码任务下发
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md)
|
|
|
|
|
|
- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json)
|
2026-09-20 03:23:00 +08:00
|
|
|
|
- 完成后由 PM(本 agent)验收:按「验收流程」的 7 项(cargo check + test → 前端真实类型检查 → 结构漂移巡检 → 端到端)逐条跑 → 并行子代理审查代码 → 不通过则写返工任务文件
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
|
|
|
|
|
|
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
|
|
|
|
|
|
- 已完成的任务文件及时清理
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
### 质量约束模板
|
|
|
|
|
|
|
|
|
|
|
|
所有编码任务必须追加以下约束:
|
|
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
1. 完成代码后执行对应栈静态校验(Rust→`cargo check`;TS→**`npm run typecheck`(即 `tsc -b`)**),零报错零警告
|
|
|
|
|
|
- ⛔ **不要写 `tsc --noEmit`**:`software/web/tsconfig.json` 是 **solution 型**(`files: []` + `references`),
|
|
|
|
|
|
不带 `-b` 时 tsc **一个文件都不编译、直接 exit 0** ⇒ 假绿。详见「踩过的坑」第 2 条
|
2026-07-02 05:38:01 +08:00
|
|
|
|
2. 分层拆分,单函数≤80行,命名语义化,完整注释
|
|
|
|
|
|
3. 所有外部IO/网络请求异常捕获,禁止裸panic
|
|
|
|
|
|
4. 分支逻辑全覆盖,不遗漏兜底分支
|
|
|
|
|
|
5. 常量抽离,不使用废弃API
|
|
|
|
|
|
6. Rust:内存安全,合理管理所有权,禁用unsafe无合理理由
|
|
|
|
|
|
7. React:函数式组件+Hooks,不写Class组件
|
2026-09-20 03:23:00 +08:00
|
|
|
|
8. **时间阈值类 SQL 必须配"刚建的不命中 + 超期的命中"双向断言**(见「踩过的坑」第 1 条)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
### 验收流程
|
|
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
**实际执行的验收项(逐条都要跑,缺一条不算通过):**
|
|
|
|
|
|
|
|
|
|
|
|
```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 项全绿
|
2026-09-19 23:40:46 +08:00
|
|
|
|
2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
|
|
|
|
|
|
3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
|
|
|
|
|
|
4. 审查通过 → 清理任务文件,更新文档
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
> ⚠️ **验收命令必须先自证能失败**:写完一条"通过"的验收命令,**故意造一个该命令应当抓到的缺陷,确认它真的报错**,
|
|
|
|
|
|
> 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(`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**。
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Rust 编码规范
|
|
|
|
|
|
|
|
|
|
|
|
### 错误处理
|
|
|
|
|
|
|
|
|
|
|
|
- 使用 `thiserror` 定义业务错误枚举,`AppError` 统一包装
|
|
|
|
|
|
- 所有 `sqlx` / `redis` 调用必须 `.map_err(AppError::Database)?`
|
|
|
|
|
|
- 网络请求/外部IO 必须 `match` 或 `if let` 处理,禁止裸 `.unwrap()`
|
|
|
|
|
|
- 关键业务路径用 `tracing::error!` 记录错误上下文
|
|
|
|
|
|
|
|
|
|
|
|
### Async 模式
|
|
|
|
|
|
|
|
|
|
|
|
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
|
|
|
|
|
|
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 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` 并发处理。
|
2026-07-04 04:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
### 模块划分
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
src/
|
|
|
|
|
|
├── routes/ # HTTP 路由处理器(按业务拆分)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
├── middleware/ # 中间件(认证、日志、CORS、scope_guard 组织归属校验)
|
2026-07-04 04:03:02 +08:00
|
|
|
|
├── db/ # 数据库连接池、迁移
|
2026-09-19 23:40:46 +08:00
|
|
|
|
├── commands/ # 设备指令下发逻辑(含 AppState 定义)
|
2026-07-04 04:03:02 +08:00
|
|
|
|
├── 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`(如用户信息、权限)
|
|
|
|
|
|
|
|
|
|
|
|
### 样式规范
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 使用 Arco Design 组件库,优先使用组件自带的样式
|
|
|
|
|
|
- 内联 style 仅用于动态计算值,静态样式用 CSS class
|
2026-07-04 04:03:02 +08:00
|
|
|
|
- 表格列统一 `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信号显示`
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- `fix(mp): 修复登录后token未持久化问题`
|
2026-07-04 04:03:02 +08:00
|
|
|
|
- `docs(api): 更新充电记录接口文档`
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 测试标准
|
|
|
|
|
|
|
|
|
|
|
|
### 测试类型
|
|
|
|
|
|
|
|
|
|
|
|
| 类型 | 工具 | 要求 |
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
| 单元测试 | Rust `#[test]` / Vitest | 核心业务逻辑必须覆盖 |
|
|
|
|
|
|
| 集成测试 | API 请求验证 | 关键接口(登录/充电/开门)必须验证 |
|
2026-09-20 03:23:00 +08:00
|
|
|
|
| 静态校验 | `cargo check` / **`npm run typecheck`(`tsc -b`)** | 零报错零警告;⛔ TS 侧不得用 `tsc --noEmit`(solution 型 tsconfig 下空跑假绿) |
|
2026-07-04 04:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
### 测试覆盖率
|
|
|
|
|
|
|
|
|
|
|
|
- 核心业务逻辑(充电控制、指令下发、权限校验):≥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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 安全规范
|
|
|
|
|
|
|
|
|
|
|
|
### 认证授权
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- JWT Token 有效期:24小时,过期需重新登录
|
|
|
|
|
|
- JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret
|
2026-07-04 04:03:02 +08:00
|
|
|
|
- 密码存储:argon2 哈希,禁止明文或 MD5/SHA1
|
2026-09-20 03:23:00 +08:00
|
|
|
|
- RBAC 权限码:精确到按钮级别(如 `charge:open_door`、`device:operate`;权威清单=`db/migrate.rs::seed_permissions` 的 31 条)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
|
2026-07-04 04:03:02 +08:00
|
|
|
|
- 敏感操作(删除/禁用/开门)需二次确认
|
2026-09-20 02:44:51 +08:00
|
|
|
|
- **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`)。
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
|
|
|
|
|
|
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
|
|
|
|
|
|
- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定
|
|
|
|
|
|
- **未登录连接不予保持**:建连后 30 秒内未 `login` 成功即主动断开
|
2026-07-04 04:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
### 输入校验
|
|
|
|
|
|
|
|
|
|
|
|
- 所有 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 格式,懒加载
|
|
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
## 部署与维护
|
|
|
|
|
|
|
|
|
|
|
|
### 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@"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 数据库连接
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
所有数据库操作通过 ZeroTier 直接连接 `10.8.0.252`,无需 SSH 隧道到服务器。
|
|
|
|
|
|
|
|
|
|
|
|
**MySQL(通过 ZeroTier):**
|
2026-07-02 14:58:47 +08:00
|
|
|
|
```bash
|
2026-07-04 04:03:02 +08:00
|
|
|
|
# 命令行
|
2026-07-02 14:58:47 +08:00
|
|
|
|
mysql -h 10.8.0.252 -u root -p
|
|
|
|
|
|
# 密码:Hbhyg731024@
|
|
|
|
|
|
# 测试环境库:pms_dev
|
|
|
|
|
|
# 正式环境库:pms
|
2026-07-04 04:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```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') # 测试环境
|
|
|
|
|
|
```
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
**Redis(通过 ZeroTier):**
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 命令行
|
2026-07-02 14:58:47 +08:00
|
|
|
|
redis-cli -h 10.8.0.252 -a "Hbhyg731024@"
|
2026-07-04 04:03:02 +08:00
|
|
|
|
# 测试环境: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) # 测试环境
|
2026-07-02 14:58:47 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
**数据库工具 `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 直连方式。
|
|
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
### Nginx配置
|
|
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
> ⚠️ **下面是"骨架节选",不是线上全文** —— 线上配置还包含(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 条。
|
|
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
**正式环境** (`/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;
|
|
|
|
|
|
|
2026-07-02 15:43:59 +08:00
|
|
|
|
# 产品介绍页
|
|
|
|
|
|
root /data/landing;
|
2026-07-02 14:58:47 +08:00
|
|
|
|
index index.html;
|
|
|
|
|
|
|
2026-07-02 15:18:45 +08:00
|
|
|
|
location /doc/ {
|
2026-07-02 14:58:47 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
# PMS API 入口(微服务统一前缀 /api/{服务})
|
|
|
|
|
|
location /api/pms/ {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
proxy_pass http://127.0.0.1:9000/api/;
|
2026-07-02 14:58:47 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 15:43:59 +08:00
|
|
|
|
# PMS管理前端
|
2026-07-02 14:58:47 +08:00
|
|
|
|
location /pms/ {
|
2026-07-02 15:43:59 +08:00
|
|
|
|
alias /data/pms/web/;
|
2026-07-02 14:58:47 +08:00
|
|
|
|
try_files $uri $uri/ /pms/index.html;
|
|
|
|
|
|
}
|
2026-07-03 04:50:01 +08:00
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
# 微信回调(免认证)
|
|
|
|
|
|
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;
|
2026-07-03 04:50:01 +08:00
|
|
|
|
}
|
2026-07-02 14:58:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**测试环境** (`/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;
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
# PMS API 入口(微服务统一前缀 /api/{服务})
|
|
|
|
|
|
location /api/pms/ {
|
|
|
|
|
|
proxy_pass http://127.0.0.1:3000/api/;
|
2026-07-02 14:58:47 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-03 04:50:01 +08:00
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
# 微信回调(免认证)
|
|
|
|
|
|
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;
|
2026-07-04 04:03:02 +08:00
|
|
|
|
}
|
2026-07-02 14:58:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> 测试环境SSL证书与正式环境共用(test是app证书的SAN域名),部署时复制pem+key即可。
|
|
|
|
|
|
|
|
|
|
|
|
### 部署步骤
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
> ✅ **推荐用一键脚本(在本地开发机执行)**:
|
|
|
|
|
|
> ```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`(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。
|
|
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
**1. 本地编译**
|
|
|
|
|
|
```bash
|
2026-09-19 23:40:46 +08:00
|
|
|
|
# 后端(Rust)— Windows 交叉编译 Linux 目标
|
2026-07-02 14:58:47 +08:00
|
|
|
|
cd software/api-server
|
2026-09-19 23:40:46 +08:00
|
|
|
|
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
|
|
|
|
|
cd software/device-server
|
2026-09-19 23:40:46 +08:00
|
|
|
|
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
# 前端(React 管理端)
|
2026-07-02 14:58:47 +08:00
|
|
|
|
cd software/web
|
|
|
|
|
|
npm run build
|
2026-09-19 23:40:46 +08:00
|
|
|
|
|
|
|
|
|
|
# 微信小程序用户端(uni-app)
|
|
|
|
|
|
cd software/h5-uniapp
|
|
|
|
|
|
npm run build:mp-weixin
|
|
|
|
|
|
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
2026-07-02 14:58:47 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**2. 传输到服务器**
|
2026-09-20 03:23:00 +08:00
|
|
|
|
|
|
|
|
|
|
> 产物名以 `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/` 下根本没有这个文件。
|
|
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
```bash
|
|
|
|
|
|
# 传输后端二进制(测试环境)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
|
2026-09-20 03:23:00 +08:00
|
|
|
|
# ⚠️ 测试环境的落地文件名目前是 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
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
# 传输后端二进制(正式环境)—— 生产落地名就是 Cargo.toml 产物名
|
2026-09-19 23:40:46 +08:00
|
|
|
|
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/
|
2026-09-20 03:23:00 +08:00
|
|
|
|
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms/device-server/
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
|
|
|
|
|
# 传输前端静态文件
|
|
|
|
|
|
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-20 03:23:00 +08:00
|
|
|
|
> 服务器实测(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 条),并在报告里注明。
|
2026-09-19 23:40:46 +08:00
|
|
|
|
|
|
|
|
|
|
**2.1 微信小程序上传**
|
|
|
|
|
|
|
|
|
|
|
|
小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传:
|
|
|
|
|
|
|
|
|
|
|
|
1. 打开微信开发者工具
|
|
|
|
|
|
2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin`
|
|
|
|
|
|
3. 填入 AppID(已配置在 `manifest.json`)
|
|
|
|
|
|
4. 预览/真机调试 → 上传体验版/正式版
|
2026-07-03 04:50:01 +08:00
|
|
|
|
|
2026-07-02 14:58:47 +08:00
|
|
|
|
**3. 服务器重启服务**
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# SSH到服务器
|
|
|
|
|
|
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
# 正式环境
|
|
|
|
|
|
systemctl restart pms-api
|
|
|
|
|
|
systemctl restart pms-device
|
2026-07-02 14:58:47 +08:00
|
|
|
|
|
|
|
|
|
|
# 重启Nginx
|
|
|
|
|
|
systemctl restart nginx
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 服务管理
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 查看服务状态
|
2026-09-19 23:40:46 +08:00
|
|
|
|
systemctl status pms-api # 正式环境API
|
|
|
|
|
|
systemctl status pms-device # 正式环境TCP
|
2026-07-02 14:58:47 +08:00
|
|
|
|
systemctl status nginx
|
|
|
|
|
|
|
|
|
|
|
|
# 查看日志
|
2026-09-19 23:40:46 +08:00
|
|
|
|
journalctl -u pms-api -f
|
|
|
|
|
|
journalctl -u pms-device -f
|
2026-07-02 14:58:47 +08:00
|
|
|
|
tail -f /var/log/nginx/error.log
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
## 硬件架构
|
|
|
|
|
|
|
|
|
|
|
|
- 柜控板(ID128)×1 + 仓控板(ID1-N)×N,每仓控板管6个仓体
|
|
|
|
|
|
- 仓控板与电源:1TN RS485协议(翌工)
|
|
|
|
|
|
- 仓控板与BMS:星恒Modbus / 天能485 / 无协议(平台可配置)
|
|
|
|
|
|
- 仓控板与柜控板:自定义485协议(14+功能码)
|
|
|
|
|
|
- 柜控板与后台:4G TCP长连接(Air780E模块)
|
|
|
|
|
|
|
|
|
|
|
|
## 开发范围(当前阶段)
|
|
|
|
|
|
|
|
|
|
|
|
### 做
|
|
|
|
|
|
|
|
|
|
|
|
- 后台管理系统(Web端)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
- 设备TCP通讯服务(协议文档已就绪)
|
|
|
|
|
|
|
|
|
|
|
|
### 不做
|
|
|
|
|
|
|
|
|
|
|
|
- 支付/计费(本系统不涉及)
|
|
|
|
|
|
|
|
|
|
|
|
## 组织架构
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
组织(企业) → 项目(放置地点) → 设备(充电柜)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。
|
|
|
|
|
|
|
2026-09-20 02:44:51 +08:00
|
|
|
|
### 层级与可见性(**全系统统一口径,所有组织隔离改动一律以此为准**)
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
柜子工厂(我们,总管理员 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` 归属,与项目/设备没有关联列,项目级范围无法映射。
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
## 数据模型
|
|
|
|
|
|
|
|
|
|
|
|
### 核心关系
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
organization(组织/企业)
|
|
|
|
|
|
├── project(项目/放置地点)
|
|
|
|
|
|
│ └── cabinet(充电柜)
|
2026-09-20 03:23:00 +08:00
|
|
|
|
│ ├── 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)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
**外键与唯一键要点(改动前必读)**
|
|
|
|
|
|
- `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`(唯一) 同理。
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
### 数据库表
|
|
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
**实测:测试库 `pms_dev` 与生产库 `pms` 各 21 张表**(依据:
|
|
|
|
|
|
`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=...`,两库表名完全一致)。
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
| 表名 | 说明 |
|
|
|
|
|
|
|------|------|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
| `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` 查。
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-07-03 02:45:00 +08:00
|
|
|
|
### Redis 实时状态存储
|
|
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
> **权威依据:`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`(路由)。**
|
|
|
|
|
|
> 下表是**逐字段核对代码后的实际写入契约**,不是设计稿。
|
2026-07-03 02:45:00 +08:00
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
**层级结构(实测:测试库 db6 的真实 key/hash 与下表完全一致):**
|
2026-07-03 02:45:00 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
2026-09-20 03:23:00 +08:00
|
|
|
|
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 维护,供结单取电量)
|
2026-07-03 02:45:00 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
> ⚠️ **关键事实(旧文档写错了,逐条纠正):**
|
|
|
|
|
|
> 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 的位定义表),**不再有"待硬件团队确认"的悬置项**。
|
|
|
|
|
|
|
2026-07-03 02:45:00 +08:00
|
|
|
|
**写入时机:**
|
2026-09-20 03:23:00 +08:00
|
|
|
|
|
|
|
|
|
|
| 事件 | 写入内容 |
|
|
|
|
|
|
|------|----------|
|
|
|
|
|
|
| `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/`。
|
2026-07-03 02:45:00 +08:00
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
## TCP通讯协议
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
> 📌 **文档口径优先级(避免"两份文档谁对"的扯皮)**:
|
|
|
|
|
|
> 1. **`docs/柜控板固件对接文档.md`** —— 设备协议(帧格式/`act`/字段/签名)的**唯一权威**;
|
|
|
|
|
|
> 2. **本文件(AGENTS.md)** —— **平台侧实现口径**的权威:Redis 键/字段、接口行为、库结构、
|
|
|
|
|
|
> 验收流程、部署注意事项。凡与 `docs/API.md` 的**平台存储/行为描述**不一致时,**以本文件为准**
|
|
|
|
|
|
> (本文件的每一条都按代码契约与库实测核对过);
|
|
|
|
|
|
> 3. **`docs/API.md`** —— **HTTP 接口清单与响应结构**的权威(含数据单位规范);
|
|
|
|
|
|
> 4. 代码与文档冲突时:**先确认是不是代码错**;确认代码对就改文档并在报告里点出。
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
## 数据单位规范
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
> ✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 `write_number` API 参数为 f64(`i64 as f64` 转换,非业务浮点)。
|
|
|
|
|
|
|
|
|
|
|
|
## 微信小程序用户端
|
|
|
|
|
|
|
|
|
|
|
|
### 小程序配置
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
| 参数 | 值 |
|
|
|
|
|
|
|------|-----|
|
|
|
|
|
|
| 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/,用微信开发者工具导入上传
|
|
|
|
|
|
```
|
2026-07-02 17:07:01 +08:00
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
### 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
|
|
|
|
|
|
```
|
2026-07-02 17:07:01 +08:00
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
### 信号强度显示(RSSI)
|
2026-07-02 17:07:01 +08:00
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
设备卡片右上角显示 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。
|
2026-07-02 17:07:01 +08:00
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
**相关文件:**
|
2026-09-20 03:23:00 +08:00
|
|
|
|
- 纯函数(权威): `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`
|
2026-07-04 04:03:02 +08:00
|
|
|
|
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
|
2026-09-20 03:23:00 +08:00
|
|
|
|
- 一致性用例: `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 格)。
|
|
|
|
|
|
> 影响页面:小程序首页设备卡片、小程序设备详情页头部。
|
2026-07-02 17:07:01 +08:00
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
### 近期 UI 改动
|
2026-07-02 17:07:01 +08:00
|
|
|
|
|
2026-09-20 03:23:00 +08:00
|
|
|
|
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 禁用`)—— 只是**卡片**去掉了,别理解成"全平台都去掉了"。
|
2026-07-04 04:03:02 +08:00
|
|
|
|
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
|
|
|
|
|
|
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
|
2026-07-02 17:07:01 +08:00
|
|
|
|
|
2026-09-20 02:44:51 +08:00
|
|
|
|
### 状态位展示约定(**状态位按归属层级展示**)
|
|
|
|
|
|
|
|
|
|
|
|
> 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。
|
|
|
|
|
|
|
|
|
|
|
|
1. **柜级状态位 → 显示在"柜子"层面**
|
2026-09-20 03:23:00 +08:00
|
|
|
|
- 数据:**⚠️ 这两个名字不是 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`(详情)。
|
2026-09-20 02:44:51 +08:00
|
|
|
|
- 位定义(§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 断言两端一致)。
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
### 开发约束(uni-app 特定)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
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>` 上写死
|
2026-09-19 23:40:46 +08:00
|
|
|
|
|
|
|
|
|
|
## 代码审查豁免
|
|
|
|
|
|
|
|
|
|
|
|
以下模式已确认无需修改:
|
|
|
|
|
|
|
|
|
|
|
|
| 审查项 | 原因 |
|
|
|
|
|
|
|--------|------|
|
|
|
|
|
|
| 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`),设备侧不可见 |
|