charging-cabinet/AGENTS.md
12451 999885c38f feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复
协议(唯一权威文档:docs/柜控板固件对接文档.md)
- 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc
- dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除)
- id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI
- msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报
- 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login

平台侧实现
- 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status
- 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库)
- 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除
- 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs)
- 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单

修复(均为部署后才暴露的问题)
- 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是
  "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒
- sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上)
- 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic)
- 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换

部署
- 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向)
- 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测)
- 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、
  并用本地 .env 覆盖线上 .env)

质量
- 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约)
- 两服务 cargo check 零错误零告警;web tsc --noEmit 通过
- test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:41:09 +08:00

960 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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,精确到按钮级别(权限码如 device:door:unlock)
## 项目结构
```
D:\charging-cabinet\
├── tasks/ # 任务文件(opencode执行)
├── docs/ # 开发文档、协议文档
├── deploy/ # 部署配置文件
│ ├── nginx/ # Nginx配置
│ ├── *.service # systemd服务单元
│ └── deploy-*.sh # 部署脚本
├── tools/ # 开发工具
│ └── db.py # 数据库工具
├── software/
│ ├── api-server/ # Rust API服务
│ ├── device-server/ # Rust TCP设备服务
│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
│ └── web/ # React前端(WEB管理端)
├── hardware/ # 硬件资料(原理图/PCB等)
└── test/ # 测试用例/报告
```
> **技能库不入库** — 仓库内不存放任何 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` / `pms-device-test`
> 已停止并卸载,`:3000` / `:3001` 已释放。
> - `test.anzhizhichong.com` 仍服务于静态前端(`/data/pms_dev/web`),但其 `/api/pms/` 因为
> **后端不在**会返回 **502** —— 这是**刻意的**:此前它指向 `:3000`,而 `:3000` 曾是生产 API,
> 等于**测试后台在操作生产数据**。**不要再把测试域名的 API 指回生产端口。**
> - 如需临时联调环境:`/data/pms_dev/*`(含 `.env`、旧二进制)与 `pms_dev` 库、Redis db6 均**保留**,
> 重新安装 `deploy/pms-*-test.service` 即可恢复(端口用 3000/3001)。
**部署路径:**
- 产品介绍页:/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
```
### 证书
- 阿里云免费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)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
- 已完成的任务文件及时清理
### 质量约束模板
所有编码任务必须追加以下约束:
1. 完成代码后执行对应栈静态校验(Rust→cargo check,TS→tsc --noEmit),零报错零警告
2. 分层拆分,单函数≤80行,命名语义化,完整注释
3. 所有外部IO/网络请求异常捕获,禁止裸panic
4. 分支逻辑全覆盖,不遗漏兜底分支
5. 常量抽离,不使用废弃API
6. Rust:内存安全,合理管理所有权,禁用unsafe无合理理由
7. React:函数式组件+Hooks,不写Class组件
### 验收流程
1. `cargo check` 两个服务(api-server + device-server)零错误
2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
4. 审查通过 → 清理任务文件,更新文档
---
## 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` / `tsc --noEmit` | 零报错零警告 |
### 测试覆盖率
- 核心业务逻辑(充电控制、指令下发、权限校验):≥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 权限码:精确到按钮级别(如 `device:door:unlock`)
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
- 敏感操作(删除/禁用/开门)需二次确认
- TCP 连接认证:设备 login 成功(响应含 `spec` + `suc=1`)前,敏感指令(open/pow_on/pow_off/reset/auth_str)不投递
- 协议 `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配置
**正式环境** (`/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. 传输到服务器**
```bash
# 传输后端二进制(测试环境)
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms_dev/device-server/
# 传输后端二进制(正式环境)
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/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/ # 正式环境
```
> ⚠️ 后端二进制必须用 `cargo zigbuild --release --target x86_64-unknown-linux-gnu` 交叉编译,服务器 2核/3.5G 会 OOM,禁止在服务器上编译 Rust。
>
> ⚠️ **`pscp`/`scp` 上传后必须 `chmod +x`**(否则 systemd 报 `203/EXEC`);
> 且**运行中的二进制不可直接覆盖**(Linux 报 `ETXTBSY`)—— 正确姿势:
> 传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。
>
> ⚠️ **`.env` 不支持行内注释**:`PORT=3000 # 注释` 会让配置解析失败、服务起不来,注释必须独占一行。
**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通讯服务(协议文档已就绪)
### 不做
- 支付/计费(本系统不涉及)
## 组织架构
```
组织(企业) → 项目(放置地点) → 设备(充电柜)
```
设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。
## 数据模型
### 核心关系
```
organization(组织/企业)
├── project(项目/放置地点)
│ └── cabinet(充电柜)
│ ├── cabin_board(仓控板×N)
│ │ └── compartment(仓体×6)
│ ├── charge_record(充电记录)
│ └── energy_stat(能耗统计)
├── user(用户)
└── device_log(设备日志)
operation_log(操作日志)
```
### 数据库表
| 表名 | 说明 |
|------|------|
| organizations | 组织表 |
| projects | 项目表 |
| cabinets | 充电柜表(IMEI、abstract_id、auth_str) |
| cabin_boards | 仓控板表 |
| compartments | 仓体表 |
| users | 用户表(手机号登录) |
| charge_records | 充电记录表 |
| device_logs | 设备日志表 |
| energy_stats | 能耗统计表 |
| operation_logs | 操作日志表 |
### Redis 实时状态存储
设备实时状态通过 Redis Hash 层级存储,按 `status_post` 上报内容解析写入。
**层级结构:**
```
device:{imei} # 设备级 Hash
├─ online = "1" # 在线标记(TTL 300秒)
├─ rssi = "85" # 信号强度
├─ pow_fail_dc = "0/1" # 直流停电(12V掉电)
├─ pow_fail_ac = "0/1" # 交流停电(380V掉电)
└─ device:{imei}:board:{board_idx} # 板级 Hash(仓控板1~N)
├─ status_hex = "000100020003000400050006" # 原始hex状态
└─ device:{imei}:board:{board_idx}:ch:{0-5} # 通道级 Hash(6仓)
├─ on = "0/1" # 充电中
├─ full = "0/1" # 充满
├─ fault = "0/1" # 故障
└─ hex = "00010002" # 原始hex
```
**写入时机:**
| 事件 | 写入位置 | 说明 |
|------|----------|------|
| `login` | `device:{imei}.online` | 标记在线,设置TTL 300秒 |
| `status_post` | `device:{imei}` + board + ch | 解析status字典,按层级写入 |
| `pow_fail` | `device:{imei}.pow_fail_dc/ac` | 更新停电状态 |
**通道状态映射(hex → 语义):**
- 空闲(idle)
- 插入(inserted)
- 充电中(charging)
- 充满(full)
- 故障(fault)
> ⚠️ hex编码格式待硬件团队最终确认,当前实现预留状态字段,按 bit 位解析。
**API 接口:**
| 接口 | 说明 | 查询方式 |
|------|------|----------|
| `GET /api/cabinets/filter` | 设备列表(设备级状态) | Pipeline 批量 HGET,1次往返 |
| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | KEYS 扫描 + HMGET |
## TCP通讯协议
详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
## 数据单位规范
详见 [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)
设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和小程序共用同一套逻辑。
**RSSI 映射:**
| RSSI 范围 | 图标 (Lucide) | 信号格 | 颜色 |
|-----------|--------------|--------|------|
| ≥ 70 | signal | 5 格(含 M22) | #16a34a 绿色 |
| ≥ 55 | signal-high | 4 格(含 M17) | #2563eb 蓝色 |
| ≥ 40 | signal-medium | 3 格(含 M12) | #f59e0b 黄色 |
| ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 |
| < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) |
**实现方式(小程序):**
```vue
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" ...>
<path v-if="(rssi ?? -1) >= 30" d="M7 20v-4"/>
<path v-if="(rssi ?? -1) >= 40" d="M12 20v-8"/>
<path v-if="(rssi ?? -1) >= 55" d="M17 20V8"/>
<path v-if="(rssi ?? -1) >= 70" d="M22 4v16"/>
<path d="M2 20h.01"/>
</svg>
```
- `v-if` 按 RSSI 阈值动态显示信号格
- `<text>` 不支持 `display: flex`,统一用 `<view>` 包裹
**实现方式(PC 管理端):**
React 组件 `SignalIcon`,同上逻辑用条件渲染 `<path>`。
**后端 RSSI 获取:**
设备列表接口通过 Redis pipeline 批量 HGET 查询 `device:{imei}` 的 `rssi` 字段:
```
HSET device:{imei} online 1 rssi 85
```
**相关文件:**
- PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件
- 小程序: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板
- 小程序 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
### 近期 UI 改动
1. **设备卡片头部**:去掉"在线/离线"文字 badge,改为信号图标 + RSSI 数值
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
### 开发约束(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`),设备侧不可见 |