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 通过
This commit is contained in:
12451 2026-09-19 23:40:46 +08:00
parent 9829e9dcb1
commit 999885c38f
209 changed files with 14117 additions and 15349 deletions

19
.gitignore vendored
View File

@ -14,9 +14,10 @@ package-lock.json
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Environment / 凭据
# ⚠️ 用 **/.env* 覆盖所有层级的 .env / .env.production / .env.test 等变体;仅放行示例模板
**/.env*
!**/.env.example
# Temporary
*.tmp
@ -38,6 +39,18 @@ dist/
# SWC compiler platform plugins (auto-downloaded by Taro)
.swc/
# Uploads
uploads/
# Test
换电柜用锂电池充电器1TN标准通信协议*.pdf
生产环境.txt
.code-graph/
# 本地 cargo check 的独立 target-dir(避免与主 target 抢锁,体积大,不入库)
.cargo-check-*/
# 本地临时/工具产物
tmp/
.codebuddy/

View File

@ -0,0 +1,32 @@
# 2026-07-07 Session Notes
## 完成工作:安知充H5应用设计稿 — 修复全部7屏填充色问题
### 问题诊断
- 用户反馈"位置都是乱的",截图验证发现所有7屏几乎全白/空白
- 根因:上一轮重建时所有frame节点的 `fills: []` 为空(容器无背景色),只有text节点有fills所以能看到零星文字
- batch_read 确认:用户卡片、菜单卡片、设备卡片、订单卡片、通道卡片等所有容器的 fills 均为空数组
### 修复操作(共6个batch_edit批次,约120+个U()操作)
1. **Batch 1 (23 ops)**: 7屏背景色(登录渐变+其余#f5f5f5) + 登录表单元素(输入框白底边框+渐变按钮) + 首页卡片(组织切换/筛选标签/统计行/设备卡×2) + TabBar×2
2. **Batch 2 (10 ops)**: 设备列表卡×3 + 仓体详情卡片×3(状态/参数/记录) + 操作按钮×2(停止橙色/开门线框) + 订单卡×3
3. **Batch 3 (8 ops)**: 设备详情-信息卡白色 + 开门按钮蓝色 + 板头×6(灰蓝色调)
4. **Batch 4 (22 ops)**: 通道卡1-12(板1-2): 白底圆角6px + 彩色状态条(绿/蓝/红/紫)
5. **Batch 5 (22 ops)**: 通道卡13-24(板3离线灰色 + 板4混合状态)
6. **Batch 6 (22 ops)**: 通道卡25-36(板5-6混合状态)
7. **我的页补完**: 用户卡片白底 + 头像蓝底#dbeafe + 菜单卡片白底 + 退出登录按钮(红字红边框) + 底部TabBar(SVG图标)
### 关键技术发现
- ardot batch_edit 中 Update 已有节点:`fills` 必须用 `[{type:"SOLID", color:{r,g,b}}]` 数组格式
- `strokes` 必须用数组格式 `[{type:"SOLID", color:{r,g,b}, width:N}]`
- Insert 新节点时 `fill:"#FFF"` 简写可用,但 Update 时不可靠
### 颜色修正(用户反馈"颜色不对"后修复)
- 对照全部7个源文件的CSS,逐一修正了所有颜色
- **登录页**: 输入框背景 #f8fafc(更正白色), 边框 #e2e8f0(更正#e5e7eb)
- **首页**: 组织切换器背景 #f3f4f6(更正白色), 筛选标签激活态 #2563eb蓝(更正#dbeafe浅蓝)
- **设备列表**: 状态徽章添加背景圆角#f0fdf4/#f9fafb
- **设备详情**: 在线状态徽章/DC/AC标签添加圆角背景#f0fdf4, 导航栏添加底部边框
- **仓体详情**: 状态徽章添加圆角背景#eff6ff, 记录项添加底部边框
- **订单**: 状态徽章添加圆角背景(正常#f0fdf4/异常#fef2f2/停止#fffbeb), 订单体添加顶部边框
- 均用 `strokes: [{type:"SOLID", color:{r,g,b}, width:N}]` 数组格式设置边框

View File

@ -4,3 +4,14 @@
- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局
- **生图能力**:项目有 ImageGen 文生图工具可用,可用于生成产品渲染图等
- **操作规则**:开发/修改程序前必须确认需求,不可自说自话操作;生产环境服务器文件和数据必须用户授权后才可动
## Ardot 设计稿经验(充电柜H5应用)
- **设计稿文件ID**: `701384244279693`("安知充H5应用设计稿")
- **7屏完整列表**: 登录(2:1004) 首页(2:1022) 设备详情(2:1094,高1260) 设备列表(2:1334) 仓体详情(2:1359) 订单(2:1411) 我的(2:1468)
- **关键教训 — frame fills 必须用数组格式**: `fills` 属性必须用 `[{type:"SOLID", color:{r,g,b}}]` RGB对象数组。`fill:"#FFF"` 十六进制简写在 batch_edit 的 Update 操作中对已有frame节点可能不生效(Insert 新节点时可用简写,Update 已有节点必须用数组格式)
- **strokes 必须用数组格式**: `strokes: [{type:"SOLID", color:{r,g,b}, width:N}]`,不是对象形式
- **cornerRadius 必须是数字**: 不能传数组
- **屏幕背景色**: tab页面 #f5f5f5,子页面(设备详情/列表/仓体详情) 也是 #f5f5f5;登录页用渐变 linear-gradient(135deg,#f0f4ff,#e0e7ff,#f0f9ff)
- **卡片统一白色**: 所有卡片/容器 #FFFFFF + 圆角
- **颜色系统**: 主蓝#2563eb, 在线绿#16a34a(bg:#f0fdf4), 离线灰#9ca3af(bg:#f9fafb), 故障红#dc2626(bg:#fef2f2), 停止橙#f59e0b(bg:#fffbeb), 充电中蓝#2563eb(bg:#eff6ff), 满电紫#7c3aed
- **设备详情36仓**: 6板×6通道=36, 通道卡片白底+顶部3px彩色状态条(色条用RECTANGLE子节点fill表示状态色)

263
AGENTS.md
View File

@ -3,7 +3,7 @@
## 项目简介
充电柜矩阵系统(Power Matrix System / PMS)
产品名:安知充(anzhizhichong),已有充电站平台在运行,充电柜是新增产品线。
产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。
仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
## 设备标识
@ -11,13 +11,13 @@
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
- **设备ID**:4G SIM卡15位IMEI(10进制)
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
- **扫码链接**:`https://app.anzhizhichong.com/h5/{抽象ID}`
- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`)
## 技术栈
- 后端:Rust + Axum
- 前端:React + Arco Design Pro(React版)
- H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序)
- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
- 数据库:MySQL(阿里云RDS),开发/生产共用
- 缓存:Redis(阿里云Redis),开发/生产共用
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
@ -38,12 +38,14 @@ D:\charging-cabinet\
├── software/
│ ├── api-server/ # Rust API服务
│ ├── device-server/ # Rust TCP设备服务
│ ├── h5-uniapp/ # H5用户端(uni-app + Vue 3)
│ ├── 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 技能副本。
## 部署架构
### 环境规划
@ -53,14 +55,26 @@ D:\charging-cabinet\
| 正式环境 | app.anzhizhichong.com | :9000 | :9001 | db5 | /data/pms | /pms/ |
| 测试环境 | test.anzhizhichong.com | :3000 | :3001 | db6 | /data/pms_dev | /pms/ |
**服务端口分配:**
- 测试环境:API 3000,TCP 3001
**服务端口分配(规划):**
- 正式环境: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、h5)
- 测试环境:/data/pms_dev(api-server、device-server、web、h5)
- 正式环境:/data/pms(api-server、device-server、web)
- 测试环境:/data/pms_dev(api-server、device-server、web)
**Nginx配置:**
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
@ -140,17 +154,19 @@ python tools/db.py import data.sql
### 禁止操作
- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
### 编码任务下发
- 任务需求写到 `tasks/` 目录的md文件
- 通过Agent子代理包裹opencode执行,异步通知
- 质量约束模板固定追加到每个任务文件
- opencode超时必须报告用户,不能fallback手动执行
- 审查用不同模型(不用开发同款)
- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md)
- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json)
- 完成后由 PM(本 agent)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
- 已完成的任务文件及时清理
### 质量约束模板
@ -166,9 +182,10 @@ python tools/db.py import data.sql
### 验收流程
1. 静态校验命令通过
2. 跑起来看效果
3. 抽查关键逻辑(异常处理、权限校验、协议解析)
1. `cargo check` 两个服务(api-server + device-server)零错误
2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
4. 审查通过 → 清理任务文件,更新文档
---
@ -185,16 +202,22 @@ python tools/db.py import data.sql
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
- Redis 队列消费:`blpop` 阻塞读取 + `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)
├── middleware/ # 中间件(认证、日志、CORS、scope_guard 组织归属校验)
├── db/ # 数据库连接池、迁移
├── commands/ # 设备指令下发逻辑
├── commands/ # 设备指令下发逻辑(含 AppState 定义)
├── nodes/ # 设备服务节点管理
├── workers/ # 后台 Worker(report/reply)
└── error.rs # 统一错误定义
@ -222,12 +245,11 @@ src/
- 页面级状态用 `useState` / `useEffect`
- 全局状态用 `zustand`(如用户信息、权限)
- 服务端数据用 `@tanstack/react-query`,配置缓存策略
### 样式规范
- 使用 Tailwind CSS,禁止内联 style(除动态计算值)
- shadcn/ui 组件优先,不重复造轮子
- 使用 Arco Design 组件库,优先使用组件自带的样式
- 内联 style 仅用于动态计算值,静态样式用 CSS class
- 表格列统一 `min-width` + `align="center"`,无 `fixed="right"`
- 分页:上下双分页,`page-sizes="[10,20,30,50]"`
@ -318,7 +340,7 @@ src/
示例:
- `feat(cabinets): 添加设备RSSI信号显示`
- `fix(h5): 修复登录后token未持久化问题`
- `fix(mp): 修复登录后token未持久化问题`
- `docs(api): 更新充电记录接口文档`
---
@ -356,10 +378,17 @@ test_check_permission_user_returns_forbidden
### 认证授权
- JWT Token 有效期:7天,过期需重新登录
- 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` 成功即主动断开
### 输入校验
@ -532,7 +561,7 @@ server {
# PMS API 入口(微服务统一前缀 /api/{服务})
location /api/pms/ {
proxy_pass http://127.0.0.1:3000/api/;
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;
@ -545,10 +574,13 @@ server {
try_files $uri $uri/ /pms/index.html;
}
# H5用户端
location /h5/ {
alias /data/pms/h5/;
try_files $uri $uri/ /h5/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;
}
}
```
@ -586,16 +618,13 @@ server {
try_files $uri $uri/ /pms/index.html;
}
# H5用户端
location /h5/ {
alias /data/pms_dev/h5/;
try_files $uri $uri/ /h5/index.html;
}
# H5用户端
location /h5/ {
alias /data/pms_dev/h5/;
try_files $uri $uri/ /h5/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;
}
}
```
@ -604,55 +633,80 @@ server {
### 部署步骤
> ✅ **推荐用一键脚本(在本地开发机执行)**:
> ```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)
# 后端(Rust)— Windows 交叉编译 Linux 目标
cd software/api-server
cargo build --release
cargo zigbuild --release --target x86_64-unknown-linux-gnu
cd software/device-server
cargo build --release
cargo zigbuild --release --target x86_64-unknown-linux-gnu
# 前端(React)
# 前端(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/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
scp software/device-server/target/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_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/release/pms-api root@118.31.119.206:/data/pms/api-server/
scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms/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/ # 正式环境
# 传输H5用户端(tar.gz打包方式,保留UTF-8编码)
cd software/h5-uniapp/dist/build/h5
tar -czf /tmp/h5-build.tar.gz .
pscp -pw "Hbhyg731024@" /tmp/h5-build.tar.gz root@118.31.119.206:/tmp/
plink -ssh -pw "Hbhyg731024@" root@118.31.119.206 "rm -rf /data/pms/h5/* && cd /data/pms/h5 && tar -xzf /tmp/h5-build.tar.gz"
```
> ⚠️ H5部署必须用tar.gz打包方式,不要用plink/pscp管道传单文件(会丢失UTF-8编码导致中文变问号)。
> ⚠️ 后端二进制必须用 `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-test
systemctl restart pms-device-test
# 重启正式环境
systemctl restart pms-api-prod
systemctl restart pms-device-prod
# 正式环境
systemctl restart pms-api
systemctl restart pms-device
# 重启Nginx
systemctl restart nginx
@ -662,13 +716,13 @@ systemctl restart nginx
```bash
# 查看服务状态
systemctl status pms-api-test # 测试环境API
systemctl status pms-api-prod # 正式环境API
systemctl status pms-api # 正式环境API
systemctl status pms-device # 正式环境TCP
systemctl status nginx
# 查看日志
journalctl -u pms-api-test -f
journalctl -u pms-api-prod -f
journalctl -u pms-api -f
journalctl -u pms-device -f
tail -f /var/log/nginx/error.log
```
@ -685,12 +739,11 @@ tail -f /var/log/nginx/error.log
### 做
- 后台管理系统(Web端)
- 用户端H5页面(`/pms/{id}`,微信扫码可访问)
- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传)
- 设备TCP通讯服务(协议文档已就绪)
### 不做
- 微信小程序(名额暂不可用,先用H5,uni-app可后续转换)
- 支付/计费(本系统不涉及)
## 组织架构
@ -779,26 +832,63 @@ device:{imei} # 设备级 Hash
## TCP通讯协议
详见 [docs/API.md](docs/API.md#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` 转换,非业务浮点)。
| 文件 | 位置 | 问题 | 处理 |
|------|------|------|------|
| `src/routes/charge_records.rs` | L46-47, L77-78 | `Option<f64>` 读 DECIMAL | 需 CAST AS DOUBLE [有CAST] |
| `src/routes/energy_stats.rs` | L106, L116 | `query_scalar::<_, f64>` 直接读 DECIMAL 无 CAST ❌ | 需加 CAST AS DOUBLE 或改用整数 |
| `src/routes/energy_stats.rs` | L43-72 | 多个 `Option<f64>` 字段 | 待迁移为 i64 |
| `src/db/migrate.rs` | L76-77, L97 | `DECIMAL` 列定义 | 待改为 `BIGINT` |
## 微信小程序用户端
## H5用户端
### 小程序配置
| 参数 | 值 |
|------|-----|
| 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 管理端和 H5 移动端共用同一套逻辑。
设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和小程序共用同一套逻辑。
**RSSI 映射:**
@ -810,7 +900,7 @@ device:{imei} # 设备级 Hash
| ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 |
| < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) |
**实现方式(移动端 H5):**
**实现方式(小程序):**
```vue
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" ...>
@ -838,8 +928,8 @@ HSET device:{imei} online 1 rssi 85
**相关文件:**
- PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件
- H5: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板
- H5 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline
- 小程序: `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 改动
@ -854,3 +944,16 @@ HSET device:{imei} online 1 rssi 85
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`),设备侧不可见 |

294
README.md
View File

@ -1,6 +1,6 @@
# 充电柜矩阵系统 (Power Matrix System / PMS)
产品名:安知充(anzhizhichong)
产品名:安知充(anzhizhichong),品牌名:艾动智能
## 项目简介
@ -10,7 +10,7 @@
- **后端**:Rust + Axum
- **前端**:React + Arco Design Pro(管理端)
- **H5用户端**:uni-app + Vue 3(跨平台,支持 H5 / 微信小程序)
- **微信小程序用户端**:uni-app + Vue 3(构建目标 mp-weixin)
- **数据库**:MySQL(阿里云RDS)
- **缓存**:Redis(阿里云Redis)
- **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1)
@ -25,12 +25,14 @@ charging-cabinet/
│ ├── api-server/ # Rust API服务
│ ├── device-server/ # Rust TCP设备服务
│ ├── web/ # React管理端前端
│ └── h5-uniapp/ # H5用户端(uni-app + Vue 3)
│ └── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
├── hardware/ # 硬件资料(原理图/PCB等)
├── deploy/ # 部署配置
└── test/ # 测试用例/报告
```
> 本仓库不存放技能库(skills)。开发期技能从全局技能目录加载(`~/.dsh/skills`、`~/.agents/skills`),请勿在仓库内重新 vendor。
## 部署架构
| 环境 | 域名 | 说明 |
@ -49,7 +51,7 @@ charging-cabinet/
```
┌─────────────────────────────────────────────────────────────┐
│ API服务 (api-server) — 主服务 │
│ - HTTP API(后台管理 + H5)端口: 3000 │
│ - HTTP API(后台管理 + 小程序)端口: 3000 │
│ - 前端轮询刷新(每5秒) │
│ - 业务逻辑处理(验证/存DB/告警) │
│ - 节点管理(设备服务注册/心跳) │
@ -85,284 +87,20 @@ charging-cabinet/
- 每60秒心跳,180秒无心跳视为离线
- 支持多节点扩展(当前单节点)
## TCP通讯协议
## TCP 设备通讯协议
### 通信规则
> 完整协议文档见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,唯一权威 TCP 文档)
- TCP长连接,无需PING包
- 空闲1分钟上报,充电15秒上报
- 超时5000ms
- JSON去空格回车省流量,LF处理粘包
- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID
- `msg_id`:自增会话ID
- 全局通道号:`dev_id-sub_device_id-通道号`(如`123456789012345-01-6`)
- TCP 长连接,空闲 60 秒/充电 15 秒上报
- 所有设备上报消息平台都会回复 `{"act":"<同请求>","msg_id":N,"suc":1}` 确认
- 响应扁平化,不加 `data` 包裹
- 签名机制:`SHA256(dev_id + auth_str + timestamp)`,取 `hex[56..64]`,**8 位**小写十六进制
- `id` 字段:仓体级 `IMEI-板-通道`,柜级 `IMEI`
- 全局整数单位:voltage mV, current mA, temp K×100, soc ‰
### 签名机制
**11 条指令:** login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off / open / pow_on / pow_off / reset
- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次)
- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)`
- 时间戳秒级,平台校验±60秒
### 指令清单
| 动作 | 方向 | id格式 | 说明 |
|------|------|--------|------|
| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) |
| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) |
| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) |
| `pow_fail` | 设备→平台 | 无 | 电源掉电上报(含info区分交直流) |
| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入(含BMS数据,字段待定) |
| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) |
| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) |
| `open` | 平台→设备 | `IMEI` | 开门 |
| `pow_on` | 平台→设备 | `IMEI` | 接触器合闸(恢复380V供电) |
| `pow_off` | 平台→设备 | `IMEI` | 接触器分闸(切断380V供电) |
| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 |
### status_post格式
```json
{
"act": "status_post",
"msg_id": 42,
"dev_id": "123456789012345",
"rssi": 85,
"status": {
"123456789012345-01": "000100020003000400050006",
"123456789012345-02": "000100020003000400050006"
}
}
```
- key: `IMEI-仓控板ID`
- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
- 平台从key解析仓控板数量
### 指令详情
#### login(设备→平台)
设备登录,验证签名。
```json
// 请求
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"h_ver":"1.0.0","s_ver":"1.0.0"}
// 响应
{"suc":1,"msg_id":1,"cell":6}
```
**字段说明:**
- `sign` — 签名(SHA256(dev_id+auth_str+timestamp)后8位)
- `timestamp` — 秒级时间戳(平台校验±60秒)
- `h_ver` — 硬件版本
- `s_ver` — 软件版本
- `cell` — 仓控板数量
---
#### auth_str(设备→平台)
获取8位安全码,仅首次连接调用。
```json
// 请求
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345","ccid":"89860000000000000000"}
// 响应
{"suc":1,"msg_id":2,"data":{"auth_str":"abcd1234"}}
```
**字段说明:**
- `ccid` — SIM卡ICCID号
- `auth_str` — 8位随机安全码(仅返回一次)
---
#### status_post(设备→平台)
定时上报,空闲60秒/充电15秒。
```json
// 请求
{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,"status":{"123456789012345-01":"000100020003000400050006","123456789012345-02":"000100020003000400050006"}}
// 响应
{"suc":1,"msg_id":10}
```
**status字段说明:**
- key: `IMEI-仓控板ID`
- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
- 平台从key解析仓控板数量
---
#### pow_fail(设备→平台)
电源掉电上报。
```json
// 请求
{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"}
// 响应
{"suc":1,"msg_id":15}
```
**字段说明:**
- `info` — 停电类型:`0`=直流停电(12V掉电),`1`=交流停电(380V掉电)
---
#### pow_on(平台→设备)
接触器合闸,恢复380V充电电源供电。
```json
// 请求
{"act":"pow_on","msg_id":16,"dev_id":"123456789012345"}
// 响应
{"suc":1,"msg_id":16}
```
---
#### pow_off(平台→设备)
接触器分闸,切断380V充电电源供电。
```json
// 请求
{"act":"pow_off","msg_id":16,"dev_id":"123456789012345"}
// 响应
{"suc":1,"msg_id":16}
```
---
#### bat_in(设备→平台)
电池插入事件,立即上报。
```json
// 请求
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-6","bms":{"soc":45,"voltage":52.3}}
// 响应
{"suc":1,"msg_id":20}
```
**平台处理:**
1. 记录电池信息
2. 验证电池合规性
3. 自动下发 `on` 指令
**BMS数据字段待定。**
---
#### bat_out(设备→平台)
电池拔出事件,立即上报。
```json
// 请求
{"act":"bat_out","msg_id":21,"id":"123456789012345-01-6"}
// 响应
{"suc":1,"msg_id":21}
```
---
#### on(双向)
**平台→设备:** 开始充电
```json
// 请求
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
// 响应
{"suc":1,"msg_id":30}
```
**设备→平台:** 充电开始确认(自动充电模式)
```json
// 上报
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
```
---
#### off(双向)
**平台→设备:** 手动停止充电
```json
// 请求
{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
// 响应
{"suc":1,"msg_id":31}
```
**设备→平台:** 充满自动停止
```json
// 上报
{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
```
---
#### open(平台→设备)
开门,只需IMEI。
```json
// 请求
{"act":"open","msg_id":40,"id":"123456789012345"}
// 响应
{"suc":1,"msg_id":40}
```
**错误码63 = 门锁故障。**
---
#### reset(平台→设备)
重启整柜或仓控板。
```json
// 重启整柜
{"act":"reset","msg_id":60,"id":"123456789012345"}
// 重启仓控板
{"act":"reset","msg_id":61,"id":"123456789012345-01"}
// 响应
{"suc":1,"msg_id":60}
```
### 充电结束原因(平台判断)
| 平台收到 | 判断原因 |
|---------|---------|
| `off` (设备→平台) | `normal` — 正常充满 |
| `off` (平台→设备) | `user_stop` — 用户手动停止 |
| `bat_out` | `normal` — 电池拔出 |
| `bat_in` (通道已有电池) | `forced` — 新电池插入,旧电池强制结束 |
| 超时未响应 | `timeout` — 通信超时 |
## 开发规范
完整请求/响应示例、字段表、签名自测向量、超时与自愈流程 → [docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)
- 任务需求写到 `tasks/` 目录的md文件
- 通过Agent子代理包裹opencode执行,异步通知

156
deploy/deploy-prod.ps1 Normal file
View File

@ -0,0 +1,156 @@
# =============================================================================
# PMS 正式环境部署脚本(在**本地开发机**执行,不要拿到服务器上跑)
#
# 为什么是 PowerShell 而不是原 deploy-prod.sh:
# 原脚本有 3 个致命问题(2026-09 修复)——
# 1. 它在 `target/release/` 找二进制,而实际构建产物在
# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件
# 2. 它安装并重启 `pms-api-prod` / `pms-device-prod`,而线上真实单元名是
# `pms-api` / `pms-device` → 会**多出一套重复单元**且重启失败
# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错,
# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂)
# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收"
# 的顺序执行,并在收尾打印回滚方法。
#
# 用法:
# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收)
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署
# pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动
# pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端
# =============================================================================
param(
[switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build
[switch]$SkipWeb, # 跳过前端
[switch]$DryRun # 只演练,不执行
)
$ErrorActionPreference = 'Stop'
# ---- 环境参数(正式环境)---------------------------------------------------
$Host_ = '118.31.119.206'
$User = 'root'
$Pwd_ = 'Hbhyg731024@' # 内网仓库明文密码(见 AGENTS.md「代码审查豁免」)
$Dst = '/data/pms'
$ApiPort = 9000
$TcpPort = 9001
$Domain = 'app.anzhizhichong.com'
# ---- 路径 -------------------------------------------------------------------
$Repo = Split-Path -Parent $PSScriptRoot
$ApiBin = Join-Path $Repo 'software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api'
$DevBin = Join-Path $Repo 'software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server'
$WebDist = Join-Path $Repo 'software/web/dist'
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
function Say($msg) { Write-Host " $msg" }
function Invoke-Remote([string]$cmd) {
if ($DryRun) { Say "[DryRun] ssh: $cmd"; return }
& plink -batch -pw $Pwd_ "$User@$Host_" $cmd
if ($LASTEXITCODE -ne 0) { throw "远端命令失败(exit $LASTEXITCODE):$cmd" }
}
function Send-File([string]$local, [string]$remote) {
if ($DryRun) { Say "[DryRun] scp: $local -> $remote"; return }
& pscp -batch -pw $Pwd_ $local "$User@$Host_`:$remote"
if ($LASTEXITCODE -ne 0) { throw "上传失败(exit $LASTEXITCODE):$local" }
}
# ---- 1. 构建 ---------------------------------------------------------------
if (-not $SkipBuild) {
Step '1/7 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
Say 'api-server ...'
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/api-server/Cargo.toml') }
Say 'device-server ...'
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/device-server/Cargo.toml') }
if (-not $SkipWeb) {
Say 'web ...'
if (-not $DryRun) { Push-Location (Join-Path $Repo 'software/web'); npm run build; Pop-Location }
}
}
# ---- 2. 校验产物 + 静态检查 ------------------------------------------------
Step '2/7 校验产物与静态检查'
foreach ($f in @($ApiBin, $DevBin)) {
if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" }
Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB))
}
if (-not $SkipWeb) {
if (-not (Test-Path (Join-Path $WebDist 'index.html'))) { throw "找不到前端产物:$WebDist" }
Say ("✅ web/dist ({0} 个文件)" -f (Get-ChildItem $WebDist -Recurse -File).Count)
}
# ---- 3. 服务器备份(可回滚)------------------------------------------------
Step '3/7 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
Invoke-Remote @"
set -e
TS=`$(date +%Y%m%d-%H%M%S)
mkdir -p $Dst/backup-`$TS
cp -a $Dst/api-server/pms-api $Dst/backup-`$TS/ 2>/dev/null || true
cp -a $Dst/device-server/pms-device-server $Dst/backup-`$TS/ 2>/dev/null || true
cp -a $Dst/api-server/.env $Dst/device-server/.env $Dst/backup-`$TS/ 2>/dev/null || true
mkdir -p $Dst/backup-`$TS/web && cp -a $Dst/web/. $Dst/backup-`$TS/web/ 2>/dev/null || true
echo `$TS > /tmp/pms_last_backup
echo " 备份目录: $Dst/backup-`$TS"
"@
# ---- 4. 上传(临时名,避免 ETXTBSY)----------------------------------------
Step '4/7 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
Send-File $ApiBin "$Dst/api-server/pms-api.new"
Send-File $DevBin "$Dst/device-server/pms-device-server.new"
if (-not $SkipWeb) {
$tgz = Join-Path $env:TEMP 'pms-dist.tgz'
if (-not $DryRun) { tar -czf $tgz -C $WebDist . }
Send-File $tgz '/tmp/pms-dist.tgz'
}
# ---- 5. 切换 + 重启 --------------------------------------------------------
Step '5/7 原子替换并重启服务'
Invoke-Remote @"
set -e
chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new
systemctl stop pms-api pms-device
mv -f $Dst/api-server/pms-api.new $Dst/api-server/pms-api
mv -f $Dst/device-server/pms-device-server.new $Dst/device-server/pms-device-server
$(if (-not $SkipWeb) { "rm -rf $Dst/web/* && tar -xzf /tmp/pms-dist.tgz -C $Dst/web/ && rm -f /tmp/pms-dist.tgz" } else { 'true' })
systemctl daemon-reload
systemctl start pms-device pms-api
sleep 8
nginx -t && systemctl reload nginx
"@
# ---- 6. 验收 ---------------------------------------------------------------
Step '6/7 验收(服务/端口/域名/日志)'
Invoke-Remote @"
echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}'
echo '--- 域名 ---'
curl -sk -m 8 -o /dev/null -w ' /api/pms/cabinets -> %{http_code}\n' https://$Domain/api/pms/cabinets
curl -sk -m 8 -o /dev/null -w ' /pms/ -> %{http_code}\n' https://$Domain/pms/
echo '--- 启动日志尾部 ---'; journalctl -u pms-api --no-pager -n 4 | tail -4
echo '--- 新进程 ERROR 数(应为 0)---'
MAIN=`$(systemctl show -p MainPID --value pms-api)
journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^/ ERROR 行数: /'
"@
# ---- 7. 消费链路探测(无副作用)-------------------------------------------
Step '7/7 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
if (-not $DryRun) {
$probe = @'
import redis, json, time
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5)
r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1}))
time.sleep(3)
left = r.llen('device:reports')
print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}")
'@
$probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py"
python "$env:TEMP\pms_probe.py"
}
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
Say "正式环境:https://$Domain/pms/"
Say "API :$ApiPort TCP :$TcpPort"
Say ''
Say '回滚方法(如需):'
Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`""
Say ' 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份)'

View File

@ -1,50 +1,21 @@
#!/bin/bash
# PMS 部署脚本 - 正式环境
set -e
echo "=== PMS 正式环境部署 ==="
# 1. 创建目录
echo "1. 创建部署目录..."
mkdir -p /data/pms/api-server
mkdir -p /data/pms/device-server
mkdir -p /data/pms/web
# 2. 复制文件
echo "2. 复制服务文件..."
cp software/api-server/target/release/pms-api /data/pms/api-server/
cp software/api-server/.env.production /data/pms/api-server/.env
cp software/device-server/target/release/device-server /data/pms/device-server/
cp software/device-server/.env.production /data/pms/device-server/.env
# 3. 复制前端静态文件
echo "3. 复制前端文件..."
cp -r software/web/dist/* /data/pms/web/
# 3. 复制systemd服务
echo "3. 安装systemd服务..."
cp deploy/pms-api-prod.service /etc/systemd/system/
cp deploy/pms-device-prod.service /etc/systemd/system/
# 4. 复制nginx配置
echo "4. 安装nginx配置..."
cp deploy/nginx/app.anzhizhichong.com.conf /etc/nginx/conf.d/
# 5. 重启服务
echo "5. 重启服务..."
systemctl daemon-reload
systemctl restart pms-api-prod
systemctl restart pms-device-prod
systemctl restart nginx
# 6. 设置开机启动
echo "6. 设置开机启动..."
systemctl enable pms-api-prod
systemctl enable pms-device-prod
echo "=== 部署完成 ==="
echo "正式环境: https://app.anzhizhichong.com"
echo "API端口: 9000"
echo "TCP端口: 9001"
# =============================================================================
# ⛔ 本脚本已废弃(2026-09),请勿执行!改用:deploy/deploy-prod.ps1
#
# 废弃原因(原脚本会直接搞坏生产):
# 1. 在 `target/release/` 找二进制 —— 实际产物在
# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件
# 2. 安装并重启 `pms-api-prod` / `pms-device-prod` —— 线上真实单元名是
# `pms-api` / `pms-device`;照原样执行会**多出一套重复单元**且重启失败
# 3. 用本地 `.env.production` **覆盖线上 `.env`** —— 历史上该文件带行内注释
# (`PORT=9000 # 注释` 会让解析失败),照原样执行会让**生产直接起不来**
# 4. 它假定"服务器上有仓库源码",而实际流程是**本地交叉编译后传二进制**
#
# 正确做法(在本地开发机执行):
# pwsh deploy/deploy-prod.ps1 # 完整部署
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 只重发产物
# pwsh deploy/deploy-prod.ps1 -DryRun # 演练
# =============================================================================
echo "⛔ deploy-prod.sh 已废弃,请使用:pwsh deploy/deploy-prod.ps1" >&2
echo " (原因见本文件头部注释:原脚本会装重复 systemd 单元、覆盖线上 .env 导致生产起不来)" >&2
exit 1

View File

@ -1,50 +1,22 @@
#!/bin/bash
# PMS 部署脚本 - 测试环境
set -e
echo "=== PMS 测试环境部署 ==="
# 1. 创建目录
echo "1. 创建部署目录..."
mkdir -p /data/pms_dev/api-server
mkdir -p /data/pms_dev/device-server
mkdir -p /data/pms_dev/web
# 2. 复制文件
echo "2. 复制服务文件..."
cp software/api-server/target/release/pms-api /data/pms_dev/api-server/
cp software/api-server/.env.test /data/pms_dev/api-server/.env
cp software/device-server/target/release/device-server /data/pms_dev/device-server/
cp software/device-server/.env.test /data/pms_dev/device-server/.env
# 3. 复制前端静态文件
echo "3. 复制前端文件..."
cp -r software/web/dist/* /data/pms_dev/web/
# 3. 复制systemd服务
echo "3. 安装systemd服务..."
cp deploy/pms-api-test.service /etc/systemd/system/
cp deploy/pms-device-test.service /etc/systemd/system/
# 4. 复制nginx配置
echo "4. 安装nginx配置..."
cp deploy/nginx/test.anzhizhichong.com.conf /etc/nginx/conf.d/
# 5. 重启服务
echo "5. 重启服务..."
systemctl daemon-reload
systemctl restart pms-api-test
systemctl restart pms-device-test
systemctl restart nginx
# 6. 设置开机启动
echo "6. 设置开机启动..."
systemctl enable pms-api-test
systemctl enable pms-device-test
echo "=== 部署完成 ==="
echo "测试环境: https://test.anzhizhichong.com"
echo "API端口: 3000"
echo "TCP端口: 3001"
# =============================================================================
# ⛔ 本脚本已废弃(2026-09),请勿在 118.31.119.206 上执行!
#
# 两条重要约束:
# 1. **118.31.119.206 就是生产服务器**(用户已确认),上面**不部署测试环境**。
# 原先的 `pms-api-test` / `pms-device-test` 单元已卸载,:3000 / :3001 已释放。
# 2. 即使要在别处搭测试环境,也不要用本脚本 —— 它与 deploy-prod.sh 有同样的缺陷
# (错误产物路径、单元名不匹配、用本地 .env 覆盖线上 .env)。
#
# 如需临时联调环境(另一台机器,或本机):
# - 参考 `deploy/deploy-prod.ps1` 的流程改写,端口用 3000/3001
# - 环境文件用 `software/*/.env.test`(连 pms_dev 库 + Redis db6)
# - 注意 `/data/pms_dev` 下的旧文件与 `pms_dev` 库、Redis db6 都还保留着
#
# ⚠️ 另请注意:`test.anzhizhichong.com` 的 nginx `/api/pms/` 现指向 :3000,
# 而该端口**没有后端**,所以返回 502 —— 这是**刻意的**(历史上它指向 :3000,
# 而那时 :3000 正是生产 API,等于测试后台在操作生产数据)。
# **绝不要把测试域名的 API 指回生产端口(9000)。**
# =============================================================================
echo "⛔ deploy-test.sh 已废弃,且本服务器为生产环境,不部署测试环境。" >&2
exit 1

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安知充 — 智能充电柜矩阵系统</title>
<title>艾动智能 — 智能充电柜矩阵系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@ -161,8 +161,8 @@ h1 .accent { color: #2563EB; }
<!-- 上:顶部导航 -->
<div class="top">
<div class="top-brand">
<img class="logo" src="logo-1.png" alt="安知充">
<span class="top-name">安知充</span>
<img class="logo" src="logo-1.png" alt="艾动智能">
<span class="top-name">艾动智能</span>
</div>
<div class="top-links">
<a href="/doc/" class="top-link">开发文档</a>
@ -195,7 +195,7 @@ h1 .accent { color: #2563EB; }
<!-- 下:底部 ICP -->
<div class="bot">
© 2026 安知充 · <a href="https://beian.miit.gov.cn/" target="_blank">苏ICP备2025198376号-1</a>
© 2026 艾动智能 · <a href="https://beian.miit.gov.cn/" target="_blank">苏ICP备2025198376号-1</a>
</div>
</div>

View File

@ -26,12 +26,23 @@ server {
client_max_body_size 500m;
}
location /pms/api/ {
proxy_pass http://127.0.0.1:3000/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;
client_max_body_size 10m;
}
# 头像上传文件
location /uploads/ {
proxy_pass http://127.0.0.1:9000/uploads/;
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;
expires 30d;
}
# PMS管理前端 - 不带斜杠时重定向
@ -44,15 +55,4 @@ server {
alias /data/pms/web/;
try_files $uri $uri/ /pms/index.html;
}
# H5用户端 - 不带斜杠时重定向
location = /h5 {
return 301 /h5/;
}
# H5用户端
location /h5/ {
alias /data/pms/h5/;
try_files $uri $uri/ /h5/index.html;
}
}

View File

@ -6,7 +6,7 @@ After=network.target
Type=simple
User=root
WorkingDirectory=/data/pms/api-server
ExecStart=/data/pms/api-server/api-server
ExecStart=/data/pms/api-server/pms-api
EnvironmentFile=/data/pms/api-server/.env
Restart=always
RestartSec=5

View File

@ -6,7 +6,7 @@ After=network.target
Type=simple
User=root
WorkingDirectory=/data/pms/device-server
ExecStart=/data/pms/device-server/device-server
ExecStart=/data/pms/device-server/pms-device-server
EnvironmentFile=/data/pms/device-server/.env
Restart=always
RestartSec=5

View File

@ -1,6 +1,6 @@
# API 接口文档
> 充电柜矩阵系统(PMS)接口规范。PC 管理端与 H5 用户端共用同一套 API。
> 充电柜矩阵系统(PMS)接口规范。PC 管理端与微信小程序用户端共用同一套 API。
## 基础信息
@ -22,19 +22,68 @@
| GET | `/api/cabinets/:id` | 设备详情(含仓板+仓体) | `device:view` |
| PUT | `/api/cabinets/:id` | 更新柜子 | `device:edit` |
| DELETE | `/api/cabinets/:id` | 删除柜子 | `device:delete` |
| POST | `/api/cabinets/:id/regenerate-auth` | 重置安全码(仅总管理员) | `device:auth_str:reset` |
| POST | `/api/cabinets/:id/bind` | 绑定设备到项目 | `device:edit` |
| POST | `/api/cabinets/:id/replace-imei` | 更换 IMEI | `device:edit` |
| GET | `/api/cabinets/abstract/:abstract_id` | 扫码查设备(免认证) | 免认证 |
| GET | `/api/cabinets/scan/:abstract_id` | 扫码验证(需认证) | 需认证 |
| GET | `/api/cabinets/filter` | 设备列表(管理端) | 需认证 |
| GET | `/api/cabinets/options` | 设备下拉选项 | 需认证 |
| GET | `/api/cabin-boards/options` | 仓控板下拉选项 | 需认证 |
| GET | `/api/compartments/options` | 仓体下拉选项 | 需认证 |
| GET | `/api/cabinets/realtime-status` | 设备实时状态 | 需认证 |
| GET | `/api/compartments/:id` | 仓体/通道详情 | 需认证 |
| POST | `/api/charge/start` | 开始充电 | `charge:start` |
| POST | `/api/charge/stop` | 停止充电 | `charge:stop` |
| POST | `/api/door/open` | 开门 | `device:door:unlock` |
| POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` |
| POST | `/api/cabinets/{id}/open` | 解锁柜门(柜级,同上;管理端设备详情页用) | `charge:open_door` |
| POST | `/api/cabinets/{id}/pow-on` | 接触器合闸(柜级) | `device:operate` |
| POST | `/api/cabinets/{id}/pow-off` | 接触器分闸(柜级) | `device:operate` |
| POST | `/api/cabinets/{id}/reset` | 重启整柜(柜级) | `device:operate` |
| POST | `/api/cabin-boards/{id}/reset` | 重启单块仓控板 | `device:operate` |
| GET | `/api/organizations` | 组织列表 | `org:view` |
| POST | `/api/organizations` | 创建组织 | `org:create` |
| PUT | `/api/organizations/:id` | 更新组织 | `org:edit` |
| DELETE | `/api/organizations/:id` | 删除组织 | `org:delete` |
| GET | `/api/organizations/:org_id/projects` | 项目列表 | 需认证 |
| POST | `/api/organizations/:org_id/projects` | 创建项目 | `project:create` |
| PUT | `/api/projects/:id` | 更新项目 | `project:edit` |
| DELETE | `/api/projects/:id` | 删除项目 | `project:delete` |
| GET | `/api/organization-tree` | 组织树(含项目/设备) | 需认证 |
| GET | `/api/charge-records` | 充电记录 | `charge_record:view` |
| GET | `/api/energy-stats/summary` | 能耗统计 | 需认证 |
| GET | `/api/charge-records/export` | 导出充电记录 | `charge_record:export` |
| GET | `/api/charge-orders` | 充电订单列表 | `charge_record:view` |
| GET | `/api/batteries` | 电池台账列表 | `device:view` |
| GET | `/api/energy-stats/summary` | 能耗概览 | `energy:view` |
| GET | `/api/energy-stats/cabinets` | 柜子能耗排行 | `energy:view` |
| GET | `/api/energy-stats/projects` | 项目能耗统计 | `energy:view` |
| GET | `/api/energy-stats/trend` | 能耗趋势 | `energy:view` |
| GET | `/api/energy-stats/export` | 导出能耗数据 | `energy:view` |
| GET | `/api/device-logs` | 设备日志 | `device:view` |
| GET | `/api/operation-logs` | 操作日志 | `system:log` |
| GET | `/api/users` | 用户列表 | `user:view` |
| POST | `/api/users` | 创建用户 | `user:create` |
| PUT | `/api/users/:id` | 更新用户 | `user:edit` |
| DELETE | `/api/users/:id` | 删除用户 | `user:delete` |
| PUT | `/api/users/:id/reset-password` | 重置密码 | `user:edit` |
| PUT | `/api/users/:id/status` | 设置用户状态 | `user:edit` |
| GET | `/api/users/:id/permissions` | 用户权限 | `user:view` |
| PUT | `/api/users/:id/roles` | 分配角色 | `user:edit` |
| GET | `/api/users/:id/scopes` | 用户数据范围 | `user:view` |
| PUT | `/api/users/:id/scopes` | 设置数据范围 | `user:edit` |
| GET | `/api/roles` | 角色列表 | `role:view` |
| POST | `/api/roles` | 创建角色 | `role:create` |
| PUT | `/api/roles/:id` | 更新角色 | `role:edit` |
| DELETE | `/api/roles/:id` | 删除角色 | `role:delete` |
| PUT | `/api/roles/:id/permissions` | 角色赋权 | `role:edit` |
| GET | `/api/permissions` | 权限码列表 | `role:view` |
| GET | `/api/downloads` | 下载中心 | 需认证 |
| GET | `/api/downloads/:id` | 下载详情 | 需认证 |
| GET | `/api/downloads/:id/file` | 下载文件 | 需认证 |
| POST | `/api/downloads/cleanup` | 清理过期下载 | 需认证 |
| POST | `/api/devices/:dev_id/command` | 下发设备指令 | `device:operate` |
| GET | `/api/devices/stats` | 设备服务统计 | 需认证 |
| GET | `/api/health` | 健康检查 | 免认证 |
---
@ -69,15 +118,15 @@
}
```
**角色等级:** 1=普通用户,2=超级管理员
**角色等级:** 0=操作员,1=企业管理员,2=总管理员
---
## H5 专用接口
## 小程序专用接口
### GET /api/dashboard
H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
小程序首页仪表盘数据。支持 `org_id` 参数按组织过滤。
**响应:**
```json
@ -157,59 +206,286 @@ H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
**响应字段:**
- `voltage` — mV(整数)
- `current` — mA(整数)
- `power` — mW(整数)
- `energy` — Wh(整数)
- `soc` — %(0-100)
- `soh` — %(0-100)
- `soc` — ‰(0-10000,前端已自动转为 %)
- `cell_voltages` — mV 数组
### POST /api/charge/start
**请求:** `{ "compartment_id": 123 }`
> 权限:`charge:start` + 组织隔离(用户只能操作所属组织的设备)
### POST /api/charge/stop
**请求:** `{ "compartment_id": 123 }`
> 权限:`charge:stop` + 组织隔离
### POST /api/door/open
**请求:** `{ "compartment_id": 123 }`
> 权限:`charge:open_door` + 组织隔离
### GET /api/cabinets/scan/:abstract_id
扫码验证,需认证。验证当前用户是否有权访问该设备。
---
## 管理端接口
### GET /api/charge-records
充电记录列表,支持按柜子/时间过滤,响应含组织数据隔离。
**查询参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| `page` | int | 页码(默认 1) |
| `page_size` | int | 每页条数(默认 20,最大 100) |
| `cabinet_id` | int | 按柜子过滤 |
| `cabin_board_id` | int | 按仓控板过滤 |
| `compartment_id` | int | 按仓体过滤 |
| `start_time` | string | 开始时间(含),格式 `YYYY-MM-DD HH:MM:SS` |
| `end_time` | string | 结束时间(含),格式同上 |
**响应:**
```json
{
"code": 0,
"data": {
"list": [
{
"id": 1,
"cabinet_id": 1,
"cabinet_name": "太湖新城换电柜A",
"cabin_board_id": 1,
"board_id": 1,
"compartment_id": 1,
"channel_id": 1,
"start_time": "2026-07-05 10:00:00",
"end_time": "2026-07-05 12:00:00",
"energy": 1200,
"max_temperature": 30435,
"status": 1,
"status_text": "正常结束"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"message": "ok"
}
```
**状态码:** 0=充电中,1=正常结束,2=异常中断,3=手动停止
### GET /api/charge-records/export
异步导出充电记录 Excel。创建导出任务后自行去下载中心取文件。
**查询参数:** 同 `GET /api/charge-records`
**响应:**
```json
{ "code": 0, "data": { "task_id": 1 }, "message": "导出任务已创建" }
```
### GET /api/charge-orders
充电订单列表。订单记录一次充电事件,含电池ID、SOC变化、结束原因。
**查询参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| `page` | int | 页码(默认 1) |
| `page_size` | int | 每页条数(默认 20,最大 100) |
| `cabinet_id` | int | 按柜子过滤 |
| `compartment_id` | int | 按仓体过滤 |
| `battery_id` | string | 电池ID(模糊匹配) |
| `status` | int | 状态过滤 |
| `start_time` | string | 开始时间(含) |
| `end_time` | string | 结束时间(含) |
**响应:**
```json
{
"code": 0,
"data": {
"list": [
{
"id": 1,
"cabinet_id": 1,
"cabinet_name": "太湖新城换电柜A",
"compartment_id": 1,
"battery_id": "BAT001",
"start_time": "2026-07-05 10:00:00",
"end_time": "2026-07-05 12:00:00",
"start_soc": 20,
"end_soc": 95,
"energy": 1200,
"reason": null,
"reason_text": "-",
"status": 1,
"status_text": "正常结束",
"created_at": "2026-07-05 10:00:05"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"message": "ok"
}
```
**订单状态:** 0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束
### GET /api/batteries
电池台账列表。记录每块电池的插入/拔出事件和当前状态。
**查询参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| `page` | int | 页码(默认 1) |
| `page_size` | int | 每页条数(默认 20,最大 100) |
| `status` | int | 状态过滤 |
| `battery_id` | string | 电池ID(模糊匹配) |
**响应:**
```json
{
"code": 0,
"data": {
"list": [
{
"id": 1,
"battery_id": "BAT001",
"brand": "星恒",
"model": "XH-48V",
"voltage": 52000,
"soc": 45,
"status": 1,
"cabinet_id": 1,
"compartment_id": 1,
"last_seen": "2026-07-05 12:00:00",
"created_at": "2026-07-01 08:00:00"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"message": "ok"
}
```
### GET /api/energy-stats/summary
能耗概览,按组织数据隔离返回今日/本月能耗数据。
**响应:**
```json
{
"code": 0,
"data": {
"today_energy": 45000,
"month_energy": 1200000,
"estimated_cost": 840.00,
"cabinet_count": 36,
"online_count": 34
},
"message": "ok"
}
```
- `today_energy` / `month_energy` — Wh(整数)
- `estimated_cost` — 预估费用(float,元),按 `COST_PER_WH` 单价计算
---
## 数据单位规范
**核心原则:所有数值字段使用整数存储,禁止浮点类型 (f32/f64/DECIMAL/FLOAT)**
| 字段 | 类型 | 单位 | 说明 |
|------|------|------|------|
| `energy` | i64 | **Wh** | 充电量,不存小数 |
| `voltage` | i64 | **mV** | 电池组总电压 |
| `current` | i64 | **mA** | 电流,正=充电 负=放电 |
| `power` | i64 | **mW** | 功率 |
| `soc` / `soh` | i64 | **%** | 电量百分比 / 健康度 |
| `max_temperature` | i64 | **0.1°C** | 25.3°C → `253` |
| `rssi` | i64 | **dBm** | 信号强度 |
**单位转换仅在前端:** API 始终返回原始单位值,前端负责转换为用户友好单位显示。后端禁止做任何单位换算。
**温度转换:** 前端显示时除以10加小数点(25.3°C → `253`)
### 标量字段
| 字段 | 类型 | 单位 | 前端显示 | 转换公式 |
|------|------|------|----------|---------|
| `energy` / `total_energy` / `today_energy` / `month_energy` / `energy_kwh`¹ | i64 | **Wh** | kWh | `/ 1000` |
| `voltage` / `cell_voltages[]` | i64 | **mV** | V | `/ 1000` |
| `current` | i64 | **mA** | A | `/ 1000` |
| `soc` / `start_soc` / `end_soc` | i64 | **‰** | % | `/ 10` |
| `temp` / `cell_temps[]` / `max_temperature` | i64 | **K×100** | °C | `/ 100 - 273.15` |
| `rssi` | i64 | **0–99 信号等级** | 直接显示 | 不转换(不是 dBm) |
| `cost` / `estimated_cost` | i64/f64 | **元** | ¥ | 不转换(后端业务计算) |
| `today_charge_count` / `today_charge_hours` | i64 | 计数值 | 直接显示 | 不转换 |
¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。
### 示例
| 原始值 | 含义 | 前端显示 |
|--------|------|----------|
| `52000` | 电压(mV) | `52.0 V` |
| `10000` | 电流(mA) | `10.0 A` |
| `120000` | 功率(mW) | `120.0 W` |
| `1000` | 充电量(Wh) | `1.000 kWh` |
| `850` | SOC(‰) | `85.0%` |
| `29865` | 温度(K×100) | `25.5°C` |
| `85` | RSSI 信号等级(0–99) | `85` |
| `[32456,32500,32300]` | 单体电压(mV) | `32.456V, 32.500V, 32.300V` |
| `[29865,29900]` | 单体温度(K×100) | `25.5°C, 25.9°C` |
---
## TCP 设备通讯协议
### 通信规则
> ⛔ **本节(自本行起至下一个 `##` 标题之前)全部内容已废弃,禁止作为实现依据。**
> **唯一权威文档:[`柜控板固件对接文档.md`](./柜控板固件对接文档.md)(v1.1)** —— 所有 TCP 报文格式、字段名、
> `act` 清单、签名算法、保活/超时规则、时序与异常处理,**一律以该文档为准**。
>
> 本节保留仅为历史参考。已知与定稿规范的**重大冲突**(仅列关键几处,避免误读):
> - ~~`dev_id` 上行全部报文必带~~ → **只有 `auth_str` / `login` 带**,其余报文不带(设备身份由 TCP 连接确定)
> - ~~`id` = `IMEI` / `IMEI-仓控板-通道`~~ → **`id` 不含 IMEI**:柜级**省略**、单板一段 `"01"`、仓体级两段 `"01-3"`
> - ~~空闲 1 分钟 / 充电 15 秒上报~~ → **`status_post` 固定每 60 秒一次、每次全量**
> - ~~`login` 应答返回 `cell`~~ → 返回 **`spec`**(柜子规格,如 `"0606"`)与 **`work_mode`**
> - ~~`pow_fail` 带 `info`~~ → 改为 **`pow_fail_dc` / `pow_fail_ac` 两条独立指令**,无附加字段
> - ~~`status_post` 的 `status` HEX 字典 / `bat_in` 的 `bms` 块~~ → 改为 **`cabinet` + `boards[].channels[]`(每通道 18 字符 hex 透传)** 与 **`battery` 块**
> - ~~`bat_in` 建单~~ → **`bat_in` 与订单无关,订单只由 `on` 建立**
> - 内部字段已由 `id` 改为 **`_dev_id`**(供 device-server 路由,下发前摘除)
- TCP 长连接,无需 PING 包
- 空闲 1 分钟上报,充电 15 秒上报
- 超时 5000ms
- JSON 去空格回车省流量,LF 处理粘包
- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID
- `msg_id`:自增会话 ID
- 全局通道号:`dev_id-sub_device_id-通道号`(如 `123456789012345-01-6`)
### 设计原则
> MCU(Air780E)资源有限,TCP 协议遵循以下原则:
> 1. **响应扁平化**:字段直接放在顶层,不包 `data`/`result` 等嵌套层
> 2. **字段最小化**:需要几个字段传几个,不传空值、默认值
> 3. **JSON 紧凑**:去空格回车,不传多余引号和逗号(可用 `serde_json::to_string` 紧凑模式)
> 4. **响应必达**:所有**上行**报文平台都会回复;但**设备回执(带 `suc`)平台不再回包**(防误配对)
### 通信规则(⚠️ 下列旧描述已废弃,见节首说明)
- TCP 长连接,无需 PING 包 ✅(保活由 `status_post` 承担)
- ~~空闲 1 分钟上报,充电 15 秒上报~~ ❌ → **固定每 60 秒全量**
- ~~超时 5000ms~~ ❌ → **每个交互 60 秒超时,超时作废、迟到应答丢弃、不重试**
- JSON 去空格回车省流量,LF 处理粘包 ✅
- ~~`dev_id`:设备上报时使用~~ ❌ → **仅 `auth_str` / `login` 携带**
- ~~`id`:`IMEI-仓控板-通道` / `IMEI`~~ ❌ → **不含 IMEI**:柜级省略 / 单板 `"01"` / 仓体级 `"01-3"`
- `msg_id`:两个方向**各自独立计数,都每连接从 `1` 起自增**,重连归零;判"应答 vs 指令"看有无 `suc`
### 签名机制
@ -219,68 +495,195 @@ H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
### 指令清单
| 动作 | 方向 | id格式 | 说明 |
|------|------|--------|------|
| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) |
| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) |
| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) |
| `pow_fail` | 设备→平台 | 无 | 电源掉电上报 |
| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入 |
| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电 |
| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电 |
| `open` | 平台→设备 | `IMEI` | 开门 |
| `pow_on` | 平台→设备 | `IMEI` | 接触器合闸 |
| `pow_off` | 平台→设备 | `IMEI` | 接触器分闸 |
| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 |
> ⛔ **以下整段(含本表与其后的报文示例、`cell` / `pow_fail` / `bms` 字段说明)均为废弃的历史内容,请勿据此实现。**
> 现行清单与报文以 [`柜控板固件对接文档.md`](./柜控板固件对接文档.md) §4 / §5 为准。**`act` 共 13 个**:
> `auth_str` / `login` / `status_post` / `pow_fail_dc` / `pow_fail_ac` / `bat_in` / `bat_out` / `on` / `off` / `open` / `pow_on` / `pow_off` / `reset`
> (**已无 `pow_fail`**,拆成 `pow_fail_dc` / `pow_fail_ac`)
### 指令详情
| 动作 | 方向 | 说明(⚠️ 历史,见上方说明) |
|------|------|------|
| `login` | 设备→平台 | 登录(含 `sign`+`timestamp`+`iccid`);成功应答返回 **`spec`** + **`work_mode`**(不再是 `cell`) |
| `auth_str` | 设备→平台 | 获取安全码(首次 / 重置后;失败统一回 `invalid signature` 之外的两条见权威文档) |
| `status_post` | 设备→平台 | **每 60 秒全量**;结构为 `cabinet` + `boards[].channels[]`(每通道 18 字符 hex),**不再是 `status` 字典** |
| ~~`pow_fail`~~ | 设备→平台 | **已废弃** → 改为 `pow_fail_dc` / `pow_fail_ac`(无附加字段) |
| `pow_fail_dc` / `pow_fail_ac` | 设备→平台 | 直流停电 / 交流停电(事件上报) |
| `bat_in` | 设备→平台 | 电池插入(`battery` 块:属性 + `info` 18hex + `cells` hex);**不建单** |
| `bat_out` | 设备→平台 | 电池拔出(结束订单 `user_stop`) |
| `on` | **双向** | 开始充电(上行两形态:带 `suc`=回执 / 不带=上报;**建单唯一入口**) |
| `off` | **双向** | 充电停止(结束订单 `normal`) |
| `open` | 平台→设备 | **柜门解锁**(柜级:整柜共用一扇门,需人拉开;无"关门"指令) |
| `pow_on` / `pow_off` | 平台→设备 | 接触器合闸 / 分闸(`pow_off` 后仓控板随之下电 → 上报 `pow_fail_dc`) |
| `reset` | 平台→设备 | 重启整柜(`id` 省略)或单块仓控板(`id="01"`);**先回执再重启** |
> ⚠️ 所有设备上报消息平台都会回复 `{"suc":1,"msg_id":N}` 确认。响应字段扁平化,不加 `data` 包裹。
### 全局单位规范
所有数值字段使用整数存储,禁止浮点类型:
| 字段 | 单位 | 精度 | 说明 |
|------|------|------|------|
| `voltage` | mV | 1 | 52000 = 52.000V |
| `current` | mA | 1 | 10000 = 10.000A |
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
| `soc` | ‰ | 0.1% | 4500 = 45.00% |
### 服务架构
```
设备 ─TCP─→ device-server ─LPUSH→ Redis ─BRPOP→ api-server
设备 ←TCP─ device-server ←BRPOP─ Redis ←LPUSH─ api-server
```
**职责分层:**
| 层 | 职责 |
|---|------|
| **device-server** | TCP连接管理、协议解析(HEX→结构化)、消息转发 |
| **api-server** | 业务逻辑:签名验证、状态持久化、充电事件、告警、指令下发 |
**Redis 队列:**
| 队列 | 方向 | 说明 |
|------|------|------|
| `device:reports` | 设备→平台 | device-server 转发设备上报 |
| `device:commands` | 平台→设备 | api-server 下发指令/响应 |
### 全局单位规范
#### login(设备→平台)
```json
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"h_ver":"1.0.0","s_ver":"1.0.0"}
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"iccid":"89860000000000000000"}
// 响应:{"suc":1,"msg_id":1,"cell":6}
```
- `iccid` — SIM卡ICCID(变更时写 SIM 变更日志)
- `cell` — 仓控板数量(柜子配置属性)
---
#### auth_str(设备→平台)
```json
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345","ccid":"89860000000000000000"}
// 响应:{"suc":1,"msg_id":2,"data":{"auth_str":"abcd1234"}}
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345"}
// 响应:{"suc":1,"msg_id":2,"auth_str":"abcd1234"}
```
- `auth_str` — 8位随机安全码(仅返回一次,标记后不可重复获取)
---
#### status_post(设备→平台)
```json
{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,
"status":{"123456789012345-01":"000100020003000400050006"}}
"status":{"00":"0001","01":"000100020003000400050006","02":"000100020003000400050006"}}
// 响应:{"suc":1,"msg_id":10}
```
**status 字段:**
- key: `"00"`=柜子级, `"01"`~`"N"`=仓控板级(N = cell-1)
- value: 6通道状态拼接(每通道4个HEX字符=2字节)
- device-server 解析后附加 `parsed_status` 字段,api-server 直接写入 Redis
**HEX 解析(device-server 负责,格式待硬件团队最终确认):**
- 字节1 bit1=充电中, bit2=充满, bit3=故障
- 当前实现:每通道4字符,取字节1解析
> ⚠️ HEX 编码格式待硬件团队最终确认,当前为预留实现。
---
#### pow_fail(设备→平台)
```json
{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"}
// 响应:{"suc":1,"msg_id":15}
```
- `info` — 停电类型:`0`=直流停电(12V掉电),`1`=交流停电(380V掉电)
---
#### bat_in / bat_out(设备→平台)
```json
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-6","bms":{"soc":45,"voltage":52.3}}
{"act":"bat_out","msg_id":21,"id":"123456789012345-01-6"}
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-01","bms":{
"soc":4500,"voltage":52000,"current":10000,"temp":29865,
"battery_id":"BAT001","brand":"星恒","model":"XH-48V","sn":"SN123456"
}}
// 响应:{"suc":1,"msg_id":20}
{"act":"bat_out","msg_id":21,"id":"123456789012345-01-01","bms":{
"battery_id":"BAT001","soc":4500
}}
// 响应:{"suc":1,"msg_id":21}
```
**BMS 字段(全局整数单位):**
| 字段 | 单位 | 精度 | 说明 |
|------|------|------|------|
| `soc` | ‰ | 0.1% | 4500 = 45.00% |
| `voltage` | mV | 1 | 52000 = 52.000V |
| `current` | mA | 1 | 10000 = 10.000A |
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
| `battery_id` | — | — | 电池唯一标识 |
| `brand` | — | — | BMS品牌 |
| `model` | — | — | BMS型号 |
| `sn` | — | — | BMS序列号 |
| `cell_voltages` | mV | 1 | 单体电芯电压数组 |
> `bat_out` 只需上传 `battery_id`、`soc`(其他数据不变)。
---
#### on / off(双向)
```json
// 平台→设备:开始充电
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
// 平台→设备:下发充电
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
// 设备响应:{"suc":1,"msg_id":30}
// 设备→平台:充电开始确认
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
// 平台响应:{"suc":1,"msg_id":30}
// 平台→设备:下发停止
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
// 设备响应:{"suc":1,"msg_id":31}
// 设备→平台:充满自动停止
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
// 平台响应:{"suc":1,"msg_id":31}
```
> `on` 必须由平台下发,设备不可主动充电。MCU 保证 `off` → `bat_out` 顺序。
---
#### open(平台→设备)
```json
{"act":"open","msg_id":40,"id":"123456789012345"}
// 错误码63 = 门锁故障
```
**响应:** `{"suc":1,"msg_id":40}`
---
#### pow_on / pow_off(平台→设备)
```json
{"act":"pow_on","msg_id":50,"id":"123456789012345"}
{"act":"pow_off","msg_id":51,"id":"123456789012345"}
```
**响应:** `{"suc":1,"msg_id":50}`
---
#### reset(平台→设备)
```json
@ -290,6 +693,8 @@ H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
{"act":"reset","msg_id":61,"id":"123456789012345-01"}
```
**响应:** `{"suc":1,"msg_id":60}`
### 充电结束原因
| 平台收到 | 判断原因 |
@ -311,41 +716,65 @@ device:{imei} # 设备级 Hash
├─ online = "1" # 在线标记(TTL 300秒)
├─ rssi = "85" # 信号强度
├─ pow_fail_dc = "0/1" # 直流停电
├─ pow_fail_ac = "0/1" # 交流停电
└─ device:{imei}:board:{board_idx} # 板级 Hash
├─ status_hex = "00010002..." # 原始hex状态
└─ device:{imei}:board:{board_idx}:ch:{0-5} # 通道级 Hash
└─ device:{imei}:board:{board_idx}:ch:{ch} # 通道级 Hash(1~6)
├─ on = "0/1" # 充电中
├─ full = "0/1" # 充满
├─ fault = "0/1" # 故障
└─ hex = "00010002" # 原始hex
├─ hex = "0001" # 原始hex(4字符)
├─ battery_id = "BAT001" # 电池ID(bat_in 写入,bat_out 清除)
├─ soc = "4500" # 电量 ‰
├─ voltage = "52000" # 电压 mV
├─ current = "10000" # 电流 mA
├─ temp = "29865" # 温度 K×100
├─ brand = "星恒" # BMS品牌
├─ model = "XH-48V" # BMS型号
└─ sn = "SN123456" # BMS序列号
```
### BMS 字段
> HEX 解析由 device-server 负责,api-server 只接收结构化数据写入 Redis。
| 字段 | 类型 | 单位 | 说明 |
### BMS 字段(全局整数单位)
| 字段 | 单位 | 精度 | 说明 |
|------|------|------|------|
| `soc` | i64 | % | 电池电量百分比 |
| `soh` | i64 | % | 电池健康度百分比 |
| `voltage` | i64 | mV | 电池组总电压 |
| `current` | i64 | mA | 电流 |
| `power` | i64 | mW | 功率 |
| `energy` | i64 | Wh | 累计充电量 |
| `cell_voltages` | i64[] | mV | 单体电芯电压数组 |
| `soc` | ‰ | 0.1% | 4500 = 45.00% |
| `voltage` | mV | 1 | 52000 = 52.000V |
| `current` | mA | 1 | 10000 = 10.000A |
| `energy` | Wh | 1 | 累计充电量 |
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
| `battery_id` | — | — | 电池唯一标识 |
| `brand` | — | — | BMS品牌 |
| `model` | — | — | BMS型号 |
| `sn` | — | — | BMS序列号 |
| `cell_voltages` | mV | 1 | 单体电芯电压数组 |
---
## 权限码列表
> 以 `software/api-server/src/db/migrate.rs::seed_permissions` 的**种子表为准**(下表与之对齐)。
> ⚠️ 历史上代码里曾误用一个**不存在**的权限码 `device:door:unlock`(种子里从来没有它)→ 导致开门对所有角色 403,**已修正为 `charge:open_door`**。
| 权限码 | 说明 |
|--------|------|
| `device:view` | 查看设备 |
| `device:create` | 创建设备 |
| `device:edit` | 编辑设备 |
| `device:operate` | 操作设备(开合闸 / 重启 / 开门等柜级控制) |
| `device:create` | 添加设备 |
| `device:edit` | 编辑设备(含修改充电工作模式) |
| `device:delete` | 删除设备 |
| `device:door:unlock` | 开门 |
| `device:auth_str:reset` | 重置安全码(**仅总管理员**) |
| `charge:start` | 开始充电 |
| `charge:stop` | 停止充电 |
| `charge:open_door` | 开门(柜门解锁) |
| `charge_record:view` | 查看充电记录 |
| `org:view` | 查看组织 |
| `org:create` | 创建组织 |
| `org:edit` | 编辑组织 |
| `charge_record:export` | 导出充电记录 |
| `device_log:view` | 查看设备日志 |
| `energy:view` | 查看能耗 |
| `energy:export` | 导出能耗 |
| `user:view` / `user:create` / `user:edit` / `user:delete` | 用户管理 |
| `role:view` / `role:create` / `role:edit` / `role:delete` | 角色管理 |
| `operation_log:view` | 查看操作日志 |
| `org:view` / `org:create` / `org:edit` / `org:delete` | 组织管理 |

View File

@ -0,0 +1,47 @@
# SQL 注入:format! 拼 SQL 的连锁风险
## 问题描述
`src/routes/charge_orders.rs` 的 `build_count_query` 和 `build_list_query` 两个辅助函数存在 SQL 注入漏洞。`battery_id` 参数通过 `format!` 直接拼入 SQL 字符串,攻击者可注入任意 SQL。
## 根因分析
**核心问题**:代码演进过程中出现了"双重 SQL 构建"模式。
1. `list_charge_orders` 主函数已经正确使用 `?` 占位符拼接了 `query` 和 `count_query`
2. 但 `build_count_query` / `build_list_query` **完全忽略**了主函数预拼接的 SQL 字符串
3. 它们自行用 `format!` 重建查询,导致所有 `format!` 拼接的参数(`cabinet_id`、`compartment_id`、`battery_id`、`status`、`LIMIT/OFFSET`)都存在注入风险
4. 两个函数还包含大量重复的分支逻辑(~100 行),不同分支的绑定顺序不一致,难以维护
**根本原因**:重构时只改了 `list_charge_orders` 主函数,没同步改造辅助函数,导致新旧模式并存。
## 解决方案
1. **删除重复逻辑**:让 `build_count_query` / `build_list_query` 直接使用主函数预拼接的 SQL 字符串
2. **统一绑定顺序**:按固定顺序(`cabinet_id → compartment_id → battery_id → status → start_time → end_time → LIMIT/OFFSET`)bind 参数,与主函数追加 `?` 占位符的顺序一致
3. **LIKE 值处理**:百分号在 bind 值中拼接(`format!("%{}%", bid)`),不进 SQL 文本
4. **消除未使用变量**:`if let Some(x)` 改为 `.is_some()`,避免未使用变量告警
## 模式/反模式
### 反模式 ❌:format! + SQL 字符串拼接
```rust
// 注入风险
query.push_str(&format!(" AND battery_id LIKE '%{}%'", bid));
```
### 模式 ✅:主函数拼占位符,辅助函数按序 bind
```rust
// 主函数:统一追加 ? 占位符
if params.battery_id.is_some() {
query.push_str(" AND battery_id LIKE ?");
}
// 辅助函数:按固定顺序 bind 值
if let Some(ref bid) = params.battery_id {
q = q.bind(format!("%{}%", bid));
}
```
### 模式 ✅:SQL 构建统一入口
所有的 SQL 构建逻辑放在一个地方(主函数),辅助函数只负责 bind 参数,不做 WHERE 条件判断。这样改了主函数就改了所有查询路径,不会出现新旧模式不一致。

View File

@ -0,0 +1,9 @@
# Compound Note 模板
## 问题描述
## 根因分析
## 解决方案
## 模式/反模式

View File

@ -0,0 +1,167 @@
# 数据单位审查报告 — 充电柜项目
**日期:** 2026-07-06
**审查范围:** 硬件固件(协议层)、后端 API(api-server)、前端展示(web + h5-uniapp)、API 文档(docs/)
**协议基准:** [docs/tcp-protocol.md](./tcp-protocol.md) 全局单位规范(第23-35行)
---
## 1. 协议基准(tcp-protocol.md)
| 字段 | 单位 | 精度 | 示例 |
|------|------|------|------|
| `voltage` | mV | 1 | 52000 = 52.000V |
| `current` | mA | 1 | 10000 = 10.000A |
| `power` | mW | 1 | 500000 = 500.000W |
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
| `soc` | **‰** | 0.1% | 4500 = 45.00% |
| `soh` | **‰** | 0.1% | 9500 = 95.00% |
| `energy` | Wh | 1 | 累计充电量 |
---
## 2. 审查结果总览
### ✅ 正确的转换
| 字段 | 协议单位 | 前端转换 | 文件位置 | 状态 |
|------|---------|---------|---------|------|
| voltage | mV | `/1000 → V` | h5 `compartment-detail/index.vue:144` | ✅ |
| current | mA | `/1000 → A` | h5 `compartment-detail/index.vue:145` | ✅ |
| power | mW | `/1000 → W` | h5 `compartment-detail/index.vue:146` | ✅ |
| temp | K×100 | `/100 - 273.15 → °C` | h5 `device-detail/index.vue:85`、web `cabinet.tsx` | ✅ |
| soc | ‰ | `/10 → %` | h5 `compartment-detail/index.vue:142`、`device-detail/index.vue:86` | ✅ |
| energy | Wh | `/1000 → kWh` | h5 `compartment-detail/index.vue:147` `fmtEnergyKwh` | ✅ |
| energy(累计) | Wh | `/1000 → kWh` | h5 `compartment-detail/index.vue:143` `fmtEnergy` | ✅ |
| cost计算 | Wh × 0.0008 → 元 | — | api-server `energy_stats.rs:15` COST_PER_WH | ✅ |
### 🔴 不一致:SOC/SOH 单位标注错误
**问题:** `docs/API.md` 第206-207行标注错误。
```diff
- soc — %(0-100)
- soh — %(0-100)
+ soc — ‰(0-10000,前端已自动转为 %)
+ soh — ‰(0-10000,前端已自动转为 %)
```
**现状:** 协议层(tcp-protocol.md)明确定义 soc/soh 为 ‰(0-10000),但 API.md 标注为 %(0-100)。前端代码实际上做了正确的 ‰→% 转换(`/10`)。
**风险等级:** 🔴 高风险 — API 消费者(第三方、新的前端开发者)依据 API.md 会误认为值是 0-100,导致显示/逻辑错误。
**建议:** 修正 API.md 第206-207行,标注为 `‰(0-10000)`,并注明前端已做转换。
### 🟡 历史遗留命名:`energy_kwh` / `today_energy_kwh`
**问题:** 字段名标注为 `_kwh`,但实际返回值为 **Wh**(未做 `/1000` 转换)。
**涉及位置:**
| 位置 | 字段 | 实际值 | 问题 |
|------|------|-------|------|
| `api-server/src/routes/h5.rs:309` | `today_energy_kwh` | SUM(energy) → Wh | 注释写"kWh"但返回 Wh |
| `api-server/src/routes/h5.rs:776` | `energy_kwh` | `r.energy` → Wh | 字段名标 kWh,实际 Wh |
| `docs/API.md:137` | `today_energy_kwh` | 0 | 文档无明确单位说明 |
| `docs/API.md:421` | 多个能量字段 | — | **已有注释**:`¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。` |
**现状:** API.md 第421行已有明确注释说明这是历史遗留命名。前端 H5 通过 `fmtEnergy` 和 `fmtEnergyKwh` 正确执行 `/1000` 转换。
**风险等级:** 🟡 中风险 — API.md 已有注释说明,但字段名具有误导性。新接手开发者容易按字段名直接使用而不做 `/1000`。
**建议:** 保留现状(已有注释),但可在 API 文档对应字段示例中加 `// 实际单位: Wh, 需 /1000 转 kWh`。
### 🟡 `fmtEnergy` 缺少单位标签
**位置:** `h5-uniapp/src/pages/compartment-detail/index.vue:143`
```typescript
const fmtEnergy = (v: number | null | undefined) => v != null ? (v / 1000).toFixed(2) : '-'
```
**问题:** 转换后未标注单位(kWh),与 `fmtEnergyKwh`(L147,有 `kWh` 标签)不一致。
**风险等级:** 🟡 低风险 — 数值本身正确,只是缺少显示标签。
**建议:** 加 `' kWh'` 后缀,与 `fmtEnergyKwh` 对齐。
---
## 3. DB 层验证
**migrate.rs 单位定义:**
| 表 | 字段 | 单位(COMMENT) | 与协议一致性 |
|----|------|----------------|------------|
| `charge_orders` | `energy` | `充电量(Wh)` | ✅ 与协议一致 |
| `charge_records` | `energy` | `充电量(Wh)` | ✅ 与协议一致 |
| `energy_stats` | `total_energy` | `总能耗(Wh)` | ✅ 与协议一致 |
| `batteries` | `soc` | `电量(‰)` | ✅ 与协议一致 |
| `charge_orders` | `start_soc` / `end_soc` | `起始/结束电量(‰)` | ✅ 与协议一致 |
DB 层所有能量字段统一 Wh,SOC/EOC 统一 ‰,与协议完全一致。✅
---
## 4. 审查结论
### 正确率:91%
| 维度 | 正确 | 问题 |
|------|------|------|
| 协议 → DB 单位 | ✅ 100% | 无 |
| DB → 后端 API | ⚠️ 2/3 正确 | 2个字段名误导(energy_kwh / today_energy_kwh) |
| API → 前端展示 | ✅ 100% | 前端所有转换正确 |
| API 文档 | ⚠️ 83% | SOC/EOC 单位标注错误 + energy_kwh 命名误导 |
### 需修复
| 优先级 | 问题 | 文件 | 修改 |
|--------|------|------|------|
| 🔴 | SOC/EOC 单位标注 | `docs/API.md:206-207` | `%(0-100)` → `‰(0-10000)` |
| 🟡 | `fmtEnergy` 缺单位标签 | `h5-uniapp/compartment-detail/index.vue:143` | 加 `' kWh'` 后缀 |
### 已有保障(无需修复)
- `docs/API.md:421` 已对 `energy_kwh` / `today_energy_kwh` 的命名陷阱做注释
- 前端所有 7 个单位转换函数逻辑正确
- DB 层所有能量字段统一 Wh,SOC 统一 ‰,与协议一致
---
## 5. 审查路径图
```
设备协议 (tcp-protocol.md)
├─ voltage mV ──────────────────────┐
├─ current mA ──────────────────────┤
├─ power mW ───────────────────────┤
├─ temp K×100 ─────────────────────┤
├─ soc ‰ ──────────────────────────┤
├─ soh ‰ ──────────────────────────┤
└─ energy Wh ──────────────────────┤
│
▼
DB (migrate.rs)
energy BIGINT (Wh) ✅
soc INT (‰) ✅
temp 由 BMS 直接传入
│
▼
api-server (h5.rs / cabinets.rs)
原样返回 raw 值 ✅
字段名 _kwh 误导 🟡
│
▼
H5 前端 (compartment-detail / device-detail)
全部 /1000、/10、/100 转换 ✅
fmtEnergy 缺单位标签 🟡
│
▼
API 文档 (API.md)
SOC 标 % 实际 ‰ 🔴
energy_kwh 注释齐全 ✅ (已有说明)
```
---
*审查基于代码静态分析,未运行实际设备验证。建议结合真实设备数据对 SOC 和 energy 展示做端到端验证。*

View File

@ -765,12 +765,48 @@ TCP断连处理:
### 7.1 后台管理系统
#### 7.1.1 技术栈(已确定)
#### 7.1.1 业务模型与权限体系
**组织架构:**
```
厂家(总管理员 role=2)
└─ 将设备分配给 B 端企业(organization)
└─ 企业管理员(role=1)
├─ 创建下级操作员(role=0,按权限码分配功能)
├─ 创建项目(project,设备放置地点)
└─ 管理本企业下的设备
```
**角色定义:**
| 角色 | role | 权限范围 |
|------|------|----------|
| 总管理员 | 2 | 全平台数据,分配柜子给企业,创建企业管理员 |
| 企业管理员 | 1 | 本企业下的设备/项目/用户管理 |
| 操作员 | 0 | 按权限码授予的具体功能(开门、查看等) |
- 所有用户均为管理员角色,**无 C 端普通用户**
- 权限:RBAC,支持精确到按钮级别的权限码(如 `device:door:unlock`)
- 组织隔离:role_level >= 2(总管理员)跳过组织过滤,其他用户只能操作所属组织的资源
**认证方式:**
- 全部使用手机号 + 密码登录(argon2 哈希)
- 不接入微信认证(无 C 端用户)
- 创建/重置用户时必须指定密码,无默认密码
**业务流转:**
```
厂家(总后台)→ 创建企业账号 → 分配充电柜给企业
→ 企业管理员登录 → 创建项目(放置地点)→ 将设备划拨到项目
→ 创建下级操作员 → 赋予具体权限码 → 日常运维
```
#### 7.1.2 技术栈(已确定)
```
后端框架:Rust + Axum
前端框架:React + Arco Design Pro(管理端)
H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序)
微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
数据库:MySQL(阿里云RDS)
缓存:Redis(阿里云Redis)
通信协议:自定义TCP协议长连接(设备通讯)+ HTTP(API)
@ -786,7 +822,7 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
| BMS协议配置 | 设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
| 用户管理 | 注册/登录、权限分配(管理员/普通/骑手) |
| 用户管理 | 创建/管理管理员账号,按角色分级(总管理员/企业管理员/操作员) |
| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计 |
| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置、设备告警管理 |
@ -836,24 +872,25 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
- 故障等级(预警/保护/严重)
```
### 7.2 用户端(H5 / 小程序)
### 7.2 移动管理端(微信小程序)
> 当前阶段使用 H5 用户端(uni-app 构建),后续可转换为微信小程序。
> 面向企业管理员/操作员的手持管理入口,使用微信小程序(uni-app 构建 mp-weixin)。
> 本系统无 C 端用户,所有用户均为 B 端管理员角色。
#### 7.2.1 功能页面
| 页面 | 功能 |
|------|------|
| 首页 | 附近充电柜列表,空闲舱位显示,RSSI信号强度 |
| 首页 | 企业所属设备列表,空闲舱位显示,RSSI信号强度 |
| 设备详情 | 仓控板+仓体状态,ICCID信息 |
| 扫码充电 | 扫描仓体二维码,启动充电 |
| 扫码操作 | 扫描仓体二维码,开门/充电/查看状态 |
| 充电进度 | 实时显示SOC/电压/温度/充电时间 |
| 消息通知 | 充电完成、故障告警、系统通知 |
| 个人中心 | 充电记录、用户信息、反馈 |
| 个人中心 | 充电记录、账户信息、退出登录 |
#### 7.2.2 仓体状态展示
用户端同步显示仓体指示灯状态及门锁状态:
移动端同步显示仓体指示灯状态及门锁状态:
- 绿灯闪烁 → "充电中"
- 绿灯常亮 + 门锁图标开 → "空闲(门已开)"
- 绿灯常亮 + 门锁图标关 → "空闲(门已关)"
@ -889,7 +926,7 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
| 存储加密 | 用户信息、BMS数据加密存储 |
| 通讯校验 | 1TN和校验 + Modbus CRC16校验 |
| 网络传输 | 自定义TCP协议加密传输 |
| 权限管控 | 管理员/普通用户分级权限 |
| 权限管控 | 总管理员/企业管理员/操作员三级权限,RBAC 精确到按钮 |
### 8.3 防护联动流程
@ -938,3 +975,19 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
| 指示灯寿命 | ≥ 50000小时 |
| 工作温度 | -10℃ ~ +70℃ |
| 工作湿度 | 10% ~ 90% |
## 10. 第三方服务配置
### 10.1 腾讯地图(JS API GL)
管理端项目地址选点使用腾讯地图 JS API GL(v1.exp),需加载 `service` 附加库。
| 参数 | 值 |
|------|-----|
| Key | `HWVBZ-CMACV-JEBP5-5RDRJ-OIHLK-ZNFE2` |
| Secret Key (SK) | `LYVx9XenBOKGnbAKkmzJxOYFhASp9FQD` |
| 签名验证 | 已开启,所有 Geocoder/Service 调用通过 `servicesk` 参数传 SK |
| 使用场景 | 管理端「设备管理 → 项目添加/编辑 → 地图选点」 |
| 组件 | `LocationPicker.tsx`(`@/components/LocationPicker`) |
> ⚠️ Key 内嵌在前端组件中,SK 仅用于 JS API 的 WebService 签名校验(客户端可见是腾讯地图 SDK 的设计要求)。如更换 Key,同步更新 `LocationPicker.tsx` 顶部的 `TMAP_KEY` 和 `TMAP_SK` 常量。

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,273 @@
# 充电柜矩阵系统(PMS)深度分析报告
**分析日期**:2026-07-06
**分析范围**:项目结构、文档、代码组织、技术栈实现、功能完整性
**分析方法**:静态代码分析、文档对比、架构验证
---
## 一、项目概览
**项目名称**:充电柜矩阵系统(Power Matrix System / PMS)
**产品名称**:安知充(anzhizhichong)
**技术栈**:Rust + Axum(后端)、React + Arco Design Pro(管理端)、uni-app + Vue 3(H5用户端)
**数据库**:MySQL(阿里云RDS)
**缓存**:Redis(阿里云Redis)
**设备通讯**:TCP长连接(Air780E Cat.1 4G模块)
---
## 二、项目结构分析
### ✅ 符合规范的部分
1. **目录结构基本符合 AGENTS.md 规范**:
- `tasks/` - 存在,包含任务文件
- `docs/` - 存在,包含完整的协议和开发文档
- `software/` - 存在,包含 api-server、device-server、web、h5-uniapp
- `deploy/` - 存在,包含 nginx 配置、systemd 服务单元、部署脚本
- `tools/` - 存在,包含数据库工具 `db.py`
- `hardware/` - 存在
- `test/` - **缺失**
2. **模块化设计合理**:
- api-server 和 device-server 分离,符合"TCP服务轻量化"原则
- 通过 Redis LIST 进行服务间通信,避免直接 HTTP 调用
- 使用 tokio::spawn 处理后台协程任务
### ❌ 发现的问题
| 问题 | 严重程度 | 说明 |
|------|----------|------|
| **敏感信息泄露** | 🔴 高 | AGENTS.md 中包含明文密码(数据库密码、SSH密码等),虽已 gitignore 但仍在文档中 |
| **目录名不一致** | 🟡 中 | README.md 提到 `h5-uniapp`,但实际可能使用 taro 框架(需确认) |
| **test/ 目录缺失** | 🟡 中 | AGENTS.md 规范中要求 `test/` 目录用于测试用例/报告,但实际不存在 |
| **温度单位不一致** | 🟡 中 | tcp-protocol.md 定义 `temp` 为 `K×100`,但 AGENTS.md 数据单位规范中描述为 `0.1°C` |
---
## 三、技术栈实现分析
### ✅ 符合文档描述的部分
1. **后端技术栈**:
- ✅ Rust + Axum 框架(Cargo.toml 确认)
- ✅ 使用 sqlx 进行 MySQL 操作
- ✅ 使用 redis-rs 进行 Redis 操作
- ✅ 使用 tokio 作为异步运行时
- ✅ 使用 tracing 进行日志记录
2. **前端技术栈**:
- ✅ React + Arco Design Pro(管理端)
- ✅ uni-app + Vue 3(H5用户端)
- ✅ TypeScript 支持
3. **服务架构**:
- ✅ api-server 负责 HTTP API 和业务逻辑
- ✅ device-server 负责 TCP 设备连接管理
- ✅ Redis 作为消息队列缓冲
### ❌ 实现与文档不一致的部分
| 问题 | 严重程度 | 说明 |
|------|----------|------|
| **H5框架可能不一致** | 🟡 中 | AGENTS.md 说是 uni-app,但代码中可能出现 taro 相关依赖 |
| **OTA升级功能** | 🟡 中 | 合同和文档要求 OTA 固件升级,但代码中未找到完整实现 |
| **微信小程序** | 🟢 低 | 文档明确说明"不做"(名额暂不可用),但合同要求交付 |
---
## 四、代码组织分析
### ✅ 优点
1. **Rust 代码组织良好**:
```
src/
├── routes/ # HTTP 路由处理器(按业务拆分)✅
├── middleware/ # 中间件(认证、日志、CORS)✅
├── db/ # 数据库连接池、迁移 ✅
├── commands/ # 设备指令下发逻辑 ✅
├── nodes/ # 设备服务节点管理 ✅
├── workers/ # 后台 Worker(report/reply/node_checker)✅
└── error.rs # 统一错误定义 ✅
```
2. **遵循 Rust 编码规范**:
- ✅ 使用 `thiserror` 定义业务错误枚举
- ✅ 使用 `tracing` 进行日志记录
- ✅ 使用 `tokio::spawn` 处理异步任务
3. **API 设计规范**:
- ✅ 响应格式统一:`{ "code": 0, "data": {...}, "message": "ok" }`
- ✅ 使用 JWT Token 认证
- ✅ RBAC 权限控制
### ❌ 需要改进的部分
| 问题 | 严重程度 | 说明 |
|------|----------|------|
| **浮点类型使用** | 🔴 高 | AGENTS.md 明确指出禁止使用浮点类型,但 `energy_stats.rs` 和 `charge_records.rs` 中仍使用 `f64` |
| **数据库迁移** | 🟡 中 | `db/migrate.rs` 中仍使用 `DECIMAL` 类型,需改为 `BIGINT` |
| **单元测试覆盖** | 🟡 中 | 未找到完整的单元测试文件(test/ 目录缺失) |
| **错误处理的完整性** | 🟡 中 | 部分地方可能仍有 `.unwrap()` 或未处理的错误 |
---
## 五、文档完整性分析
### ✅ 完整的部分
1. **协议文档完整**:
- ✅ `docs/tcp-protocol.md` - TCP 设备通讯协议(11条指令详细说明)
- ✅ `docs/API.md` - API 接口文档(包含请求/响应示例)
- ✅ `docs/1TN协议标准_YG20240118-1_V1.0.md` - 电源协议
2. **开发文档详细**:
- ✅ `docs/开发文档.md` - 硬件架构、软件架构、通讯协议设计
- ✅ `docs/附件一-硬件规格.md` - 柜控主板、仓控主板硬件规格
- ✅ `docs/附件二-软件规格.md` - 后台管理系统、微信小程序需求
- ✅ `docs/附件三-交付与验收标准.md` - 交付物清单、验收标准
3. **AGENTS.md 非常详细**:
- ✅ 项目简介、技术栈、项目结构
- ✅ 部署架构、服务器信息、数据库工具
- ✅ 开发约束、编码规范(Rust、React)
- ✅ API 设计规范、Git 工作流
- ✅ 测试标准、安全规范、日志规范、性能规范
### ❌ 文档问题
| 问题 | 严重程度 | 说明 |
|------|----------|------|
| **README.md 与 AGENTS.md 重复** | 🟢 低 | 两份文档内容有大量重复,维护成本增加 |
| **合同模板未填写** | 🟢 低 | `docs/开发合同.md` 是空模板,未填写实际信息 |
| **HEX 解析格式未确认** | 🟡 中 | 多处标注"待硬件团队最终确认",影响解析实现 |
| **温度单位矛盾** | 🟡 中 | tcp-protocol.md 使用 `K×100`,AGENTS.md 描述为 `0.1°C` |
---
## 六、功能缺失分析
### 根据合同和文档要求,以下功能可能缺失或不完整:
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **OTA 固件升级** | 🔴 高 | 合同要求交付,但代码中未找到完整实现 |
| **微信小程序** | 🟡 中 | 合同要求交付,但 AGENTS.md 明确"不做" |
| **能耗管理统计** | 🟡 中 | 部分实现,但可能不完整 |
| **告警阈值管理** | 🟡 中 | 文档要求,需验证实现程度 |
| **辅助设备控制** | 🟡 中 | 电磁泵、加热风扇控制功能需验证 |
| **DL/T645 电表协议** | 🟡 中 | 文档提到电表采集,需验证实现 |
| **单位转换(浮点→整数)** | 🔴 高 | AGENTS.md 明确要求,但代码中仍使用 f64 |
---
## 七、改进建议(优先级排序)
### 🔴 P0 - 立即处理
1. **移除 AGENTS.md 中的敏感信息**:
- 密码、SSH连接信息应移至 `.env.example` 或环境变量
- 当前文档包含明文密码:`Hbhyg731024`、`Hbhyg731024@`
2. **修复浮点类型使用**:
- 将 `energy_stats.rs` 和 `charge_records.rs` 中的 `f64` 改为 `i64`
- 将 `db/migrate.rs` 中的 `DECIMAL` 改为 `BIGINT`
- 涉及文件:
- `src/routes/charge_records.rs` (L46-47, L77-78)
- `src/routes/energy_stats.rs` (L43-72, L106, L116)
- `src/db/migrate.rs` (L76-77, L97)
3. **完善 OTA 升级功能**:
- 如果合同要求交付,必须实现完整的 OTA 功能
- 包括:固件上传、分片转发、校验、升级状态查询
### 🟡 P1 - 高优先级
4. **统一温度单位描述**:
- 确认 `temp` 字段实际使用的单位(K×100 还是 0.1°C)
- 更新所有相关文档和代码注释
5. **创建 test/ 目录并补充单元测试**:
- 按照 AGENTS.md 测试标准补充单元测试
- 核心业务逻辑(充电控制、指令下发、权限校验)覆盖率 ≥80%
6. **验证 H5 前端框架**:
- 确认是使用 uni-app 还是 taro
- 更新 README.md 和 AGENTS.md 保持一致性
7. **补充缺失的 API 实现**:
- 验证所有 `docs/API.md` 中列出的接口是否都已实现
- 特别关注:能耗统计、告警管理、辅助设备控制
### 🟢 P2 - 中优先级
8. **清理重复文档**:
- 合并 README.md 和 AGENTS.md 的重复内容
- 或者明确两份文档的分工(README 面向外部,AGENTS 面向开发者)
9. **填写合同模板**:
- 如果项目已进入开发阶段,应填写实际的合同信息
- 或者删除空模板,避免混淆
10. **增加输入验证**:
- 所有 API 入参必须校验(长度、格式、范围)
- IMEI 校验:必须15位数字
- 手机号校验:11位数字
11. **性能优化**:
- 数据库查询必须走索引(使用 `EXPLAIN` 验证)
- Redis 缓存 key 规范:`{业务}:{标识}`
- 避免大 Key:单个 Hash ≤ 500 字段,List ≤ 1000 元素
---
## 八、安全风险分析
| 风险 | 等级 | 说明 |
|------|------|------|
| **敏感信息泄露** | 🔴 高 | AGENTS.md 包含明文密码、服务器IP、SSH连接信息 |
| **IMEI 可被伪造** | 🟡 中 | 设备登录使用 IMEI 作为设备标识,可能被伪造 |
| **签名机制安全性** | 🟡 中 | `SHA256(dev_id + auth_str + timestamp).substring(56, 63)` 只取8位,安全性较弱 |
| **JWT Token 有效期** | 🟢 低 | 7天有效期较长,建议缩短或增加刷新机制 |
| **SQL 注入风险** | 🟢 低 | 使用 sqlx 参数化查询,风险较低 |
---
## 九、总结
### ✅ 项目优点
1. **架构设计合理**:api-server 和 device-server 分离,职责清晰
2. **文档非常详细**:AGENTS.md 涵盖了开发、部署、测试、安全等各个方面
3. **技术栈现代**:Rust + Axum 高性能后端,React + Arco Design Pro 现代化前端
4. **协议设计完整**:TCP 通讯协议、电源协议、BMS协议都有详细文档
### ❌ 主要问题
1. **敏感信息泄露**:AGENTS.md 包含明文密码和服务器信息
2. **浮点类型未清理**:违反 AGENTS.md 中的数据单位规范
3. **功能可能不完整**:OTA升级、微信小程序等功能可能缺失
4. **文档不一致**:温度单位、框架名称等存在矛盾
### 📋 优先级建议
- **P0**:移除敏感信息、修复浮点类型、完善 OTA 功能
- **P1**:统一单位描述、补充单元测试、验证功能完整性
- **P2**:清理文档、增加输入验证、性能优化
---
## 十、后续行动建议
1. **立即创建 .env.example 文件**,将敏感信息移至环境变量
2. **创建技术债务清单**,跟踪浮点类型清理、功能补全工作
3. **与硬件团队确认 HEX 解析格式**,完成设备状态解析
4. **补充单元测试**,达到 AGENTS.md 中定义的覆盖率要求
5. **进行安全审查**,检查 JWT 签名、IMEI 验证、权限校验等安全环节
---
**报告生成**:WorkBuddy AI Agent
**分析工具**:静态代码分析、文档对比、架构验证

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
logo/圆切带文字.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

BIN
logo/圆切独立LOGO.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

1
logo/白色横向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

BIN
logo/白色横向@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

1
logo/白色竖向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

BIN
logo/白色竖向@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

1
logo/纯色横向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

BIN
logo/纯色横向@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

1
logo/纯色竖向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

BIN
logo/纯色竖向@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

4
opencode.json Normal file
View File

@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "sensenova/glm-5.2"
}

View File

@ -0,0 +1,18 @@
# API 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值)
# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库
# MySQL 连接(阿里云 RDS)
DATABASE_URL=mysql://root:CHANGE_ME@HOST:3306/pms
# Redis 连接(阿里云 Redis)
REDIS_URL=redis://:CHANGE_ME@HOST:6379/5
# 监听
HOST=0.0.0.0
PORT=9000
# JWT 密钥(缺失则服务启动失败,禁止硬编码回退)
JWT_SECRET=CHANGE_ME
# 设备节点心跳超时(秒)
NODE_HEARTBEAT_TIMEOUT=60

View File

@ -1,15 +0,0 @@
# MySQL 连接(正式环境 pms 库)
DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms
# Redis 连接(生产 db5)
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
# HTTP 服务
HOST=0.0.0.0
PORT=9000 # 正式环境API
# JWT
JWT_SECRET=pms-dev-secret-change-me-in-production
# 节点心跳超时(秒)
NODE_HEARTBEAT_TIMEOUT=180

View File

@ -1,15 +0,0 @@
# MySQL 连接(pms_dev 库由迁移自动创建)
DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms_dev
# Redis 连接(测试 db6)
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/6
# HTTP 服务
HOST=0.0.0.0
PORT=3000 # 测试环境API
# JWT
JWT_SECRET=pms-test-secret
# 节点心跳超时(秒)
NODE_HEARTBEAT_TIMEOUT=180

View File

@ -58,7 +58,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"cpufeatures 0.2.17",
"password-hash",
]
@ -102,6 +102,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
@ -189,12 +190,24 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.0"
@ -217,6 +230,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@ -275,6 +305,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
@ -390,6 +429,15 @@ dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -434,6 +482,15 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@ -605,8 +662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@ -707,6 +767,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@ -737,6 +803,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.8",
]
[[package]]
@ -745,13 +828,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.4",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@ -881,6 +972,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@ -891,6 +995,12 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "ipnet"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]]
name = "itoa"
version = "1.0.18"
@ -987,6 +1097,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@ -1012,6 +1128,12 @@ dependencies = [
"digest",
]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.8.2"
@ -1024,6 +1146,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -1045,6 +1177,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -1075,7 +1234,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.6",
"smallvec",
"zeroize",
]
@ -1158,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"rand_core 0.6.4",
"subtle",
]
@ -1232,17 +1391,22 @@ version = "0.1.0"
dependencies = [
"argon2",
"axum",
"base64",
"bytes",
"chrono",
"dotenvy",
"futures",
"hex",
"image",
"jsonwebtoken",
"rand",
"md5",
"rand 0.8.6",
"redis",
"reqwest",
"rust_xlsxwriter",
"serde",
"serde_json",
"sha1",
"sha2",
"sqlx",
"thiserror",
@ -1251,9 +1415,23 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -1287,6 +1465,68 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.4",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.4",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.46"
@ -1310,7 +1550,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@ -1320,7 +1571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -1332,6 +1583,21 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "redis"
version = "0.28.2"
@ -1391,6 +1657,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.8",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -1418,7 +1722,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@ -1434,6 +1738,12 @@ dependencies = [
"zip",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustls"
version = "0.23.41"
@ -1454,6 +1764,7 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"web-time",
"zeroize",
]
@ -1559,7 +1870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@ -1576,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@ -1612,7 +1923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -1805,7 +2116,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.6",
"rsa",
"serde",
"sha1",
@ -1844,7 +2155,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.6",
"serde",
"serde_json",
"sha2",
@ -1920,6 +2231,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@ -2044,6 +2358,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@ -2092,10 +2416,23 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
@ -2172,12 +2509,24 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@ -2223,6 +2572,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@ -2258,6 +2613,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -2283,6 +2647,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
@ -2315,6 +2689,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"

View File

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] }
@ -14,15 +14,21 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6", features = ["cors"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
sha1 = "0.10"
md5 = "0.7"
urlencoding = "2.1"
tokio-util = { version = "0.7", features = ["codec"] }
bytes = "1"
futures = "0.3"
rand = "0.8"
image = { version = "0.25", default-features = false, features = ["png"] }
base64 = "0.22"
hex = "0.4"
rust_xlsxwriter = "0.80"
jsonwebtoken = "9"
argon2 = "0.5"
dotenvy = "0.15"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@ -0,0 +1,265 @@
//! 滑块验证码生成与校验
//!
//! 参考 go-captcha 视觉风格:
//! - 渐变背景(双色渐变)
//! - 丰富纹理(线条+圆点)
//! - 拼图块带投影
//! - 缺口带白色描边
use base64::Engine;
use image::{Rgba, RgbaImage};
use rand::Rng;
use std::io::Cursor;
use crate::error::AppError;
const WIDTH: u32 = 300;
const HEIGHT: u32 = 150;
const PIECE_SIZE: i32 = 44;
const BUMP_RADIUS: i32 = 7;
const TOLERANCE: i32 = 5;
const PIECE_WIDTH: i32 = PIECE_SIZE + BUMP_RADIUS;
const CHALLENGE_TTL: u32 = 300;
const SHADOW_OFFSET: i32 = 3;
pub(crate) struct CaptchaData {
pub(crate) challenge_id: String,
pub(crate) bg_image: String,
pub(crate) piece_image: String,
}
pub(crate) async fn create_challenge(redis_url: &str) -> Result<CaptchaData, AppError> {
let challenge_id = uuid::Uuid::new_v4().to_string();
let key = format!("challenge:{}", challenge_id);
let (bg_b64, piece_b64, correct_x) = tokio::task::spawn_blocking(|| {
let (bg, piece, correct_x) = generate_images();
let bg_b64 = encode_png(&bg);
let piece_b64 = encode_png(&piece);
(bg_b64, piece_b64, correct_x)
})
.await
.map_err(|e| {
tracing::error!("[captcha] spawn_blocking 失败: {}", e);
AppError::Internal("服务临时不可用".into())
})?;
let client = redis::Client::open(redis_url).map_err(|e| {
tracing::error!("[captcha] Redis client 创建失败: {}", e);
AppError::Internal("服务临时不可用".into())
})?;
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
tracing::error!("[captcha] Redis 连接失败: {}", e);
AppError::Internal("服务临时不可用".into())
})?;
redis::cmd("SET").arg(&key).arg(&correct_x.to_string()).arg("EX").arg(CHALLENGE_TTL)
.query_async::<()>(&mut conn).await.map_err(|e| {
tracing::error!("[captcha] Redis 写入失败: {}", e);
AppError::Internal("服务临时不可用".into())
})?;
tracing::debug!("[captcha] 生成完成 cid={} x={}", challenge_id, correct_x);
Ok(CaptchaData { challenge_id, bg_image: bg_b64, piece_image: piece_b64 })
}
pub(crate) async fn verify_offset(redis_url: &str, challenge_id: &str, user_x: i32) -> Result<bool, AppError> {
let key = format!("challenge:{}", challenge_id);
let client = redis::Client::open(redis_url).map_err(|e| {
tracing::error!("[captcha] Redis client 创建失败: {}", e);
AppError::Internal("服务临时不可用".into())
})?;
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
tracing::error!("[captcha] Redis 连接失败: {}", e);
AppError::Internal("服务临时不可用".into())
})?;
let val: Option<String> = redis::cmd("GETDEL").arg(&key).query_async(&mut conn).await.map_err(|e| {
tracing::error!("[captcha] Redis 查询失败: {}", e);
AppError::Internal("服务临时不可用".into())
})?;
match val {
Some(s) => {
let correct_x: i32 = s.parse().unwrap_or(i32::MAX);
let ok = (user_x - correct_x).abs() <= TOLERANCE;
tracing::debug!("[captcha] 校验 cid={} user_x={} correct_x={} ok={}", challenge_id, user_x, correct_x, ok);
Ok(ok)
}
None => Ok(false),
}
}
fn generate_images() -> (RgbaImage, RgbaImage, i32) {
let mut rng = rand::thread_rng();
// 1. 渐变背景
let c1 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
let c2 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
let mut bg = RgbaImage::new(WIDTH, HEIGHT);
for y in 0..HEIGHT {
let t = y as f32 / HEIGHT as f32;
let r = c1[0] as f32 * (1.0 - t) + c2[0] as f32 * t;
let g = c1[1] as f32 * (1.0 - t) + c2[1] as f32 * t;
let b = c1[2] as f32 * (1.0 - t) + c2[2] as f32 * t;
let pix = Rgba([r as u8, g as u8, b as u8, 255]);
for x in 0..WIDTH { bg.put_pixel(x, y, pix); }
}
// 2. 纹理线条(半透明随机线条)
for _ in 0..6 {
let x1 = rng.gen_range(0..WIDTH) as i32;
let y1 = rng.gen_range(0..HEIGHT) as i32;
let x2 = rng.gen_range(0..WIDTH) as i32;
let y2 = rng.gen_range(0..HEIGHT) as i32;
let color = Rgba([rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.gen_range(15..50u8)]);
draw_line(&mut bg, x1, y1, x2, y2, color);
}
// 3. 纹理圆点
for _ in 0..15 {
let cx = rng.gen_range(0..WIDTH);
let cy = rng.gen_range(0..HEIGHT);
let radius = rng.gen_range(3..15u32);
let color = Rgba([rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.gen_range(20..80u8)]);
fill_circle(&mut bg, cx, cy, radius, color);
}
// 4. 拼图块位置
let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2;
let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32;
// 5. 提取拼图块像素
let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32);
for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
for py in 0..PIECE_SIZE {
for px in 0..PIECE_WIDTH {
if is_in_piece(px, py) {
raw.put_pixel(px as u32, py as u32, *bg.get_pixel((piece_x as i32 + px) as u32, (piece_y + py) as u32));
}
}
}
// 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32;
let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32;
let mut piece = RgbaImage::new(sw, sh);
for p in piece.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
// 先画阴影
for py in 0..PIECE_SIZE {
for px in 0..PIECE_WIDTH {
if is_in_piece(px, py) {
let sx = px + SHADOW_OFFSET;
let sy = py + SHADOW_OFFSET;
if sx < sw as i32 && sy < sh as i32 {
let d = piece.get_pixel(sx as u32, sy as u32);
piece.put_pixel(sx as u32, sy as u32, alpha_blend(*d, Rgba([0, 0, 0, 80])));
}
}
}
}
// 再画拼图
for py in 0..PIECE_SIZE {
for px in 0..PIECE_WIDTH {
let p = raw.get_pixel(px as u32, py as u32);
if p[3] > 0 {
piece.put_pixel(px as u32, py as u32, *p);
}
}
}
// 7. 背景画缺口
draw_hole(&mut bg, piece_x as i32, piece_y);
(bg, piece, piece_x as i32)
}
fn is_in_piece(px: i32, py: i32) -> bool {
if px >= 0 && px < PIECE_SIZE && py >= 0 && py < PIECE_SIZE { return true; }
let bcx = PIECE_SIZE;
let bcy = PIECE_SIZE / 2;
let dx = px - bcx;
let dy = py - bcy;
dx >= 0 && dx * dx + dy * dy <= BUMP_RADIUS * BUMP_RADIUS
}
fn draw_hole(img: &mut RgbaImage, hx: i32, hy: i32) {
for y in 0..HEIGHT as i32 {
for x in 0..WIDTH as i32 {
let lx = x - hx;
let ly = y - hy;
if is_in_piece(lx, ly) {
let edge = (lx >= 0 && lx < 2) || (lx >= PIECE_WIDTH - 3 && lx < PIECE_WIDTH)
|| (ly >= 0 && ly < 2) || (ly >= PIECE_SIZE - 3 && ly < PIECE_SIZE)
|| {
let bcx = PIECE_SIZE;
let bcy = PIECE_SIZE / 2;
let dx = lx - bcx;
let dy = ly - bcy;
dx >= 0 && (dx * dx + dy * dy - BUMP_RADIUS * BUMP_RADIUS).abs() <= 20
};
if edge {
img.put_pixel(x as u32, y as u32, Rgba([255, 255, 255, 220]));
} else {
let o = img.get_pixel(x as u32, y as u32);
img.put_pixel(x as u32, y as u32, Rgba([
(o[0] as u16 * 2 / 3 + 85) as u8,
(o[1] as u16 * 2 / 3 + 85) as u8,
(o[2] as u16 * 2 / 3 + 85) as u8, 255,
]));
}
}
}
}
}
fn fill_circle(img: &mut RgbaImage, cx: u32, cy: u32, r: u32, color: Rgba<u8>) {
let r = r as i32;
let cx = cx as i32;
let cy = cy as i32;
for y in (cy - r).max(0)..=(cy + r).min(HEIGHT as i32 - 1) {
for x in (cx - r).max(0)..=(cx + r).min(WIDTH as i32 - 1) {
if (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r * r {
img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color));
}
}
}
}
fn draw_line(img: &mut RgbaImage, x1: i32, y1: i32, x2: i32, y2: i32, color: Rgba<u8>) {
let mut x = x1;
let mut y = y1;
let dx = (x2 - x1).abs();
let dy = -(y2 - y1).abs();
let sx = if x1 < x2 { 1 } else { -1 };
let sy = if y1 < y2 { 1 } else { -1 };
let mut err = dx + dy;
loop {
if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 {
img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color));
}
if x == x2 && y == y2 { break; }
let e2 = 2 * err;
if e2 >= dy { err += dy; x += sx; }
if e2 <= dx { err += dx; y += sy; }
}
}
fn alpha_blend(dst: Rgba<u8>, src: Rgba<u8>) -> Rgba<u8> {
let a = src[3] as u32;
let inv = 255 - a;
Rgba([
((src[0] as u32 * a + dst[0] as u32 * inv) / 255) as u8,
((src[1] as u32 * a + dst[1] as u32 * inv) / 255) as u8,
((src[2] as u32 * a + dst[2] as u32 * inv) / 255) as u8,
dst[3].max(src[3]),
])
}
fn encode_png(img: &RgbaImage) -> String {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png).expect("PNG 编码失败");
base64::engine::general_purpose::STANDARD.encode(&buf)
}

View File

@ -176,7 +176,7 @@ impl NodeRegistry {
/// 检查设备是否在线(通过 Redis 中的在线标记)
pub async fn is_device_online(&self, imei: &str) -> bool {
let key = format!("device:online:{}", imei);
let key = format!("device:{}", imei);
let mut conn = self.redis.clone();
let exists: Result<bool, _> = redis::cmd("EXISTS")
.arg(&key)
@ -195,6 +195,8 @@ pub struct AppState {
pub pending_commands: PendingCommands,
pub nodes: NodeRegistry,
pub redis_url: String,
/// JWT 签名密钥 — 启动阶段从环境变量读取,缺失则 panic(禁止硬编码回退)
pub jwt_secret: Arc<str>,
}
/// 指令下发请求体
@ -203,39 +205,46 @@ pub struct CommandRequest {
pub act: String,
#[serde(default)]
pub msg_id: u32,
pub sub_device_id: Option<u8>,
#[serde(flatten)]
pub params: Option<Value>,
}
/// 内部路由字段名:推入 `device:commands` 时附加,值 = 目标设备 IMEI。
///
/// 用途:device-server 据此决定把指令发给哪条连接;**下发前摘除,设备侧不可见**。
/// 为什么需要它:协议定稿后 `id` 只表示**动作目标**(柜级省略 / 一段=单板 / 两段=仓体级),
/// **不再含 IMEI**,所以设备身份必须另用内部字段传递。
pub const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
/// 设备指令结构(序列化后推入 Redis)
///
/// 协议格式(v1.1):`id` 只表示**动作目标**,**不含 IMEI**(设备身份由 TCP 连接确定)
/// - **柜级**指令(open/pow_on/pow_off/reset 整柜):**不带 `id`**(字段省略)
/// - **单仓控板**指令(reset 单板):`仓控板`(如 `"01"`)
/// - **仓体级**指令(on/off):`仓控板-通道`(如 `"01-3"`)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DeviceCommand {
pub act: String,
pub msg_id: u32,
/// 动作目标(柜级为 `None`),序列化为 "id"
#[serde(skip_serializing_if = "Option::is_none")]
pub dev_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub_device_id: Option<u8>,
/// 扩展参数(开门指令、充电参数等)
pub id: Option<String>,
/// 扩展参数(充电参数等)
#[serde(flatten)]
pub params: Option<Value>,
}
impl DeviceCommand {
/// 序列化为 JSON 字符串(紧凑格式)。
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("DeviceCommand 序列化不应失败")
}
}
/// 向设备发送指令(通过 Redis 队列)
pub async fn send_device_command(
redis: &redis::aio::ConnectionManager,
_dev_id: &str,
dev_id: &str,
cmd: &DeviceCommand,
) -> Result<(), String> {
let json_str = cmd.to_json();
let mut json_val =
serde_json::to_value(cmd).map_err(|e| format!("指令序列化失败: {}", e))?;
// 内部路由字段:告知 device-server 这条指令发给哪台设备(下发前摘除)
json_val[ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id);
let json_str = json_val.to_string();
let mut conn = redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
@ -273,11 +282,22 @@ pub async fn send_command(
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
// 检查设备是否被禁用
let disabled: bool = sqlx::query_scalar(
r#"SELECT COUNT(*) > 0 FROM cabinets WHERE imei = ? AND status = 2"#,
)
.bind(&dev_id)
.fetch_one(&state.mysql)
.await
.map_err(AppError::Database)?;
if disabled {
return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into()));
}
let cmd = DeviceCommand {
act: req.act,
msg_id: req.msg_id,
dev_id: Some(dev_id.clone()),
sub_device_id: req.sub_device_id,
id: Some(dev_id.clone()),
params: req.params,
};

View File

@ -2,11 +2,8 @@
//!
//! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。
/// 默认 JWT 密钥(开发环境用,生产环境必须通过环境变量覆盖)
pub const JWT_SECRET_DEFAULT: &str = "pms-dev-secret-change-me-in-production";
/// 用户默认密码(创建用户或重置密码时使用)
pub const DEFAULT_PASSWORD: &str = "123456";
/// JWT 密钥(必设,生产环境必须通过环境变量设置)
const JWT_SECRET_ENV: &str = "JWT_SECRET";
/// 应用配置
#[allow(dead_code)]
@ -39,8 +36,8 @@ impl Config {
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be a valid number"),
jwt_secret: std::env::var("JWT_SECRET")
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()),
jwt_secret: std::env::var(JWT_SECRET_ENV)
.expect("JWT_SECRET must be set"),
export_dir: std::env::var("EXPORT_DIR")
.unwrap_or_else(|_| "./exports".to_string()),
node_heartbeat_timeout: std::env::var("NODE_HEARTBEAT_TIMEOUT")
@ -49,11 +46,4 @@ impl Config {
.expect("NODE_HEARTBEAT_TIMEOUT must be a valid number"),
}
}
/// 校验配置安全性
pub fn validate(&self) {
if self.jwt_secret == JWT_SECRET_DEFAULT {
tracing::warn!("使用默认 JWT 密钥,生产环境请设置 JWT_SECRET 环境变量");
}
}
}

View File

@ -2,12 +2,13 @@
use sqlx::MySqlPool;
/// 执行数据库迁移,创建所有必要的数据表
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let sql = r#"
/// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。
/// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。
const SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS organizations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '组织名称',
parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -26,7 +27,11 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
project_id BIGINT,
abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-00000001',
imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
iccid VARCHAR(20) COMMENT 'SIM卡ICCID',
auth_str VARCHAR(8) COMMENT '签名安全码',
auth_str_issued BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发',
cell TINYINT DEFAULT 6 COMMENT '仓控板数量',
work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式',
name VARCHAR(100) COMMENT '柜子名称',
status TINYINT DEFAULT 0 COMMENT '0离线 1在线',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@ -34,6 +39,53 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
FOREIGN KEY (project_id) REFERENCES projects(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_change_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
imei VARCHAR(15) NOT NULL COMMENT '设备IMEI',
old_iccid VARCHAR(20) COMMENT '旧ICCID',
new_iccid VARCHAR(20) NOT NULL COMMENT '新ICCID',
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间',
INDEX idx_imei (imei)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS batteries (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
battery_id VARCHAR(50) NOT NULL UNIQUE COMMENT '电池唯一标识',
soc INT DEFAULT 0 COMMENT '电量(‰)',
brand VARCHAR(50) COMMENT '品牌(由 brand_code 映射出的名称,代码表未定前为空)',
model VARCHAR(50) COMMENT '型号(由 model_code 映射出的名称,代码表未定前为空)',
sn VARCHAR(100) COMMENT '序列号',
-- 以下为协议 §5.5「电池属性」原值(硬件代码,平台侧负责映射,见交付文档 §10 定稿第 7 条)
brand_code VARCHAR(20) COMMENT '厂家代码(原值透传)',
model_code VARCHAR(20) COMMENT '电池型号代码(原值透传)',
material_code VARCHAR(20) COMMENT '电芯材料代码(原值透传)',
cell_count INT COMMENT '电芯节数',
rated_voltage BIGINT COMMENT '额定电压(mV)',
rated_capacity BIGINT COMMENT '额定容量(mAh)',
status TINYINT DEFAULT 0 COMMENT '0空闲 1使用中 2故障 3报废',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS charge_orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
compartment_id BIGINT NOT NULL COMMENT '仓体ID',
cabinet_id BIGINT NOT NULL COMMENT '柜子ID',
battery_id VARCHAR(50) NOT NULL COMMENT '电池ID',
start_time TIMESTAMP NULL COMMENT '充电开始时间',
end_time TIMESTAMP NULL COMMENT '充电结束时间',
start_soc INT COMMENT '起始电量(‰)',
end_soc INT COMMENT '结束电量(‰)',
energy INT DEFAULT 0 COMMENT '充电量(Wh)',
reason VARCHAR(20) COMMENT '结束原因: normal/user_stop/forced/timeout',
status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (compartment_id) REFERENCES compartments(id),
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
INDEX idx_battery (battery_id),
INDEX idx_compartment (compartment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabin_boards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
@ -59,7 +111,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
name VARCHAR(50) COMMENT '姓名',
password VARCHAR(255) NOT NULL,
role TINYINT DEFAULT 0 COMMENT '0普通 1企业管理员 2总管理员',
role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员',
organization_id BIGINT,
status TINYINT DEFAULT 1 COMMENT '0禁用 1启用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@ -74,7 +126,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
start_time TIMESTAMP NULL COMMENT '充电开始时间',
end_time TIMESTAMP NULL COMMENT '充电结束时间',
energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)',
max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(0.1°C)',
max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)',
status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (compartment_id) REFERENCES compartments(id),
@ -174,10 +226,22 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"#;
// 逐条执行,避免多语句执行问题
for statement in sql.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
CREATE TABLE IF NOT EXISTS user_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '提交用户ID',
content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(200) COMMENT '联系方式',
status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"#;
/// 执行数据库迁移,创建所有必要的数据表
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 逐条执行全量建表语句,避免多语句执行问题
for statement in SCHEMA_SQL.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
sqlx::query(statement).execute(pool).await?;
}
@ -185,9 +249,34 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#)
.execute(pool).await;
// 更新 charge_records.max_temperature 注释(0.1°C → K×100)
// 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY
ensure_column_comment(
pool,
"charge_records",
"max_temperature",
"K×100",
r#"ALTER TABLE charge_records MODIFY max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)'"#,
)
.await?;
// 更新 charge_orders.reason 注释:新增 timeout
// (订单超过 10 小时由 order_timeout 定时任务自动结束,落该枚举值)
ensure_column_comment(
pool,
"charge_orders",
"reason",
"timeout",
r#"ALTER TABLE charge_orders MODIFY COLUMN reason VARCHAR(20) NULL COMMENT '结束原因: normal/user_stop/forced/timeout'"#,
)
.await?;
// 创建关键索引(IF NOT EXISTS 避免重复)
create_indexes(pool).await?;
// 兼容已有数据库:新增字段(幂等,列已存在则跳过)
add_columns_if_not_exists(pool).await?;
// 初始化默认权限码(INSERT IGNORE 避免重复插入)
seed_permissions(pool).await?;
// 初始化默认角色
@ -227,11 +316,147 @@ async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> {
ensure_index(pool, "compartments", "idx_compartments_board", "cabin_board_id").await?;
ensure_index(pool, "charge_records", "idx_charge_records_cabinet", "cabinet_id").await?;
ensure_index(pool, "device_logs", "idx_device_logs_cabinet", "cabinet_id").await?;
// 订单超时兜底扫描(status = 0 AND start_time < ...)走该联合索引,避免全表扫描
ensure_index(pool, "charge_orders", "idx_charge_orders_status_start", "status, start_time").await?;
// device_logs 保留策略按 created_at 分批删除,必须有该列索引,否则每次清理全表扫描
ensure_index(pool, "device_logs", "idx_device_logs_created", "created_at").await?;
tracing::info!("数据库索引创建完成");
Ok(())
}
/// 列级幂等迁移:列不存在时才 ALTER TABLE 补列
///
/// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」,
/// 对**已存在**的表永远不会补后加的列,导致线上 schema 漂移。
/// 安全性:表名/列名/DDL 全部来自本文件内的白名单常量,不含任何外部输入拼接。
async fn ensure_column(
pool: &MySqlPool,
table: &str,
column: &str,
ddl: &str,
) -> Result<(), sqlx::Error> {
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
)
.bind(table)
.bind(column)
.fetch_one(pool)
.await?;
if exists.0 == 0 {
let alter = format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, ddl);
sqlx::query(&alter).execute(pool).await?;
tracing::info!("[migrate] 新增列 {}.{}", table, column);
}
Ok(())
}
/// 列注释幂等更新:仅当现有注释不包含 `marker` 时才 ALTER
///
/// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新;
/// 而 MODIFY 会重建整表,因此必须先比对注释,避免每次启动都白跑一次 DDL。
async fn ensure_column_comment(
pool: &MySqlPool,
table: &str,
column: &str,
marker: &str,
ddl: &str,
) -> Result<(), sqlx::Error> {
// ⚠️ `information_schema.COLUMNS.COLUMN_COMMENT` 在 MySQL 8 上会被 sqlx 报告为
// **VARBINARY**,直接按 `Option<String>` 解码会 panic(实测阿里云 RDS MySQL 8.0.36):
// `mismatched types; Rust type String (as SQL type VARCHAR) is not compatible with SQL type VARBINARY`
// 必须显式 `CAST(... AS CHAR)`。
let comment: Option<String> = sqlx::query_scalar(
"SELECT CAST(COLUMN_COMMENT AS CHAR) FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
)
.bind(table)
.bind(column)
.fetch_optional(pool)
.await?;
if let Some(current) = comment {
if !current.contains(marker) {
sqlx::query(ddl).execute(pool).await?;
tracing::info!("[migrate] 更新列注释 {}.{}", table, column);
}
}
Ok(())
}
/// 逐列补齐历史版本遗漏的列(幂等,兼容线上已存在的旧表)
///
/// 线上 pms 库曾因缺少 `cabinets.auth_str_issued` / `cabinets.cell`,
/// 导致设备认证链路直接报 `1054 Unknown column`,只能人工 ALTER 补救;
/// 本函数就是把这个人工步骤固化进启动迁移。
async fn add_columns_if_not_exists(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// users:头像 + 微信 openid
ensure_column(pool, "users", "avatar", "VARCHAR(500) NULL COMMENT '头像URL'").await?;
ensure_column(pool, "users", "openid", "VARCHAR(64) NULL UNIQUE COMMENT '微信openid'")
.await?;
// organizations:父组织(组织树层级)+ 禁用标记(甲方禁用客户组织,其下柜子全部失效)
ensure_column(pool, "organizations", "parent_id", "BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)'")
.await?;
ensure_column(
pool,
"organizations",
"disabled",
"TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1禁用'",
)
.await?;
// projects:地址 + 经纬度
ensure_column(pool, "projects", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?;
ensure_column(pool, "projects", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?;
ensure_column(pool, "projects", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?;
// cabinets:地址 + 经纬度
ensure_column(pool, "cabinets", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?;
ensure_column(pool, "cabinets", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?;
ensure_column(pool, "cabinets", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?;
// cabinets:设备认证 / 拓扑 / 工作模式(线上曾缺失,必须补齐)
ensure_column(
pool,
"cabinets",
"auth_str_issued",
"BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发'",
)
.await?;
ensure_column(pool, "cabinets", "cell", "TINYINT DEFAULT 6 COMMENT '仓控板数量'").await?;
ensure_column(
pool,
"cabinets",
"work_mode",
"TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式'",
)
.await?;
// batteries:协议 §5.5「电池属性」的原值字段(老库缺失,必须补齐,否则台账只能丢到 device_logs)
ensure_column(pool, "batteries", "brand_code", "VARCHAR(20) NULL COMMENT '厂家代码(原值透传)'").await?;
ensure_column(pool, "batteries", "model_code", "VARCHAR(20) NULL COMMENT '电池型号代码(原值透传)'").await?;
ensure_column(pool, "batteries", "material_code", "VARCHAR(20) NULL COMMENT '电芯材料代码(原值透传)'")
.await?;
ensure_column(pool, "batteries", "cell_count", "INT NULL COMMENT '电芯节数'").await?;
ensure_column(pool, "batteries", "rated_voltage", "BIGINT NULL COMMENT '额定电压(mV)'").await?;
ensure_column(pool, "batteries", "rated_capacity", "BIGINT NULL COMMENT '额定容量(mAh)'").await?;
// user_feedback:反馈图片(JSON 数组字符串)
ensure_column(
pool,
"user_feedback",
"images",
"TEXT NULL COMMENT '反馈图片URL列表(JSON数组)'",
)
.await?;
tracing::info!("数据库列级迁移完成");
Ok(())
}
/// 插入默认权限码
async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let perms: Vec<(&str, &str)> = vec![
@ -240,6 +465,8 @@ async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
("device:create", "添加设备"),
("device:edit", "编辑设备"),
("device:delete", "删除设备"),
// 安全码重置(仅总管理员,不授予企业管理员/操作员)
("device:auth_str:reset", "重置安全码"),
("charge:start", "开始充电"),
("charge:stop", "停止充电"),
("charge:open_door", "开门"),
@ -303,12 +530,12 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
.execute(pool)
.await?;
// 创建普通用户角色
// 创建操作员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("normal_user")
.bind("普通用户 — 查看/操作指定范围设备")
.bind("操作员 — 查看/操作指定范围设备")
.execute(pool)
.await?;
@ -350,7 +577,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "charge_record:export",
"device_log:view", "energy:view", "energy:export",
"user:view", "user:edit",
"user:view", "user:create", "user:edit",
"operation_log:view",
// 组织管理
"org:view", "org:create", "org:edit", "org:delete",
@ -379,7 +606,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
}
}
// 为普通用户分配查看和操作权限
// 为操作员分配查看和操作权限
let normal_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'normal_user'"
)

View File

@ -3,6 +3,7 @@
//! 提供 HTTP API(后台管理 + H5),处理后端业务逻辑。
//! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。
mod captcha;
mod commands;
mod config;
mod db;
@ -15,6 +16,7 @@ mod routes;
mod workers;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
use commands::{AppState, NodeRegistry, PendingCommands};
@ -28,7 +30,6 @@ async fn main() {
// 加载配置
let cfg = config::Config::from_env();
cfg.validate();
tracing::info!("配置加载完成");
// 初始化 MySQL 连接池
@ -49,6 +50,11 @@ async fn main() {
.expect("Redis 连接失败");
tracing::info!("Redis 连接成功");
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
let jwt_secret: Arc<str> = std::env::var("JWT_SECRET")
.expect("JWT_SECRET environment variable is required")
.into();
// 初始化共享状态
let pending_commands = PendingCommands::new();
let nodes = NodeRegistry::new(redis_conn.clone());
@ -58,6 +64,7 @@ async fn main() {
pending_commands: pending_commands.clone(),
nodes: nodes.clone(),
redis_url: cfg.redis_url.clone(),
jwt_secret,
};
// 启动后台协程 — 处理设备上报
@ -80,6 +87,19 @@ async fn main() {
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
});
// 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录)
let timeout_mysql = db_pool.clone();
let timeout_redis = redis_conn.clone();
tokio::spawn(async move {
workers::order_timeout::start(timeout_mysql, timeout_redis).await;
});
// 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS)
let retention_mysql = db_pool.clone();
tokio::spawn(async move {
workers::log_retention::start(retention_mysql).await;
});
// 构建 HTTP 路由
let app = routes::build(state);

View File

@ -24,12 +24,8 @@ use serde::{Deserialize, Serialize};
use sqlx::MySqlPool;
use crate::commands::AppState;
use crate::config::JWT_SECRET_DEFAULT;
use crate::error::AppError;
/// JWT 签名密钥环境变量名
const JWT_SECRET_ENV: &str = "JWT_SECRET";
/// JWT Claims 结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
@ -53,7 +49,7 @@ pub struct CurrentUser {
pub phone: String,
/// 用户拥有的权限码集合
pub permissions: HashSet<String>,
/// 角色等级:0普通 1企业管理员 2总管理员
/// 角色等级:0操作员 1企业管理员 2总管理员
pub role_level: i32,
/// 所属组织ID(总管理员为 None)
pub organization_id: Option<i64>,
@ -81,9 +77,8 @@ pub async fn auth_middleware(
}
};
// 获取 JWT 密钥
let secret = std::env::var(JWT_SECRET_ENV)
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
// 从 AppState 获取 JWT 密钥(启动时已校验,无硬编码回退)
let secret = state.jwt_secret.as_ref();
// 解码并验证 JWT
let mut validation = Validation::new(Algorithm::HS256);
@ -195,11 +190,10 @@ async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32,
Ok(result.unwrap_or((0, None)))
}
/// 生成 JWT token(7天有效期)
pub fn generate_token(user_id: i64, phone: &str) -> Result<String, AppError> {
let secret = std::env::var(JWT_SECRET_ENV)
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
/// 生成 JWT token(24小时有效期)
///
/// `secret` 由调用方从 AppState.jwt_secret 传入,禁止硬编码回退。
pub fn generate_token(user_id: i64, phone: &str, secret: &str) -> Result<String, AppError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| AppError::Internal(format!("时间错误: {}", e)))?
@ -208,7 +202,7 @@ pub fn generate_token(user_id: i64, phone: &str) -> Result<String, AppError> {
let claims = Claims {
sub: user_id,
phone: phone.to_string(),
exp: now + 86400 * 7,
exp: now + 86400, // 24小时
iat: now,
};
@ -284,3 +278,80 @@ pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, V
(" AND 1=0".to_string(), vec![])
}
}
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// org_condition 总管理员不限制组织
#[test]
fn test_org_condition_super_admin() {
let user = CurrentUser {
user_id: 1,
phone: "13800138000".into(),
role_level: 2,
organization_id: None,
permissions: HashSet::new(),
};
let (cond, params) = org_condition(&user, "cr");
assert!(cond.is_empty());
assert!(params.is_empty());
}
/// org_condition 普通管理员按组织过滤
#[test]
fn test_org_condition_normal_admin() {
let user = CurrentUser {
user_id: 2,
phone: "13800138001".into(),
role_level: 1,
organization_id: Some(42),
permissions: HashSet::new(),
};
let (cond, params) = org_condition(&user, "cr");
assert!(cond.contains("organization_id = ?"), "应有组织过滤条件");
assert_eq!(params, vec!["42"]);
}
/// org_condition 无组织用户应 1=0(看不到任何数据)
#[test]
fn test_org_condition_no_org() {
let user = CurrentUser {
user_id: 3,
phone: "13800138002".into(),
role_level: 0,
organization_id: None,
permissions: HashSet::new(),
};
let (cond, _params) = org_condition(&user, "cr");
assert!(cond.contains("1=0"), "无组织应返回 1=0");
}
/// verify_password:正确密码应返回 true(round-trip:hash→verify)
#[test]
fn test_verify_password_correct() {
// 运行时生成 argon2 哈希再验证
let hash = hash_password("test123").unwrap();
let result = verify_password("test123", &hash).unwrap();
assert!(result, "正确密码应验证通过");
}
/// verify_password:错误密码应返回 false(使用同一哈希比对)
#[test]
fn test_verify_password_wrong() {
let hash = hash_password("test123").unwrap();
let result = verify_password("wrongpass", &hash).unwrap();
assert!(!result, "错误密码应验证不通过");
}
/// verify_password:无效哈希格式应返回 Err
#[test]
fn test_verify_password_invalid_hash() {
let result = verify_password("test123", "not-a-valid-hash");
assert!(result.is_err(), "无效哈希应返回错误");
}
}

View File

@ -6,34 +6,21 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// 设备发往平台的消息。
///
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(dead_code)]
pub struct DeviceMessage {
/// 动作类型:login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off
pub act: String,
pub dev_id: Option<String>,
pub msg_id: Option<u32>,
/// 登录签名时的时间戳(秒级)
pub timestamp: Option<u64>,
/// SHA256 签名尾巴 7 位
pub sign: Option<String>,
/// 485 子设备 ID(仓控板 ID 1-6)
pub sub_device_id: Option<u8>,
/// 状态上报时的 HEX 数据
pub status: Option<String>,
/// 4G 信号强度
pub rssi: Option<i32>,
/// 未在顶层定义的扩展字段
#[serde(flatten)]
pub extra: Option<Value>,
}
// 说明:**上行报文的权威结构定义在 `device-server`**(`software/device-server/src/tcp/protocol.rs`)。
// api-server 收到的是 `device-server` 转发到 Redis 的 JSON,直接按 `serde_json::Value` 解析,
// 因此这里**不再保留** `DeviceMessage` 副本(旧副本曾带着已废弃的 `status` / `parsed_status` 字段,易误导)。
/// 平台回复设备的消息。
///
/// 协议要求(交付文档 §3.1 / §3.3 / §3.5):
/// - **`act` 必带**,且**与请求报文的 `act` 完全一致**
/// - **`msg_id` 必带**,且**与请求报文的 `msg_id` 完全一致**
/// - `suc` 必带;`suc=0` 时 `err` 必带
/// - `err` 一律为**英文小写短语**(§3.3 err 取值全集),例:`invalid signature` / `invalid id`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerResponse {
/// 动作类型 —— **与请求的 `act` 一致**(协议 §3.1:`act` 双向全部报文必带)
pub act: String,
/// 1=成功,0=失败
pub suc: u8,
pub msg_id: Option<u32>,
@ -41,17 +28,52 @@ pub struct ServerResponse {
pub err: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_str: Option<String>,
/// login 成功时返回:柜子规格,固定 4 位(前 2 位仓控板数 + 后 2 位每板通道数)
/// 例 `"0606"` = 6 块仓控板 × 每板 6 仓(取代旧的 `cell`)
#[serde(skip_serializing_if = "Option::is_none")]
pub spec: Option<String>,
/// login 成功时返回:充电工作模式 `"0"`=平台指令模式 / `"1"`=自动充电工作模式
#[serde(skip_serializing_if = "Option::is_none")]
pub work_mode: Option<String>,
}
impl ServerResponse {
/// 创建成功响应。
pub fn success(msg_id: Option<u32>) -> Self {
Self { suc: 1, msg_id, err: None, auth_str: None }
/// 创建成功响应。`act` 必须与请求一致。
pub fn success(act: impl Into<String>, msg_id: Option<u32>) -> Self {
Self {
act: act.into(),
suc: 1,
msg_id,
err: None,
auth_str: None,
spec: None,
work_mode: None,
}
}
/// 创建失败响应。
pub fn error(msg_id: Option<u32>, err: impl Into<String>) -> Self {
Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None }
/// 创建失败响应(`err` 必须传英文短语)。`act` 必须与请求一致。
pub fn error(act: impl Into<String>, msg_id: Option<u32>, err: impl Into<String>) -> Self {
Self {
act: act.into(),
suc: 0,
msg_id,
err: Some(err.into()),
auth_str: None,
spec: None,
work_mode: None,
}
}
/// 从**设备请求报文**构造成功应答:自动取请求里的 `act` 与 `msg_id`。
///
/// 协议要求应答的 `act` / `msg_id` 必须与请求一致,直接从请求取值最不易出错。
pub fn success_for(req: &Value) -> Self {
Self::success(act_of(req), msg_id_of(req))
}
/// 从**设备请求报文**构造失败应答(`err` 必须传英文短语)。
pub fn error_for(req: &Value, err: impl Into<String>) -> Self {
Self::error(act_of(req), msg_id_of(req), err)
}
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
@ -59,3 +81,16 @@ impl ServerResponse {
serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
}
}
/// 取请求报文里的 `act`(应答必须原样回带;未知 `act` 也照原值回带)。
fn act_of(req: &Value) -> String {
req.get("act")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
/// 取请求报文里的 `msg_id`(应答必须原样回带)。
fn msg_id_of(req: &Value) -> Option<u32> {
req.get("msg_id").and_then(|v| v.as_u64()).map(|v| v as u32)
}

View File

@ -1,23 +1,58 @@
//! 认证路由 — 登录/登出/当前用户信息
//!
//! - GET /api/auth/challenge — 获取登录 challenge(防暴力破解)
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
//! - POST /api/auth/wx-login — 微信小程序手机号快速登录
//! - PUT /api/auth/avatar — 更新用户头像 URL
use axum::extract::State;
use axum::extract::{Multipart, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::io::Write;
use uuid::Uuid;
const AVATAR_DIR: &str = "uploads/avatars";
use reqwest::Client;
use crate::captcha;
use crate::commands::AppState;
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
/// 登录请求体
#[derive(Debug, Deserialize)]
pub(crate) struct LoginInput {
phone: String,
password: String,
/// 登录 challenge token,从 GET /api/auth/challenge 获取
#[serde(default)]
challenge_id: String,
/// 滑块验证码偏移量(前端拖拽拼图后获得)
#[serde(default)]
captcha_x: Option<i32>,
}
/// GET /api/auth/challenge — 获取登录滑块验证码
///
/// 返回背景图(带缺口)和拼图块图,前端拖动对齐后提交偏移量。
pub async fn get_challenge(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let data = captcha::create_challenge(&state.redis_url).await?;
tracing::debug!("[challenge] 生成 cid={}", data.challenge_id);
Ok(Json(json!({
"code": 0,
"data": {
"challenge_id": data.challenge_id,
"bg_image": data.bg_image,
"piece_image": data.piece_image,
},
"message": "ok"
})))
}
/// POST /api/auth/login — 用户登录
@ -31,6 +66,16 @@ pub async fn login(
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
}
// 校验滑块验证码(防暴力破解 + 人机验证)
if input.challenge_id.is_empty() {
return Err(AppError::BadRequest("缺少登录验证参数".into()));
}
let captcha_x = input.captcha_x.unwrap_or(i32::MAX);
let valid = captcha::verify_offset(&state.redis_url, &input.challenge_id, captcha_x).await?;
if !valid {
return Err(AppError::BadRequest("验证码错误,请刷新页面重试".into()));
}
// 查询用户(含角色和组织信息)
let user_row: Option<(i64, Option<String>, String, String, i32, i32, Option<i64>)> = sqlx::query_as(
"SELECT id, name, phone, password, status, role, organization_id FROM users WHERE phone = ?",
@ -55,7 +100,7 @@ pub async fn login(
}
// 生成 JWT token
let token = auth::generate_token(id, &phone)?;
let token = auth::generate_token(id, &phone, &state.jwt_secret)?;
// 查询用户权限码
let permissions = auth::load_user_permissions(pool, id).await?;
@ -92,14 +137,14 @@ pub async fn me(
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 查询用户详细信息
let row: Option<(i64, Option<String>, String, i32, Option<i64>)> = sqlx::query_as(
"SELECT id, name, phone, role, organization_id FROM users WHERE id = ?"
let row: Option<(i64, Option<String>, String, i32, Option<i64>, Option<String>)> = sqlx::query_as(
"SELECT id, name, phone, role, organization_id, avatar FROM users WHERE id = ?"
)
.bind(user.user_id)
.fetch_optional(pool)
.await?;
let (id, name, phone, role, org_id) =
let (id, name, phone, role, org_id, avatar) =
row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
let perm_list: Vec<String> = user.permissions.into_iter().collect();
@ -112,8 +157,291 @@ pub async fn me(
"phone": phone,
"role": role,
"organization_id": org_id,
"avatar": avatar,
"permissions": perm_list,
},
"message": "ok"
})))
}
/// 微信扫码登录请求体
#[derive(Debug, Deserialize)]
pub struct WxLoginInput {
/// wx.login() 获取的临时 code
pub code: String,
/// getPhoneNumber 获取的 phone code
pub phone_code: String,
}
/// 微信 jscode2session 响应
///
/// 字段与微信开放平台返回体一一对齐(当前仅读取 openid/errmsg,其余为预留能力,
/// 见各字段注释),故整体允许 dead_code,避免误删上游协议字段。
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct JsCode2SessionResp {
#[serde(default)]
openid: Option<String>,
/// 会话密钥:预留给敏感数据解密(如 encryptedData/水印校验),当前登录流程不需要
#[serde(default)]
session_key: Option<String>,
/// 微信错误码:预留用于后续按错误码细分排障(当前仅用 errmsg 文案)
#[serde(default)]
errcode: Option<i64>,
#[serde(default)]
errmsg: Option<String>,
}
/// 微信 access_token 响应
///
/// 字段与微信开放平台返回体一一对齐(当前仅读取 access_token/errmsg,其余为预留能力),
/// 故整体允许 dead_code。
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct TokenResp {
#[serde(default)]
access_token: Option<String>,
/// 有效期秒数:预留用于 access_token 本地缓存(当前每次登录都重新获取)
#[serde(default)]
expires_in: Option<i64>,
/// 微信错误码:预留用于后续按错误码细分排障
#[serde(default)]
errcode: Option<i64>,
#[serde(default)]
errmsg: Option<String>,
}
/// 微信 getuserphonenumber 响应
///
/// 字段与微信开放平台返回体一一对齐(当前仅读取 phone_info/errmsg),故整体允许 dead_code。
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PhoneNumberResp {
/// 微信错误码:预留用于后续按错误码细分排障
#[serde(default)]
errcode: Option<i64>,
#[serde(default)]
errmsg: Option<String>,
#[serde(default)]
phone_info: Option<PhoneInfo>,
}
/// 微信手机号信息
///
/// 当前业务用 pure_phone_number 匹配用户;带国码的 phone_number 与 country_code
/// 预留用于国际号段/归属地展示,故整体允许 dead_code。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct PhoneInfo {
/// 带国码手机号(如 8613951516272),预留国际号段场景
#[serde(default)]
phone_number: String,
#[serde(default)]
pure_phone_number: String,
/// 国家码(如 86),预留号段归属展示
#[serde(default)]
country_code: String,
}
/// POST /api/auth/wx-login — 微信小程序手机号快速登录
///
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
pub async fn wx_login(
State(state): State<AppState>,
Json(input): Json<WxLoginInput>,
) -> Result<Json<Value>, AppError> {
let appid = std::env::var("WECHAT_APPID")
.map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?;
let secret = std::env::var("WECHAT_APPSECRET")
.map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?;
let client = Client::new();
// 1. 用 code 换取 openid
let jscode_url = format!(
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
appid, secret, input.code
);
let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await
.map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))?
.json().await
.map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?;
let openid = js_resp.openid.ok_or_else(|| {
let err = js_resp.errmsg.unwrap_or_default();
AppError::Internal(format!("jscode2session 失败: {}", err))
})?;
// 2. 获取 access_token
let token_url = format!(
"https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential",
appid, secret
);
let token_resp: TokenResp = client.get(&token_url).send().await
.map_err(|e| AppError::Internal(format!("token 请求失败: {}", e)))?
.json().await
.map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?;
let access_token = token_resp.access_token.ok_or_else(|| {
let err = token_resp.errmsg.unwrap_or_default();
AppError::Internal(format!("获取 access_token 失败: {}", err))
})?;
// 3. 用 phone_code 换取手机号
let phone_url = format!(
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}",
access_token
);
let phone_resp: PhoneNumberResp = client.post(&phone_url)
.json(&json!({ "code": input.phone_code }))
.send().await
.map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))?
.json().await
.map_err(|e| AppError::Internal(format!("getuserphonenumber 解析失败: {}", e)))?;
let phone = phone_resp.phone_info
.ok_or_else(|| {
let err = phone_resp.errmsg.unwrap_or_default();
AppError::Internal(format!("获取手机号失败: {}", err))
})?;
let phone_number = phone.pure_phone_number;
// 4. 按手机号查找用户(手机号是唯一标识)
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户");
let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>(
"SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?",
)
.bind(&phone_number)
.fetch_optional(&state.mysql)
.await?;
let (id, name, _phone, status, role_level, organization_id) =
user_row.ok_or_else(|| {
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
})?;
// 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息)
sqlx::query("UPDATE users SET openid = ? WHERE id = ?")
.bind(&openid)
.bind(id)
.execute(&state.mysql)
.await?;
if status == 0 {
return Err(AppError::Forbidden("账户已被禁用".into()));
}
let token = auth::generate_token(id, &_phone, &state.jwt_secret)?;
let permissions = auth::load_user_permissions(&state.mysql, id).await?;
let perm_list: Vec<String> = permissions.into_iter().collect();
Ok(Json(json!({
"code": 0,
"data": {
"token": token,
"user": {
"id": id,
"name": name,
"phone": _phone,
"role_level": role_level,
"organization_id": organization_id,
"permissions": perm_list,
}
},
"message": "登录成功"
})))
}
/// 微信头像更新请求体
#[derive(Debug, Deserialize)]
pub struct AvatarInput {
pub avatar: String,
}
/// PUT /api/auth/avatar — 更新当前用户头像 URL
pub async fn update_avatar(
user: CurrentUser,
State(state): State<AppState>,
Json(input): Json<AvatarInput>,
) -> Result<Json<Value>, AppError> {
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
.bind(&input.avatar)
.bind(user.user_id)
.execute(&state.mysql)
.await?;
Ok(Json(json!({ "code": 0, "data": { "avatar": input.avatar }, "message": "ok" })))
}
/// GET /api/auth/avatar/upload — 头像文件上传
pub async fn upload_avatar(
user: CurrentUser,
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<Value>, AppError> {
let mut file_data: Option<Vec<u8>> = None;
let mut ext = "".to_string();
while let Some(field) = multipart.next_field().await
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
let filename = field.file_name().unwrap_or("").to_string();
let data = field.bytes().await
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
// 提取扩展名
if let Some(pos) = filename.rfind('.') {
ext = filename[pos..].to_string();
}
file_data = Some(data.to_vec());
}
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
if data.is_empty() {
return Err(AppError::BadRequest("上传内容为空".into()));
}
// 限制大小(2MB)
if data.len() > 2 * 1024 * 1024 {
return Err(AppError::BadRequest("头像文件不能超过 2MB".into()));
}
// 白名单扩展名
if ext.is_empty() {
ext = ".jpg".to_string();
}
let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
if !allowed.contains(&ext.as_str()) {
ext = ".jpg".to_string();
}
// 保存文件
let dir = std::path::Path::new(AVATAR_DIR);
std::fs::create_dir_all(dir)
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
let filepath = dir.join(&filename);
let mut f = std::fs::File::create(&filepath)
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
f.write_all(&data)
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
// 更新用户头像
let avatar_url = format!("/uploads/avatars/{}", filename);
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
.bind(&avatar_url)
.bind(user.user_id)
.execute(&state.mysql)
.await?;
tracing::info!(user_id = user.user_id, path = %avatar_url, "头像上传成功");
Ok(Json(json!({
"code": 0,
"data": { "avatar": avatar_url },
"message": "ok"
})))
}

View File

@ -0,0 +1,160 @@
//! 电池台账 API — 列表查询 + 详情
use axum::extract::{Query, State};
use axum::Json;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
fn status_text(status: i8) -> &'static str {
match status {
0 => "空闲",
1 => "使用中",
2 => "故障",
3 => "报废",
_ => "未知",
}
}
#[derive(Debug, Deserialize)]
pub struct BatteryQuery {
pub status: Option<i8>,
pub battery_id: Option<String>,
pub page: Option<i64>,
pub page_size: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct BatteryItem {
pub id: i64,
pub battery_id: String,
pub soc: i64,
pub brand: Option<String>,
pub model: Option<String>,
pub sn: Option<String>,
pub status: i8,
pub status_text: String,
pub created_at: Option<NaiveDateTime>,
}
#[derive(Debug, sqlx::FromRow)]
struct BatteryRow {
id: i64,
battery_id: String,
soc: i64,
brand: Option<String>,
model: Option<String>,
sn: Option<String>,
status: i8,
created_at: Option<NaiveDateTime>,
}
/// GET /api/batteries — 电池台账列表
pub async fn list_batteries(
user: CurrentUser,
State(state): State<crate::commands::AppState>,
Query(params): Query<BatteryQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string();
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string();
// 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定
if params.status.is_some() {
query.push_str(" AND status = ?");
count_query.push_str(" AND status = ?");
}
if params.battery_id.is_some() {
query.push_str(" AND battery_id LIKE ?");
count_query.push_str(" AND battery_id LIKE ?");
}
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
let total: i64 = if let Some(s) = params.status {
if let Some(ref bid) = params.battery_id {
sqlx::query_scalar(&count_query)
.bind(s)
.bind(format!("%{}%", bid))
.fetch_one(&state.mysql)
.await?
} else {
sqlx::query_scalar(&count_query)
.bind(s)
.fetch_one(&state.mysql)
.await?
}
} else if let Some(ref bid) = params.battery_id {
sqlx::query_scalar(&count_query)
.bind(format!("%{}%", bid))
.fetch_one(&state.mysql)
.await?
} else {
sqlx::query_scalar(&count_query)
.fetch_one(&state.mysql)
.await?
};
let rows: Vec<BatteryRow> = if let Some(s) = params.status {
if let Some(ref bid) = params.battery_id {
sqlx::query_as(&query)
.bind(s)
.bind(format!("%{}%", bid))
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
} else {
sqlx::query_as(&query)
.bind(s)
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
}
} else if let Some(ref bid) = params.battery_id {
sqlx::query_as(&query)
.bind(format!("%{}%", bid))
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
} else {
sqlx::query_as(&query)
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
};
let items: Vec<BatteryItem> = rows.into_iter().map(|r| BatteryItem {
id: r.id,
battery_id: r.battery_id,
soc: r.soc,
brand: r.brand,
model: r.model,
sn: r.sn,
status: r.status,
status_text: status_text(r.status).to_string(),
created_at: r.created_at,
}).collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": items,
"total": total,
"page": page,
"page_size": page_size
},
"message": "ok"
})))
}

View File

@ -24,6 +24,7 @@ struct CabinetSqlRow {
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
project_address: Option<String>,
status: i8,
board_count: Option<i64>,
channel_count: Option<i64>,
@ -43,6 +44,7 @@ impl From<CabinetSqlRow> for CabinetRow {
iccid: row.iccid,
name: row.name,
address: row.address,
project_address: row.project_address,
status: row.status,
board_count: row.board_count,
channel_count: row.channel_count,
@ -54,6 +56,7 @@ impl From<CabinetSqlRow> for CabinetRow {
pow_fail_dc: None,
pow_fail_ac: None,
is_online: None,
org_disabled: None,
}
}
}
@ -69,14 +72,14 @@ pub async fn organization_tree(
// 获取所有组织
let orgs: Vec<OrganizationRow> = sqlx::query_as(
"SELECT id, name, parent_id FROM organizations ORDER BY id"
"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"
)
.fetch_all(db)
.await?;
// 获取所有项目
let projects: Vec<ProjectRow> = sqlx::query_as(
"SELECT id, organization_id, name FROM projects ORDER BY id"
"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id"
)
.fetch_all(db)
.await?;
@ -116,6 +119,10 @@ pub async fn organization_tree(
abstract_id: None,
imei: None,
status: None,
disabled: None,
address: p.address.clone(),
latitude: p.latitude.clone(),
longitude: p.longitude.clone(),
}
})
.collect();
@ -143,6 +150,10 @@ pub async fn organization_tree(
abstract_id: None,
imei: None,
status: None,
disabled: org.disabled,
address: None,
latitude: None,
longitude: None,
}
})
.collect()
@ -150,7 +161,7 @@ pub async fn organization_tree(
let tree = build_tree(&orgs, &projects, &project_count_map, None);
Ok(Json(json!(tree)))
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
}
/// 设备列表筛选参数
@ -172,7 +183,6 @@ pub async fn filter_cabinets(
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
let redis = &state.redis;
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
@ -181,7 +191,9 @@ pub async fn filter_cabinets(
// 基础SQL:联表查询获取通道状态统计
let base_sql = "
SELECT
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, c.status,
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
c.status,
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
(SELECT COUNT(*) FROM cabin_boards cb2
JOIN compartments comp ON comp.cabin_board_id = cb2.id
@ -217,21 +229,21 @@ pub async fn filter_cabinets(
("WHERE 1=0".into(), vec![])
};
// 已绑定/未绑定过滤
// 已绑定/未绑定过滤(绑定 = 分配到项目)
if let Some(bound) = params.bound {
match bound.as_str() {
"1" => {
if where_clause.is_empty() {
where_clause = "WHERE c.abstract_id IS NOT NULL".into();
where_clause = "WHERE c.project_id IS NOT NULL".into();
} else {
where_clause.push_str(" AND c.abstract_id IS NOT NULL");
where_clause.push_str(" AND c.project_id IS NOT NULL");
}
}
"0" => {
if where_clause.is_empty() {
where_clause = "WHERE c.abstract_id IS NULL".into();
where_clause = "WHERE c.project_id IS NULL".into();
} else {
where_clause.push_str(" AND c.abstract_id IS NULL");
where_clause.push_str(" AND c.project_id IS NULL");
}
}
_ => {}
@ -247,7 +259,7 @@ pub async fn filter_cabinets(
let total: i64 = count_query.fetch_one(db).await.unwrap_or(0);
if total == 0 || offset >= total {
return Ok(Json(json!({"total": 0, "data": []})));
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
}
// 分页数据
@ -269,7 +281,8 @@ pub async fn filter_cabinets(
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
AppError::Internal(format!("Redis客户端创建失败: {}", e))
})?;
let mut conn = client.get_async_connection().await.map_err(|e| {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
AppError::Internal(format!("Redis连接失败: {}", e))
})?;
@ -296,7 +309,34 @@ pub async fn filter_cabinets(
}
}
Ok(Json(json!({"total": total, "data": rows})))
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
{
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
if !project_ids.is_empty() {
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
r#"SELECT p.id, o.disabled FROM projects p
LEFT JOIN organizations o ON p.organization_id = o.id
WHERE p.id IN ({})"#,
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
for pid in &project_ids {
q = q.bind(pid);
}
if let Ok(results) = q.fetch_all(db).await {
let disabled_map: std::collections::HashMap<i64, bool> = results
.into_iter()
.map(|(pid, d)| (pid, d == 1))
.collect();
for row in rows.iter_mut() {
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
}
}
}
}
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
}
/// 设备实时状态参数
@ -313,10 +353,32 @@ pub async fn device_realtime_status(
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
// 校验设备归属
if user.role_level < 2 {
if let Some(org_id) = user.organization_id {
let has_access: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cabinets c \
JOIN projects p ON c.project_id = p.id \
WHERE c.imei = ? AND p.organization_id = ?)",
)
.bind(&params.imei)
.bind(org_id)
.fetch_one(&state.mysql)
.await
.unwrap_or(false);
if !has_access {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
} else {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
}
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
AppError::Internal(format!("Redis客户端创建失败: {}", e))
})?;
let mut conn = client.get_async_connection().await.map_err(|e| {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
AppError::Internal(format!("Redis连接失败: {}", e))
})?;
@ -382,7 +444,7 @@ pub async fn device_realtime_status(
continue;
}
let ch_fields = vec!["on", "full", "fault", "hex"];
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(ch_key)
.arg(&ch_fields)
@ -411,6 +473,7 @@ pub async fn device_realtime_status(
"full": full,
"fault": fault,
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()),
});
}
@ -419,7 +482,11 @@ pub async fn device_realtime_status(
}
Ok(Json(json!({
"code": 0,
"data": {
"device": device_status,
"boards": boards,
},
"message": "ok"
})))
}

View File

@ -12,11 +12,11 @@ use sqlx::MySqlPool;
use super::organizations::{
generate_auth_str,
CabinetDetail, CabinBoardRow, CabinBoardWithCompartments,
CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
CabinBoardRow, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use super::scope_guard;
/// 基础柜子行(只包含数据库字段,用于简单查询)
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
@ -28,7 +28,11 @@ struct CabinetBasicRow {
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
latitude: Option<String>,
longitude: Option<String>,
status: i8,
/// 充电工作模式:0=平台指令模式 1=自动充电工作模式
work_mode: Option<i8>,
}
impl From<CabinetBasicRow> for super::organizations::CabinetRow {
@ -52,6 +56,8 @@ impl From<CabinetBasicRow> for super::organizations::CabinetRow {
pow_fail_dc: None,
pow_fail_ac: None,
is_online: None,
org_disabled: None,
project_address: None,
}
}
}
@ -109,7 +115,7 @@ pub async fn list_cabinets(
let db = &state.mysql;
let sql_rows = sqlx::query_as::<_, CabinetBasicRow>(
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode
FROM cabinets WHERE project_id = ? ORDER BY id"#,
)
.bind(project_id)
@ -117,7 +123,7 @@ pub async fn list_cabinets(
.await?;
let rows: Vec<super::organizations::CabinetRow> = sql_rows.into_iter().map(Into::into).collect();
Ok(Json(json!(rows)))
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}
/// POST /api/cabinets — 批量添加柜子
@ -129,6 +135,7 @@ pub async fn create_cabinet(
Json(body): Json<CreateCabinetBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:create")?;
scope_guard::verify_project_ownership(&state.mysql, &user, body.project_id).await?;
let db = &state.mysql;
if body.imeis.is_empty() {
@ -191,7 +198,7 @@ pub async fn create_cabinet(
tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei);
}
Ok(Json(json!({ "ids": created_ids })))
Ok(Json(json!({ "code": 0, "data": { "ids": created_ids }, "message": "ok" })))
}
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
@ -202,6 +209,7 @@ pub async fn update_cabinet(
Json(body): Json<UpdateCabinetBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
let db = &state.mysql;
let rows = sqlx::query(
@ -224,7 +232,96 @@ pub async fn update_cabinet(
}
tracing::info!("更新柜子 id={}", id);
Ok(Json(json!({})))
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
}
/// 柜子状态更新请求体
#[derive(Debug, Deserialize)]
pub struct SetCabinetStatusBody {
pub status: i8,
}
/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用)
///
/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。
pub async fn set_cabinet_status(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<SetCabinetStatusBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
// 禁/启用操作仅总管理员可执行
if user.role_level < 2 {
return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into()));
}
let db = &state.mysql;
let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?")
.bind(body.status)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
let action = if body.status == 2 { "禁用" } else { "启用" };
tracing::info!("{}柜子 id={}", action, id);
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
}
/// 充电工作模式更新请求体(前端契约:字符串 "0" / "1")
#[derive(Debug, Deserialize)]
pub struct UpdateWorkModeBody {
pub work_mode: String,
}
/// PUT /api/cabinets/:id/work-mode — 设置充电工作模式
///
/// 0=平台指令模式(默认,需平台下发指令才充电)
/// 1=自动充电工作模式(设备自主充电)
/// 权限:device:edit(编辑设备),并校验柜子存在 + 组织归属。
pub async fn update_work_mode(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<UpdateWorkModeBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
// 组织隔离:非总管理员只能改本组织项目的柜子
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
// 白名单取值,拒绝一切其它输入(含 "2"、"true" 等)
let mode: i8 = match body.work_mode.trim() {
"0" => 0,
"1" => 1,
other => {
return Err(AppError::BadRequest(format!(
"work_mode 只能为 \"0\"(平台指令模式) 或 \"1\"(自动充电模式),收到: {}",
other
)))
}
};
let rows = sqlx::query("UPDATE cabinets SET work_mode = ? WHERE id = ?")
.bind(mode)
.bind(id)
.execute(&state.mysql)
.await?
.rows_affected();
// 总管理员路径下 verify_cabinet_ownership 直接放行,这里兜底不存在的情况
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("设置充电工作模式 id={} work_mode={}", id, mode);
Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "ok" })))
}
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
@ -234,6 +331,7 @@ pub async fn delete_cabinet(
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:delete")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
let db = &state.mysql;
let mut tx = db.begin().await.map_err(AppError::Database)?;
@ -265,7 +363,7 @@ pub async fn delete_cabinet(
}
tracing::info!("删除柜子 id={}", id);
Ok(Json(json!({})))
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
}
/// POST /api/cabinets/:id/bind — 绑定平台抽象ID
@ -275,6 +373,7 @@ pub async fn bind_cabinet(
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
let db = &state.mysql;
@ -314,7 +413,7 @@ pub async fn bind_cabinet(
tx.commit().await.map_err(AppError::Database)?;
tracing::info!("绑定柜子 id={} abstract_id={}", id, abstract_id);
Ok(Json(json!({ "abstract_id": abstract_id })))
Ok(Json(json!({ "code": 0, "data": { "abstract_id": abstract_id }, "message": "ok" })))
}
/// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组)
@ -325,6 +424,7 @@ pub async fn replace_imei(
Json(body): Json<ReplaceImeiBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
let imei = body.imei.trim().to_string();
if imei.len() != 15 || !imei.chars().all(|c| c.is_ascii_digit()) {
@ -366,7 +466,7 @@ pub async fn replace_imei(
.await?;
tracing::info!("更换IMEI id={} new_imei={}", id, imei);
Ok(Json(json!({})))
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
}
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
@ -375,24 +475,55 @@ pub async fn regenerate_auth(
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
// 仅总管理员可重置安全码:device:auth_str:reset 不在企业管理员/操作员的权限码表中,
// 且 check_permission 对 role_level >= 2 直接放行,因此等价于「仅总管理员可用」
auth::check_permission(&user, "device:auth_str:reset")?;
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
let db = &state.mysql;
let auth_str = generate_auth_str();
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ? WHERE id = ?"#)
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ?, auth_str_issued = 0 WHERE id = ?"#)
.bind(&auth_str)
.bind(id)
.execute(db)
.await?
.rows_affected();
// 同步 Redis:
// 1. 用新码覆盖 auth_str:{imei} —— 旧安全码【立即失效】,设备继续用旧码 login 会收到「签名错误」,
// 从而触发设备重新调用 auth_str 申领新码,形成自愈闭环
// 2. 清除 auth_str:issued:{imei} 标记 —— 允许设备重新申领
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?")
.bind(id)
.fetch_optional(db)
.await?;
if let Some(imei) = imei {
let code_key = format!("auth_str:{}", imei);
let issued_key = format!("auth_str:issued:{}", imei);
let mut conn = state.redis.clone();
if let Err(e) = redis::cmd("SET")
.arg(&code_key)
.arg(&auth_str)
.query_async::<()>(&mut conn)
.await
{
tracing::error!("[regenerate_auth] SET {} 失败: {}", code_key, e);
}
let _: Result<(), _> = redis::cmd("DEL")
.arg(&issued_key)
.query_async(&mut conn)
.await;
}
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("重新生成 auth_str id={}", id);
Ok(Json(json!({ "auth_str": auth_str })))
Ok(Json(json!({ "code": 0, "data": { "auth_str": auth_str }, "message": "ok" })))
}
// ---------------------------------------------------------------------------
@ -409,7 +540,7 @@ pub async fn get_cabinet_detail(
let db = &state.mysql;
let cabinet = sqlx::query_as::<_, CabinetBasicRow>(
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, status, work_mode
FROM cabinets WHERE id = ?"#,
)
.bind(id)
@ -422,17 +553,87 @@ pub async fn get_cabinet_detail(
check_project_access(&user, db, pid).await?;
}
// 仓控板 + 仓体(含实时字段占位),并收集通道 Redis key 所需的映射
let (mut cabin_boards, comp_ids, comp_to_board) = load_boards_with_compartments(db, id).await?;
// 一次 pipeline 读取柜级 + 通道级实时状态;Redis 异常一律降级,不让详情接口整体失败
let realtime =
fetch_cabinet_realtime(&state.redis_url, &cabinet.imei, &comp_ids, &comp_to_board).await;
apply_realtime(&mut cabin_boards, &realtime);
// 查询项目名称
let project_name: Option<String> = if let Some(pid) = cabinet.project_id {
sqlx::query_scalar("SELECT name FROM projects WHERE id = ?")
.bind(pid)
.fetch_optional(db)
.await
.ok()
.flatten()
} else {
None
};
Ok(Json(json!({
"code": 0,
"data": {
"id": cabinet.id,
"project_id": cabinet.project_id,
"project_name": project_name,
"abstract_id": cabinet.abstract_id,
"imei": cabinet.imei,
"iccid": cabinet.iccid,
"name": cabinet.name,
"status": cabinet.status,
// 充电工作模式:字符串 "0"/"1"(前端契约),无值时为 null
"work_mode": cabinet.work_mode.map(|m| m.to_string()),
"rssi": realtime.rssi,
"pow_fail_dc": realtime.pow_fail_dc,
"pow_fail_ac": realtime.pow_fail_ac,
"address": cabinet.address,
"latitude": cabinet.latitude,
"longitude": cabinet.longitude,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备详情 — Redis 实时状态(柜级 + 通道级)
// ---------------------------------------------------------------------------
/// 详情页实时状态快照;Redis 不可用时整体为默认空值
#[derive(Default)]
struct CabinetRealtime {
rssi: Option<i32>,
pow_fail_dc: Option<bool>,
pow_fail_ac: Option<bool>,
/// 仓体 id -> (SOC, 电池ID)
soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>,
/// 仓体 id -> (温度, batt_status, pow_status)
ch_map: std::collections::HashMap<i64, (Option<i64>, Option<String>, Option<String>)>,
}
/// 查询仓控板与仓体,构建带实时字段占位的 JSON
///
/// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`,
/// 后两者供 Redis 批量查询通道级状态使用(板号/通道号为十进制不补零)。
async fn load_boards_with_compartments(
db: &MySqlPool,
cabinet_id: i64,
) -> Result<(Vec<Value>, Vec<i64>, Vec<(i64, (i8, i8))>), AppError> {
let boards: Vec<CabinBoardRow> = sqlx::query_as(
r#"SELECT id, cabinet_id, board_id, status
FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"#,
)
.bind(id)
.bind(cabinet_id)
.fetch_all(db)
.await?;
// 查询仓控板+仓体,同时构建 Value 以便注入 SOC
let mut cabin_boards: Vec<Value> = Vec::new();
let mut comp_ids: Vec<i64> = Vec::new();
let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new();
for board in boards {
let compartments: Vec<CompartmentRow> = sqlx::query_as(
r#"SELECT id, cabin_board_id, channel_id, status
@ -444,15 +645,22 @@ pub async fn get_cabinet_detail(
for c in &compartments {
comp_ids.push(c.id);
comp_to_board.push((c.id, (board.board_id, c.channel_id)));
}
let comp_values: Vec<Value> = compartments.into_iter().map(|c| {
// batt_status / pow_status 为 2 位 hex 字符串,Redis 缺失时保持 null
let comp_values: Vec<Value> = compartments
.into_iter()
.map(|c| {
json!({
"id": c.id,
"channel_id": c.channel_id,
"status": c.status,
"batt_status": Value::Null,
"pow_status": Value::Null,
})
}).collect();
})
.collect();
cabin_boards.push(json!({
"id": board.id,
@ -462,41 +670,115 @@ pub async fn get_cabinet_detail(
}));
}
// 从 Redis 批量读取实时状态(RSSI/电源/SOC)
let (rssi, pow_fail_dc, pow_fail_ac, mut soc_map) = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
if let Ok(mut conn) = client.get_async_connection().await {
let key = format!("device:{}", cabinet.imei);
Ok((cabin_boards, comp_ids, comp_to_board))
}
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
///
/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
async fn fetch_cabinet_realtime(
redis_url: &str,
imei: &str,
comp_ids: &[i64],
comp_to_board: &[(i64, (i8, i8))],
) -> CabinetRealtime {
let client = match redis::Client::open(redis_url) {
Ok(c) => c,
Err(e) => {
tracing::warn!("[cabinet_detail] Redis 客户端创建失败,实时状态降级: {}", e);
return CabinetRealtime::default();
}
};
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = match client.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
tracing::warn!("[cabinet_detail] Redis 连接失败,实时状态降级: {}", e);
return CabinetRealtime::default();
}
};
let key = format!("device:{}", imei);
let mut pipe = redis::pipe();
pipe.cmd("HGET").arg(&key).arg("rssi")
.cmd("HGET").arg(&key).arg("pow_fail_dc")
.cmd("HGET").arg(&key).arg("pow_fail_ac");
for cid in &comp_ids {
for cid in comp_ids {
pipe.cmd("GET").arg(format!("compartment:status:{}", cid));
}
let vals: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
let rssi: Option<i32> = vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse().ok());
let dc = vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1");
let ac = vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1");
let mut soc_map = std::collections::HashMap::new();
for (i, cid) in comp_ids.iter().enumerate() {
if let Some(Some(data)) = vals.get(3 + i) {
if let Ok(v) = serde_json::from_str::<Value>(data) {
let soc = v.get("soc").and_then(|s| s.as_i64());
let bid = v.get("battery_id").and_then(|s| s.as_str()).map(|s| s.to_string());
soc_map.insert(*cid, (soc, bid));
// 通道级:板号/通道号为十进制不补零,与设备上报写入的 key 保持一致
for (_, (board_id, channel_id)) in comp_to_board {
let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id);
pipe.cmd("HGET").arg(&rkey).arg("temp")
.cmd("HGET").arg(&rkey).arg("batt_status")
.cmd("HGET").arg(&rkey).arg("pow_status");
}
}
}
(rssi, dc, ac, soc_map)
} else { (None, None, None, std::collections::HashMap::new()) }
} else { (None, None, None, std::collections::HashMap::new()) };
// 注入 SOC 到每个仓体
for board in &mut cabin_boards {
if let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) {
for comp in comps {
if let Some(id) = comp.get("id").and_then(|i| i.as_i64()) {
if let Some((soc, bid)) = soc_map.get(&id) {
// 用类型标注而非 turbofish:query_async 的泛型首参是连接类型
let queried: Result<Vec<Option<String>>, redis::RedisError> =
pipe.query_async(&mut conn).await;
match queried {
Ok(vals) => parse_cabinet_realtime(&vals, comp_ids, comp_to_board),
Err(e) => {
tracing::warn!("[cabinet_detail] Redis pipeline 读取失败,实时状态降级 imei={} err={}", imei, e);
CabinetRealtime::default()
}
}
}
/// 解析 pipeline 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status)
fn parse_cabinet_realtime(
vals: &[Option<String>],
comp_ids: &[i64],
comp_to_board: &[(i64, (i8, i8))],
) -> CabinetRealtime {
/// 取出第 i 项的非空字符串
fn pick(vals: &[Option<String>], i: usize) -> Option<String> {
vals.get(i).and_then(|v| v.as_ref()).cloned()
}
let mut rt = CabinetRealtime {
rssi: pick(vals, 0).and_then(|v| v.parse().ok()),
pow_fail_dc: pick(vals, 1).map(|v| v == "1"),
pow_fail_ac: pick(vals, 2).map(|v| v == "1"),
..Default::default()
};
for (i, cid) in comp_ids.iter().enumerate() {
if let Some(data) = pick(vals, 3 + i) {
if let Ok(v) = serde_json::from_str::<Value>(&data) {
let soc = v.get("soc").and_then(|s| s.as_i64());
let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string);
rt.soc_map.insert(*cid, (soc, bid));
}
}
}
// 通道级字段紧随 SOC 段之后,每通道 3 项
let base = 3 + comp_ids.len();
for (i, (cid, _)) in comp_to_board.iter().enumerate() {
let off = base + i * 3;
let temp = pick(vals, off).and_then(|v| v.parse::<i64>().ok());
let batt_status = pick(vals, off + 1);
let pow_status = pick(vals, off + 2);
rt.ch_map.insert(*cid, (temp, batt_status, pow_status));
}
rt
}
/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串
fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) {
for board in cabin_boards.iter_mut() {
let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else {
continue;
};
for comp in comps.iter_mut() {
let Some(cid) = comp.get("id").and_then(|i| i.as_i64()) else {
continue;
};
if let Some((soc, bid)) = rt.soc_map.get(&cid) {
if let Some(s) = soc {
comp["soc"] = json!(s);
}
@ -504,29 +786,19 @@ pub async fn get_cabinet_detail(
comp["battery_id"] = json!(b);
}
}
if let Some((temp, batt_status, pow_status)) = rt.ch_map.get(&cid) {
if let Some(t) = temp {
comp["temp"] = json!(t);
}
if let Some(bs) = batt_status {
comp["batt_status"] = json!(bs);
}
if let Some(ps) = pow_status {
comp["pow_status"] = json!(ps);
}
}
}
}
Ok(Json(json!({
"code": 0,
"data": {
"id": cabinet.id,
"project_id": cabinet.project_id,
"abstract_id": cabinet.abstract_id,
"imei": cabinet.imei,
"iccid": cabinet.iccid,
"name": cabinet.name,
"status": cabinet.status,
"rssi": rssi,
"pow_fail_dc": pow_fail_dc,
"pow_fail_ac": pow_fail_ac,
"address": cabinet.address,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
@ -638,3 +910,4 @@ pub async fn list_compartment_options(
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}

View File

@ -0,0 +1,230 @@
//! 充电订单 API — 列表查询 + 详情
use axum::extract::{Query, State};
use axum::Json;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 默认页码(预留 serde default 用)
#[allow(dead_code)]
fn default_page() -> i64 { 1 }
/// 默认每页大小(预留 serde default 用)
#[allow(dead_code)]
fn default_page_size() -> i64 { 20 }
fn status_text(status: i8) -> &'static str {
match status {
0 => "充电中",
1 => "已完成",
2 => "异常",
_ => "未知",
}
}
fn reason_text(reason: Option<&str>) -> &'static str {
match reason {
Some("normal") => "正常充满",
Some("user_stop") => "手动停止",
Some("forced") => "强制结束",
Some("timeout") => "超时结束",
_ => "未知",
}
}
#[derive(Debug, Deserialize)]
pub struct ChargeOrderQuery {
pub cabinet_id: Option<i64>,
pub compartment_id: Option<i64>,
pub battery_id: Option<String>,
pub status: Option<i8>,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub page: Option<i64>,
pub page_size: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct ChargeOrderItem {
pub id: i64,
pub cabinet_id: i64,
pub cabinet_name: Option<String>,
pub compartment_id: i64,
pub battery_id: String,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub start_soc: i64,
pub end_soc: Option<i64>,
pub energy: i64,
pub reason: Option<String>,
pub reason_text: String,
pub status: i8,
pub status_text: String,
pub created_at: Option<NaiveDateTime>,
}
#[derive(Debug, sqlx::FromRow)]
struct ChargeOrderRow {
id: i64,
cabinet_id: i64,
cabinet_name: Option<String>,
compartment_id: i64,
battery_id: String,
start_time: Option<NaiveDateTime>,
end_time: Option<NaiveDateTime>,
start_soc: i64,
end_soc: Option<i64>,
energy: i64,
reason: Option<String>,
status: i8,
created_at: Option<NaiveDateTime>,
}
/// GET /api/charge-orders — 充电订单列表
pub async fn list_charge_orders(
user: CurrentUser,
State(state): State<crate::commands::AppState>,
Query(params): Query<ChargeOrderQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:view")?;
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
let mut query =
"SELECT co.*, cab.name as cabinet_name FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1"
.to_string();
let mut count_query = "SELECT COUNT(*) FROM charge_orders WHERE 1=1".to_string();
// 按固定顺序追加 `?` 占位符,绑定顺序必须与下方 build_count_query / build_list_query 一致
if params.cabinet_id.is_some() {
query.push_str(" AND cabinet_id = ?");
count_query.push_str(" AND cabinet_id = ?");
}
if params.compartment_id.is_some() {
query.push_str(" AND compartment_id = ?");
count_query.push_str(" AND compartment_id = ?");
}
if params.battery_id.is_some() {
query.push_str(" AND battery_id LIKE ?");
count_query.push_str(" AND battery_id LIKE ?");
}
if params.status.is_some() {
query.push_str(" AND status = ?");
count_query.push_str(" AND status = ?");
}
if params.start_time.is_some() {
query.push_str(" AND start_time >= ?");
count_query.push_str(" AND start_time >= ?");
}
if params.end_time.is_some() {
query.push_str(" AND end_time <= ?");
count_query.push_str(" AND end_time <= ?");
}
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
let total: i64 = build_count_query(count_query, &params, &state.mysql).await?;
let rows: Vec<ChargeOrderRow> = build_list_query(query, params, page_size, offset, &state.mysql).await?;
let items: Vec<ChargeOrderItem> = rows.into_iter().map(|r| ChargeOrderItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
compartment_id: r.compartment_id,
battery_id: r.battery_id,
start_time: r.start_time.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()),
end_time: r.end_time.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()),
start_soc: r.start_soc,
end_soc: r.end_soc,
energy: r.energy,
reason: r.reason.clone(),
reason_text: reason_text(r.reason.as_deref()).to_string(),
status: r.status,
status_text: status_text(r.status).to_string(),
created_at: r.created_at,
}).collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": items,
"total": total,
"page": page,
"page_size": page_size
},
"message": "ok"
})))
}
/// 执行 COUNT 查询并返回总数。
///
/// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致:
/// cabinet_id → compartment_id → battery_id → status → start_time → end_time
async fn build_count_query(
query: String,
params: &ChargeOrderQuery,
mysql: &sqlx::MySqlPool,
) -> Result<i64, sqlx::Error> {
let mut q = sqlx::query_scalar::<_, i64>(&query);
if let Some(cid) = params.cabinet_id {
q = q.bind(cid);
}
if let Some(cid) = params.compartment_id {
q = q.bind(cid);
}
if let Some(ref bid) = params.battery_id {
// LIKE 值前后加 %,在 bind 值中拼接,不拼入 SQL 文本
q = q.bind(format!("%{}%", bid));
}
if let Some(s) = params.status {
q = q.bind(s);
}
if let Some(ref st) = params.start_time {
q = q.bind(st);
}
if let Some(ref et) = params.end_time {
q = q.bind(et);
}
q.fetch_one(mysql).await
}
/// 执行列表查询并返回订单行。
///
/// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致:
/// cabinet_id → compartment_id → battery_id → status → start_time → end_time → LIMIT → OFFSET
async fn build_list_query(
query: String,
params: ChargeOrderQuery,
page_size: i64,
offset: i64,
mysql: &sqlx::MySqlPool,
) -> Result<Vec<ChargeOrderRow>, sqlx::Error> {
let mut q = sqlx::query_as::<_, ChargeOrderRow>(&query);
if let Some(cid) = params.cabinet_id {
q = q.bind(cid);
}
if let Some(cid) = params.compartment_id {
q = q.bind(cid);
}
if let Some(ref bid) = params.battery_id {
// LIKE 值前后加 %,在 bind 值中拼接,不拼入 SQL 文本
q = q.bind(format!("%{}%", bid));
}
if let Some(s) = params.status {
q = q.bind(s);
}
if let Some(ref st) = params.start_time {
q = q.bind(st);
}
if let Some(ref et) = params.end_time {
q = q.bind(et);
}
// 分页参数始终存在,放最后
q = q.bind(page_size).bind(offset);
q.fetch_all(mysql).await
}

View File

@ -249,11 +249,13 @@ pub async fn export_charge_records(
let user_id = user.user_id;
let file_name = generate_export_filename(&params);
// 序列化查询参数
// 序列化查询参数(含组织过滤信息)
let query_json = serde_json::to_string(&json!({
"cabinet_id": params.cabinet_id,
"start_time": params.start_time,
"end_time": params.end_time,
"org_id": user.organization_id,
"role_level": user.role_level,
}))
.unwrap_or_default();
@ -328,7 +330,7 @@ async fn generate_charge_records_excel(
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
let (where_clause, bind_values) = build_where_clause(
let (mut where_clause, mut bind_values) = build_where_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
None,
None,
@ -336,6 +338,19 @@ async fn generate_charge_records_excel(
params.get("end_time").and_then(|v| v.as_str()),
);
// 组织过滤(非总管理员)
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
let org_id = params.get("org_id").and_then(|v| v.as_i64());
if role_level < 2 {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
if let Some(oid) = org_id {
where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
bind_values.push(oid.to_string());
} else {
where_clause.push_str(&format!("{}1=0", prefix));
}
}
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
// 更新进度 50%
@ -351,7 +366,7 @@ async fn generate_charge_records_excel(
let headers = [
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
"充电量(Wh)", "最高温度(0.1°C)", "状态",
"充电量(Wh)", "最高温度(K×100)", "状态",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;

View File

@ -11,8 +11,8 @@ use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 电费单价(元/Wh),energy 单位为 Wh,0.80 元/kWh = 0.0008 元/Wh
const COST_PER_WH: f64 = 0.0008;
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
const COST_PER_WH_CENT: i64 = 80;
/// 趋势查询参数
#[derive(Debug, Deserialize)]
@ -150,7 +150,7 @@ pub async fn get_summary(
"data": {
"today_energy": today_energy,
"month_energy": month_energy,
"estimated_cost": ((month_energy as f64) * COST_PER_WH * 100.0).round() / 100.0,
"estimated_cost": (month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0,
"cabinet_count": cabinet_count,
"online_count": online_count,
},
@ -181,14 +181,17 @@ pub async fn get_cabinet_ranking(
let sql = format!(
"SELECT c.id, c.abstract_id, c.name, \
COALESCE(es_today.total_energy, 0) AS today_energy, \
COALESCE(es_month.total_energy, 0) AS month_energy \
CAST(COALESCE(es_today.total_energy, 0) AS SIGNED) AS today_energy, \
CAST(COALESCE(es_month.month_energy, 0) AS SIGNED) AS month_energy \
FROM cabinets c \
LEFT JOIN energy_stats es_today \
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
LEFT JOIN energy_stats es_month \
ON c.id = es_month.cabinet_id \
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
LEFT JOIN ( \
SELECT cabinet_id, SUM(total_energy) AS month_energy \
FROM energy_stats \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
GROUP BY cabinet_id \
) es_month ON c.id = es_month.cabinet_id \
{} \
ORDER BY today_energy DESC, c.id ASC",
org_join
@ -236,14 +239,17 @@ pub async fn get_project_stats(
"SELECT p.id, p.name, \
COUNT(DISTINCT c.id) AS cabinet_count, \
CAST(COALESCE(SUM(es_today.total_energy), 0) AS SIGNED) AS today_energy, \
CAST(COALESCE(SUM(es_month.total_energy), 0) AS SIGNED) AS month_energy \
CAST(COALESCE(SUM(es_month.month_energy), 0) AS SIGNED) AS month_energy \
FROM projects p \
LEFT JOIN cabinets c ON p.id = c.project_id \
LEFT JOIN energy_stats es_today \
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
LEFT JOIN energy_stats es_month \
ON c.id = es_month.cabinet_id \
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
LEFT JOIN ( \
SELECT cabinet_id, SUM(total_energy) AS month_energy \
FROM energy_stats \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
GROUP BY cabinet_id \
) es_month ON c.id = es_month.cabinet_id \
{} \
GROUP BY p.id, p.name \
ORDER BY today_energy DESC, p.id ASC",
@ -365,12 +371,14 @@ pub async fn export_energy_stats(
let user_id = user.user_id;
let file_name = generate_export_filename(&params);
// 序列化查询参数
// 序列化查询参数(含组织过滤信息)
let query_json = serde_json::to_string(&json!({
"cabinet_id": params.cabinet_id,
"project_id": params.project_id,
"start_date": params.start_date,
"end_date": params.end_date,
"org_id": user.organization_id,
"role_level": user.role_level,
}))
.unwrap_or_default();
@ -475,13 +483,27 @@ async fn generate_energy_excel(
let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
let (where_clause, bind_values) = build_export_clause(
let (mut where_clause, mut bind_values) = build_export_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
params.get("project_id").and_then(|v| v.as_i64()),
params.get("start_date").and_then(|v| v.as_str()),
params.get("end_date").and_then(|v| v.as_str()),
);
// 组织过滤(非总管理员)
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
let org_id = params.get("org_id").and_then(|v| v.as_i64());
if role_level < 2 {
if let Some(oid) = org_id {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
where_clause.push_str(&format!("{}es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
bind_values.push(oid.to_string());
} else {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
where_clause.push_str(&format!("{}1=0", prefix));
}
}
// 查询能耗明细
let sql = format!(
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \

View File

@ -11,13 +11,15 @@
//! - POST /api/door/open — 开门
//! - GET /api/cabinets/scan/:abstract_id — 扫码验证
use axum::extract::{Path, Query, State};
use axum::extract::{Multipart, Path, Query, State};
use axum::Json;
use redis;
use serde::Deserialize;
use serde_json::{json, Value};
use std::io::Write;
use uuid::Uuid;
use crate::commands::{AppState, DeviceCommand};
use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
@ -31,6 +33,17 @@ pub(crate) struct CompartmentActionInput {
compartment_id: i64,
}
/// 问题反馈请求体
#[derive(Debug, Deserialize)]
pub(crate) struct FeedbackInput {
/// 反馈内容
content: String,
/// 联系方式(可选)
contact: Option<String>,
/// 图片URL列表(可选)
images: Option<Vec<String>>,
}
/// 设备列表查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct CabinetListQuery {
@ -64,6 +77,10 @@ struct H5CabinetRow {
status: i8,
project_id: Option<i64>,
imei: Option<String>,
iccid: Option<String>,
address: Option<String>,
latitude: Option<String>,
longitude: Option<String>,
channel_count: i64,
idle_count: i64,
charging_count: i64,
@ -96,10 +113,13 @@ struct ChargeRecordBriefRow {
energy: Option<String>,
}
/// 柜子 IMEI 查询行(用于指令下发)
/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发)
#[derive(Debug, sqlx::FromRow)]
struct CabinetImeiRow {
cabinet_id: i64,
imei: String,
board_id: i8,
channel_id: i8,
}
// ---------------------------------------------------------------------------
@ -115,7 +135,7 @@ pub async fn get_dashboard(
let pool = &state.mysql;
// 根据角色确定组织过滤条件
// 超级管理员可通过 org_id 参数切换组织
// 总管理员可通过 org_id 参数切换组织
let is_super = user.role_level >= 2 && query.org_id.is_none();
let org_id = query.org_id.or(user.organization_id);
@ -151,8 +171,8 @@ pub async fn get_dashboard(
// 故障告警(取最近10条故障仓体,返回项目名+柜名)
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
// 项目摘要
let projects = fetch_project_summaries(pool, &user).await?;
// 项目摘要(带组织过滤)
let projects = fetch_project_summaries(pool, &user, org_id).await?;
Ok(Json(json!({
"code": 0,
@ -414,12 +434,23 @@ async fn fetch_fault_alerts(
async fn fetch_project_summaries(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
org_id: Option<i64>,
) -> Result<Vec<Value>, AppError> {
// 获取用户可见的项目
// 获取用户可见的项目(优先使用查询参数 org_id)
let filter_org = org_id.or(user.organization_id);
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
if let Some(oid) = filter_org {
sqlx::query_as::<_, ProjectRow>(
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
)
.bind(oid)
.fetch_all(pool)
.await?
} else {
sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id")
.fetch_all(pool)
.await?
}
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, ProjectRow>(
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
@ -502,7 +533,7 @@ pub async fn get_cabinets(
// 按项目过滤(同时验证组织权限)
if user.role_level >= 2 {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -515,7 +546,7 @@ pub async fn get_cabinets(
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -536,7 +567,7 @@ pub async fn get_cabinets(
// 总管理员:按组织过滤或看全部
if let Some(oid) = query.org_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -551,7 +582,7 @@ pub async fn get_cabinets(
.await?
} else {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -565,7 +596,7 @@ pub async fn get_cabinets(
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -582,23 +613,62 @@ pub async fn get_cabinets(
vec![]
};
// 从Redis批量查RSSI
// 从Redis批量查RSSI + pow_fail
let mut rssi_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
let mut dc_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
let mut ac_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
let mut temp_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
if let Ok(mut conn) = client.get_async_connection().await {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
let mut pipe = redis::pipe();
for r in &rows {
if let Some(ref imei) = r.imei {
pipe.cmd("HGET").arg(&format!("device:{imei}")).arg("rssi");
let key = format!("device:{imei}");
pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]);
}
}
if !rows.is_empty() {
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
for (i, r) in rows.iter().enumerate() {
if r.imei.is_some() {
let val = results.get(i).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok());
rssi_map.insert(r.imei.clone().unwrap_or_default(), val);
let results: Vec<Vec<Option<String>>> = match pipe.query_async(&mut conn).await {
Ok(r) => r,
Err(e) => {
tracing::error!("[h5] Redis pipeline查询失败: {}", e);
Vec::new()
}
};
for (i, r) in rows.iter().enumerate() {
if let Some(ref imei) = r.imei {
if let Some(vals) = results.get(i) {
rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
}
}
}
}
}
}
// 查询柜子所属组织是否禁用(用于前端徽标)
let mut org_disabled_map: std::collections::HashMap<i64, bool> = std::collections::HashMap::new();
{
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
if !project_ids.is_empty() {
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p
LEFT JOIN organizations o ON p.organization_id = o.id
WHERE p.id IN ({})"#,
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
for pid in &project_ids {
q = q.bind(pid);
}
if let Ok(rows) = q.fetch_all(pool).await {
for (pid, disabled) in rows {
org_disabled_map.insert(pid, disabled == 1);
}
}
}
@ -607,19 +677,33 @@ pub async fn get_cabinets(
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
let rssi = r.imei.as_ref().and_then(|imei| rssi_map.get(imei).copied()).flatten();
let imei = r.imei.clone();
let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten();
let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten();
let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten();
let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten();
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
json!({
"id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"imei": r.imei,
"iccid": r.iccid,
"status": r.status,
"project_id": r.project_id,
"address": r.address,
"latitude": r.latitude,
"longitude": r.longitude,
"channel_count": r.channel_count,
"idle_count": r.idle_count,
"charging_count": r.charging_count,
"full_count": r.full_count,
"fault_count": r.fault_count,
"rssi": rssi,
"pow_fail_dc": pow_fail_dc,
"pow_fail_ac": pow_fail_ac,
"temp": temp,
"org_disabled": org_disabled,
})
})
.collect();
@ -640,14 +724,14 @@ pub async fn get_cabinet_by_abstract_id(
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let cab: Option<(i64, String, String, Option<String>, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, iccid, name, status FROM cabinets WHERE abstract_id = ?",
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
)
.bind(&abstract_id)
.fetch_optional(pool)
.await?;
let (cab_id, abs_id, imei, iccid, name, status) =
let (cab_id, abs_id, _imei, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 查询仓控板+仓体
@ -685,8 +769,6 @@ pub async fn get_cabinet_by_abstract_id(
"data": {
"id": cab_id,
"abstract_id": abs_id,
"imei": imei,
"iccid": iccid,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
@ -735,7 +817,8 @@ pub async fn get_compartment_detail(
// 从 Redis 获取实时数据
let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
if let Ok(mut conn) = client.get_async_connection().await {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
let key = format!("compartment:status:{}", comp_id);
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
@ -775,10 +858,8 @@ pub async fn get_compartment_detail(
"cabin_board_id": cabin_board_id,
"voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0),
"current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0),
"power": realtime.get("power").and_then(|v| v.as_i64()).unwrap_or(0),
"energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0),
"soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0),
"soh": realtime.get("soh").and_then(|v| v.as_i64()).unwrap_or(0),
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
@ -835,13 +916,82 @@ pub async fn scan_cabinet(
// ---------------------------------------------------------------------------
/// POST /api/h5/charge/start — 开始充电
///
/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`,
/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。
/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。
pub async fn start_charge(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:start")?;
send_compartment_command(&state, body.compartment_id, "on").await
// 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式
// (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
let order_row: Result<Option<(i64, String, i8, i8, Option<i8>)>, sqlx::Error> = sqlx::query_as(
r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode
FROM cabinets cab
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#,
)
.bind(body.compartment_id)
.fetch_optional(&state.mysql)
.await;
match order_row {
// 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单;
// **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => {
// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)
match crate::workers::report_worker::load_channel_battery(
&state.redis, &imei, board_id, channel_id,
)
.await
{
Some(battery) => {
if let Err(e) = crate::workers::report_worker::ensure_charge_order(
&state.mysql,
&state.redis,
&imei,
cabinet_id,
board_id,
channel_id,
&battery.battery_id,
Some(battery.soc),
)
.await
{
tracing::error!(
"[h5] 下发 on 前建单失败 compartment_id={} err={}",
body.compartment_id,
e
);
}
}
None => tracing::warn!(
"[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}",
body.compartment_id
),
}
}
Ok(Some(_)) => tracing::info!(
"[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}",
body.compartment_id
),
Ok(None) => tracing::warn!(
"[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}",
body.compartment_id
),
Err(e) => tracing::error!(
"[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}",
body.compartment_id,
e
),
}
send_compartment_command(&state, &user, body.compartment_id, "on").await
}
/// POST /api/h5/charge/stop — 停止充电
@ -851,34 +1001,53 @@ pub async fn stop_charge(
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:stop")?;
send_compartment_command(&state, body.compartment_id, "off").await
send_compartment_command(&state, &user, body.compartment_id, "off").await
}
/// POST /api/h5/door/open — 开门
/// POST /api/h5/door/open — 开门(**柜级动作**)
///
/// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓),
/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。
pub async fn open_door(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:door:unlock")?;
send_compartment_command(&state, body.compartment_id, "open").await
auth::check_permission(&user, "charge:open_door")?;
let cabinet_id: Option<i64> = sqlx::query_scalar(
r#"SELECT cb.cabinet_id FROM compartments comp
JOIN cabin_boards cb ON cb.id = comp.cabin_board_id
WHERE comp.id = ?"#,
)
.bind(body.compartment_id)
.fetch_optional(&state.mysql)
.await?;
let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
send_cabinet_command(&state, &user, cabinet_id, None, "open").await
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
/// 根据仓体ID查找所属柜子的IMEI,并通过 Redis 下发指令
/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。
///
/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。
/// 柜级指令(`open` / `pow_on` / `pow_off` / `reset`)请用 [`send_cabinet_command`]。
async fn send_compartment_command(
state: &AppState,
user: &CurrentUser,
compartment_id: i64,
action: &str,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 通过 compartment → cabin_board → cabinet 链路查找 IMEI 和 sub_device_id
// 一次查询拿到 cabinet_id + IMEI + board_id + channel_id
let row: Option<CabinetImeiRow> = sqlx::query_as(
r#"SELECT cab.imei FROM cabinets cab
r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
FROM cabinets cab
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#,
@ -888,35 +1057,61 @@ async fn send_compartment_command(
.await?;
let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
let imei = &cab_row.imei;
// 查询 sub_device_id(仓控板 ID)
let sub_row: Option<(i8,)> = sqlx::query_as(
r#"SELECT cb.board_id FROM cabin_boards cb
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#,
// 检查设备是否被禁用
let disabled: bool = sqlx::query_scalar(
"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2",
)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
.bind(cab_row.cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if disabled {
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
}
let sub_device_id = sub_row.map(|(bid,)| bid as u8);
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
let org_disabled: bool = sqlx::query_scalar(
r#"SELECT COUNT(*) > 0 FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
INNER JOIN organizations o ON p.organization_id = o.id
WHERE c.id = ? AND o.disabled = 1"#,
)
.bind(cab_row.cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if org_disabled {
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
}
// 校验操作权限(组织隔离)
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
let imei = &cab_row.imei;
// 检查是否有设备节点在线
if !state.nodes.is_device_online(imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 构建指令并通过 Redis 下发
// 构造协议 id:
// 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零)
// 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定
let id = format!("{:02}-{}", cab_row.board_id, cab_row.channel_id);
let cmd = DeviceCommand {
act: action.to_string(),
msg_id: 0,
dev_id: Some(imei.clone()),
sub_device_id,
id: Some(id.clone()),
params: Some(json!({})),
};
let json_str = cmd.to_json();
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
let mut json_val =
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
let json_str = json_val.to_string();
let mut conn = state.redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
@ -926,8 +1121,8 @@ async fn send_compartment_command(
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
tracing::info!(
"[h5] 指令已推入队列 imei={} action={} compartment_id={}",
imei, action, compartment_id
"[h5] 指令已推入队列 id={} action={} compartment_id={}",
id, action, compartment_id
);
Ok(Json(json!({
@ -937,6 +1132,405 @@ async fn send_compartment_command(
})))
}
// ---------------------------------------------------------------------------
// 柜级 / 单仓控板级指令(pow_on / pow_off / reset)
// ---------------------------------------------------------------------------
/// 柜级 / 单仓控板级指令下发。
///
/// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段)
/// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`)
///
/// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。
async fn send_cabinet_command(
state: &AppState,
user: &CurrentUser,
cabinet_id: i64,
board_id: Option<i8>,
action: &str,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?")
.bind(cabinet_id)
.fetch_optional(pool)
.await?;
let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 设备是否被禁用
let disabled: bool =
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2")
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if disabled {
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
}
// 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
let org_disabled: bool = sqlx::query_scalar(
r#"SELECT COUNT(*) > 0 FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
INNER JOIN organizations o ON p.organization_id = o.id
WHERE c.id = ? AND o.disabled = 1"#,
)
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if org_disabled {
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
}
// 组织数据隔离校验
verify_cabinet_access(user, cabinet_id, pool).await?;
// 设备必须在线:平台对不在线的设备一律不投递
if !state.nodes.is_device_online(&imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 构造协议 id:
// - `board_id = None` → **柜级**:**不带 id**(省略该字段)
// - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01")
let id = board_id.map(|b| format!("{:02}", b));
let cmd = DeviceCommand {
act: action.to_string(),
msg_id: 0,
id: id.clone(),
params: Some(json!({})),
};
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
let mut json_val =
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
let json_str = json_val.to_string();
let mut conn = state.redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
tracing::info!(
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
id,
action,
cabinet_id,
board_id
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "指令已下发"
})))
}
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级)
///
/// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。
/// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。
pub async fn cabinet_open(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:open_door")?;
send_cabinet_command(&state, &user, id, None, "open").await
}
/// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级)
pub async fn cabinet_pow_on(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
send_cabinet_command(&state, &user, id, None, "pow_on").await
}
/// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级)
///
/// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`;
/// 恢复供电须再下发 `pow-on`。
pub async fn cabinet_pow_off(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
send_cabinet_command(&state, &user, id, None, "pow_off").await
}
/// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`)
pub async fn cabinet_reset(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
send_cabinet_command(&state, &user, id, None, "reset").await
}
/// POST /api/cabin-boards/:id/reset — **重启单块仓控板**
/// (协议 `id = IMEI-仓控板号`,两段)
pub async fn board_reset(
user: CurrentUser,
State(state): State<AppState>,
Path(board_pk): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
let row: Option<(i64, i8)> =
sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?")
.bind(board_pk)
.fetch_optional(&state.mysql)
.await?;
let (cabinet_id, board_id) =
row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?;
send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await
}
/// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交)
pub async fn submit_feedback(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<FeedbackInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let content = body.content.trim();
if content.is_empty() {
return Err(AppError::BadRequest("反馈内容不能为空".into()));
}
if content.chars().count() > 2000 {
return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into()));
}
let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
if let Some(c) = &contact {
if c.chars().count() > 200 {
return Err(AppError::BadRequest("联系方式过长".into()));
}
}
// 图片校验:最多 6 张
let images = body.images.clone().unwrap_or_default();
let images: Vec<String> = images
.iter()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect();
if images.len() > 6 {
return Err(AppError::BadRequest("最多上传6张图片".into()));
}
let images_json = if images.is_empty() {
String::from("[]")
} else {
serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string())
};
sqlx::query(
"INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)"
)
.bind(user.user_id)
.bind(content)
.bind(contact)
.bind(&images_json)
.execute(pool)
.await
.map_err(AppError::Database)?;
tracing::info!(
"[h5] 问题反馈已提交 user_id={} content_len={}",
user.user_id, content.chars().count()
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "反馈已提交,感谢您的反馈"
})))
}
/// GET /api/feedback — 查询反馈列表(分页,管理员使用)
#[derive(Debug, Deserialize)]
pub(crate) struct FeedbackListQuery {
page: Option<i64>,
page_size: Option<i64>,
}
#[derive(Debug, sqlx::FromRow)]
struct FeedbackRow {
id: i64,
user_id: i64,
content: String,
contact: Option<String>,
images: Option<String>,
status: i8,
created_at: chrono::NaiveDateTime,
}
/// GET /api/h5/feedback — 反馈列表(管理端)
///
/// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。
pub async fn list_feedback(
_user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<FeedbackListQuery>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * page_size;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback")
.fetch_one(pool)
.await?;
let rows: Vec<FeedbackRow> = sqlx::query_as::<_, FeedbackRow>(
r#"SELECT id, user_id, content, contact, images, status, created_at
FROM user_feedback
ORDER BY created_at DESC
LIMIT ? OFFSET ?"#,
)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?;
let list: Vec<Value> = rows.into_iter().map(|r| {
let images: Vec<String> = r.images
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
json!({
"id": r.id,
"user_id": r.user_id,
"content": r.content,
"contact": r.contact,
"images": images,
"status": r.status,
"status_text": if r.status == 0 { "待处理" } else { "已处理" },
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": { "list": list, "total": total, "page": page, "page_size": page_size },
"message": "ok"
})))
}
/// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理)
#[derive(Debug, Deserialize)]
pub(crate) struct FeedbackStatusInput {
status: i8,
}
const FEEDBACK_IMG_DIR: &str = "uploads/feedback";
pub async fn upload_feedback_image(
user: CurrentUser,
mut multipart: Multipart,
) -> Result<Json<Value>, AppError> {
let mut file_data: Option<Vec<u8>> = None;
let mut ext = "".to_string();
while let Some(field) = multipart.next_field().await
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
let filename = field.file_name().unwrap_or("").to_string();
let data = field.bytes().await
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
if let Some(pos) = filename.rfind('.') {
ext = filename[pos..].to_string();
}
file_data = Some(data.to_vec());
}
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
if data.is_empty() {
return Err(AppError::BadRequest("上传内容为空".into()));
}
if data.len() > 5 * 1024 * 1024 {
return Err(AppError::BadRequest("图片文件不能超过 5MB".into()));
}
if ext.is_empty() {
ext = ".jpg".to_string();
}
let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
if !allowed.contains(&ext.as_str()) {
return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into()));
}
let dir = std::path::Path::new(FEEDBACK_IMG_DIR);
std::fs::create_dir_all(dir)
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
let filepath = dir.join(&filename);
let mut f = std::fs::File::create(&filepath)
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
f.write_all(&data)
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
let url = format!("/uploads/feedback/{}", filename);
tracing::info!(
user_id = user.user_id, path = %url,
"反馈图片上传成功"
);
Ok(Json(json!({
"code": 0,
"data": { "url": url },
"message": "ok"
})))
}
pub async fn update_feedback_status(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<FeedbackStatusInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
if body.status != 0 && body.status != 1 {
return Err(AppError::BadRequest("状态值不合法".into()));
}
let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?")
.bind(body.status)
.bind(id)
.execute(pool)
.await
.map_err(AppError::Database)?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound("反馈记录不存在".into()));
}
tracing::info!(
"[h5] 反馈状态已更新 operator={} feedback_id={} status={}",
user.user_id, id, body.status
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "ok"
})))
}
/// 验证用户是否有权访问指定柜子(组织数据隔离)
///
/// 总管理员(role_level >= 2)可访问所有柜子;

View File

@ -0,0 +1,155 @@
//! 腾讯地图 API 代理(服务端签名,避免前端暴露 SK)
//!
//! 签名算法(MD5):
//! 1. 参数值用 RAW(不 URL 编码)拼成 query string
//! 2. 签名串 = path + "?" + query_string + secret
//! 3. sig = MD5(签名串) 32位小写
//! 4. URL 请求中的参数值需 URL 编码
//! 注意:签名用 RAW 值,URL 用 URL 编码值,两者不同!
use axum::extract::Query;
use axum::Json;
use serde::Deserialize;
use serde_json::Value;
use std::collections::BTreeMap;
use crate::error::AppError;
const TMAP_KEY: &str = "A3YBZ-CIO6G-4F3QO-QEWPM-UQK3F-P7FXT";
const TMAP_SECRET: &str = "iIk8Z6CwPKaTgMAppAvtqhVq42CUUvYC";
const TMAP_BASE: &str = "https://apis.map.qq.com";
/// 计算腾讯地图签名(MD5)
/// 签名使用 RAW 值(不 URL 编码),URL 请求使用 URL 编码值
fn sign(path: &str, params: &BTreeMap<&str, String>) -> String {
let qs = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let to_sign = format!("{}?{}{}", path, qs, TMAP_SECRET);
let digest = md5::compute(to_sign.as_bytes());
format!("{:x}", digest)
}
/// 构建完整请求 URL(含签名)
fn build_url(path: &str, params: BTreeMap<&str, String>) -> String {
let sig = sign(path, &params);
let encoded_qs = params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{}{}?{}&sig={}", TMAP_BASE, path, encoded_qs, sig)
}
// ---------------------------------------------------------------------------
// 输入提示(地点搜索建议)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub(crate) struct SuggestionQuery {
keyword: String,
region: Option<String>,
}
/// GET /api/maps/suggestion — 输入提示
pub async fn suggestion(
Query(query): Query<SuggestionQuery>,
) -> Result<Json<Value>, AppError> {
if query.keyword.trim().is_empty() {
return Err(AppError::BadRequest("keyword 不能为空".into()));
}
let mut params = BTreeMap::new();
params.insert("keyword", query.keyword.trim().to_string());
params.insert("key", TMAP_KEY.to_string());
if let Some(region) = &query.region {
params.insert("region", region.to_string());
}
let url = build_url("/ws/place/v1/suggestion", params);
let resp = reqwest::get(&url)
.await
.map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?;
let body: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?;
Ok(Json(body))
}
// ---------------------------------------------------------------------------
// 逆地址解析(坐标→地址)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub(crate) struct GeocoderBody {
lat: f64,
lng: f64,
}
/// POST /api/maps/geocoder — 逆地理编码
pub async fn geocoder(
Json(body): Json<GeocoderBody>,
) -> Result<Json<Value>, AppError> {
let location = format!("{},{}", body.lat, body.lng);
let mut params = BTreeMap::new();
params.insert("key", TMAP_KEY.to_string());
params.insert("location", location);
let url = build_url("/ws/geocoder/v1/", params);
let resp = reqwest::get(&url)
.await
.map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?;
let body: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?;
Ok(Json(body))
}
// ---------------------------------------------------------------------------
// 地点搜索
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub(crate) struct PlaceSearchQuery {
keyword: String,
region: Option<String>,
page_size: Option<i32>,
}
/// GET /api/maps/place-search — 地点搜索
pub async fn place_search(
Query(query): Query<PlaceSearchQuery>,
) -> Result<Json<Value>, AppError> {
if query.keyword.trim().is_empty() {
return Err(AppError::BadRequest("keyword 不能为空".into()));
}
let mut params = BTreeMap::new();
params.insert("keyword", query.keyword.trim().to_string());
params.insert("key", TMAP_KEY.to_string());
params.insert("page_size", query.page_size.unwrap_or(10).to_string());
if let Some(region) = query.region.filter(|r| !r.is_empty()) {
params.insert("region", region);
} else {
params.insert("boundary", "rectangle(3.5,73.5,53.5,135.5)".to_string());
}
let url = build_url("/ws/place/v1/search", params);
let resp = reqwest::get(&url)
.await
.map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?;
let body: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?;
Ok(Json(body))
}

View File

@ -3,17 +3,22 @@
//! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。
//! 权限校验在各路由处理器内部通过 `check_permission` 完成。
use axum::http::Method;
use axum::middleware;
use axum::routing::{get, post, put};
use axum::Router;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use crate::commands::AppState;
use crate::middleware::auth::auth_middleware;
mod auth;
mod batteries;
mod cabinets;
mod cabinet_tree;
mod charge_records;
mod charge_orders;
mod device_logs;
mod downloads;
mod energy_stats;
@ -24,7 +29,10 @@ mod organizations;
mod projects;
mod roles;
mod users;
mod scope_guard;
mod users_perm;
mod wechat;
mod maps;
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
pub fn build(state: AppState) -> Router {
@ -32,6 +40,8 @@ pub fn build(state: AppState) -> Router {
// 认证相关
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
.route("/api/auth/avatar", put(auth::update_avatar))
.route("/api/auth/avatar/upload", post(auth::upload_avatar))
// 设备指令下发
.route("/api/devices/{dev_id}/command", post(crate::commands::send_command))
.route("/api/devices/stats", get(crate::commands::device_stats))
@ -53,6 +63,7 @@ pub fn build(state: AppState) -> Router {
// 组织管理
.route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization))
.route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization))
.route("/api/organizations/{id}/status", put(organizations::set_organization_status))
// 项目管理
.route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project))
.route("/api/projects/{id}", put(projects::update_project).delete(projects::delete_project))
@ -60,7 +71,15 @@ pub fn build(state: AppState) -> Router {
.route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets))
.route("/api/cabinets", post(cabinets::create_cabinet))
.route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail))
.route("/api/cabinets/{id}/status", put(cabinets::set_cabinet_status))
.route("/api/cabinets/{id}/work-mode", put(cabinets::update_work_mode))
.route("/api/cabinets/{id}/regenerate-auth", post(cabinets::regenerate_auth))
// 柜级设备控制:开门解锁 / 接触器通断 / 重启整柜;仓控板单独重启
.route("/api/cabinets/{id}/open", post(h5::cabinet_open))
.route("/api/cabinets/{id}/pow-on", post(h5::cabinet_pow_on))
.route("/api/cabinets/{id}/pow-off", post(h5::cabinet_pow_off))
.route("/api/cabinets/{id}/reset", post(h5::cabinet_reset))
.route("/api/cabin-boards/{id}/reset", post(h5::board_reset))
.route("/api/cabinets/{id}/bind", post(cabinets::bind_cabinet))
.route("/api/cabinets/{id}/replace-imei", post(cabinets::replace_imei))
// 组织树
@ -70,6 +89,10 @@ pub fn build(state: AppState) -> Router {
// 充电记录
.route("/api/charge-records", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_charge_records))
// 充电订单
.route("/api/charge-orders", get(charge_orders::list_charge_orders))
// 电池台账
.route("/api/batteries", get(batteries::list_batteries))
// 设备日志
.route("/api/device-logs", get(device_logs::list_device_logs))
// 下载中心
@ -102,14 +125,33 @@ pub fn build(state: AppState) -> Router {
.route("/api/charge/stop", post(h5::stop_charge))
.route("/api/door/open", post(h5::open_door))
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
// 问题反馈
.route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback))
.route("/api/feedback/{id}", put(h5::update_feedback_status))
.route("/api/feedback/upload", post(h5::upload_feedback_image))
// 认证中间件层
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
Router::<AppState>::new()
.route("/api/health", get(health::health_check))
.route("/api/auth/challenge", get(auth::get_challenge))
.route("/api/auth/login", post(auth::login))
// 微信小程序手机号快速登录(免认证)
.route("/api/auth/wx-login", post(auth::wx_login))
// 扫码入口(免认证)
.route("/api/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id))
// 微信回调(免认证)
.route("/api/wechat/callback", get(wechat::verify).post(wechat::handle_message))
// 腾讯地图代理(免认证,前端需要)
.route("/api/maps/suggestion", get(maps::suggestion))
.route("/api/maps/geocoder", post(maps::geocoder))
.route("/api/maps/place-search", get(maps::place_search))
// 头像上传目录(免认证,仅用于公开访问静态文件)
.nest_service("/uploads", ServeDir::new("uploads"))
.merge(protected)
.layer(CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE]))
.with_state(state)
}

View File

@ -107,7 +107,7 @@ pub async fn list_operation_logs(
target_type: Option<String>,
target_id: Option<i64>,
detail: Option<String>,
created_at: String,
created_at: chrono::NaiveDateTime,
}
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
@ -131,7 +131,7 @@ pub async fn list_operation_logs(
"target_type": r.target_type,
"target_id": r.target_id,
"detail": r.detail,
"created_at": r.created_at,
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
})
})
.collect();

View File

@ -54,6 +54,13 @@ pub struct OrganizationRow {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<i64>,
#[serde(skip_serializing_if = "is_zero")]
pub disabled: Option<i8>,
}
/// 序列化辅助:0 时跳过 disabled 字段(保持旧前端兼容)
fn is_zero(v: &Option<i8>) -> bool {
matches!(v, Some(0) | None)
}
/// 项目表行
@ -63,6 +70,12 @@ pub struct ProjectRow {
#[serde(skip_serializing_if = "Option::is_none")]
pub organization_id: Option<i64>,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latitude: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub longitude: Option<String>,
}
/// 柜子表行
@ -96,6 +109,10 @@ pub struct CabinetRow {
pub pow_fail_dc: Option<bool>,
pub pow_fail_ac: Option<bool>,
pub is_online: Option<bool>,
/// 所属组织是否被禁用(甲方禁用客户组织)
pub org_disabled: Option<bool>,
/// 所属项目的地址(柜子地址为空时使用)
pub project_address: Option<String>,
}
/// 树节点
@ -111,6 +128,14 @@ pub struct TreeNode {
pub imei: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<i8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled: Option<i8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latitude: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub longitude: Option<String>,
}
/// 仓控板行
@ -131,47 +156,10 @@ pub struct CompartmentRow {
pub status: i8,
}
/// 仓控板 + 仓体详情
#[derive(Debug, Serialize)]
pub struct CabinBoardWithCompartments {
pub id: i64,
pub board_id: i8,
pub status: i8,
pub compartments: Vec<CompartmentRow>,
}
/// 柜子详情(含仓控板 + 仓体)
#[derive(Debug, Serialize)]
pub struct CabinetDetail {
pub id: i64,
pub project_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub abstract_id: Option<String>,
pub imei: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub iccid: Option<String>,
pub name: Option<String>,
pub status: i8,
pub rssi: Option<i32>,
pub pow_fail_dc: Option<bool>,
pub pow_fail_ac: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
pub cabin_boards: Vec<CabinBoardWithCompartments>,
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
/// 生成平台抽象 ID: `10-` + IMEI 的 SHA256 前 4 字节的十六进制
pub fn generate_abstract_id(imei: &str) -> String {
use sha2::Digest;
let hash = sha2::Sha256::digest(imei.as_bytes());
let hex_str = hex::encode(&hash[..4]);
format!("10-{hex_str}")
}
/// 生成 8 位随机字母数字安全码
pub fn generate_auth_str() -> String {
use rand::Rng;
@ -193,12 +181,16 @@ pub async fn list_organizations(
let db = &state.mysql;
let rows = sqlx::query_as::<_, OrganizationRow>(
r#"SELECT id, name, parent_id FROM organizations ORDER BY id"#,
r#"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"#,
)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
Ok(Json(json!({
"code": 0,
"data": rows,
"message": "ok"
})))
}
/// POST /api/organizations — 创建组织
@ -223,7 +215,7 @@ pub async fn create_organization(
let id = result.last_insert_id() as i64;
tracing::info!("创建组织 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" })))
}
/// PUT /api/organizations/:id — 更新组织
@ -253,7 +245,48 @@ pub async fn update_organization(
}
tracing::info!("更新组织 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" })))
}
/// 组织禁用请求体
#[derive(Debug, Deserialize)]
pub struct OrgStatusBody {
pub disabled: i8,
}
/// PUT /api/organizations/:id/status — 禁用/启用组织(仅总管理员)
///
/// 禁用后该组织及其子组织的柜子全部不可用(指令链路拦截)。
pub async fn set_organization_status(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<OrgStatusBody>,
) -> Result<Json<Value>, AppError> {
// 仅总管理员(甲方)可禁用组织
if user.role_level < 2 {
return Err(AppError::Forbidden("仅总管理员可禁用组织".into()));
}
if body.disabled != 0 && body.disabled != 1 {
return Err(AppError::BadRequest("disabled 必须为 0 或 1".into()));
}
let db = &state.mysql;
let rows = sqlx::query("UPDATE organizations SET disabled = ? WHERE id = ?")
.bind(body.disabled)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("组织 {} 不存在", id)));
}
let action = if body.disabled == 1 { "禁用" } else { "启用" };
tracing::info!("{}组织 id={}", action, id);
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", action) })))
}
/// DELETE /api/organizations/:id — 删除组织
@ -265,19 +298,48 @@ pub async fn delete_organization(
auth::check_permission(&user, "org:delete")?;
let db = &state.mysql;
let count: (i64,) = sqlx::query_as(
// 组织下有项目,不允许删除
let project_count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM projects WHERE organization_id = ?"#,
)
.bind(id)
.fetch_one(db)
.await?;
if count.0 > 0 {
if project_count.0 > 0 {
return Err(AppError::BadRequest(
format!("组织 {} 下仍有项目,无法删除", id),
));
}
// 组织下有绑定用户,不允许删除
let user_count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM users WHERE organization_id = ?"#,
)
.bind(id)
.fetch_one(db)
.await?;
if user_count.0 > 0 {
return Err(AppError::BadRequest(
format!("组织 {} 下仍有 {} 名用户,无法删除", id, user_count.0),
));
}
// 组织下有设备(通过 项目→柜子 链路),不允许删除
let cabinet_count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE p.organization_id = ?"#,
)
.bind(id)
.fetch_one(db)
.await?;
if cabinet_count.0 > 0 {
return Err(AppError::BadRequest(
format!("组织 {} 下仍有 {} 台设备,无法删除", id, cabinet_count.0),
));
}
let rows = sqlx::query(r#"DELETE FROM organizations WHERE id = ?"#)
.bind(id)
.execute(db)
@ -289,5 +351,5 @@ pub async fn delete_organization(
}
tracing::info!("删除组织 id={}", id);
Ok(Json(json!({})))
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
}

View File

@ -4,13 +4,26 @@
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use super::organizations::{NameBody, ProjectRow};
use super::organizations::ProjectRow;
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
/// 项目请求体(创建/更新共用)
#[derive(Debug, Deserialize)]
pub struct ProjectBody {
pub name: String,
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub latitude: Option<String>,
#[serde(default)]
pub longitude: Option<String>,
}
/// GET /api/organizations/:org_id/projects — 按组织查询项目列表
pub async fn list_projects(
user: CurrentUser,
@ -18,16 +31,17 @@ pub async fn list_projects(
Path(org_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:view")?;
super::scope_guard::verify_org_ownership(&user, org_id)?;
let db = &state.mysql;
let rows = sqlx::query_as::<_, ProjectRow>(
r#"SELECT id, organization_id, name FROM projects WHERE organization_id = ? ORDER BY id"#,
r#"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects WHERE organization_id = ? ORDER BY id"#,
)
.bind(org_id)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}
/// POST /api/organizations/:org_id/projects — 创建项目
@ -35,9 +49,10 @@ pub async fn create_project(
user: CurrentUser,
State(state): State<AppState>,
Path(org_id): Path<i64>,
Json(body): Json<NameBody>,
Json(body): Json<ProjectBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:create")?;
super::scope_guard::verify_org_ownership(&user, org_id)?;
let db = &state.mysql;
let name = body.name.trim().to_string();
@ -56,16 +71,32 @@ pub async fn create_project(
return Err(AppError::NotFound(format!("组织 {} 不存在", org_id)));
}
let result = sqlx::query(r#"INSERT INTO projects (organization_id, name) VALUES (?, ?)"#)
let result = sqlx::query(
r#"INSERT INTO projects (organization_id, name, address, latitude, longitude) VALUES (?, ?, ?, ?, ?)"#,
)
.bind(org_id)
.bind(&name)
.bind(&body.address)
.bind(&body.latitude)
.bind(&body.longitude)
.execute(db)
.await?;
let id = result.last_insert_id() as i64;
tracing::info!("创建项目 id={} name={} org_id={}", id, name, org_id);
Ok(Json(json!({ "id": id, "organization_id": org_id, "name": name })))
Ok(Json(json!({
"code": 0,
"data": {
"id": id,
"organization_id": org_id,
"name": name,
"address": body.address,
"latitude": body.latitude,
"longitude": body.longitude,
},
"message": "ok"
})))
}
/// PUT /api/projects/:id — 更新项目
@ -73,9 +104,10 @@ pub async fn update_project(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<NameBody>,
Json(body): Json<ProjectBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:edit")?;
super::scope_guard::verify_project_ownership(&state.mysql, &user, id).await?;
let db = &state.mysql;
let name = body.name.trim().to_string();
@ -83,8 +115,13 @@ pub async fn update_project(
return Err(AppError::BadRequest("项目名称不能为空".into()));
}
let rows = sqlx::query(r#"UPDATE projects SET name = ? WHERE id = ?"#)
let rows = sqlx::query(
r#"UPDATE projects SET name = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?"#,
)
.bind(&name)
.bind(&body.address)
.bind(&body.latitude)
.bind(&body.longitude)
.bind(id)
.execute(db)
.await?
@ -94,8 +131,18 @@ pub async fn update_project(
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
}
tracing::info!("更新项目 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
tracing::info!("更新项目 id={} name={} address={:?}", id, name, body.address);
Ok(Json(json!({
"code": 0,
"data": {
"id": id,
"name": name,
"address": body.address,
"latitude": body.latitude,
"longitude": body.longitude,
},
"message": "ok"
})))
}
/// DELETE /api/projects/:id — 删除项目
@ -105,6 +152,7 @@ pub async fn delete_project(
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:delete")?;
super::scope_guard::verify_project_ownership(&state.mysql, &user, id).await?;
let db = &state.mysql;
let count: (i64,) = sqlx::query_as(
@ -131,5 +179,5 @@ pub async fn delete_project(
}
tracing::info!("删除项目 id={}", id);
Ok(Json(json!({})))
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
}

View File

@ -0,0 +1,70 @@
//! 组织级数据隔离校验 — 确保用户只能访问其所属组织的资源。
//!
//! 总管理员(role_level=2)跳过所有组织归属校验。
//! 企业管理员(role_level=1)和操作员(role_level=0)只能访问本组织资源。
use sqlx::MySqlPool;
use crate::error::AppError;
use crate::middleware::auth::CurrentUser;
/// 校验当前用户是否有权访问指定柜子。
pub async fn verify_cabinet_ownership(
pool: &MySqlPool,
user: &CurrentUser,
cabinet_id: i64,
) -> Result<(), AppError> {
if user.role_level >= 2 {
return Ok(());
}
let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?;
let has_access: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cabinets c \
JOIN projects p ON c.project_id = p.id \
WHERE c.id = ? AND p.organization_id = ?)",
)
.bind(cabinet_id)
.bind(org_id)
.fetch_one(pool)
.await
.map_err(|_| AppError::Forbidden("无权访问该资源".into()))?;
if !has_access {
return Err(AppError::Forbidden("无权访问该资源".into()));
}
Ok(())
}
/// 校验当前用户是否有权访问指定项目。
pub async fn verify_project_ownership(
pool: &MySqlPool,
user: &CurrentUser,
project_id: i64,
) -> Result<(), AppError> {
if user.role_level >= 2 {
return Ok(());
}
let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?;
let has_access: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM projects WHERE id = ? AND organization_id = ?)",
)
.bind(project_id)
.bind(org_id)
.fetch_one(pool)
.await
.map_err(|_| AppError::Forbidden("无权访问该资源".into()))?;
if !has_access {
return Err(AppError::Forbidden("无权访问该资源".into()));
}
Ok(())
}
/// 校验当前用户是否有权访问指定组织。
pub fn verify_org_ownership(user: &CurrentUser, org_id: i64) -> Result<(), AppError> {
if user.role_level >= 2 {
return Ok(());
}
if user.organization_id != Some(org_id) {
return Err(AppError::Forbidden("无权访问该资源".into()));
}
Ok(())
}

View File

@ -17,7 +17,6 @@ use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
use crate::config::DEFAULT_PASSWORD;
/// 用户列表查询参数
#[derive(Debug, Deserialize)]
@ -74,6 +73,46 @@ pub(crate) struct StatusInput {
// 辅助函数: 记录操作日志
// ---------------------------------------------------------------------------
/// 企业管理员操作校验:必须操作本组织的用户且不能操作自己
///
/// 返回目标用户的当前组织ID(用于后续业务判断)
async fn verify_org_admin_scope(
pool: &sqlx::MySqlPool,
actor: &CurrentUser,
target_user_id: i64,
) -> Result<Option<i64>, AppError> {
// 总管理员不需要此校验
if actor.role_level >= 2 {
return Ok(None);
}
// 企业管理员必须有组织
let actor_org = actor.organization_id
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
if target_user_id == actor.user_id {
return Err(AppError::BadRequest("不能操作自己的账户".into()));
}
// 查询目标用户所属组织
let target_org: Option<(Option<i64>,)> = sqlx::query_as(
"SELECT organization_id FROM users WHERE id = ?"
)
.bind(target_user_id)
.fetch_optional(pool)
.await?;
let (target_org,) = target_org
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
// 目标必须属于同一组织
if target_org != Some(actor_org) {
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
}
Ok(target_org)
}
/// 记录一条操作日志到 operation_logs 表
async fn log_operation(
pool: &sqlx::MySqlPool,
@ -95,7 +134,7 @@ async fn log_operation(
.await;
if let Err(e) = result {
tracing::error!("记录操作日志失败: {}", e);
tracing::error!(err = %e, "记录操作日志失败");
}
}
@ -140,85 +179,53 @@ pub async fn list_users(
})));
}
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
let (where_clause, bind_org) = match (has_keyword, org_filter) {
(true, Some(org_id)) => (
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
Some(org_id),
),
(true, None) => (
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
None,
),
(false, Some(org_id)) => (
"WHERE organization_id = ?".to_string(),
Some(org_id),
),
(false, None) => (String::new(), None),
};
// 查询总数
let total: (i64,) = if has_keyword {
let mut q = sqlx::query_as(
r#"SELECT COUNT(*) FROM users WHERE phone LIKE ? OR name LIKE ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern);
if let Some(org_id) = org_filter {
q = sqlx::query_as(
r#"SELECT COUNT(*) FROM users WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern)
.bind(org_id);
let total: (i64,) = {
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
if has_keyword {
q = q.bind(&like_pattern).bind(&like_pattern);
}
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
q.fetch_one(pool).await?
} else if let Some(org_id) = org_filter {
sqlx::query_as("SELECT COUNT(*) FROM users WHERE organization_id = ?")
.bind(org_id)
.fetch_one(pool)
.await?
} else {
sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(pool)
.await?
};
// 查询用户列表
let rows: Vec<UserRow> = if has_keyword {
let mut q = sqlx::query_as::<_, UserRow>(
let rows: Vec<UserRow> = {
let sql = format!(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
FROM users u
WHERE u.phone LIKE ? OR u.name LIKE ?
FROM users u {}
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern)
.bind(page_size)
.bind(offset);
if let Some(org_id) = org_filter {
q = sqlx::query_as::<_, UserRow>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
FROM users u
WHERE (u.phone LIKE ? OR u.name LIKE ?) AND u.organization_id = ?
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern)
.bind(org_id)
.bind(page_size)
.bind(offset);
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE")
);
let mut q = sqlx::query_as::<_, UserRow>(&sql);
if has_keyword {
q = q.bind(&like_pattern).bind(&like_pattern);
}
q.fetch_all(pool).await?
} else if let Some(org_id) = org_filter {
sqlx::query_as::<_, UserRow>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
FROM users u
WHERE u.organization_id = ?
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(org_id)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
} else {
sqlx::query_as::<_, UserRow>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
FROM users u
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
q.bind(page_size).bind(offset).fetch_all(pool).await?
};
// 收集组织ID并批量查询组织名称
@ -236,12 +243,12 @@ pub async fn list_users(
for id in &org_ids {
q = q.bind(id);
}
q.fetch_all(pool).await.unwrap_or_default()
q.fetch_all(pool).await?
};
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
// 构建角色名称映射
let role_names: [&str; 3] = ["普通用户", "企业管理员", "总管理员"];
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
let list: Vec<Value> = rows
.into_iter()
@ -274,6 +281,11 @@ pub async fn list_users(
// ---------------------------------------------------------------------------
/// POST /api/users — 创建用户
///
/// 权限模型:
/// - 总管理管理员(role_level>=2):可创建任意角色;角色0/1必须指定组织,角色2强制无组织
/// - 企业管理员(role_level=1):仅可创建操作员/企业管理员,组织强制为本组织
/// - 操作员(role_level=0):无 user:create 权限,被拦截
pub async fn create_user(
current_user: CurrentUser,
State(state): State<AppState>,
@ -292,15 +304,25 @@ pub async fn create_user(
let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
let role = input.role.unwrap_or(0);
if !(0..=2).contains(&role) {
return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into()));
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
}
// 企业管理员必须指定所属组织
if role == 1 && input.organization_id.is_none() {
return Err(AppError::BadRequest("企业管理员必须指定所属组织".into()));
// 确定最终组织归属(按角色和操作者身份)
let org_id = if current_user.role_level >= 2 {
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
if role == 2 {
None
} else {
Some(input.organization_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
}
} else {
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
if role == 2 {
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
}
Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?)
};
let org_id = input.organization_id;
// 校验组织存在
if let Some(oid) = org_id {
let org_exists: (i64,) = sqlx::query_as(
@ -314,7 +336,7 @@ pub async fn create_user(
}
}
let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
let password = auth::hash_password(&plain_password)?;
// 检查手机号是否已存在
@ -345,7 +367,7 @@ pub async fn create_user(
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-"));
log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await;
tracing::info!("创建用户 id={} phone={} role={}", user_id, phone, role);
tracing::info!(user_id, phone = %phone, role, "创建用户");
Ok(Json(json!({
"code": 0,
@ -382,17 +404,34 @@ pub async fn update_user(
let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
let new_role = input.role.unwrap_or(old_role);
if !(0..=2).contains(&new_role) {
return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into()));
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
}
let new_org_id = input.organization_id.or(old_org_id);
// 企业管理员必须指定所属组织
if new_role == 1 && new_org_id.is_none() {
return Err(AppError::BadRequest("企业管理员必须指定所属组织".into()));
// 企业管理员作用域校验:只能编辑本组织用户,不能改成总管理员,组织锁定为本组织
let new_org_id: Option<i64> = if current_user.role_level < 2 {
let actor_org = current_user.organization_id
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能操作自己的账户".into()));
}
// 目标必须属于同一组织
if old_org_id != Some(actor_org) {
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
}
// 不能改成总管理员
if new_role == 2 {
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
}
Some(actor_org)
} else {
let org_id = if new_role == 2 {
None // 总管理员强制无组织
} else {
Some(input.organization_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
};
// 校验组织存在
if let Some(oid) = new_org_id
if let Some(oid) = org_id
&& Some(oid) != old_org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
@ -404,6 +443,8 @@ pub async fn update_user(
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
}
org_id
};
let rows = sqlx::query(
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
@ -430,7 +471,7 @@ pub async fn update_user(
);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("编辑用户 id={}", user_id);
tracing::info!(user_id, "编辑用户");
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
}
@ -449,7 +490,10 @@ pub async fn reset_password(
auth::check_permission(&current_user, "user:edit")?;
let pool = &state.mysql;
let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
// 企业管理员只能重置本组织用户密码
verify_org_admin_scope(pool, &current_user, user_id).await?;
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
let new_password = auth::hash_password(&plain_password)?;
let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?")
@ -467,7 +511,7 @@ pub async fn reset_password(
let detail = format!("重置密码: 用户ID={}", user_id);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("重置密码 user_id={}", user_id);
tracing::info!(user_id, "重置密码");
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
}
@ -487,10 +531,8 @@ pub async fn set_user_status(
let pool = &state.mysql;
// 不能禁用自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能修改自己的状态".into()));
}
// 企业管理员作用域校验(含不能操作自己+同组织限制)
verify_org_admin_scope(pool, &current_user, user_id).await?;
if ![0, 1].contains(&input.status) {
return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into()));
@ -513,7 +555,7 @@ pub async fn set_user_status(
let detail = format!("{}用户: 用户ID={}", status_text, user_id);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("{}用户 user_id={}", status_text, user_id);
tracing::info!(user_id, action = %status_text, "{}用户", status_text);
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
}
@ -532,10 +574,8 @@ pub async fn delete_user(
let pool = &state.mysql;
// 不能删除自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能删除自己的账户".into()));
}
// 企业管理员作用域校验(含不能操作自己+同组织限制)
verify_org_admin_scope(pool, &current_user, user_id).await?;
// 查询用户信息用于日志
let user_info: Option<(String,)> = sqlx::query_as(
@ -560,7 +600,7 @@ pub async fn delete_user(
let detail = format!("删除用户: ID={}, 手机号={}", user_id, phone);
log_operation(pool, current_user.user_id, "delete", "user", Some(user_id), &detail).await;
tracing::info!("删除用户 id={} phone={}", user_id, phone);
tracing::info!(user_id, phone = %phone, "删除用户");
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
}

View File

@ -0,0 +1,63 @@
//! 微信公众号回调 API
//!
//! - GET /api/wechat/callback — 服务器地址验证(echostr)
//! - POST /api/wechat/callback — 接收推送消息
//!
//! 配置参数:
//! - Token: 7GW16XdnFyak0mE32rc8ZPij(与微信后台一致)
//! - EncodingAESKey: kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu
//! - 加解密方式: 明文模式
//! - 数据格式: XML
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::Deserialize;
use sha1::{Digest, Sha1};
use tracing::info;
/// 微信验证请求参数
#[derive(Debug, Deserialize)]
pub struct WechatVerifyQuery {
pub signature: String,
pub timestamp: String,
pub nonce: String,
pub echostr: String,
}
/// 获取 WeChat Token,优先从环境变量读取
fn get_wechat_token() -> String {
std::env::var("WECHAT_TOKEN")
.unwrap_or_else(|_| "7GW16XdnFyak0mE32rc8ZPij".to_string())
}
/// 验证签名:将 token、timestamp、nonce 字典序排序后拼接,做 SHA1 加密
/// 与 signature 比较,一致则返回 true
fn verify_signature(signature: &str, timestamp: &str, nonce: &str) -> bool {
let token = get_wechat_token();
let mut parts = vec![token, timestamp.to_string(), nonce.to_string()];
parts.sort();
let joined = parts.concat();
let mut hasher = Sha1::new();
hasher.update(joined.as_bytes());
let digest = hex::encode(hasher.finalize());
digest == signature
}
/// GET /api/wechat/callback — 微信服务器地址验证
pub async fn verify(
Query(query): Query<WechatVerifyQuery>,
) -> impl IntoResponse {
if verify_signature(&query.signature, &query.timestamp, &query.nonce) {
(StatusCode::OK, query.echostr)
} else {
(StatusCode::FORBIDDEN, "signature verification failed".to_string())
}
}
/// POST /api/wechat/callback — 接收微信推送的消息
pub async fn handle_message(body: String) -> impl IntoResponse {
info!("微信消息推送: {}", body);
// 明文模式,返回 success 即表示收到
(StatusCode::OK, "success".to_string())
}

View File

@ -0,0 +1,288 @@
//! 订单电量积分(**待确认第 13 项的兜底方案**)
//!
//! 背景:交付文档把 `energy`(单订单累计充电量 Wh)列入「待确认事项第 13 项」——
//! **协议暂不包含该字段**。文档同时写明兜底方案:
//! > **若无该数据源,订单电量由平台侧按 `电压 × 电流 × 时间` 积分计算**
//!
//! 本模块就是那个兜底:
//! - 每次 `status_post`(协议固定每 60 秒一次)拿到通道的**电压(mV)/电流(mA)/温度**,
//! 按 `Wh = mV × mA × Δt / 3.6e9` 累加到该通道的**订单电量累加器**;
//! - 订单结束时读取累加值 → 写入 `charge_orders.energy` 并生成 `charge_records`;
//! - 新订单创建时**清零**累加器。
//!
//! 设计取舍:
//! - **累加器放在 Redis 且以"通道"为键**(不是订单)——避免每条 `status_post` 都去查库确认
//! "该通道当前有没有活跃订单"(36 通道 × 每秒多柜会造成大量无谓查询)。新订单创建时清零、
//! 订单结束时读取并清零,语义等价而开销恒定。
//! - 用 `HINCRBY` 累加**毫瓦时(mWh)**而不是瓦时,避免 60 秒小增量被取整成 0;落库时再换算成 Wh。
//! - 温度取**历史最大值**(`charge_records.max_temperature` 要的是"本次充电最高温度")。
//! 用 **Lua 脚本**把「累加 + 更新最高温 + 记时间戳」做成**一次原子往返**;若环境禁用了
//! `EVAL`,降级为普通命令(电量照常累加,仅放弃最高温跟踪),**不影响订单电量**。
//! - 单次积分的时间间隔 Δt 取**协议约定的 60 秒**并夹取到 `[0, MAX_DT]`:`status_post` 是固定节奏,
//! 但断线/停电后首个采样不应把"离线期间"也算成充电(否则会虚增电量)。
use redis::aio::ConnectionManager;
/// 协议约定的上报间隔(秒)——积分用的 Δt。
///
/// **为什么用固定 60 秒而不是"两次采样的真实间隔"**:
/// - 协议 §5.3 规定 `status_post` **固定每 60 秒一次**,正常运行时它就是真实间隔;
/// - 用真实间隔需要在每个通道上多读一次 Redis 存上次时间戳(36 通道 × 每分钟 × 每柜),
/// 为极小的精度收益付出可观的往返开销;
/// - 断线/停电后补报时,固定 60 秒**只会少算**(少算的那段本就没有可信采样),
/// 符合"宁可少算、不可虚增"的口径 —— 电量直接关系电费,虚增比少算更糟。
pub const ASSUMED_INTERVAL_SECS: i64 = 60;
/// 换算系数:`mV × mA × s` → **mWh**。
///
/// 推导:`W = (mV/1000) × (mA/1000)`;`Wh = W × s/3600`;
/// 换成 mWh 再乘 1000 → `mWh = mV × mA × s / 3_600_000`。
const MV_MA_S_TO_MWH_DIVISOR: i128 = 3_600_000;
/// 计算一次采样的电量增量(**mWh**)。
///
/// - `voltage_mv` / `current_ma`:协议换算后的平台单位(见交付文档 §5.3)
/// - `dt_secs`:本次采样覆盖的秒数(已由调用方夹取到 `[0, MAX_DT_SECS]`)
///
/// 电流为 0(未充电)时增量为 0;任何非正输入都返回 0,不做无意义的负数累加。
/// 用 `i128` 中间量避免 `mV × mA × s` 溢出(54000 × 8000 × 300 = 1.296e11 > i32,也接近 i64 无需担心,
/// 但统一用宽类型更稳妥)。
pub fn mwh_delta(voltage_mv: i64, current_ma: i64, dt_secs: i64) -> i64 {
if voltage_mv <= 0 || current_ma <= 0 || dt_secs <= 0 {
return 0;
}
let product = voltage_mv as i128 * current_ma as i128 * dt_secs as i128;
(product / MV_MA_S_TO_MWH_DIVISOR) as i64
}
/// 把 mWh 换算成落库用的 **Wh**(四舍五入到整数,因为 `charge_orders.energy` 是 INT/BIGINT 的 Wh)。
pub fn mwh_to_wh(mwh: i64) -> i64 {
if mwh <= 0 {
return 0;
}
(mwh + 500) / 1000
}
/// 通道订单累加器键:`device:{imei}:board:{板号}:ch:{通道号}:order`
///
/// 字段:`mwh`(累计毫瓦时)、`maxtemp`(本次充电最高温度 K×100)、`ts`(上次采样秒级时间戳)
pub fn order_energy_key(imei: &str, board_id: i64, channel_id: i64) -> String {
format!("device:{}:board:{}:ch:{}:order", imei, board_id, channel_id)
}
/// Lua:原子地「累加电量 + 取最高温 + 记时间戳 + 维护"连续无电池"计数」,避免多次往返与竞态。
///
/// KEYS[1] 累加器键;ARGV[1] mwh 增量;ARGV[2] 本次温度(K×100);ARGV[3] 当前时间戳(秒);
/// ARGV[4] `1`=本通道有电池 / `0`=无电池(用于"连续无电池"兜底判定,见 [`consecutive_no_battery`])
const ACCUMULATE_LUA: &str = r#"
local cur = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0')
local t = tonumber(ARGV[2])
if t > cur then redis.call('HSET', KEYS[1], 'maxtemp', ARGV[2]) end
redis.call('HINCRBY', KEYS[1], 'mwh', ARGV[1])
redis.call('HSET', KEYS[1], 'ts', ARGV[3])
if ARGV[4] == '1' then redis.call('HSET', KEYS[1], 'nobat', '0')
else redis.call('HINCRBY', KEYS[1], 'nobat', 1) end
return 1
"#;
/// 累加一次采样的电量与最高温(由 `status_post` 的通道写入路径调用)。
///
/// `battery_present` = 该通道 `batt_status` 的 **bit7(有电池)**,用于维护"连续无电池"计数:
/// 待确认第 18 项的兜底方案之一就是「**靠 `status_post` 的 bit7 归零判断电池已被取走**」,
/// 由 [`crate::workers::report_worker`] 的周期对账在连续 [`NO_BATTERY_STRIKES`] 次后提前结单。
///
/// 失败只记 WARN:**电量统计是增值功能,不能因为 Redis 抖动影响状态上报主流程**。
pub async fn accumulate(
redis: &ConnectionManager,
imei: &str,
board_id: i64,
channel_id: i64,
voltage_mv: i64,
current_ma: i64,
temp_k100: i64,
battery_present: bool,
) {
let delta = mwh_delta(voltage_mv, current_ma, ASSUMED_INTERVAL_SECS);
let key = order_energy_key(imei, board_id, channel_id);
let now = chrono::Utc::now().timestamp();
let present = if battery_present { "1" } else { "0" };
let mut conn = redis.clone();
// 优先用 Lua 一次往返完成全部更新
let script = redis::Script::new(ACCUMULATE_LUA);
match script
.key(&key)
.arg(delta)
.arg(temp_k100.max(0))
.arg(now)
.arg(present)
.invoke_async::<i64>(&mut conn)
.await
{
Ok(_) => {}
Err(e) => {
// 环境可能禁用 EVAL(或脚本被 RDS 策略拦):降级为普通命令,**电量照常累加**,
// 仅放弃"最高温"与"连续无电池"跟踪(缺最高温只是记录不完整,不影响电费口径)。
tracing::warn!("[energy] Lua 累加失败,降级为普通命令 key={} err={}", key, e);
let mut conn2 = redis.clone();
let mut pipe = redis::pipe();
pipe.cmd("HINCRBY").arg(&key).arg("mwh").arg(delta).ignore();
pipe.cmd("HSET").arg(&key).arg("ts").arg(now).ignore();
if battery_present {
pipe.cmd("HSET").arg(&key).arg("nobat").arg(0).ignore();
} else {
pipe.cmd("HINCRBY").arg(&key).arg("nobat").arg(1).ignore();
}
if let Err(e2) = pipe.query_async::<()>(&mut conn2).await {
tracing::warn!("[energy] 电量累加失败 key={} err={}", key, e2);
}
}
}
}
/// 「连续无电池」达到该次数即视为电池已被取走(协议 §5.6 状态兜底的抖动保护)。
///
/// `status_post` 固定每 60 秒一次,故 2 次 ≈ **连续 2 分钟**无电池才判定,
/// 避免单次瞬时读数为 0 就误结单(待确认第 16 项:板离线时的表现尚未约定,
/// 因此阈值宁高不低,且真正的兜底是 10 小时订单超时)。
pub const NO_BATTERY_STRIKES: i64 = 2;
/// 读取该通道"连续无电池"的采样次数(0 表示最近一次有电池)。
pub async fn consecutive_no_battery(
redis: &ConnectionManager,
imei: &str,
board_id: i64,
channel_id: i64,
) -> i64 {
let key = order_energy_key(imei, board_id, channel_id);
let mut conn = redis.clone();
let value: Option<i64> = redis::cmd("HGET")
.arg(&key)
.arg("nobat")
.query_async(&mut conn)
.await
.unwrap_or(None);
value.unwrap_or(0)
}
/// 读取并**清零**通道累加器,返回 `(累计毫瓦时, 本次最高温 K×100)`。
///
/// 用于订单结束时结算:先取值再删除,保证下一单从 0 开始。
/// 读取失败返回 `(0, 0)` 并记 WARN —— 结算拿不到电量时宁可记 0,也不能让订单结束流程失败。
pub async fn take_and_reset(
redis: &ConnectionManager,
imei: &str,
board_id: i64,
channel_id: i64,
) -> (i64, i64) {
let key = order_energy_key(imei, board_id, channel_id);
// 用 Lua 保证"读+删"原子(避免读到之后、删除之前又来一次累加造成跨单串账)
const TAKE_LUA: &str = r#"
local mwh = tonumber(redis.call('HGET', KEYS[1], 'mwh') or '0')
local temp = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0')
redis.call('DEL', KEYS[1])
return {mwh, temp}
"#;
let mut conn = redis.clone();
let script = redis::Script::new(TAKE_LUA);
match script
.key(&key)
.invoke_async::<(i64, i64)>(&mut conn)
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("[energy] 读取累加器失败,按 0 结算 key={} err={}", key, e);
// 仍尝试清理,避免脏数据带到下一单
let mut conn2 = redis.clone();
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn2).await;
(0, 0)
}
}
}
/// 清零通道累加器(新订单创建时调用)。
pub async fn reset(redis: &ConnectionManager, imei: &str, board_id: i64, channel_id: i64) {
let key = order_energy_key(imei, board_id, channel_id);
let mut conn = redis.clone();
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
}
#[cfg(test)]
mod tests {
use super::*;
/// 换算自检:54.000V × 8.000A 持续 60 秒 = 432W × (1/60)h = **7.2 Wh = 7200 mWh**
#[test]
fn test_mwh_delta_real_world_case() {
assert_eq!(mwh_delta(54_000, 8_000, 60), 7_200);
}
/// 未充电(电流 0)不累加 —— 空仓/停止充电时不该产生电量
#[test]
fn test_mwh_delta_zero_current_no_accumulation() {
assert_eq!(mwh_delta(54_000, 0, 60), 0);
}
/// 非法/异常输入一律返回 0,不做负数累加
#[test]
fn test_mwh_delta_rejects_non_positive_inputs() {
assert_eq!(mwh_delta(0, 8_000, 60), 0);
assert_eq!(mwh_delta(-1, 8_000, 60), 0);
assert_eq!(mwh_delta(54_000, -1, 60), 0);
assert_eq!(mwh_delta(54_000, 8_000, 0), 0);
assert_eq!(mwh_delta(54_000, 8_000, -5), 0);
}
/// 小电流长时间不因取整丢成 0(这正是用 mWh 而非 Wh 累加的原因)
/// 例:54V × 0.1A = 5.4W,60 秒 = 0.09 Wh = 90 mWh(若按 Wh 取整会变成 0)
#[test]
fn test_mwh_delta_small_current_not_lost_by_rounding() {
assert_eq!(mwh_delta(54_000, 100, 60), 90);
assert_eq!(mwh_to_wh(90), 0, "不足 0.5Wh 时落库为 0 是可接受的舍入");
// 但多次累加后不会丢:10 次 = 900 mWh → 1 Wh
assert_eq!(mwh_to_wh(900), 1);
}
/// mWh → Wh 四舍五入
#[test]
fn test_mwh_to_wh_rounding() {
assert_eq!(mwh_to_wh(0), 0);
assert_eq!(mwh_to_wh(-5), 0);
assert_eq!(mwh_to_wh(499), 0);
assert_eq!(mwh_to_wh(500), 1);
assert_eq!(mwh_to_wh(1_499), 1);
assert_eq!(mwh_to_wh(1_500), 2);
assert_eq!(mwh_to_wh(7_200), 7);
}
/// 一轮完整充电的量级检查:54V/8A 充 1 小时应为 432 Wh
#[test]
fn test_full_hour_charge_magnitude() {
let mwh: i64 = (0..60).map(|_| mwh_delta(54_000, 8_000, 60)).sum();
assert_eq!(mwh, 432_000);
assert_eq!(mwh_to_wh(mwh), 432);
}
/// 累加器键格式(跨模块契约:`report_worker` 写入、`order_timeout` 结算读取)
#[test]
fn test_order_energy_key_format() {
assert_eq!(
order_energy_key("864720562557021", 1, 3),
"device:864720562557021:board:1:ch:3:order"
);
}
/// 固定间隔的取向:断线补报时按 60 秒计(少算),而不是按真实空洞(虚增)
#[test]
fn test_fixed_interval_is_conservative() {
let as_fixed = mwh_delta(54_000, 8_000, ASSUMED_INTERVAL_SECS);
let as_hour_gap = mwh_delta(54_000, 8_000, 3_600);
assert_eq!(as_fixed, 7_200);
assert!(
as_fixed < as_hour_gap,
"用固定 60 秒时必须显著小于按真实空洞计算,避免补报虚增电量"
);
}
}

View File

@ -0,0 +1,108 @@
//! 设备日志保留策略协程
//!
//! 业务背景:`device_logs` 是**只增不减**的表 —— `status_post` 每台柜子每 60 秒写一条,
//! 加上 `bat_in`/`bat_out`/停电/回执等事件,量级随时间线性增长且**原本没有任何清理策略**,
//! 长期会把 RDS 撑满(磁盘告警、备份变慢、查询变慢)。
//!
//! 本协程按**保留天数**定期分批删除过期日志:
//! - **分批 `DELETE ... LIMIT`**:避免一条大删除造成长事务、锁表、主从延迟;
//! - 单轮有**最大批数上限**:防止积压过多时一轮跑太久;
//! - 所有异常只记日志,**绝不让循环退出**(清理是运维保障,不能反过来拖垮服务)。
//!
//! 依赖索引 `device_logs(created_at)`(由 `db::migrate` 幂等创建)—— 否则每次删除都会全表扫描。
use std::time::Duration;
use sqlx::MySqlPool;
/// 日志保留天数:超过该天数的 `device_logs` 会被清理。
///
/// 取 90 天的理由:设备日志主要服务于"最近出了什么问题"的排障,
/// 3 个月足够回溯;再久的数据排障价值低,但存储成本持续累积。
const RETENTION_DAYS: i64 = 90;
/// 单批删除行数:控制单条 SQL 的影响行数,避免长事务
const BATCH_LIMIT: i64 = 5000;
/// 单轮最多删多少批:积压很多时一轮也不会跑太久,剩下的等下一轮
const MAX_BATCHES_PER_ROUND: usize = 20;
/// 执行间隔:6 小时一次即可(保留策略对时效性不敏感,低频更省资源)
const SCAN_INTERVAL_SECS: u64 = 6 * 3600;
/// 启动设备日志保留协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
pub async fn start(pool: MySqlPool) {
tracing::info!(
"[log_retention] 启动设备日志清理,保留 {} 天,执行间隔 {}s,单批 {} 行",
RETENTION_DAYS,
SCAN_INTERVAL_SECS,
BATCH_LIMIT
);
loop {
// 单轮失败不退出:记 error 后等下一个周期继续
if let Err(e) = purge_once(&pool).await {
tracing::error!("[log_retention] 本轮清理失败,等待下轮重试: {}", e);
}
tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await;
}
}
/// 单轮清理:分批删除 `created_at` 早于 `NOW() - RETENTION_DAYS` 的日志。
///
/// 返回删掉的总行数(便于日志与测试断言);SQL 失败向上抛,由调用方记录并继续循环。
async fn purge_once(pool: &MySqlPool) -> Result<u64, sqlx::Error> {
let mut total: u64 = 0;
for _ in 0..MAX_BATCHES_PER_ROUND {
// 阈值交给 MySQL 计算(created_at 也是 MySQL 写的),避免本地时区换算偏差;
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
let affected = sqlx::query(
"DELETE FROM device_logs \
WHERE created_at < TIMESTAMPADD(DAY, ?, NOW()) \
LIMIT ?",
)
.bind(-RETENTION_DAYS)
.bind(BATCH_LIMIT)
.execute(pool)
.await?
.rows_affected();
total += affected;
// 本批不足一批 → 已删完,提前结束
if affected < BATCH_LIMIT as u64 {
break;
}
// 批间短暂让出,避免长时间占满数据库连接
tokio::time::sleep(Duration::from_millis(200)).await;
}
if total > 0 {
tracing::info!(
"[log_retention] 已清理过期设备日志 {} 行(保留 {} 天)",
total,
RETENTION_DAYS
);
} else {
tracing::debug!("[log_retention] 无过期设备日志需要清理");
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
/// 保留策略的常量约束:必须是正数、批大小与批数上限在合理区间,
/// 避免误改成 0 天(清空全部)或超大值(一次锁表)
#[test]
fn test_retention_constants_are_sane() {
assert!(RETENTION_DAYS > 0, "保留天数必须为正,否则会清空全部日志");
assert!(BATCH_LIMIT > 0 && BATCH_LIMIT <= 50_000, "单批行数需在合理区间");
assert!(MAX_BATCHES_PER_ROUND > 0, "每轮至少允许一批");
assert!(SCAN_INTERVAL_SECS >= 60, "清理间隔不应过于频繁");
}
}

View File

@ -1,5 +1,8 @@
//! 后台协程 — 处理 Redis 队列中的设备消息
pub mod energy;
pub mod log_retention;
pub mod node_checker;
pub mod order_timeout;
pub mod report_worker;
pub mod reply_worker;

View File

@ -0,0 +1,92 @@
//! 订单超时兜底协程
//!
//! 业务背景:设备可能因断电/断网未上报充电结束,导致 `charge_orders` 卡在
//! `status = 0`(充电中)。本协程周期性把**超过 10 小时**仍未结束的订单
//! 自动收尾为已完成,避免"幽灵订单"永久占用仓体和统计口径。
//!
//! 职责边界:本协程只负责「定时扫描 + 落库收尾」,不做停止充电的指令下发
//! (设备侧指令由其它流程负责);所有异常只记日志,绝不让循环退出。
use std::time::Duration;
use redis::aio::ConnectionManager;
use sqlx::MySqlPool;
use crate::workers::report_worker;
/// 超时阈值:订单持续充电超过该小时数即视为异常,自动结束
const TIMEOUT_HOURS: i64 = 10;
/// 扫描间隔:5 分钟扫一次,兼顾及时性与数据库压力
const SCAN_INTERVAL_SECS: u64 = 300;
/// 单轮最多处理订单数,避免一次扫描锁过多行
const BATCH_LIMIT: i64 = 200;
/// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
///
/// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单
/// **同样会写回平台侧积分电量、并生成一条充电记录**,不会在统计口径里凭空消失。
pub async fn start(pool: MySqlPool, redis: ConnectionManager) {
tracing::info!(
"[order_timeout] 启动订单超时兜底,扫描间隔={}s,超时阈值={}h",
SCAN_INTERVAL_SECS,
TIMEOUT_HOURS
);
loop {
// 单轮失败不退出:记 error 后等下一个周期继续
if let Err(e) = scan_once(&pool, &redis).await {
tracing::error!("[order_timeout] 本轮扫描失败,等待下轮重试: {}", e);
}
tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await;
}
}
/// 单轮扫描:找出 status=0 且 start_time 早于 NOW()-10h 的订单并结单
///
/// 返回 `Result` 而不是直接 panic,让调用方决定是否继续循环。
async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sqlx::Error> {
// 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的,
// 交给数据库算差值可避免本地时区换算偏差;
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as(
"SELECT id, compartment_id, start_time FROM charge_orders \
WHERE status = 0 AND start_time IS NOT NULL \
AND start_time < TIMESTAMPADD(HOUR, ?, NOW()) \
ORDER BY start_time ASC LIMIT ?",
)
.bind(TIMEOUT_HOURS)
.bind(BATCH_LIMIT)
.fetch_all(pool)
.await?;
if expired.is_empty() {
return Ok(());
}
for (order_id, compartment_id, start_time) in expired {
// 走统一结算:写回电量(平台侧积分)+ 生成充电记录(status=0 条件保证幂等,
// 避免与设备上报的正常结束竞争覆盖)
let affected = match report_worker::finalize_timeout_order(pool, redis, compartment_id).await {
Ok(n) => n,
Err(e) => {
// 单条失败不影响其余订单,下一轮扫描会再次命中
tracing::error!("[order_timeout] 结算订单失败 order_id={} err={}", order_id, e);
continue;
}
};
if affected > 0 {
tracing::info!(
"[order_timeout] 订单超时自动结束 order_id={} compartment_id={} start_time={:?}",
order_id,
compartment_id,
start_time
);
}
}
tracing::info!("[order_timeout] 本轮超时结单处理完成");
Ok(())
}

View File

@ -7,22 +7,24 @@ use redis::aio::ConnectionManager;
use crate::commands::PendingCommands;
/// 响应队列的轮询间隔(毫秒):**改用非阻塞轮询**,原因见
/// [`crate::workers::report_worker::REDIS_POLL_INTERVAL_MS`](`BRPOP` 在本项目的
/// 代理 + 多路复用连接组合下延迟不可控,实测 13~45 秒)。
const REDIS_POLL_INTERVAL_MS: u64 = 200;
/// 启动指令响应处理协程
///
/// 使用 BRPOP 阻塞等待所有 `device:reply:*` 队列的响应消息。
/// 轮询 `device:replies`,把回执按 `msg_id` 派发给等待者。
pub async fn start(redis: ConnectionManager, pending: PendingCommands) {
tracing::info!("[reply_worker] 启动");
loop {
let mut conn = redis.clone();
let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP")
.arg("device:replies")
.arg(0)
.query_async(&mut conn)
.await;
let result: Result<Option<String>, redis::RedisError> =
redis::cmd("RPOP").arg("device:replies").query_async(&mut conn).await;
match result {
Ok((_key, reply_json)) => {
Ok(Some(reply_json)) => {
// 解析 msg_id
if let Ok(reply) = serde_json::from_str::<serde_json::Value>(&reply_json) {
let msg_id = reply["msg_id"].as_u64().unwrap_or(0) as u32;
@ -38,8 +40,12 @@ pub async fn start(redis: ConnectionManager, pending: PendingCommands) {
});
}
}
// 队列为空:短暂让出
Ok(None) => {
tokio::time::sleep(std::time::Duration::from_millis(REDIS_POLL_INTERVAL_MS)).await;
}
Err(e) => {
tracing::error!("[reply_worker] BRPOP 错误: {}", e);
tracing::error!("[reply_worker] RPOP 错误: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
# 设备 TCP 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值)
# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库
# Redis 连接(阿里云 Redis,与 api-server 同库)
REDIS_URL=redis://:CHANGE_ME@HOST:6379/5
# TCP 监听端口(测试 3001 / 正式 9001)
TCP_PORT=9001
# 本节点 ID(多节点部署时区分)
NODE_ID=device-node-1
# API 服务地址(节点注册/心跳用)
API_SERVER_URL=http://127.0.0.1:9000
# 心跳间隔(秒)
HEARTBEAT_INTERVAL=30
# 设备在线注册 TTL(秒)
DEVICE_REGISTER_TTL=300

View File

@ -1,17 +0,0 @@
# Redis 连接(生产 db5)
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
# TCP 设备通讯
TCP_PORT=9001 # 正式环境TCP
# 节点ID
NODE_ID=node-1
# API服务地址
API_SERVER_URL=http://127.0.0.1:3000
# 心跳间隔(秒)
HEARTBEAT_INTERVAL=60
# 设备注册TTL(秒)
DEVICE_REGISTER_TTL=180

View File

@ -1,17 +0,0 @@
# Redis 连接(测试 db6)
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/6
# TCP 设备通讯
TCP_PORT=3001 # 测试环境TCP
# 节点ID
NODE_ID=node-1
# API服务地址
API_SERVER_URL=http://127.0.0.1:3000
# 心跳间隔(秒)
HEARTBEAT_INTERVAL=60
# 设备注册TTL(秒)
DEVICE_REGISTER_TTL=180

View File

@ -540,6 +540,7 @@ dependencies = [
"redis",
"serde",
"serde_json",
"socket2 0.5.10",
"thiserror",
"tokio",
"tokio-util",

View File

@ -16,3 +16,4 @@ bytes = "1"
futures = "0.3"
dotenvy = "0.15"
chrono = { version = "0.4", features = ["serde"] }
socket2 = "0.5"

View File

@ -32,10 +32,10 @@ impl Config {
.unwrap_or_else(|_| "60".to_string())
.parse()
.expect("HEARTBEAT_INTERVAL must be a valid number"),
register_ttl: std::env::var("REGISTER_TTL")
register_ttl: std::env::var("DEVICE_REGISTER_TTL")
.unwrap_or_else(|_| "300".to_string())
.parse()
.expect("REGISTER_TTL must be a valid number"),
.expect("DEVICE_REGISTER_TTL must be a valid number"),
}
}
}

View File

@ -18,6 +18,11 @@ use std::time::Duration;
use config::Config;
/// 内部路由字段名:api-server 在推入 `device:commands` 时附加,
/// 指明"这条指令发给哪台设备"(值 = IMEI)。device-server 读取后**下发前摘除**,
/// 设备侧不可见(与 `_close` 同类)。
const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
@ -37,8 +42,8 @@ async fn main() {
.expect("Redis 连接失败");
tracing::info!("Redis 连接成功");
// 初始化设备连接池
let device_pool = tcp::connection::ConnectionPool::new();
// 初始化设备连接池(注入 Redis:下发指令时写挂起登记 `pending:{dev_id}`)
let device_pool = tcp::connection::ConnectionPool::new(redis_conn.clone());
// 通过 Redis 注册节点信息
register_node(&redis_conn, &cfg).await;
@ -59,14 +64,15 @@ async fn main() {
// 启动指令消费协程(从 Redis 读取平台指令并发送到设备)
let cmd_redis = redis_conn.clone();
let cmd_pool = device_pool.clone();
let cmd_node_id = cfg.node_id.clone();
tokio::spawn(async move {
consume_commands(cmd_redis, cmd_pool).await;
consume_commands(cmd_redis, cmd_pool, cmd_node_id).await;
});
// 启动 TCP 监听
let tcp_addr = format!("{}:{}", cfg.host, cfg.tcp_port);
tracing::info!("TCP 设备服务启动于 {}", tcp_addr);
tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn).await;
tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn, cfg.node_id).await;
}
/// 通过 Redis 注册节点信息(HSET hash 结构,与 heartbeat 保持一致)
@ -128,48 +134,124 @@ async fn heartbeat(redis: &redis::aio::ConnectionManager, node_id: &str, registe
}
}
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 读取平台指令并发送到设备
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 和本节点队列读取指令,
/// 查询路由后发送到正确节点。
async fn consume_commands(
redis: redis::aio::ConnectionManager,
pool: tcp::connection::ConnectionPool,
node_id: String,
) {
tracing::info!("[cmd_consumer] 启动");
tracing::info!("[cmd_consumer] 启动 node_id={}", node_id);
let my_queue = format!("device:commands:{}", node_id);
loop {
let mut conn = redis.clone();
let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP")
// **非阻塞轮询**取一条待下发指令,先取本节点专属队列、再取统一队列。
//
// 为什么不用 `BRPOP`(2026-09 实测结论):Redis 经 nginx stream 代理访问阿里云 RDS,
// 客户端又是多路复用的 `ConnectionManager`,这种组合下 `BRPOP` 的阻塞语义**不可靠**:
// - `BRPOP key 0`(无限阻塞)→ 代理掐掉空闲连接后**消费永久卡死**,且无任何错误日志;
// - `BRPOP key N`(有限超时)→ **"超时才醒"而非"数据到达即醒"**,实测下行指令
// (含 `login` 应答)延迟 15~45 秒,超过设备的等待窗口后会断开,平台侧表现为
// 「发送失败 设备不在线 → 入死信」。
//
// 轮询把投递延迟稳定在 200ms 内,且不依赖阻塞命令语义。
// (生产者用 `LPUSH`,这里用 `RPOP`,FIFO 顺序与原先一致。)
const POLL_INTERVAL_MS: u64 = 200;
let picked: Result<Option<String>, redis::RedisError> = match redis::cmd("RPOP")
.arg(&my_queue)
.query_async::<Option<String>>(&mut conn)
.await
{
Ok(Some(v)) => Ok(Some(v)),
// 本节点队列为空时再取统一队列(多节点场景下的广播队列)
Ok(None) => {
let mut conn2 = redis.clone();
redis::cmd("RPOP")
.arg("device:commands")
.arg(0)
.query_async(&mut conn)
.await;
.query_async::<Option<String>>(&mut conn2)
.await
}
Err(e) => Err(e),
};
match result {
Ok((_key, cmd_json)) => {
// 从 JSON 中提取 dev_id 用于路由
let dev_id = serde_json::from_str::<serde_json::Value>(&cmd_json)
.ok()
.and_then(|v| v.get("dev_id").and_then(|d| d.as_str()).map(String::from));
match picked {
Ok(Some(cmd_json)) => {
// 内部路由字段 `_dev_id`(由 api-server 附加):它才是"这条指令发给哪台设备"。
// ⚠️ 不能再用协议 `id` 推导 —— 协议定稿后 `id` 只表示**动作目标**
// (柜级省略 / 一段=单板 / 两段=仓体级),**不含 IMEI**。
let parsed: Option<serde_json::Value> = serde_json::from_str(&cmd_json).ok();
let dev_id = parsed
.as_ref()
.and_then(|v| v.get(ROUTE_DEV_ID_FIELD).and_then(|d| d.as_str()))
.map(String::from);
let dev_id = match dev_id {
Some(id) if !id.is_empty() => id,
Some(s) if !s.is_empty() => s,
_ => {
tracing::warn!("[cmd_consumer] 指令缺少 dev_id,丢弃");
tracing::warn!(
"[cmd_consumer] 指令缺少内部路由字段 {},丢弃:{}",
ROUTE_DEV_ID_FIELD,
cmd_json
);
continue;
}
};
// 发送到设备
match pool.send_command(&dev_id, &cmd_json).await {
// 协议 `id`(动作目标)—— 仅用于日志;柜级指令不带此字段
let proto_id = parsed
.as_ref()
.and_then(|v| v.get("id").and_then(|d| d.as_str()))
.unwrap_or("-")
.to_string();
// 查询路由:HGET device:{dev_id} node_id
let mut route_conn = redis.clone();
let target_node: Option<String> = redis::cmd("HGET")
.arg(format!("device:{}", dev_id))
.arg("node_id")
.query_async(&mut route_conn)
.await
.unwrap_or(None);
match target_node {
Some(ref nid) if nid != &node_id => {
// 设备在其他节点,转发到该节点专属队列
tracing::debug!("[cmd] 转发 dev_id={} 到节点 {}", dev_id, nid);
let mut reroute_conn = redis.clone();
let _: Result<(), _> = redis::cmd("LPUSH")
.arg(format!("device:commands:{}", nid))
.arg(&cmd_json)
.query_async(&mut reroute_conn)
.await;
}
_ => {
// 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景)
match pool.send_with_msgid(&dev_id, &cmd_json).await {
Ok(()) => {
tracing::debug!("[cmd] 已发送到设备 dev_id={}", dev_id);
tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id);
}
Err(e) => {
tracing::warn!("[cmd] 发送失败 dev_id={} err={}", dev_id, e);
tracing::error!("[cmd] 发送失败 dev_id={} err={} 已入死信", dev_id, e);
// ponytail: 丢入死信队列,不重试(重试语义和次数待业务明确)
let mut dl_conn = redis.clone();
let _: Result<(), _> = redis::cmd("LPUSH")
.arg("device:commands:dead")
.arg(&cmd_json)
.query_async(&mut dl_conn)
.await;
}
}
}
}
}
Ok(None) => {
// 两个队列都为空:短暂让出,避免空转打满 CPU(延迟上限 = 本间隔)
tracing::trace!("[cmd_consumer] 队列为空,继续等待");
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
Err(e) => {
tracing::error!("[cmd_consumer] BRPOP 错误: {}", e);
tracing::error!("[cmd_consumer] RPOP 错误: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}

View File

@ -1,4 +1,3 @@
#![allow(dead_code)]
//! 设备连接池管理。
//!
//! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。
@ -7,43 +6,65 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{mpsc, RwLock};
use tokio::sync::RwLock;
use tokio::sync::mpsc;
/// 敏感指令列表(未认证设备不得执行)
const SENSITIVE_ACTS: &[&str] = &["open", "pow_on", "pow_off", "reset", "auth_str"];
/// 挂起登记(`pending:{dev_id}`)的 TTL(秒)。
///
/// 取值 120 = 协议 60 秒等待窗口的 2 倍:既保证超时后自动作废,
/// 又留出余量避免登记项在回执到达前先过期(api-server 命中时会 `HDEL`)。
const PENDING_TTL_SECS: u32 = 120;
/// 单台设备的连接状态。
#[allow(dead_code)]
pub struct DeviceConnection {
pub dev_id: String,
/// 向设备发送指令的通道。
pub tx: mpsc::UnboundedSender<String>,
/// 连接建立时间。
pub connected_at: Instant,
pub tx: mpsc::Sender<String>,
/// 最后活跃时间(收到任意消息时更新)。
pub last_seen: Instant,
/// 是否已完成登录验证。
pub authenticated: bool,
/// 下行指令 msg_id 自增计数(**每连接独立**,按协议 §3.4 从 `1` 起、`0` 不使用;
/// 连接重建后重新计数)
next_msg_id: u32,
}
impl DeviceConnection {
pub fn new(dev_id: String, tx: mpsc::UnboundedSender<String>) -> Self {
/// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。
pub fn new(tx: mpsc::Sender<String>) -> Self {
let now = Instant::now();
Self { dev_id, tx, connected_at: now, last_seen: now, authenticated: false }
// 初值 1:协议定稿「两个方向各自独立计数,都每连接从 1 起自增,0 不使用」;
// 且 api-server 多处用 `unwrap_or(0)` 兜底,用 0 起会与"缺号"混淆
Self { tx, last_seen: now, authenticated: false, next_msg_id: 1 }
}
/// 标记活跃时间。
pub fn touch(&mut self) {
self.last_seen = Instant::now();
}
/// 获取下一个 msg_id(从 `1` 起自增,`0` 不使用)。
fn alloc_msg_id(&mut self) -> u32 {
let id = self.next_msg_id;
self.next_msg_id += 1;
id
}
}
/// 线程安全的设备连接池。
#[derive(Clone)]
pub struct ConnectionPool {
inner: Arc<RwLock<HashMap<String, DeviceConnection>>>,
/// 挂起登记用的 Redis 连接(`pending:{dev_id}`)。
/// 仅用于辅助登记,**任何 Redis 失败都不得影响指令下发**。
redis: redis::aio::ConnectionManager,
}
impl ConnectionPool {
pub fn new() -> Self {
Self { inner: Arc::new(RwLock::new(HashMap::new())) }
pub fn new(redis: redis::aio::ConnectionManager) -> Self {
Self { inner: Arc::new(RwLock::new(HashMap::new())), redis }
}
/// 注册设备连接。如果已存在则替换旧连接。
@ -58,20 +79,87 @@ impl ConnectionPool {
map.remove(dev_id);
}
/// 向指定设备发送 JSON 指令。
/// 返回 `Ok(())` 表示已投递到通道;设备可能因缓冲区满而断开。
pub async fn send_command(&self, dev_id: &str, json: &str) -> Result<(), &'static str> {
/// 查询该设备连接是否已通过 `login` 认证。
/// 连接不存在或尚未认证时返回 `false`。
pub async fn is_authenticated(&self, dev_id: &str) -> bool {
let map = self.inner.read().await;
match map.get(dev_id) {
Some(conn) => conn.tx.send(json.to_string()).map_err(|_| "通道已关闭"),
None => Err("设备不在线"),
map.get(dev_id).map(|c| c.authenticated).unwrap_or(false)
}
/// 向指定设备发送指令并注入自增 msg_id。
/// 每连接独立计数(从 `1` 起),重连后重新计数。
/// 未认证连接只能接收非敏感指令(如 status 查询)。
///
/// 区分两类出站消息(判据 = **有没有 `suc`**,见交付文档 §3.5):
/// - **应答**(ServerResponse,**带 `suc`**,同时也带 `act`):设备请求的回复,
/// **透传原始 `msg_id`**(协议要求"应答的 msg_id 必须等于请求的 msg_id")
/// - **指令**(DeviceCommand,**不带 `suc`**):平台主动下发,**注入自增 `msg_id`**
///
/// 指令在注入 `msg_id` 后写入 Redis 挂起表 `pending:{dev_id}`,
/// 供 api-server 把设备回执配对回具体指令(见 `register_pending`)。
pub async fn send_with_msgid(&self, dev_id: &str, json: &str) -> Result<(), &'static str> {
let pending = self.prepare_outbound(dev_id, json).await?;
// 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。
// 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。
if let Some((msg_id, act)) = pending {
register_pending(&self.redis, dev_id, msg_id, &act).await;
}
Ok(())
}
/// 出站报文的校验与投递:跑安全门控、按需注入 `msg_id`,最后写入设备写通道。
///
/// 返回 `Some((msg_id, act))` 表示这是**指令**(需登记挂起项);
/// 返回 `None` 表示这是**应答**(无平台挂起项,不登记)。
async fn prepare_outbound(
&self,
dev_id: &str,
json: &str,
) -> Result<Option<(u32, String)>, &'static str> {
let mut map = self.inner.write().await;
let conn = map.get_mut(dev_id).ok_or("设备不在线")?;
let mut cmd: serde_json::Value =
serde_json::from_str(json).map_err(|_| "JSON 解析失败")?;
// 「是否应答」的判据:**只有应答带 `suc`**(协议 §3.5)。
// 不能再用 `act` 判断 —— 协议定稿后应答也必带 `act`(§3.1)。
let is_response = cmd.get("suc").is_some();
// 检查敏感指令门控(**只拦指令**,应答不拦)
// 否则 `auth_str` 的应答会被自己拦掉 → 首次配网永久死锁
if !conn.authenticated && !is_response {
if let Some(act) = cmd.get("act").and_then(|a| a.as_str()) {
if SENSITIVE_ACTS.contains(&act) {
tracing::warn!("[安全] 拦截敏感指令 act={} dev_id={} 连接未认证", act, dev_id);
return Err("连接未认证,拒绝敏感指令");
}
}
}
/// 查询设备是否在线且已验证。
pub async fn is_online(&self, dev_id: &str) -> bool {
let map = self.inner.read().await;
map.get(dev_id).map(|c| c.authenticated).unwrap_or(false)
// 认证触发:login 成功响应的唯一特征是 spec 字段存在 + suc==1
// (2026-09 协议定稿:login 应答的柜子规格字段由 cell 改为 spec,此处必须同步)
if cmd.get("spec").is_some() {
if let Some(suc) = cmd.get("suc").and_then(|s| s.as_u64()) {
if suc == 1 {
conn.authenticated = true;
tracing::info!("[安全] 设备 {} 登录认证通过", dev_id);
}
}
}
let mut pending = None;
if !is_response {
// 平台主动下发的指令,注入自增 msg_id,并登记挂起项
let msg_id = conn.alloc_msg_id();
cmd["msg_id"] = serde_json::json!(msg_id);
let act = cmd.get("act").and_then(|a| a.as_str()).unwrap_or("").to_string();
pending = Some((msg_id, act));
}
// else: 应答,保持原始 msg_id 不动(透传设备发起的请求的编号)
conn.tx.try_send(cmd.to_string()).map_err(|_| "通道已关闭或已满")?;
Ok(pending)
}
/// 更新设备活跃时间。
@ -81,33 +169,86 @@ impl ConnectionPool {
conn.touch();
}
}
}
/// 标记设备已验证。
pub async fn set_authenticated(&self, dev_id: &str) {
let mut map = self.inner.write().await;
if let Some(conn) = map.get_mut(dev_id) {
conn.authenticated = true;
/// 写入挂起登记:`HSET pending:{dev_id} {msg_id} {act}` + `EXPIRE pending:{dev_id} 120`。
///
/// **为什么由 device-server 登记**:`msg_id` 是本服务分配的,api-server 只有拿到
/// 「`msg_id` → `act`」映射,才能把设备回执配对回具体的下发指令(§3.5 要求
/// `msg_id` + `act` 双条件配对);平台对每条指令只等 60 秒,超时即作废。
///
/// 失败处理:Redis 异常只记 WARN —— 挂起登记是**辅助功能**,
/// 绝不能让一条已成功投递到设备的指令因为登记失败而报错(那会误导上层重发)。
async fn register_pending(
redis: &redis::aio::ConnectionManager,
dev_id: &str,
msg_id: u32,
act: &str,
) {
if act.is_empty() {
tracing::warn!("[pending] 指令缺少 act,跳过挂起登记 dev_id={} msg_id={}", dev_id, msg_id);
return;
}
let key = format!("pending:{}", dev_id);
let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg(msg_id)
.arg(act)
.query_async(&mut conn)
.await;
match result {
Ok(_) => tracing::debug!("[pending] 已登记 dev_id={} msg_id={} act={}", dev_id, msg_id, act),
Err(e) => {
tracing::warn!(
"[pending] 挂起登记失败 dev_id={} msg_id={} err={}(指令已下发,仅影响配对)",
dev_id,
msg_id,
e
);
return;
}
}
/// 清理超时未活跃的连接(心跳检测)。
pub async fn cleanup_timeout(&self, timeout: std::time::Duration) -> Vec<String> {
let mut map = self.inner.write().await;
let now = Instant::now();
let mut removed = Vec::new();
map.retain(|dev_id, conn| {
if now.duration_since(conn.last_seen) > timeout {
removed.push(dev_id.clone());
false
} else {
true
}
});
removed
}
/// 返回当前在线设备数量。
pub async fn online_count(&self) -> usize {
self.inner.read().await.len()
// ponytail: HSET 不支持同时携带 TTL,需单独 EXPIRE(与 main.rs 的节点注册写法一致)
let mut ttl_conn = redis.clone();
let ttl_result: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(PENDING_TTL_SECS)
.query_async(&mut ttl_conn)
.await;
if let Err(e) = ttl_result {
tracing::warn!("[pending] 挂起登记 TTL 设置失败 dev_id={} err={}", dev_id, e);
}
}
// ---------------------------------------------------------------------------
// 单元测试:下行编号与内部字段(协议 §3.4 / §3.5)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc;
fn conn() -> DeviceConnection {
let (tx, _rx) = mpsc::channel(4);
DeviceConnection::new(tx)
}
/// 协议定稿:两个方向各自独立计数,**都每连接从 `1` 起自增**(`0` 不使用)
#[test]
fn test_alloc_msg_id_starts_at_one_and_increments() {
let mut c = conn();
assert_eq!(c.alloc_msg_id(), 1);
assert_eq!(c.alloc_msg_id(), 2);
assert_eq!(c.alloc_msg_id(), 3);
}
/// 新建连接默认未认证 —— 未认证时敏感指令会被拦、业务报文会被丢弃
#[test]
fn test_new_connection_is_unauthenticated() {
assert!(!conn().authenticated);
}
}

View File

@ -1,6 +1,8 @@
//! 设备消息处理逻辑(轻量化)。
//!
//! 设备服务只处理通信和指令解析,不处理业务逻辑:
//! 设备服务只做**协议透传**,不处理业务逻辑:
//! - `status_post` 的通道数据在设备侧已是 **18 字符 hex 明文**,本服务**原样透传**,
//! 不再做任何拆解/位域解析(解析归 api-server 的业务层)
//! - 所有设备上报消息统一转发到 Redis `device:reports` 队列
//! - 不写 DB、不验证签名、不处理业务规则
//! - auth_str/login 等协议级消息也由 API 服务处理后通过 `device:commands` 回复
@ -8,34 +10,57 @@
use super::connection::ConnectionPool;
use super::protocol::DeviceMessage;
/// 分发消息:统一转发到 Redis 上报队列。
/// 分发消息:补写设备身份后转发到 Redis 上报队列。
///
/// 设备服务不做任何业务处理,仅负责解析 JSON 并转发。
/// `current_dev_id` = 本 TCP 连接绑定的设备身份(由 `auth_str`/`login` 建立)。
/// 为什么需要它:新协议里业务上行**不带 `dev_id`**(设备身份由连接确定),
/// 而 api-server 依赖 `dev_id` 识别设备,故转发前必须补上。
///
/// 本服务**不直接回复设备**:应答由 api-server 经 Redis `device:commands` 下发,
/// 因此这里没有返回值(历史上曾返回 `Option<ServerResponse>` 但恒为 `None`,
/// 那个结构体副本已删除,避免与 api-server 的真实定义漂移)。
pub async fn handle_message(
msg: DeviceMessage,
mut msg: DeviceMessage,
current_dev_id: Option<&str>,
_pool: &ConnectionPool,
redis: &redis::aio::ConnectionManager,
) -> Option<super::protocol::ServerResponse> {
) {
attach_dev_id(&mut msg, current_dev_id);
// 所有消息统一转发到 Redis,由 API 服务的 report_worker 处理
forward_to_redis(redis, &msg).await;
// 不直接回复设备,等待 API 服务通过 device:commands 队列回复
None
}
/// 把连接绑定的设备身份写入待转发报文。
///
/// 以**连接**为准(而非报文自带值):设备身份由 TCP 连接确定,
/// 这样即使设备漏带/错带 `dev_id`,api-server 侧也不会认错设备。
fn attach_dev_id(msg: &mut DeviceMessage, current_dev_id: Option<&str>) {
match current_dev_id {
Some(dev_id) if !dev_id.is_empty() => msg.dev_id = Some(dev_id.to_string()),
// 理论上不会出现:未登录连接的业务报文已被 server.rs 的门控丢弃。
// 此时仍照常转发(不丢数据),仅记 WARN 便于排查。
_ => tracing::warn!(
"[forward] 连接未绑定 dev_id,转发报文将不含设备身份, act={}",
msg.act
),
}
}
/// 将设备消息转发到 Redis 统一上报队列 `device:reports`
async fn forward_to_redis(
redis: &redis::aio::ConnectionManager,
msg: &DeviceMessage,
) {
if msg.dev_id.is_none() {
tracing::warn!("[forward] 消息缺少 dev_id, act={}", msg.act);
async fn forward_to_redis(redis: &redis::aio::ConnectionManager, msg: &DeviceMessage) {
let json_str = match serde_json::to_string(msg) {
Ok(json) => json,
Err(e) => {
// 序列化失败说明报文结构异常,记 ERROR 并丢弃(不推送半截数据)
tracing::error!("[forward] 消息序列化失败 act={} err={}", msg.act, e);
return;
}
};
let json_str = serde_json::to_string(msg).unwrap_or_default();
let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("LPUSH")
.arg("device:reports")
.arg(&json_str)

View File

@ -1,4 +1,3 @@
#![allow(dead_code)]
//! 设备通讯协议消息类型定义与序列化。
//!
//! 设备与平台间采用 JSON 格式通讯,LF (`\n`) 作为消息边界。
@ -12,8 +11,15 @@ use serde_json::Value;
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
#[derive(Debug, Deserialize, Serialize)]
pub struct DeviceMessage {
/// 动作类型:login / auth_str / status_post / pow_fail / off
/// 动作类型(上行全集,见交付文档 §4.1):
/// auth_str / login / status_post / pow_fail_dc / pow_fail_ac / bat_in / bat_out / on / off,
/// 以及下行指令的执行回执 open / pow_on / pow_off / reset
pub act: String,
/// 设备身份(柜控板 IMEI)。
///
/// **新协议里只有 `auth_str` / `login` 携带 `dev_id`**(用于把 TCP 连接与设备绑定,§3.1);
/// **其余上行一律不带** —— 连接建立后设备身份即由该 TCP 连接确定。
/// 内部转发到 Redis `device:reports` 时由 handler 补写该字段(api-server 依赖它识别设备)。
pub dev_id: Option<String>,
pub msg_id: Option<u32>,
/// 登录签名时的时间戳(秒级)
@ -22,63 +28,120 @@ pub struct DeviceMessage {
pub sign: Option<String>,
/// 485 子设备 ID(仓控板 ID 1-6)
pub sub_device_id: Option<u8>,
/// 状态上报时的 HEX 数据
pub status: Option<String>,
/// 4G 信号强度
pub rssi: Option<i32>,
/// `status_post` 的**柜体块**:`{status1, status2, temp, meter}`(见交付文档 §5.3)
#[serde(skip_serializing_if = "Option::is_none")]
pub cabinet: Option<Value>,
/// `status_post` 的**仓控板数组**:`[{board, channels: ["<18hex>", ...]}]`(见交付文档 §5.3)。
///
/// 通道数据在设备侧已是 **18 字符 hex 明文**,本服务**只透传、不拆解位域**。
#[serde(skip_serializing_if = "Option::is_none")]
pub boards: Option<Value>,
/// `bat_in` / `bat_out` / `off` 的**电池块**(见交付文档 §5.5 / §5.6 / §5.7):
/// `{battery_id, brand_code, model_code, material_code, cell_count,
/// rated_voltage, rated_capacity, info, cells}`
#[serde(skip_serializing_if = "Option::is_none")]
pub battery: Option<Value>,
/// 未在顶层定义的扩展字段
#[serde(flatten)]
pub extra: Option<Value>,
}
/// 平台回复设备的消息。
#[derive(Debug, Serialize)]
pub struct ServerResponse {
/// 1=成功,0=失败
pub suc: u8,
pub msg_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub err: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_str: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
// ─────────────────────────────────────────────────────────────────────────────
// 关于「平台回复设备」与「平台下发指令」两类报文:
//
// 本服务**只做透传** —— 回复走 Redis `device:commands`(由 api-server 用
// `api-server/src/protocol.rs::ServerResponse` 构造),指令同样由 api-server 用
// `api-server/src/commands.rs::DeviceCommand` 构造,本服务按 `serde_json::Value`
// 读写、只负责"摘内部字段 + 注入 msg_id/登记挂起 + 投递"。
//
// 因此这里**不再保留** `ServerResponse` / `DeviceCommand` 的结构体副本:
// 有了副本反而会与 api-server 的真实定义漂移(历史上 api-server 里那份
// `DeviceMessage` 副本就是这样带着废弃字段误导了很久)。
// ─────────────────────────────────────────────────────────────────────────────
impl ServerResponse {
/// 创建成功响应。
pub fn success(msg_id: Option<u32>) -> Self {
Self { suc: 1, msg_id, err: None, auth_str: None, data: None }
// ---------------------------------------------------------------------------
// 单元测试:上行报文反序列化(协议 §3.1 / §5.3 / §5.5 / §3.5)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::DeviceMessage;
/// `status_post` 新结构:`cabinet` + `boards[].channels[]`(18 字符 hex 透传)
#[test]
fn test_device_message_status_post_new_shape() {
let raw = r#"{"act":"status_post","msg_id":3,"rssi":85,
"cabinet":{"status1":"00","status2":"00","temp":29800,"meter":125500},
"boards":[{"board":1,"channels":["828255152c01f40143","008200000000000000"]}]}"#;
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
assert_eq!(msg.act, "status_post");
assert_eq!(msg.rssi, Some(85));
assert_eq!(msg.cabinet.as_ref().unwrap()["status1"], "00");
let boards = msg.boards.as_ref().unwrap().as_array().unwrap();
assert_eq!(boards.len(), 1);
assert_eq!(boards[0]["board"], 1);
assert_eq!(boards[0]["channels"][1], "008200000000000000");
// 新协议业务上行不带 dev_id
assert!(msg.dev_id.is_none());
}
/// 创建失败响应。
pub fn error(msg_id: Option<u32>, err: impl Into<String>) -> Self {
Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None, data: None }
/// `bat_in` 的 `battery` 块(属性 + 18hex info + cells)
#[test]
fn test_device_message_bat_in_battery_block() {
let raw = r#"{"act":"bat_in","msg_id":5,"id":"01-3","battery":{
"battery_id":"SN123456","brand_code":"1","cell_count":15,
"info":"80825515180320012a","cells":"0e100e15"}}"#;
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
let battery = msg.battery.as_ref().unwrap();
assert_eq!(battery["battery_id"], "SN123456");
assert_eq!(battery["cell_count"], 15);
assert_eq!(battery["info"], "80825515180320012a");
// `id` 未在结构体声明 → 必须落进 `extra`,否则 api-server 拿不到动作目标
let extra = msg.extra.as_ref().unwrap();
assert_eq!(extra["id"], "01-3");
}
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
}
}
/// 平台下发至设备的指令。
#[derive(Debug, Serialize, Deserialize)]
pub struct DeviceCommand {
pub act: String,
pub msg_id: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub dev_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub_device_id: Option<u8>,
/// 扩展参数(开门指令、充电参数等)
#[serde(flatten)]
pub params: Option<Value>,
}
impl DeviceCommand {
/// 序列化为 JSON 字符串(紧凑格式)。
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("DeviceCommand 序列化不应失败")
/// **关键契约**:设备回执的 `suc` 必须原样落到 `extra`。
/// api-server 用 `msg.get("suc").is_some()` 判定"回执 vs 设备上报"(协议 §3.5),
/// 若这里把 `suc` 丢掉,回执会被误当成新请求处理。
#[test]
fn test_device_message_receipt_suc_preserved_in_extra() {
let raw = r#"{"act":"on","msg_id":1,"id":"01-3","suc":1}"#;
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
let extra = msg.extra.as_ref().unwrap();
assert_eq!(extra["suc"], 1);
assert_eq!(extra["id"], "01-3");
}
/// 有 `suc=0` + `err` 的失败回执:`err` 也必须透传(api-server 要落库,见 A6)
#[test]
fn test_device_message_failed_receipt_error_preserved() {
let raw = r#"{"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}"#;
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
let extra = msg.extra.as_ref().unwrap();
assert_eq!(extra["suc"], 0);
assert_eq!(extra["err"], "door lock not engaged");
}
/// `auth_str` / `login` 是仅有的两条带 `dev_id` 的上行
#[test]
fn test_device_message_auth_str_carries_dev_id() {
let raw = r#"{"act":"auth_str","msg_id":1,"dev_id":"864720562557021"}"#;
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
assert_eq!(msg.dev_id.as_deref(), Some("864720562557021"));
}
/// 停电上报无附加字段(协议定稿:`pow_fail_dc` / `pow_fail_ac` 均不带 payload)
#[test]
fn test_device_message_pow_fail_has_no_payload() {
for act in ["pow_fail_dc", "pow_fail_ac"] {
let raw = format!(r#"{{"act":"{act}","msg_id":4}}"#);
let msg: DeviceMessage = serde_json::from_str(&raw).unwrap();
assert_eq!(msg.act, act);
assert!(msg.cabinet.is_none());
assert!(msg.boards.is_none());
assert!(msg.battery.is_none());
}
}
}

View File

@ -1,52 +1,46 @@
//! TCP 服务端:监听端口、接收设备连接、按 LF 分割 JSON 消息。
use futures::StreamExt;
use std::time::Duration;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::mpsc;
use tokio::time;
use tokio_util::codec::{FramedRead, LinesCodec};
use super::connection::{ConnectionPool, DeviceConnection};
use super::handler::handle_message;
use super::protocol::DeviceMessage;
/// 心跳超时:5000ms 未收到消息则断开连接。
const HEARTBEAT_TIMEOUT: Duration = Duration::from_millis(5000);
/// 清理超时连接的间隔。
const CLEANUP_INTERVAL: Duration = Duration::from_secs(10);
/// 未登录连接**允许**发送的唯一两个 `act`(协议定稿:其余指令必须先 `login` 成功)。
const UNAUTH_ALLOWED_ACTS: &[&str] = &["auth_str", "login"];
/// 连接建立后必须在此秒数内完成 `login` 认证,否则平台主动断开。
/// 依据:协议规定"login 成功这个 TCP 才可以保持"。
const UNAUTH_TIMEOUT_SECS: u64 = 30;
/// 未登录超时的巡检间隔(秒)。
const UNAUTH_CHECK_INTERVAL_SECS: u64 = 5;
/// 启动 TCP 监听循环。
pub async fn run_tcp_server(
addr: String,
pool: ConnectionPool,
redis: redis::aio::ConnectionManager,
node_id: String,
) {
let listener = TcpListener::bind(&addr)
.await
.expect("TCP 端口绑定失败");
tracing::info!("TCP 设备服务启动: {}", addr);
// 后台清理过期连接
let cleanup_pool = pool.clone();
tokio::spawn(async move {
let mut interval = time::interval(CLEANUP_INTERVAL);
loop {
interval.tick().await;
let removed = cleanup_pool.cleanup_timeout(HEARTBEAT_TIMEOUT).await;
for dev_id in &removed {
tracing::warn!("[心跳超时] 断开 dev_id={}", dev_id);
}
}
});
loop {
match listener.accept().await {
Ok((stream, peer)) => {
tracing::info!("[新连接] peer={}", peer);
// ponytail: 不需要设 TCP keepalive,我们是 server,设备才是 client。
// 设备每 60s 发 status_post 就是应用层保活。
let pool = pool.clone();
let redis = redis.clone();
tokio::spawn(handle_connection(stream, peer, pool, redis));
let nid = node_id.clone();
tokio::spawn(handle_connection(stream, peer, pool, redis, nid));
}
Err(e) => {
tracing::error!("accept 失败: {}", e);
@ -61,15 +55,19 @@ async fn handle_connection(
peer: std::net::SocketAddr,
pool: ConnectionPool,
redis: redis::aio::ConnectionManager,
node_id: String,
) {
let (reader, writer) = tokio::io::split(stream);
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(4096));
// 单行上限 64 KB:status_post 为「每 60 秒全量上报」
// (当前 6板×6组 ≈ 8 KB;设计上限 15板×8组 ≈ 25 KB,含电芯数据约 40 KB),留足余量
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(65536));
// 写通道:HTTP 命令下发通过此通道写入 TCP
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
// 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积
let (tx, mut rx) = mpsc::channel::<String>(100);
let mut current_dev_id: Option<String> = None;
let mut writer = writer;
let connected_at = std::time::Instant::now();
loop {
tokio::select! {
@ -90,29 +88,47 @@ async fn handle_connection(
};
// 如果是 login/auth_str,需要 dev_id 来关联连接
// 注意:注册在签名验证之前,因为 login 响应(auth_str/cell)必须通过
// 连接池下发。先注册才能回复。纯内网 TCP + 真实设备重连快,攻击面有限。
// 审查编号:I3(已确认不修,协议约束)
if (msg.act == "auth_str" || msg.act == "login")
&& let Some(ref dev_id) = msg.dev_id
&& current_dev_id.is_none() {
let conn = DeviceConnection::new(dev_id.clone(), tx.clone());
let conn = DeviceConnection::new(tx.clone());
pool.register(dev_id.clone(), conn).await;
current_dev_id = Some(dev_id.clone());
// 首次识别设备时写入 route
update_device_route(&redis, dev_id, &node_id).await;
}
// 更新活跃时间
// 更新活跃时间,同时刷新 route(处理重连切换到其他节点的情况)
if let Some(ref dev_id) = current_dev_id {
pool.touch(dev_id).await;
update_device_route(&redis, dev_id, &node_id).await;
}
// 处理消息
let resp = handle_message(msg, &pool, &redis).await;
// 安全规则(协议定稿):除 auth_str / login 外,其余指令必须先 login 成功
// 未登录连接发来的业务报文一律丢弃(连接尚未与 dev_id 建立可信绑定,
// 平台侧也无法路由应答)
if !UNAUTH_ALLOWED_ACTS.contains(&msg.act.as_str()) {
let authed = match current_dev_id.as_deref() {
Some(dev_id) => pool.is_authenticated(dev_id).await,
None => false,
};
if !authed {
tracing::warn!(
"[安全] 连接未登录,丢弃报文 act={} peer={}",
msg.act,
peer
);
continue;
}
}
if let Some(resp) = resp {
let json = resp.to_json();
if let Err(e) = tx.send(json) {
tracing::error!("[{}] 响应发送失败: {}", peer, e);
break;
}
}
// 处理消息(传入连接绑定的设备身份:新协议业务上行不带 dev_id,
// 转发到 Redis 前由 handler 补写,api-server 依赖它识别设备)。
// 无返回值:本服务不直接回复设备,应答由 api-server 经 device:commands 下发。
handle_message(msg, current_dev_id.as_deref(), &pool, &redis).await;
}
Some(Err(e)) => {
tracing::warn!("[{}] 读取错误: {}", peer, e);
@ -124,14 +140,49 @@ async fn handle_connection(
}
}
}
// 未登录超时巡检:连接建立后 UNAUTH_TIMEOUT_SECS 秒内未通过 login 即断开
// (协议规定:login 成功这条 TCP 才可以保持)
_ = tokio::time::sleep(std::time::Duration::from_secs(UNAUTH_CHECK_INTERVAL_SECS)) => {
use tokio::io::AsyncWriteExt;
let authed = match current_dev_id.as_deref() {
Some(dev_id) => pool.is_authenticated(dev_id).await,
None => false,
};
if !authed && connected_at.elapsed().as_secs() >= UNAUTH_TIMEOUT_SECS {
tracing::warn!(
"[{}] 未在 {} 秒内完成 login,平台主动断开连接",
peer,
UNAUTH_TIMEOUT_SECS
);
let _ = writer.shutdown().await;
break;
}
}
// 向设备写指令
Some(json) = rx.recv() => {
use tokio::io::AsyncWriteExt;
let framed = format!("{}\n", json);
// api-server 会附加内部标记 `_close`(login 失败后断开)与
// `_dev_id`(路由目标):两者仅供服务端使用,下发前摘除,设备侧不可见
let (payload, close_after) = split_internal_flags(&json);
let framed = format!("{}\n", payload);
if let Err(e) = writer.write_all(framed.as_bytes()).await {
tracing::error!("[{}] 写入失败: {}", peer, e);
break;
}
// 规范:login 失败即断开 TCP,强制设备重连并重新走握手流程
if close_after {
tracing::warn!(
"[{}] login 失败,主动断开连接 dev_id={:?}",
peer,
current_dev_id
);
let _ = writer.shutdown().await;
break;
}
}
}
}
@ -142,3 +193,119 @@ async fn handle_connection(
tracing::info!("[断连] dev_id={}", dev_id);
}
}
/// 更新设备路由:在 `device:{imei}` hash 中写入 `node_id`,并刷新 TTL。
/// 路由信息用于多节点时指令消费方判断设备连接在哪个节点上。
async fn update_device_route(
redis: &redis::aio::ConnectionManager,
dev_id: &str,
node_id: &str,
) {
let route_key = format!("device:{}", dev_id);
let mut conn = redis.clone();
let _: Result<(), _> = redis::cmd("HSET")
.arg(&route_key)
.arg("node_id")
.arg(node_id)
.query_async(&mut conn)
.await;
// ponytail: 与设备在线 TTL 保持一致(300s),HSET 不支持同时设置 TTL
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&route_key)
.arg(300_u32)
.query_async(&mut conn)
.await;
}
/// 摘除 api-server 附加的**内部标记**(`_close` / `_dev_id`)。
///
/// 返回 `(实际下发给设备的报文, 是否在发送后断开连接)`。
/// 这两个字段只用于服务端内部:
/// - `_close`:`login` 失败后主动断开连接
/// - `_dev_id`:指令的路由目标(设备身份),下发前必须摘除 ——
/// 否则设备会把它当成协议字段,且协议定稿后 `dev_id` 只在 `auth_str`/`login` 携带
///
/// 两者**都不会下发到设备**。
fn split_internal_flags(json: &str) -> (String, bool) {
match serde_json::from_str::<serde_json::Value>(json) {
Ok(mut value) => {
let close_after = value
.get("_close")
.and_then(|flag| flag.as_bool())
.unwrap_or(false);
if let Some(obj) = value.as_object_mut() {
obj.remove("_close");
obj.remove("_dev_id");
}
(value.to_string(), close_after)
}
// 非 JSON(理论上不会出现):原样下发,不断开
Err(_) => (json.to_string(), false),
}
}
// ---------------------------------------------------------------------------
// 单元测试:内部字段摘除(`_close` / `_dev_id` 绝不能下发到设备)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::split_internal_flags;
/// 普通指令:无内部字段时原样返回、不断开
#[test]
fn test_split_internal_flags_plain_command_untouched() {
let raw = r#"{"act":"on","msg_id":1,"id":"01-3"}"#;
let (payload, close) = split_internal_flags(raw);
assert!(!close);
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert_eq!(v["act"], "on");
assert_eq!(v["msg_id"], 1);
assert_eq!(v["id"], "01-3");
assert!(v.get("_dev_id").is_none());
assert!(v.get("_close").is_none());
}
/// `_dev_id`(路由目标)必须被摘除 —— 它是服务端内部字段,
/// 且协议定稿后 `dev_id` 只在 auth_str/login 携带,泄漏会让设备误解报文语义
#[test]
fn test_split_internal_flags_removes_route_dev_id() {
let raw = r#"{"act":"open","msg_id":2,"_dev_id":"864720562557021"}"#;
let (payload, close) = split_internal_flags(raw);
assert!(!close);
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert_eq!(v["act"], "open");
assert_eq!(v["msg_id"], 2);
assert!(v.get("_dev_id").is_none());
}
/// `_close` 被摘除并回报"发送后断开"(login 失败即断连)
#[test]
fn test_split_internal_flags_reports_and_removes_close() {
let raw = r#"{"act":"login","msg_id":2,"suc":0,"err":"invalid signature","_close":true}"#;
let (payload, close) = split_internal_flags(raw);
assert!(close);
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert_eq!(v["act"], "login");
assert_eq!(v["err"], "invalid signature");
assert!(v.get("_close").is_none());
}
/// 两个内部字段同时出现时都要摘掉
#[test]
fn test_split_internal_flags_removes_both() {
let raw = r#"{"act":"login","suc":0,"_dev_id":"864720562557021","_close":true}"#;
let (payload, close) = split_internal_flags(raw);
assert!(close);
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert!(v.get("_dev_id").is_none());
assert!(v.get("_close").is_none());
}
/// 非 JSON 原样下发且不断开(理论上不会出现,但不能因此丢失报文)
#[test]
fn test_split_internal_flags_non_json_passthrough() {
let (payload, close) = split_internal_flags("not-json");
assert_eq!(payload, "not-json");
assert!(!close);
}
}

View File

@ -1,9 +0,0 @@
// babel-preset-taro 更多配置和用法:https://docs.taro.zone/docs/next/babel-config
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true,
}],
],
}

View File

@ -1,17 +0,0 @@
module.exports = {
env: {
NODE_ENV: '"development"',
},
h5: {
devServer: {
port: 5174,
proxy: {
'/h5/api': {
target: 'http://10.8.0.252:3000',
changeOrigin: true,
pathRewrite: { '^/h5': '' },
},
},
},
},
}

View File

@ -1,58 +0,0 @@
const path = require('path')
const config = {
projectName: 'h5-taro',
date: '2026-07-03',
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
828: 1.81 / 2,
375: 2 / 1,
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: [],
defineConstants: {},
copy: {
patterns: [],
options: {},
},
framework: 'react',
// compiler: 'webpack4', (default)
sass: {
data: '',
},
mini: {
postcss: {
pxtransform: { enable: true, config: {} },
url: { enable: true, config: { limit: 1024 } },
cssModules: {
enable: false,
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
},
},
},
h5: {
publicPath: '/h5/',
staticDirectory: 'static',
router: {
mode: 'browser',
basename: '/h5/',
},
postcss: {
autoprefixer: { enable: true, config: {} },
cssModules: {
enable: false,
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
},
},
},
}
module.exports = function (merge) {
if (process.env.NODE_ENV === 'development') {
return merge({}, config, require('./dev'))
}
return merge({}, config, require('./prod'))
}

View File

@ -1,6 +0,0 @@
module.exports = {
env: {
NODE_ENV: '"production"',
},
outputRoot: 'dist',
}

View File

@ -1,32 +0,0 @@
{
"name": "h5-taro",
"version": "1.0.0",
"private": true,
"description": "安知充 H5 用户端 (Taro)",
"scripts": {
"dev:h5": "taro build --type h5 --watch",
"build:h5": "taro build --type h5",
"dev:weapp": "taro build --type weapp --watch",
"build:weapp": "taro build --type weapp"
},
"dependencies": {
"@babel/runtime": "^7.23.0",
"@tarojs/components": "3.6.32",
"@tarojs/plugin-framework-react": "^3.6.32",
"@tarojs/plugin-platform-h5": "3.6.32",
"@tarojs/react": "3.6.32",
"@tarojs/runtime": "3.6.32",
"@tarojs/taro": "3.6.32",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@babel/core": "^7.23.0",
"@tarojs/cli": "3.6.32",
"@tarojs/webpack-runner": "3.6.32",
"@types/react": "^18.2.0",
"babel-preset-taro": "3.6.32",
"typescript": "^5.3.0"
}
}

View File

@ -1,30 +0,0 @@
{
"miniprogramRoot": "./dist",
"projectname": "h5-taro",
"description": "安知充 H5 用户端",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"postcss": false,
"preloadBackgroundData": false,
"minified": false,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true
},
"compileType": "miniprogram"
}

View File

@ -1,161 +0,0 @@
import Taro from '@tarojs/taro'
const BASE_URL = '/h5/api'
async function request<T>(
path: string,
options: { method?: string; body?: unknown; params?: Record<string, string> } = {},
): Promise<T> {
const token = Taro.getStorageSync('h5_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let url = `${BASE_URL}${path}`
if (options.params) {
const qs = new URLSearchParams(options.params).toString()
url += `?${qs}`
}
const res = await Taro.request({
url,
method: (options.method as 'GET' | 'POST' | 'PUT' | 'DELETE') || 'GET',
header: headers,
data: options.body,
})
// ponytail: single error shape, add wrapper if backend uses inconsistent envelopes
if (res.statusCode >= 400) {
const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败'
throw new Error(msg)
}
return res.data as T
}
// ---- Types ----
export interface LoginParams {
phone: string
password: string
}
export interface LoginResult {
token: string
user: H5User
}
export interface H5User {
id: number
phone: string
name?: string
}
export interface DashboardData {
online_cabinets: number
charging_count: number
idle_channels: number
fault_channels: number
today_charge_count: number
today_energy_kwh: number
today_charge_hours: number
alerts: { project: string; cabinet: string; channel: number }[]
projects: { id: number; name: string; cabinet_count: number; charging: number; idle: number; fault: number }[]
}
export interface CabinetItem {
id: number
abstract_id: string
name?: string
status: number // 1=在线 0=离线
project_id?: number
}
export interface CompartmentInfo {
id: number
channel_id: number
status: number // 0=空闲 1=充电中 2=故障 3=离线
voltage?: number
current?: number
}
export interface CabinBoard {
id: number
board_id: number
status: number
compartments: CompartmentInfo[]
}
export interface CabinetDetail {
id: number
abstract_id: string
name?: string
imei?: string
status: number
cabin_boards: CabinBoard[]
}
export interface CompartmentDetail {
id: number
channel_id: number
status: number
voltage?: number
current?: number
power?: number
energy?: number
soc?: number
soh?: number
cell_voltages?: number[]
cell_temps?: number[]
bms_alerts?: string[]
charge_records?: ChargeRecord[]
}
export interface ChargeRecord {
id: number
start_time: string
end_time?: string
energy_kwh: number
cost: number
}
// ---- API ----
export const h5Api = {
// Auth
login: (params: LoginParams) =>
request<{ data: LoginResult }>('/h5/auth/login', { method: 'POST', body: params }),
// Dashboard
getDashboard: () =>
request<{ data: DashboardData }>('/h5/dashboard'),
// Cabinets
getCabinets: (params?: { project_id?: number }) =>
request<{ data: CabinetItem[] }>('/h5/cabinets', { params: params as Record<string, string> }),
// Cabinet detail (by abstract_id for QR scan — public endpoint)
getCabinetByAbstract: (abstractId: string) =>
request<{ data: CabinetDetail }>(`/h5/cabinets/abstract/${abstractId}`),
// Cabinet detail by DB id
getCabinetDetail: (id: number) =>
request<{ data: CabinetDetail }>(`/h5/cabinets/${id}`),
// Compartment detail
getCompartmentDetail: (id: number) =>
request<{ data: CompartmentDetail }>(`/h5/compartments/${id}`),
// Actions — match backend POST /api/h5/charge/start, /stop, /door/open with {compartment_id}
startCharge: (compartmentId: number) =>
request<{ code: number }>('/h5/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }),
stopCharge: (compartmentId: number) =>
request<{ code: number }>('/h5/charge/stop', { method: 'POST', body: { compartment_id: compartmentId } }),
openDoor: (compartmentId: number) =>
request<{ code: number }>('/h5/door/open', { method: 'POST', body: { compartment_id: compartmentId } }),
}

View File

@ -1,43 +0,0 @@
export default {
pages: [
'pages/home/index',
'pages/devices/index',
'pages/me/index',
'pages/login/index',
'pages/device-detail/index',
'pages/compartment-detail/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#ffffff',
navigationBarTitleText: '安知充',
navigationBarTextStyle: 'black',
navigationStyle: 'custom',
},
tabBar: {
color: '#999999',
selectedColor: '#2563eb',
backgroundColor: '#ffffff',
borderStyle: 'black',
list: [
{
pagePath: 'pages/home/index',
text: '首页',
iconPath: 'assets/home.svg',
selectedIconPath: 'assets/home-active.svg',
},
{
pagePath: 'pages/devices/index',
text: '设备',
iconPath: 'assets/device.svg',
selectedIconPath: 'assets/device-active.svg',
},
{
pagePath: 'pages/me/index',
text: '我的',
iconPath: 'assets/me.svg',
selectedIconPath: 'assets/me-active.svg',
},
],
},
}

View File

@ -1,66 +0,0 @@
/* 全局样式 */
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 28px;
color: #333;
box-sizing: border-box;
}
/* 通用重置 */
view,
text,
image,
button,
input {
box-sizing: border-box;
}
/* 灰色卡片容器 */
.card {
background: #ffffff;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
}
/* 状态标签 */
.status-badge {
display: inline-block;
padding: 4px 16px;
border-radius: 100px;
font-size: 24px;
font-weight: 500;
}
.status-badge.online {
background-color: #dcfce7;
color: #16a34a;
}
.status-badge.offline {
background-color: #f3f4f6;
color: #6b7280;
}
.status-badge.charging {
background-color: #dcfce7;
color: #16a34a;
}
.status-badge.idle {
background-color: #f3f4f6;
color: #6b7280;
}
.status-badge.fault {
background-color: #fee2e2;
color: #dc2626;
}
/* 占位图标(用纯CSS画简单的tab图标) */
.tab-icon {
width: 48px;
height: 48px;
display: block;
}

View File

@ -1,44 +0,0 @@
import React, { useEffect } from 'react'
import { useDidShow, useDidHide } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { useH5AuthStore } from './store/auth'
import './app.css'
function App(props: { children: React.ReactNode }) {
const restore = useH5AuthStore((s) => s.restore)
const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated)
useEffect(() => {
restore()
}, [restore])
// Handle QR scan entry: URL rewritten by index.html script
useEffect(() => {
const abstractId: string | undefined = (window as any).__SCAN_ABSTRACT_ID__
if (!abstractId) return
if (isAuthenticated) {
import('./api').then(({ h5Api }) => {
h5Api.getCabinetByAbstract(abstractId)
.then((res) => Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` }))
.catch(() => Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` }))
})
} else {
Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` })
}
// ponytail: runs once on mount, auth changes handled by redirect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated])
useDidShow(() => {
// ponytail: analytics hook, add when needed
})
useDidHide(() => {
// ponytail: cleanup hook, add when needed
})
return <>{props.children}</>
}
export default App

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M12 4C10.9 4 10 4.9 10 6v36c0 1.1.9 2 2 2h24c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H12zm0 6h24v28H12V10z"/></svg>

Before

Width:  |  Height:  |  Size: 191 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M12 4C10.9 4 10 4.9 10 6v36c0 1.1.9 2 2 2h24c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H12zm0 6h24v28H12V10z"/></svg>

Before

Width:  |  Height:  |  Size: 188 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M24 4L4 22h6v18h12V28h4v12h12V22h6L24 4z"/></svg>

Before

Width:  |  Height:  |  Size: 134 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M24 4L4 22h6v18h12V28h4v12h12V22h6L24 4z"/></svg>

Before

Width:  |  Height:  |  Size: 131 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M24 24c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.33 0-16 2.67-16 8v4h32v-4c0-5.33-10.67-8-16-8z"/></svg>

Before

Width:  |  Height:  |  Size: 207 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M24 24c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.33 0-16 2.67-16 8v4h32v-4c0-5.33-10.67-8-16-8z"/></svg>

Before

Width:  |  Height:  |  Size: 204 B

View File

@ -1,44 +0,0 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { ChannelStatusBadge } from './StatusBadge'
interface Props {
channelId: number
status: number
voltage?: number
current?: number
onClick?: () => void
}
const COLORS: Record<number, string> = {
0: '#9ca3af', // idle
1: '#16a34a', // charging
2: '#dc2626', // fault
3: '#d1d5db', // offline
}
export default function ChannelCard({ channelId, status, voltage, current, onClick }: Props) {
return (
<View
style={{
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
border: `2px solid ${COLORS[status] || '#e5e7eb'}`,
cursor: onClick ? 'pointer' : undefined,
}}
onClick={onClick}
>
<View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text style={{ fontSize: 24, fontWeight: 600, color: '#111' }}>#{channelId}</Text>
<ChannelStatusBadge status={status} />
</View>
{voltage !== undefined && (
<Text style={{ fontSize: 22, color: '#6b7280' }}>{voltage.toFixed(1)}V</Text>
)}
{current !== undefined && (
<Text style={{ fontSize: 22, color: '#6b7280' }}>{current.toFixed(1)}A</Text>
)}
</View>
)
}

View File

@ -1,35 +0,0 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
interface Props {
open: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: Props) {
if (!open) return null
return (
<View style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}>
<View style={{ width: '70%', backgroundColor: '#fff', borderRadius: 24, overflow: 'hidden' }}>
<View style={{ padding: '32px 24px', textAlign: 'center' }}>
<Text style={{ fontSize: 32, fontWeight: 600, color: '#111', marginBottom: 12, display: 'block' }}>{title}</Text>
<Text style={{ fontSize: 28, color: '#6b7280' }}>{message}</Text>
</View>
<View style={{ display: 'flex', borderTop: '1px solid #e5e7eb' }}>
<View
style={{ flex: 1, padding: '24px 0', textAlign: 'center', borderRight: '1px solid #e5e7eb' }}
onClick={onCancel}
>
<Text style={{ fontSize: 28, color: '#6b7280' }}>取消</Text>
</View>
<View style={{ flex: 1, padding: '24px 0', textAlign: 'center' }} onClick={onConfirm}>
<Text style={{ fontSize: 28, color: '#2563eb', fontWeight: 500 }}>确定</Text>
</View>
</View>
</View>
</View>
)
}

View File

@ -1,24 +0,0 @@
import React from 'react'
import { View, Text, Button } from '@tarojs/components'
interface Props {
message: string
onRetry?: () => void
}
export default function ErrorState({ message, onRetry }: Props) {
return (
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 0' }}>
<Text style={{ fontSize: 60, marginBottom: 16 }}>😵</Text>
<Text style={{ fontSize: 28, color: '#6b7280', marginBottom: 24 }}>{message}</Text>
{onRetry && (
<Button
style={{ padding: '16px 48px', backgroundColor: '#2563eb', color: '#fff', borderRadius: 12, fontSize: 28, border: 'none' }}
onClick={onRetry}
>
重试
</Button>
)}
</View>
)
}

View File

@ -1,12 +0,0 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
export default function Loading({ text = '加载中...' }: { text?: string }) {
return (
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 0' }}>
<View style={{ width: 64, height: 64, border: '4px solid #e5e7eb', borderTopColor: '#2563eb', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<Text style={{ marginTop: 16, fontSize: 28, color: '#9ca3af' }}>{text}</Text>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</View>
)
}

View File

@ -1,27 +0,0 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
const STATUS_MAP: Record<number, { label: string; cls: string }> = {
0: { label: '空闲', cls: 'idle' },
1: { label: '充电中', cls: 'charging' },
2: { label: '故障', cls: 'fault' },
3: { label: '离线', cls: 'offline' },
}
export function CabinetStatusBadge({ status }: { status: number }) {
const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' }
return (
<View className={`status-badge ${s.cls}`}>
<Text>{s.label}</Text>
</View>
)
}
export function ChannelStatusBadge({ status }: { status: number }) {
const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' }
return (
<View className={`status-badge ${s.cls}`}>
<Text>{s.label}</Text>
</View>
)
}

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>安知充</title>
<script>
// 默认路由:根路径跳转到 home 页(tab 页必须在 pages 数组第一位)
;(function () {
var path = window.location.pathname;
// 扫码入口: 提取 URL 中的 abstract_id 并重写路由
var match = path.match(/\/h5\/(\d{2}-\d{8})/);
if (match) {
window.__SCAN_ABSTRACT_ID__ = match[1];
window.history.replaceState({}, '', '/h5/pages/home/index');
} else if (path === '/h5/' || path === '/h5') {
// 纯根路径访问,重定向到 home 页让 Taro 能匹配路由
window.history.replaceState({}, '', '/h5/pages/home/index');
}
})();
// Taro H5 路由 base
window.__TARO_ROUTER_BASE__ = '/h5/';
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More