- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
45 KiB
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 单元、nginxapp.anzhizhichong.com.conf的proxy_pass均已改);单元已补EnvironmentFile=/data/pms/*/.env(新二进制强制要求环境变量,缺它起不来)。- 测试环境按本表规划独立部署在同一台服务器上(测试就是测试,与生产完全隔离):
pms-api-test :3000+pms-device-test :3001,路径/data/pms_dev,连pms_dev库 + Redis db6;test.anzhizhichong.com的 nginx/api/pms/指向:3000。- ⚠️ 绝对不要把测试域名的 API 指到生产端口
:9000—— 历史事故:测试 nginx 曾指向:3000,而当时:3000正是生产 API,等于测试后台在操作生产数据。 判断方法:test域名/api/pms/返回 401(有测试后端)是对的;若返回 502 说明测试后端没起。- 两个环境必须同时满足:生产
:9000/:9001(db5/pms)、测试:3000/:3001(db6/pms_dev), 端口或库号任一串了就是事故。
部署路径:
- 产品介绍页:/data/landing
- 正式环境:/data/pms(api-server、device-server、web)
- 测试环境:/data/pms_dev(api-server、device-server、web)
Nginx配置:
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
- 测试: /etc/nginx/conf.d/test.anzhizhichong.com.conf
服务器信息
- 外网IP: 118.31.119.206
- 内网IP: 172.25.42.239
- 系统: CentOS Stream 9(2核/3.5G/49G)
- ZeroTier: 网络ID
63e843dd42f54b9c,服务器节点12a91dd47d,IP10.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
结构漂移检查(只读,建议每次改动表结构/迁移后跑一次):
# 测试库 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 卡死、
iccidNULL、SCHEMA_SQL前向引用、测试环境导出 500、生产/api/charge-orders500), 每次都是靠人肉发现。CREATE TABLE IF NOT EXISTS对已存在的表不改列定义, 漂移会长期潜伏。所有时间列必须是DATETIME(sqlx 的NaiveDateTime只与DATETIME兼容,TIMESTAMP只与DateTime<Utc>兼容,用错就是接口 500)。
端到端验收(会造临时行并自动清理,需该环境的 JWT_SECRET):
python test/schema_alignment_e2e.py --env dev --jwt-secret <JWT_SECRET>
python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-prod
⚠️ 2026-09-20 生产结构变更(行为变化,非 bug):
organizations、projects、cabinets、cabin_boards、compartments、users、roles这 7 张表的updated_at恢复为DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP—— 此前它们是"人工从TIMESTAMP改成DATETIME时漏掉了ON UPDATE"的偏离状态, 所以这些表的updated_at从"不会自动刷新"变成"随UPDATE自动刷新", 看到updated_at变动属预期行为。同批变更还把两库全部时间列统一成DATETIME(详见software/api-server/src/db/migrate.rs的LEGACY_COLUMN_TARGETS)。
证书
- 阿里云免费SSL证书(acme.sh自动续期)
- 存放路径:
/etc/nginx/ssl/{域名}.pem+/etc/nginx/ssl/{域名}.key - 已配置: app.anzhizhichong.com、test.anzhizhichong.com
内部服务
- Gitea:
https://app.anzhizhichong.com/doc/(端口3080)- 组织: anzhizhichong
- 仓库: charging-cabinet
- 用户: admin / skyp76
开发约束
禁止操作
- 禁止修改设备与TCP服务的通信协议 — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
- 禁止在服务器上编译Rust — 2核/3.5G会OOM,本地编译传二进制
- 禁止使用socat fork模式 — 进程泄漏会耗尽内存,用nginx stream代理
- 禁止提交密码/密钥到仓库 — 使用环境变量或本地配置文件(已gitignore)
编码任务下发
- 任务需求写到
tasks/目录的md文件(编号递增,如 036-xxx.md) - 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json)
- 完成后由 PM(本 agent)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
- 已完成的任务文件及时清理
质量约束模板
所有编码任务必须追加以下约束:
- 完成代码后执行对应栈静态校验(Rust→cargo check,TS→tsc --noEmit),零报错零警告
- 分层拆分,单函数≤80行,命名语义化,完整注释
- 所有外部IO/网络请求异常捕获,禁止裸panic
- 分支逻辑全覆盖,不遗漏兜底分支
- 常量抽离,不使用废弃API
- Rust:内存安全,合理管理所有权,禁用unsafe无合理理由
- React:函数式组件+Hooks,不写Class组件
验收流程
cargo check两个服务(api-server + device-server)零错误- 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
- 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
- 审查通过 → 清理任务文件,更新文档
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非阻塞轮询 + 空队列时sleep200ms(生产者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 拉出,完成后合并回 developfix/*— 修复分支,从 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 连接登录门控(上行 + 下行,同一规则):同一条 TCP 连接在
login成功前只允许auth_str/login; 其余任何act(含status_post、bat_in、on/off、open/pow_on/pow_off/reset、以及各类应答) 上行一律丢弃、下行一律不投递(设备侧收不到任何字节)。 依据:docs/柜控板固件对接文档.md§2.2 / §10 第 10 条(L1116)/ §4.1(L372)。 判据是「连接级认证标志」(device-server的is_authenticated(dev_id, conn_id), 在投递到该连接的login成功应答——含spec+suc=1——时置位), 不是 Redis 在线键device:{imei}—— 该键在验签前就会被写,用它判在线必然误判。 ⛔ 不要再按"某个act算不算敏感指令"来增删名单:规则是"登录前只有auth_str/login"(常量PRE_LOGIN_ALLOWED_ACTS)。 - 协议
id只表示动作目标、不含 IMEI:柜级省略 / 一段=单板("01")/ 两段=仓体级("01-3") - 平台应答必带
act(与请求一致)+msg_id(与请求一致);判"应答 vs 指令"只看有没有suc dev_id只在auth_str/login携带(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定- 未登录连接不予保持:建连后 30 秒内未
login成功即主动断开
输入校验
- 所有 API 入参必须校验(长度、格式、范围)
- SQL 查询使用参数化查询,禁止字符串拼接
- IMEI 校验:必须15位数字
- 手机号校验:11位数字,正则
^1[3-9]\d{9}$
数据安全
- 生产数据库密码不入代码,使用环境变量
- Redis 密码同上
- 日志禁止输出密码/Token/密钥
- 敏感字段(密码、auth_str)在序列化时跳过
日志规范
日志级别
| 级别 | 用途 | 示例 |
|---|---|---|
ERROR |
系统异常、业务错误 | 数据库连接失败、指令下发超时 |
WARN |
潜在问题、可恢复异常 | 设备离线、重试成功 |
INFO |
关键业务事件 | 设备登录、充电开始/结束、用户操作 |
DEBUG |
调试信息 | API 请求参数、SQL 执行时间 |
日志格式
// 使用 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/ 不上传到服务器,而是通过微信开发者工具上传:
- 打开微信开发者工具
- 导入项目目录
software/h5-uniapp/dist/build/mp-weixin - 填入 AppID(已配置在
manifest.json) - 预览/真机调试 → 上传体验版/正式版
3. 服务器重启服务
# SSH到服务器
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
# 正式环境
systemctl restart pms-api
systemctl restart pms-device
# 重启Nginx
systemctl restart nginx
服务管理
# 查看服务状态
systemctl status pms-api # 正式环境API
systemctl status pms-device # 正式环境TCP
systemctl status nginx
# 查看日志
journalctl -u pms-api -f
journalctl -u pms-device -f
tail -f /var/log/nginx/error.log
硬件架构
- 柜控板(ID128)×1 + 仓控板(ID1-N)×N,每仓控板管6个仓体
- 仓控板与电源:1TN RS485协议(翌工)
- 仓控板与BMS:星恒Modbus / 天能485 / 无协议(平台可配置)
- 仓控板与柜控板:自定义485协议(14+功能码)
- 柜控板与后台:4G TCP长连接(Air780E模块)
开发范围(当前阶段)
做
- 后台管理系统(Web端)
- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传)
- 设备TCP通讯服务(协议文档已就绪)
不做
- 支付/计费(本系统不涉及)
组织架构
组织(企业) → 项目(放置地点) → 设备(充电柜)
设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。
层级与可见性(全系统统一口径,所有组织隔离改动一律以此为准)
柜子工厂(我们,总管理员 role_level=2)
└── 客户组织(每个客户一个 organization)
└── 客户在自己组织下新建的操作员 / 组织管理员
| 角色 | users.role(role_level) |
可见组织范围 |
|---|---|---|
| 总管理员(工厂) | 2 |
全部组织(跨组织视角,小程序「选择组织」弹层可切换,默认「全部」) |
| 组织管理员(客户) | 1 |
仅自己所属组织(按 users.organization_id) |
| 操作员(客户) | 0 |
仅自己所属组织(按 users.organization_id) |
- 归属链:客户侧用户的组织归属只认
users.organization_id;organization_id为NULL的客户侧账号看不到任何组织/设备(空集,不是报错,也绝不退化成"全部")。 - 两条组织接口的分工(不可混用):
GET /api/organizations—— 管理端(PC)组织管理页,硬校验org:view,返回全量组织(操作员无该权限码 → 403)。语义冻结,不得改动。GET /api/my-organizations—— 小程序「选择组织」专用,仅需登录(不校验org:view),服务端按上表做隔离:工厂全量、客户仅本组织。
- 设备/项目/能耗/日志等所有业务数据的组织隔离,一律按「
role_level >= 2放行全量,否则按organization_id过滤,无组织则空集」实现(例:routes/h5.rs::visible_org_scope、middleware/auth.rs::org_condition)。
数据范围(user_scopes)—— 组织隔离之上的第二道收窄
权威实现:
software/api-server/src/middleware/auth.rs(UserScope/load_user_scopes/CurrentUser::effective_scope)、routes/scope_guard.rs(详情/写操作)、routes/users_perm.rs(配置校验)。 前端入口:PC 用户管理页 → 操作列「数据范围」(web/src/components/UserDataScopeModal.tsx)。
范围粒度到「项目级」(user_scopes.scope_type):
scope_type |
含义 | 是否参与鉴权 |
|---|---|---|
1 |
组织级 | ✅ 参与。只允许配成该用户自己所属的组织,展开为该组织下的全部项目 |
2 |
项目级 | ✅ 参与。实际生效的粒度 |
3 |
设备/柜子级 | ❌ 不参与鉴权(当不存在,读取时直接忽略;写入仍做存在性+归属校验后存档) |
四种情形的行为(必须严格遵守,改动前先读这一节):
| 情形 | 行为 |
|---|---|
| 未配置任何 scope 记录 | 不限制:沿用组织隔离,可见范围与"没做这个功能"时逐字一致。⛔ 绝不能解释成"无权限" —— 现存所有客户账号都没配过,那样会一次锁死全部账号 |
| 已配置(≥1 条有效) | 在组织隔离基础上再收窄:组织隔离 ∧ 项目∈范围(取交集,绝不取并集、绝不放大) |
总管理员(role_level >= 2) |
豁免:始终可见全量,不受 scope 限制(CurrentUser::effective_scope 恒返回"不限制",中间件连库都不查) |
| 有记录但一条有效范围都没有(脏数据 / 只有设备级 / 跨组织 / 项目已删除) | 保守忽略无效行;若因此一条有效范围都不剩 ⇒ 按未配置 = 不限制处理,并记 WARN(绝不把用户锁死) |
scope_id 校验(配置期,PUT /api/users/{id}/scopes):
- 资源必须存在,且属于该用户所在组织,否则
BadRequest(400);- 组织级:
scope_id必须等于目标用户的users.organization_id(不允许跨组织配置); - 项目级:
projects.organization_id必须等于目标用户的users.organization_id; - 设备级:柜子所属项目的组织必须等于目标用户的
users.organization_id(存档但不消费)。
- 组织级:
- 全量替换在事务里执行:校验全部通过前不删旧数据,避免中途失败留下"范围被清空"的中间态。
- 目标用户
organization_id为NULL(客户侧无组织账号)时,任何带归属的范围都不合法 ⇒ 一律 400。
鉴权侧如何消费(改动任何列表/详情接口前必须核对):
- 过滤统一落在项目这一层(
cabinets.project_id/projects.id),只有一种收窄形态:project_id IN (...)(柜子维度用cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (...))); - 列表接口:在已有的组织条件之后用
&&追加范围条件(auth::append_condition); - 详情/写操作:走
routes/scope_guard.rs(或 h5 的verify_cabinet_access),组织校验通过后再校验资源是否落在范围内,不在则Forbidden; - 后台任务(Excel 导出)必须把
scope_project_ids一并落库到任务参数里,否则后台任务会绕过范围; - ⚠️ 配了 scope 的用户被漏叠加的接口 = 数据泄露。新增任何业务列表/详情接口时,同时叠加组织条件与范围条件。
操作日志不叠加范围(经确认,非遗漏):operation_logs 按 user_id → users.organization_id 归属,与项目/设备没有关联列,项目级范围无法映射。
数据模型
核心关系
organization(组织/企业)
├── project(项目/放置地点)
│ └── cabinet(充电柜)
│ ├── cabin_board(仓控板×N)
│ │ └── 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_numberAPI 参数为 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 改动
- 设备卡片头部:去掉"在线/离线"文字 badge,改为信号图标 + RSSI 数值
- 通道统计:图标+数字一行排列,图标用
<svg>内联 Lucide(不加容器,flex 居中) - 右上角扫码:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
状态位展示约定(状态位按归属层级展示)
以下三条是协议 §3.8 / §4.2 / §5.7 的既定结论(产品已确认),后续不要再反复确认。
- 柜级状态位 → 显示在"柜子"层面
- 数据:
cabinet.status1/cabinet.status2(2 位 hex,设备上报原样落 Redisdevice:{imei}.cabinet_status1/2,接口原样透出为status1/status2)。 - 位定义(§3.8):
status1bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|bit4 柜门打开| bit5 加热风扇|bit6 接触器断开|bit7 喷淋;status2bit0 声光报警|bit1 柜门打开超时 (阈值硬件定义,平台只呈现、不自行判定);status2bit2–7 为保留位,不展示。 - 页面:PC
web/src/pages/devices/cabinet.tsx、小程序h5-uniapp/src/pages/device-detail/index.vue。 - 取不到返回
null(未知),绝不落"00"——"00"是"全部正常"的有效值。
- 数据:
- 仓体级状态位 → 显示在"仓体"层面
- 数据:通道 18 字符 hex 的
batt_status(byte0) /pow_status(byte1);保留位 (batt_statusbit2–3、pow_statusbit3–6)不展示。 - 页面:PC 柜子详情里的通道卡片、小程序
pages/compartment-detail/index.vue。 - 仓体层面不显示任何"门"状态:协议没有"按仓体(舱门)开门"的指令(§4.2 L403)。
- 数据:通道 18 字符 hex 的
- 柜门是柜级独立动作,不与业务联动,全平台只有一个开门按钮
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是历史误用的 不存在权限码,禁止再用)。
- 解析函数两端必须同源同名:
web/src/utils/deviceStatus.ts与h5-uniapp/src/utils/deviceStatus.ts各有一组parseCabinetStatus1/parseCabinetStatus2/cabinetDoorState/cabinetStatusTags(注释互相指认对方文件,改一边务必同步另一边)。 端到端一致性用例见test/device_status_parse.test.mjs(node test/device_status_parse.test.mjs, 逐位遍历 0x00–0xFF 断言两端一致)。
开发约束(uni-app 特定)
<text>不支持display: flex— uni-app 的<text>组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用<view>- SVG 图标居中:
.stat-chip svg { display: block; flex-shrink: 0 }用 CSS 统一控制尺寸,比内联属性更可靠 - 内联 SVG + v-if:Vue 3 支持在
<path>上用v-if条件渲染,适用于动态图标 - 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),设备侧不可见 |