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

34 KiB
Raw Blame History

AGENTS.md - 充电柜项目工作手册

项目简介

充电柜矩阵系统(Power Matrix System / PMS) 产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。 仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。

设备标识

  • 平台抽象ID:10-00000000(绑定柜控板,二维码对应此ID)
  • 设备ID:4G SIM卡15位IMEI(10进制)
  • 仓体全局ID:平台层ID-仓板ID-仓体ID
  • 扫码入口:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 /api/pms/)

技术栈

  • 后端:Rust + Axum
  • 前端:React + Arco Design Pro(React版)
  • 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
  • 数据库:MySQL(阿里云RDS),开发/生产共用
  • 缓存:Redis(阿里云Redis),开发/生产共用
  • 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
  • 权限:RBAC,精确到按钮级别(权限码如 device🚪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:

# 列出所有表
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 设计规范

响应格式

{
  "code": 0,           // 0=成功,非0=失败
  "data": { ... },     // 业务数据
  "message": "ok"      // 提示信息
}

错误码

code 含义
0 成功
400 请求参数错误
401 未认证(token缺失/无效)
403 无权限
404 资源不存在
500 服务器内部错误

分页约定

{
  "code": 0,
  "data": {
    "list": [...],
    "total": 100,
    "page": 1,
    "page_size": 20
  }
}

RESTful 约定

方法 路径 说明
GET /api/xxx 列表查询
GET /api/xxx/:id 详情
POST /api/xxx 创建
PUT /api/xxx/:id 更新
DELETE /api/xxx/:id 删除

Git 工作流

分支策略

  • main — 正式环境,只接受 PR 合并
  • develop — 开发分支,日常开发在此分支
  • feature/* — 功能分支,从 develop 拉出,完成后合并回 develop
  • fix/* — 修复分支,从 main 或 develop 拉出

Commit 规范

格式:<type>(<scope>): <subject>

type 说明
feat 新功能
fix 修复 bug
docs 文档变更
style 代码格式(不影响逻辑)
refactor 重构(不新增功能/修复bug)
test 测试相关
chore 构建/工具变更

示例:

  • feat(cabinets): 添加设备RSSI信号显示
  • fix(mp): 修复登录后token未持久化问题
  • docs(api): 更新充电记录接口文档

测试标准

测试类型

类型 工具 要求
单元测试 Rust #[test] / Vitest 核心业务逻辑必须覆盖
集成测试 API 请求验证 关键接口(登录/充电/开门)必须验证
静态校验 cargo check / 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 执行时间

日志格式

// 使用 tracing crate
tracing::info!(imei = %imei, board_id = board_id, "设备登录成功");
tracing::error!(err = %e, "数据库查询失败");
tracing::warn!(compartment_id = id, "仓体状态异常");

关键操作日志

以下操作必须记录:

操作 日志内容
用户登录/登出 用户ID、手机号、IP
设备指令下发 IMEI、指令类型、仓体ID
充电开始/结束 仓体ID、时长、电量
组织/项目变更 操作类型、ID、名称
权限变更 用户ID、权限码、操作人
数据删除 表名、ID、操作人

性能规范

数据库

  • 查询必须走索引,禁止全表扫描(EXPLAIN 验证)
  • 分页查询:避免深分页,用游标分页(WHERE id > ?)
  • 批量操作:用 INSERT ... VALUES (...), (...) 而非逐条插入
  • 连接池:MySQL 最大连接数 ≤ 50,Redis 连接数 ≤ 20

Redis

  • 缓存 key 规范:{业务}:{标识}(如 device:{imei})
  • TTL 设置:在线状态 300秒,缓存数据按业务设定
  • 避免大 Key:单个 Hash ≤ 500 字段,List ≤ 1000 元素
  • 批量操作:用 Pipeline 减少往返次数

API 响应

  • 列表接口响应时间 ≤ 500ms
  • 详情接口响应时间 ≤ 200ms
  • 批量操作接口响应时间 ≤ 1s
  • 响应体大小:列表 ≤ 1MB,详情 ≤ 100KB

前端

  • 首屏加载 ≤ 3s(3G网络)
  • 交互响应 ≤ 100ms
  • 列表滚动:虚拟滚动(≥100条数据)
  • 图片:WebP 格式,懒加载

部署与维护

SSH连接

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

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

数据库连接

所有数据库操作通过 ZeroTier 直接连接 10.8.0.252,无需 SSH 隧道到服务器。

MySQL(通过 ZeroTier):

# 命令行
mysql -h 10.8.0.252 -u root -p
# 密码:Hbhyg731024@
# 测试环境库:pms_dev
# 正式环境库:pms
# 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):

# 命令行
redis-cli -h 10.8.0.252 -a "Hbhyg731024@"
# 测试环境:select 6
# 正式环境:select 5
# 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):

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

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

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

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

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

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

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

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

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

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

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

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

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

    root /data/pms_dev;
    index index.html;

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

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

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

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

部署步骤

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

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

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

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

1. 本地编译

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

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

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

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

2. 传输到服务器

# 传输后端二进制(测试环境)
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. 服务器重启服务

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

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

# 重启Nginx
systemctl restart nginx

服务管理

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

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

硬件架构

  • 柜控板(ID128)×1 + 仓控板(ID1-N)×N,每仓控板管6个仓体
  • 仓控板与电源:1TN RS485协议(翌工)
  • 仓控板与BMS:星恒Modbus / 天能485 / 无协议(平台可配置)
  • 仓控板与柜控板:自定义485协议(14+功能码)
  • 柜控板与后台:4G TCP长连接(Air780E模块)

开发范围(当前阶段)

做

  • 后台管理系统(Web端)
  • 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传)
  • 设备TCP通讯服务(协议文档已就绪)

不做

  • 支付/计费(本系统不涉及)

组织架构

组织(企业) → 项目(放置地点) → 设备(充电柜)

设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。

数据模型

核心关系

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

数据单位规范

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

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

微信小程序用户端

小程序配置

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

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

构建命令

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

API 域名配置

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

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

API base URL

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

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

信号强度显示(RSSI)

设备卡片右上角使用 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 灰色(离线)

实现方式(小程序):

<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),设备侧不可见