init: PMS充电柜管理系统源代码

This commit is contained in:
12451 2026-07-02 05:38:01 +08:00
commit 12395ee9e2
184 changed files with 40938 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Rust
target/
target-fresh/
# Node
node_modules/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Temporary
*.tmp
*.log

258
AGENTS.md Normal file
View File

@ -0,0 +1,258 @@
# AGENTS.md - 充电柜项目工作手册
## 项目简介
充电柜矩阵系统Power Matrix System / PMS
产品名安知充anzhizhichong已有充电站平台在运行充电柜是新增产品线。
仓数不固定1柜控板+N仓控板每仓控板管6仓最小6仓。
## 设备标识
- **平台抽象ID**`10-00000000`绑定柜控板二维码对应此ID
- **设备ID**4G SIM卡15位IMEI10进制
- **仓体全局ID**`平台层ID-仓板ID-仓体ID`
- **扫码链接**`https://app.anzhizhichong.com/pms/{抽象ID}`
## 技术栈
- 后端Rust + Axum
- 前端React + Arco Design ProReact版
- 数据库MySQL阿里云RDS开发/生产共用
- 缓存Redis阿里云Redis开发/生产共用
- 设备通讯TCP长连接柜控板4G模块Air780E Cat.1
- 权限RBAC精确到按钮级别权限码如 device:door:unlock
## 项目结构
```
D:\charging-cabinet\
├── tasks/ # 任务文件opencode执行
├── docs/ # 开发文档、协议文档
├── software/
│ ├── server/ # Rust后端
│ └── web/ # React前端
├── hardware/ # 硬件资料(原理图/PCB等
└── test/ # 测试用例/报告
```
## 部署架构
### 环境规划
| 环境 | 域名 | 反代指向 | Nginx配置 |
|------|------|---------|----------|
| 正式环境 | app.anzhizhichong.com | 127.0.0.1:3000服务器本地 | /etc/nginx/conf.d/app.anzhizhichong.com.conf |
| 测试环境 | test.anzhizhichong.com | 10.8.0.248:3001本地电脑 | /etc/nginx/conf.d/test.anzhizhichong.com.conf |
### 服务器信息
- **外网IP**: 118.31.119.206
- **内网IP**: 172.25.42.239
- **系统**: CentOS Stream 92核/3.5G/49G
- **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`IP `10.8.0.252`
- **本地电脑ZeroTier IP**: `10.8.0.248`
- **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat开机自启
- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、Gitea(3080)、acme.sh
- **不装**: Docker/Node/Rust本地编译传二进制
- **SSH**: plink连接首次需 `echo y` 接受host key
### 本地开发环境
**数据库连接通过ZeroTier**
```
MySQL: 10.8.0.252:3306
user: root
password: Hbhyg731024@
database: pms_dev (新建不动AZZCWeChat*)
Redis: 10.8.0.252:6379
password: Hbhyg731024@
db: 5 (新建不动db1-4)
```
**代理说明:**
- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS
- systemd自启`Restart=always` 自动恢复
- 本地直连 `10.8.0.252`无需SSH隧道
### 证书
- 阿里云免费SSL证书acme.sh自动续期
- 存放路径: `/etc/nginx/ssl/{域名}.pem` + `/etc/nginx/ssl/{域名}.key`
- 已配置: app.anzhizhichong.com、test.anzhizhichong.com
### 内部服务
- **Gitea**: `https://app.anzhizhichong.com/doc/` (端口3080)
- 组织: anzhizhichong
- 仓库: charging-cabinet
- 用户: admin / skyp76
## 开发约束
### 禁止操作
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM本地编译传二进制
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存用nginx stream代理
### 编码规范
- 任务需求写到 `tasks/` 目录的md文件
- 通过Agent子代理包裹opencode执行异步通知
- 任务要拆的尽量细
- 质量约束模板固定追加到每个任务文件
- opencode超时必须报告用户不能fallback手动执行
- 审查用不同模型(不用开发同款)
### 编码任务下发
- 任务需求写到 `tasks/` 目录的md文件
- 通过Agent子代理包裹opencode执行异步通知
- 质量约束模板固定追加到每个任务文件
### 质量约束模板
所有编码任务必须追加以下约束:
1. 完成代码后执行对应栈静态校验Rust→cargo checkTS→tsc --noEmit零报错零警告
2. 分层拆分单函数≤80行命名语义化完整注释
3. 所有外部IO/网络请求异常捕获禁止裸panic
4. 分支逻辑全覆盖,不遗漏兜底分支
5. 常量抽离不使用废弃API
6. Rust内存安全合理管理所有权禁用unsafe无合理理由
7. React函数式组件+Hooks不写Class组件
### 验收流程
1. 静态校验命令通过
2. 跑起来看效果
3. 抽查关键逻辑(异常处理、权限校验、协议解析)
## 硬件架构
- 柜控板(ID128)×1 + 仓控板(ID1-N)×N每仓控板管6个仓体
- 仓控板与电源1TN RS485协议翌工
- 仓控板与BMS星恒Modbus / 天能485 / 无协议(平台可配置)
- 仓控板与柜控板自定义485协议14+功能码)
- 柜控板与后台4G TCP长连接Air780E模块
## 开发范围(当前阶段)
### 做
- 后台管理系统Web端
- 用户端H5页面`/pms/{id}`,微信扫码可访问)
- 设备TCP通讯服务协议文档已就绪
### 不做
- 微信小程序名额暂不可用先用H5
- 支付/计费(本系统不涉及)
## 组织架构
```
组织(企业) → 项目(放置地点) → 设备(充电柜)
```
设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。
## 数据模型
### 核心关系
```
organization(组织/企业)
├── project(项目/放置地点)
│ └── cabinet(充电柜)
│ ├── cabin_board(仓控板×N)
│ │ └── compartment(仓体×6)
│ ├── charge_record(充电记录)
│ └── energy_stat(能耗统计)
├── user(用户)
└── device_log(设备日志)
operation_log(操作日志)
```
### 数据库表
| 表名 | 说明 |
|------|------|
| organizations | 组织表 |
| projects | 项目表 |
| cabinets | 充电柜表IMEI、abstract_id、auth_str |
| cabin_boards | 仓控板表 |
| compartments | 仓体表 |
| users | 用户表(手机号登录) |
| charge_records | 充电记录表 |
| device_logs | 设备日志表 |
| energy_stats | 能耗统计表 |
| operation_logs | 操作日志表 |
## TCP通讯协议
- 长连接无需PING包
- 空闲1分钟上报充电15秒上报
- 超时5000ms
- JSON去空格回车省流量LF处理粘包
- `dev_id`IMEI10进制`sub_device_id`485 ID
- `msg_id`自增会话ID
- 全局通道号:`dev_id-sub_device_id-通道号`(如`123456789012345-01-6`
### 签名机制
- 设备首次连接调 `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` | 设备→平台 | 无 | 停电上报 |
| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入含BMS数据字段待定 |
| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) |
| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) |
| `open` | 平台→设备 | `IMEI` | 开门 |
| `status_get` | 平台→设备 | `IMEI-仓控板` | 查仓板状态 |
| `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解析仓控板数量
### 充电结束原因(平台判断)
| 平台收到 | 判断原因 |
|---------|---------|
| `off` (设备→平台) | `normal` — 正常充满 |
| `off` (平台→设备) | `user_stop` — 用户手动停止 |
| `bat_out` | `normal` — 电池拔出 |
| `bat_in` (通道已有电池) | `forced` — 新电池插入,旧电池强制结束 |
| 超时未响应 | `timeout` — 通信超时 |
## H5用户端
- 路径:`/pms/{抽象ID}`
- 功能:显示柜子每个通道状态,可开始/停止充电
- 无支付/计费功能

348
README.md Normal file
View File

@ -0,0 +1,348 @@
# 充电柜矩阵系统 (Power Matrix System / PMS)
产品名安知充anzhizhichong
## 项目简介
36仓体直流电瓶车充电柜仓数不固定1柜控板+N仓控板每仓控板管6仓最小6仓。
## 技术栈
- **后端**Rust + Axum
- **前端**React + Arco Design Pro
- **数据库**MySQL阿里云RDS
- **缓存**Redis阿里云Redis
- **设备通讯**TCP长连接柜控板4G模块Air780E Cat.1
## 项目结构
```
charging-cabinet/
├── tasks/ # 任务文件opencode执行
├── docs/ # 开发文档、协议文档
├── software/
│ ├── server/ # Rust后端
│ └── web/ # React前端
├── hardware/ # 硬件资料(原理图/PCB等
└── test/ # 测试用例/报告
```
## 部署架构
| 环境 | 域名 | 反代指向 |
|------|------|---------|
| 正式环境 | app.anzhizhichong.com | 127.0.0.1:3000服务器本地 |
| 测试环境 | test.anzhizhichong.com | 10.8.0.248:3001本地电脑 |
**服务器**118.31.119.206CentOS Stream 92核/3.5G/49G
**内部服务**
- Gitea: https://app.anzhizhichong.com/doc/
- 文档Wiki: https://app.anzhizhichong.com/doc/anzhizhichong/charging-cabinet/wiki/
## 服务架构
```
┌─────────────────────────────────────────────────────────────┐
│ API服务 (api-server) — 主服务 │
│ - HTTP API后台管理 + H5端口: 3000 │
│ - 前端轮询刷新每5秒
│ - 业务逻辑处理(验证/存DB/告警) │
│ - 节点管理(设备服务注册/心跳) │
│ - 后台协程处理Redis队列 │
└─────────────────────────────────────────────────────────────┘
↑ Redis LIST ↑ Redis LIST
│ device:report:{imei} │ device:cmd:{imei}
│ (设备上报) │ (平台指令)
│ │
┌─────────────────────────────────────────────────────────────┐
│ 设备服务 (device-server) — 轻量化 │
│ - TCP监听: 3002设备长连接
│ - 只处理通信和指令解析 │
│ - 不存DB、不验证签名、不处理业务 │
│ - 注册到API服务启动/心跳/注销) │
│ - Redis连接注册: device:{imei} → {node_id, ip, port} │
└─────────────────────────────────────────────────────────────┘
```
**核心设计原则:**
1. **TCP服务轻量化** — 只处理TCP通信和指令解析不处理业务逻辑
2. **Redis做缓冲** — 服务间通信用Redis LIST不用HTTP直接调用
3. **全异步处理** — 所有指令异步处理msg_id匹配响应
4. **协程处理任务** — API服务用tokio::spawn处理Redis队列消息
**通信流程:**
- **设备上报**:设备 → TCP → 设备服务 → Redis LIST → API服务协程处理
- **平台指令**API服务 → Redis LIST → 设备服务 → TCP → 设备
- **指令响应**:设备 → TCP → 设备服务 → Redis LIST → API服务匹配msg_id
**节点管理:**
- 设备服务启动时注册到API服务
- 每60秒心跳180秒无心跳视为离线
- 支持多节点扩展(当前单节点)
## TCP通讯协议
### 通信规则
- TCP长连接无需PING包
- 空闲1分钟上报充电15秒上报
- 超时5000ms
- JSON去空格回车省流量LF处理粘包
- `dev_id`IMEI10进制`sub_device_id`485 ID
- `msg_id`自增会话ID
- 全局通道号:`dev_id-sub_device_id-通道号`(如`123456789012345-01-6`
### 签名机制
- 设备首次连接调 `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` | 设备→平台 | 无 | 停电上报 |
| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入含BMS数据字段待定 |
| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) |
| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) |
| `open` | 平台→设备 | `IMEI` | 开门 |
| `status_get` | 平台→设备 | `IMEI-仓控板` | 查仓板状态 |
| `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"}
// 响应
{"suc":1,"msg_id":15}
```
---
#### 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 = 门锁故障。**
---
#### status_get平台→设备
查询仓板状态。
```json
// 请求
{"act":"status_get","msg_id":50,"id":"123456789012345-01"}
// 响应
{"suc":1,"msg_id":50,"data":{"status":"..."}}
```
---
#### 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` — 通信超时 |
## 开发规范
- 任务需求写到 `tasks/` 目录的md文件
- 通过Agent子代理包裹opencode执行异步通知
- 质量约束模板固定追加到每个任务文件
- 完成代码后执行对应栈静态校验Rust→cargo checkTS→tsc --noEmit零报错零警告

View File

@ -0,0 +1,384 @@
--- Page 1 ---
-1-
-
标准号YG20240118-1
换电柜用锂电池充电器
1TN 标准通信协议
RS485
2024 年1 月
1.0
苏州翌工电源科技有限公司
研发中心
--- Page 2 ---
-2-
-
版本修改记录:
版本
修改内容
时间
修改人
S1.0
初步建立1TN RS485 协议内容。
2024-01-18
Eleven
--- Page 3 ---
-3-
-
一、
概述
换电柜1TN 多路充电器支持通过RS485 总线进行通信,通信协议为客户自定义协议,
采用Mod Bus-BTU 传输模式波特率9600。
Mod Bus-BTU 传输模式在一根通讯线上采用主从应答方式的通讯连接方式。由主机发
起,主机的信号寻址到一台唯一地址的终端设备(从机),然后终端设备发出的应答信号以
相反的方向传输给主机,即半双工的工作模式。该协议只允许在主机和终端设备之间通讯,
而不允许独立的终端设备之间的数据交换,这样各终端设备不会在它们初始化时占据通讯线
路,而仅限于响应到达本机的查询信号。
1.1 通讯格式
信息传输为异步方式并以字节为单位在主机和从机之间传递的通讯信息是11 位的
字格式。包含1 个起始位(0)8 个数据位和1 个停止位。
信息帧格式:
地址
通道
功能码
长度
数据区
校验码
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
-
0x03
0x00
-
0x0X
0x00
-
0xFF
0x01-0xFF
DATA
有效字节数
读写有效的数据内
采用BigEndian
网络字节序
SUM =ADDR +
CHx+CMD+LEN
+DATA按字节
相加取低8 位
1 个字节
1 个字节
1 个字节
1 个字节
LEN 个字节
1 个字节
1.2 通讯信息过程
信息发送至从机时从机接收通讯命令如果SUM 校验无误则执行相应的操作,然后
把执行结果数据返送给主机。返回的信息中包括Header、CMD、ATS、执行后的数据以
及SUM 校验码和END。如果CRC 校验出错就不返回任何信息,则分析返回相应的错误
信息。
1.2.1
地址码ADDR
对应电源双拨码开关01 代表0x0110 代表0x0211 代表0x03。
对应电源双拨码开关001 代表0x01010 代表0x02011 代表0x03100 代表0x04
101 代表0x05110 代表0x06111 代表0x07。
1.2.2
通道码CHx
对应各个通道识别码。
通道
Bit1-7
Bit0
CHx Byte 解析
通道
Bit1-7
Bit0
CHx Byte 解析
CH1
00
0/1
00CH1 主通道
01CH1 副通道
CH4
03
0/1
06CH4 主通道
07CH4 副通道
--- Page 4 ---
-4-
-
CH2
01
0/1
02CH2 主通道
03CH2 副通道
CH5
04
0/1
08CH5 主通道
09CH5 副通道
CH3
02
0/1
04CH3 主通道
05CH3 副通道
CH6
05
0/1
0ACH6 主通道
0BCH6 副通道
1.2.3
功能码(CMD)
每个通讯信息帧的第4 个字节。主机发送,通过功能码告诉从机应执行什么动作。从
机响应,从机返回的功能码与从主机发送来的功能码一样,表明从机已响应主机并已执行了
相关的操作。本协议支持以下功能码:
序号
命令
CMD1
LEN
备注
1
设置通道
输出电压/电流
0x02
0x04
2
设置通道
开关机
0x03
0x01
3
通道组合指令
开关机/调压/调流
0x04
0x05
4
电源通道信息查询
0x05
0x01
5
供应商电源型号/版本
号/序列号查询
0x12
0x01
10
升级状态查询
0x18
NC
详见升级
协议
11
OTA 远程升级
0x19
NC
详见升级
协议
1.2.4
数据区长度LEN
见上表
1.2.5
数据区(DATA)
数据区随功能码不同而异。这些数据可以是数值、参考地址等。对于不同的从机地址和
数据信息都不相同,应给出通讯信息表,详见例程。
1.2.6
校验区(SUM)
校验区采用和校验方式,从地址码一直到数据域最后一位,求和并取低八位作为校验码。
--- Page 5 ---
-5-
-
指令解析:
1. 设置1#电源CH1主通道输出电压电流
主控发送给电源
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x02
0x04
0x02 58 00 64
0xC5
DATA0x0258代表60.0V, 0x0064代表10.0A.
电源回复给主控
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x02
0x01
0x01
0x05
回复0x01代表设置输出电压电流成功
2. 设置1#电源CH1主通道输出开关机
主控发送给电源
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x03
0x01
0x01
0x06
开关机0x01代表开机0x00代表关机
电源回复给主控
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x03
0x01
0x01
0x06
回复0x01代表当前设置开关机成功
3. 设置1#电源CH1主通道组合控制命令
主控发送给电源
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x04
0x05
0x01 02 58 00 64
0xC9
DATA: 0x01代表开机状态0258代表设置电压60.0V0064代表设置电流10.0A
电源回复给主控
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x04
0x07
0x02 58 00 64 01 01 1A
0xE6
回复DATA: 0258代表当前输出电压60.0V0064代表当前输出电流10.0A01 01代表故障码1A代表环温26C.
4. 读取1#电源CH1主通道运行信息
主控发送给电源
ADDR
CHx
CMD
LEN
DATA
SUM
--- Page 6 ---
-6-
-
0x01
0x00
0x05
0x01
0x00
0x07
电源回复给主控
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x05
0x0D
02 58 00 64 01 01 1A 55 F0 03 E8 08 98
0xBD
回复DATA: 0x0258代表设置电压60.0V,0064代表设置电流10.0A,0x01 01代表故障码,1A代表环温26C,55F0输
入电压220.00V03E8输入电流10.00A0898输入功率2200W
故障码解析:
故障字节1
Bit0
输入欠压
故障字节1
Bit0
硬件故障
Bit1
输入过压
Bit1
PFC故障
Bit2
NC
Bit2
风扇故障
Bit3
输出过压
Bit3
电池反接故障
Bit4
输出欠压
Bit4
NC
Bit5
输出短路
Bit5
NC
Bit6
输出过流
Bit6
NC
Bit7
过温
Bit7
NC
5. 读取1#电源供应商电源型号/版本号/序列号查询
主控发送给电源
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x12
0x01
0x00
0x14
电源回复给主控
ADDR
CHx
CMD
LEN
DATA
SUM
0x01
0x00
0x12
0x0F
YGCP1TN-A 01 01 18 02 17 00 01
+
回复DATA: ASCII码YG代表厂商CP1TN-A代表电源型号S1.0V1.0代表硬件版本1软件版本1, 18 02 17
代表24年2月23日00 01代表序列号00001.

View File

@ -0,0 +1,210 @@
# 换电柜用锂电池充电器 1TN 标准通信协议 RS485
- **标准号**YG20240118-1
- **版本**V1.0
- **制定方**:苏州翌工电源科技有限公司
- **适用范围**:换电柜 1TN 多路充电器 RS485 总线通信
---
## 版本记录
| 版本 | 修改内容 | 时间 | 修改人 |
|------|---------|------|--------|
| S1.0 | 初步建立1TN RS485协议内容 | 2024-01-18 | Eleven |
---
## 1. 概述
换电柜 1TN 多路充电器支持通过 RS485 总线进行通信,通信协议为客户自定义协议,采用 Modbus-RTU 传输模式,波特率 9600。
Modbus-RTU 传输模式在一根通讯线上采用主从应答方式的通讯连接方式。由主机发起,主机的信号寻址到一台唯一地址的终端设备(从机),然后终端设备发出的应答信号以相反的方向传输给主机,即半双工的工作模式。该协议只允许在主机和终端设备之间通讯,而不允许独立的终端设备之间的数据交换,这样各终端设备不会在它们初始化时占据通讯线路,而仅限于响应到达本机的查询信号。
### 1.1 通讯格式
信息传输为异步方式,并以字节为单位,在主机和从机之间传递的通讯信息是 11 位的字格式。包含 1 个起始位(0)8 个数据位和 1 个停止位。
### 1.2 信息帧格式
| 字段 | 字节数 | 说明 |
|------|--------|------|
| ADDR | 1 | 地址码 |
| CHx | 1 | 通道码 |
| CMD | 1 | 功能码 |
| LEN | 1 | 数据区长度 |
| DATA | LEN | 数据区BigEndian 网络字节序) |
| SUM | 1 | 校验码 = (ADDR + CHx + CMD + LEN + DATA) 按字节相加取低8位 |
### 1.3 通讯过程
信息发送至从机时,从机接收通讯命令,如果 SUM 校验无误则执行相应的操作,然后把执行结果(数据)返送给主机。返回的信息中包括 Header、CMD、ATS、执行后的数据以及 SUM 校验码和 END。如果校验出错就不返回任何信息则分析返回相应的错误信息。
---
## 2. 地址码ADDR
对应电源拨码开关。
- **双拨码开关**01→0x01, 10→0x02, 11→0x03
- **3位拨码开关**001→0x01, 010→0x02, 011→0x03, 100→0x04, 101→0x05, 110→0x06, 111→0x07
---
## 3. 通道码CHx
对应各个通道识别码。
| 通道 | Bit1-7 | CHx值Bit0=0主通道 | CHx值Bit0=1副通道 |
|------|--------|----------------------|----------------------|
| CH1 | 0x00 | 0x00 | 0x01 |
| CH2 | 0x01 | 0x02 | 0x03 |
| CH3 | 0x02 | 0x04 | 0x05 |
| CH4 | 0x03 | 0x06 | 0x07 |
| CH5 | 0x04 | 0x08 | 0x09 |
| CH6 | 0x05 | 0x0A | 0x0B |
---
## 4. 功能码CMD
| 序号 | 命令 | CMD | LEN | 备注 |
|------|------|-----|-----|------|
| 1 | 设置通道输出电压/电流 | 0x02 | 0x04 | |
| 2 | 设置通道开关机 | 0x03 | 0x01 | |
| 3 | 通道组合指令(开关机/调压/调流) | 0x04 | 0x05 | |
| 4 | 电源通道信息查询 | 0x05 | 0x01 | |
| 5 | 供应商电源型号/版本号/序列号查询 | 0x12 | 0x01 | |
| 6 | 升级状态查询 | 0x18 | NC | 详见升级协议 |
| 7 | OTA 远程升级 | 0x19 | NC | 详见升级协议 |
---
## 5. 故障码
### 故障字节1
| Bit | 含义 | Bit | 含义 |
|-----|------|-----|------|
| Bit0 | 输入欠压 | Bit4 | 输出欠压 |
| Bit1 | 输入过压 | Bit5 | 输出短路 |
| Bit2 | NC | Bit6 | 输出过流 |
| Bit3 | 输出过压 | Bit7 | 过温 |
### 故障字节2
| Bit | 含义 | Bit | 含义 |
|-----|------|-----|------|
| Bit0 | 硬件故障 | Bit4 | NC |
| Bit1 | PFC故障 | Bit5 | NC |
| Bit2 | 风扇故障 | Bit6 | NC |
| Bit3 | 电池反接故障 | Bit7 | NC |
---
## 6. 指令示例
### 6.1 设置输出电压/电流CMD=0x02
**主控→电源:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x02 | 0x04 | 0x02 0x58 0x00 0x64 | 0xC5 |
DATA0x0258 = 60.0V, 0x0064 = 10.0A
**电源→主控:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x02 | 0x01 | 0x01 | 0x05 |
回复0x01 = 设置成功
### 6.2 设置开关机CMD=0x03
**主控→电源:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x03 | 0x01 | 0x01 | 0x06 |
DATA0x01 = 开机, 0x00 = 关机
**电源→主控:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x03 | 0x01 | 0x01 | 0x06 |
回复0x01 = 设置成功
### 6.3 组合控制命令CMD=0x04
**主控→电源:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x04 | 0x05 | 0x01 0x02 0x58 0x00 0x64 | 0xC9 |
DATA: 0x01 = 开机, 0x0258 = 60.0V, 0x0064 = 10.0A
**电源→主控:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x04 | 0x07 | 0x02 0x58 0x00 0x64 0x01 0x01 0x1A | 0xE6 |
回复DATA: 0x0258 = 当前电压60.0V, 0x0064 = 当前电流10.0A, 0x01 0x01 = 故障码, 0x1A = 环温26℃
### 6.4 读取通道运行信息CMD=0x05
**主控→电源:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x05 | 0x01 | 0x00 | 0x07 |
**电源→主控:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x05 | 0x0D | 0x02 0x58 0x00 0x64 0x01 0x01 0x1A 0x55 0xF0 0x03 0xE8 0x08 0x98 | 0xBD |
回复DATA解析
| 偏移 | 数据 | 含义 |
|------|------|------|
| 0-1 | 0x0258 | 设置电压 60.0V |
| 2-3 | 0x0064 | 设置电流 10.0A |
| 4-5 | 0x0101 | 故障码 |
| 6 | 0x1A | 环温 26℃ |
| 7-8 | 0x55F0 | 输入电压 220.00V |
| 9-10 | 0x03E8 | 输入电流 10.00A |
| 11-12 | 0x0898 | 输入功率 2200W |
### 6.5 查询供应商信息CMD=0x12
**主控→电源:**
| ADDR | CHx | CMD | LEN | DATA | SUM |
|------|-----|-----|-----|------|-----|
| 0x01 | 0x00 | 0x12 | 0x01 | 0x00 | 0x14 |
**电源→主控:**
| ADDR | CHx | CMD | LEN | DATA |
|------|-----|-----|-----|------|
| 0x01 | 0x00 | 0x12 | 0x0F | 'YGCP1TN-A' + 硬件版本 + 软件版本 + 日期(年/月/日) + 序列号 |
回复DATA解析
| 偏移 | 含义 |
|------|------|
| 0-1 | 厂商 ('YG') |
| 2-9 | 电源型号 ('CP1TN-A') |
| 10 | 硬件版本 (S1.0) |
| 11 | 软件版本 (V1.0) |
| 12-14 | 生产日期 (年/月/日) |
| 15-16 | 序列号 |

Binary file not shown.

272
docs/build_contract.js Normal file
View File

@ -0,0 +1,272 @@
// 开发合同.docx 生成脚本
// 标准 Word 排版A4、宋体、清晰的层级
const {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
AlignmentType, HeadingLevel, BorderStyle, WidthType, ShadingType,
PageNumber, Footer, Header, LevelFormat
} = require('docx');
const fs = require('fs');
// 边框样式
const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
const tableBorders = {
top: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
bottom: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
left: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
right: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
};
// 标题段落(一级)
function h1(text) {
return new Paragraph({
heading: HeadingLevel.HEADING_1,
spacing: { before: 360, after: 200 },
children: [new TextRun({ text, bold: true, size: 32, font: "SimSun" })],
});
}
// 标题段落(二级)
function h2(text) {
return new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 240, after: 120 },
children: [new TextRun({ text, bold: true, size: 28, font: "SimSun" })],
});
}
// 正文段落
function p(text, opts = {}) {
return new Paragraph({
spacing: { line: 360, before: 80, after: 80, lineRule: "auto" },
alignment: opts.align || AlignmentType.JUSTIFIED,
indent: opts.indent ? { firstLine: opts.indent } : undefined,
children: [new TextRun({ text, size: 24, font: "SimSun" })],
});
}
// 粗体段落
function pBold(text, opts = {}) {
return new Paragraph({
spacing: { line: 360, before: 80, after: 80, lineRule: "auto" },
alignment: opts.align || AlignmentType.LEFT,
children: [new TextRun({ text, bold: true, size: 24, font: "SimSun" })],
});
}
// 空行
function empty() {
return new Paragraph({ children: [new TextRun({ text: "" })] });
}
// 居中粗体大标题
function bigTitle(text) {
return new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 400 },
children: [new TextRun({ text, bold: true, size: 44, font: "SimHei" })],
});
}
// 表格单元格
function cell(text, opts = {}) {
return new TableCell({
borders: tableBorders,
width: { size: opts.width || 2000, type: WidthType.DXA },
shading: opts.header ? { fill: "E7E6E6", type: ShadingType.CLEAR, color: "auto" } : undefined,
margins: { top: 100, bottom: 100, left: 120, right: 120 },
children: [new Paragraph({
alignment: opts.align || AlignmentType.LEFT,
spacing: { line: 300, before: 0, after: 0 },
children: [new TextRun({
text: String(text),
bold: !!opts.header,
size: 24,
font: "SimSun",
})],
})],
});
}
// 创建表格
function makeTable(rows, colWidths) {
return new Table({
width: { size: colWidths.reduce((a, b) => a + b, 0), type: WidthType.DXA },
columnWidths: colWidths,
rows: rows.map((row, i) => new TableRow({
children: row.map((text, j) => cell(text, { width: colWidths[j], header: i === 0 })),
})),
});
}
// ==================== 内容 ====================
const children = [];
// 大标题
children.push(bigTitle("36仓体直流电瓶车充电柜"));
children.push(bigTitle("开发合同"));
// 合同信息
children.push(p("合同编号_______________", { align: AlignmentType.LEFT }));
children.push(p("签订地点_______________", { align: AlignmentType.LEFT }));
children.push(p("签订日期______年______月______日", { align: AlignmentType.LEFT }));
children.push(empty());
// 第一条
children.push(h1("第一条 合同双方"));
children.push(pBold("甲方(委托方):"));
children.push(p("公司名称____________________________________"));
children.push(p("法定代表人________________"));
children.push(p("地址____________________________________"));
children.push(p("联系人________________ 联系电话________________"));
children.push(empty());
children.push(pBold("乙方(开发方):"));
children.push(p("公司名称____________________________________"));
children.push(p("法定代表人________________"));
children.push(p("地址____________________________________"));
children.push(p("联系人________________ 联系电话________________"));
// 第二条
children.push(h1("第二条 项目概述"));
children.push(p("1. 项目名称36仓体直流电瓶车充电柜开发项目。"));
children.push(p("2. 项目目标:开发充电柜配套的柜控主板、仓控主板、嵌入式固件、后台管理系统及微信小程序。"));
children.push(p("3. 开发周期合同生效后12周。"));
children.push(p("4. 交付成果:详见第五条及附件。"));
// 第三条
children.push(h1("第三条 开发内容"));
children.push(makeTable(
[
["序号", "开发内容", "详见附件"],
["1", "柜控主板硬件设计开发", "附件一"],
["2", "仓控主板硬件设计开发", "附件一"],
["3", "嵌入式固件开发(柜控板+仓控板)", "附件一"],
["4", "后台管理系统开发", "附件二"],
["5", "微信小程序开发", "附件二"],
["6", "通讯协议栈开发实现", "附件三"],
],
[1000, 5360, 3000]
));
children.push(empty());
children.push(p("乙方负责主板设计、元器件采购、焊接调试及联调支持。柜体、电源及外部器件由甲方提供。本合同不交付任何源代码。"));
// 第四条
children.push(h1("第四条 开发阶段"));
children.push(makeTable(
[
["阶段", "时间", "交付物"],
["M1 方案定稿", "第1-2周", "原理图、PCB、BOM、协议文档"],
["M2 开发完成", "第3-6周", "硬件样板、固件、后台、小程序"],
["M3 联调完成", "第7-9周", "集成测试报告"],
["M4 验收交付", "第10-12周", "完整成品、技术文档"],
],
[2500, 2000, 4860]
));
children.push(empty());
children.push(p("付款方式______________________________________________________________________"));
// 第五条
children.push(h1("第五条 交付与验收"));
children.push(p("交付物清单及验收标准详见附件三。整机验收时乙方到甲方现场完成联调,验收所需柜体及电源由甲方提供。"));
// 第六条
children.push(h1("第六条 知识产权"));
children.push(p("1. 乙方在本合同履行过程中产生的全部技术成果(包括但不限于源代码、设计图纸、技术文档等)的知识产权归甲乙双方共同所有。"));
children.push(p("2. 任何一方均有权在自身业务范围内自由使用上述技术成果,无需征得另一方同意或另行支付费用。"));
// 第七条
children.push(h1("第七条 保密条款"));
children.push(p("1. 双方对合同履行过程中知悉的对方商业秘密、技术秘密负有保密义务。"));
children.push(p("2. 保密期限自合同生效之日起3年。"));
children.push(p("3. 未经对方书面同意,不得向第三方披露任何与本合同相关的信息。"));
// 第八条
children.push(h1("第八条 质保与维护"));
children.push(p("1. 硬件质保期自验收通过之日起12个月质保期内免费维修或更换。"));
children.push(p("2. 软件质保期自验收通过之日起6个月质保期内免费修复Bug。"));
children.push(p("3. 质保期外的维护服务费用由双方另行协商。"));
// 第九条
children.push(h1("第九条 违约责任"));
children.push(p("1. 甲方逾期付款的,每逾期一日按应付未付金额的千分之三支付违约金。"));
children.push(p("2. 乙方逾期交付的,每逾期一日按合同总金额的千分之三支付违约金。"));
children.push(p("3. 任何一方违反知识产权或保密条款的,应赔偿对方全部损失。"));
// 第十条
children.push(h1("第十条 合同总金额"));
children.push(p("合同总金额人民币____________元大写__________________________。"));
// 第十一条
children.push(h1("第十一条 争议解决"));
children.push(p("本合同履行过程中发生争议的,双方应协商解决;协商不成的,提交合同签订地人民法院诉讼解决。"));
// 第十二条
children.push(h1("第十二条 其他"));
children.push(p("1. 本合同一式两份,甲乙双方各执一份,具有同等法律效力。"));
children.push(p("2. 本合同附件为合同的组成部分,与本合同具有同等法律效力。"));
children.push(p("3. 未尽事宜,双方可另行签订补充协议。"));
// 签章区
children.push(empty());
children.push(empty());
children.push(pBold("甲方盖章__________________________"));
children.push(p("授权代表签字________________________"));
children.push(p("日期______年______月______日"));
children.push(empty());
children.push(pBold("乙方盖章__________________________"));
children.push(p("授权代表签字________________________"));
children.push(p("日期______年______月______日"));
// ==================== 生成文档 ====================
const doc = new Document({
creator: "CodeBuddy",
title: "36仓体直流电瓶车充电柜开发合同",
styles: {
default: {
document: { run: { font: "SimSun", size: 24 } },
},
paragraphStyles: [
{
id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { size: 32, bold: true, font: "SimHei" },
paragraph: { spacing: { before: 360, after: 200 }, outlineLevel: 0 },
},
{
id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { size: 28, bold: true, font: "SimHei" },
paragraph: { spacing: { before: 240, after: 120 }, outlineLevel: 1 },
},
],
},
sections: [{
properties: {
page: {
size: { width: 11906, height: 16838 }, // A4
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
},
},
footers: {
default: new Footer({
children: [new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: "第 ", size: 20, font: "SimSun" }),
new TextRun({ children: [PageNumber.CURRENT], size: 20, font: "SimSun" }),
new TextRun({ text: " 页 / 共 ", size: 20, font: "SimSun" }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 20, font: "SimSun" }),
new TextRun({ text: " 页", size: 20, font: "SimSun" }),
],
})],
}),
},
children,
}],
});
Packer.toBuffer(doc).then(buffer => {
fs.writeFileSync("开发合同.docx", buffer);
console.log("OK: 开发合同.docx 已生成 (" + buffer.length + " bytes)");
});

22
docs/convert.js Normal file
View File

@ -0,0 +1,22 @@
const marked = require('marked');
const fs = require('fs');
const files = ['开发合同.md', '附件一-硬件规格.md', '附件二-软件规格.md', '附件三-交付与验收标准.md'];
const styles = `body{font-family:"Microsoft YaHei",sans-serif;max-width:900px;margin:40px auto;padding:20px;line-height:1.8;color:#333}
h1{text-align:center;font-size:22px;border-bottom:2px solid #333;padding-bottom:10px}
h2{font-size:18px;margin-top:30px;border-left:4px solid #333;padding-left:10px}
h3{font-size:16px;margin-top:20px}h4{font-size:15px;margin-top:15px}
table{width:100%;border-collapse:collapse;margin:15px 0;font-size:14px}
th,td{border:1px solid #999;padding:8px;text-align:left}th{background:#f0f0f0}
p,li{font-size:14px}hr{border:none;border-top:1px solid #ccc;margin:30px 0}
pre{background:#f5f5f5;padding:15px;border-radius:4px;overflow-x:auto;font-size:13px}
code{font-family:Consolas,monospace}
.blank{color:#999;border-bottom:1px solid #999;display:inline-block;min-width:150px}
.sign{margin-top:60px}.sign div{display:inline-block;width:45%;vertical-align:top}`;
files.forEach(f => {
const md = fs.readFileSync(f, 'utf8');
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${f.replace('.md','')}</title><style>${styles}</style></head><body>${marked.parse(md)}</body></html>`;
fs.writeFileSync(f.replace('.md', '.html'), html);
console.log(`OK: ${f} -> ${f.replace('.md', '.html')}`);
});

548
docs/package-lock.json generated Normal file
View File

@ -0,0 +1,548 @@
{
"name": "docs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"docx": "^9.7.1",
"marked": "^18.0.5",
"puppeteer": "^25.1.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.0.4.tgz",
"integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==",
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.7.6",
"yargs": "^17.7.2"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
}
}
},
"node_modules/@types/node": {
"version": "25.9.4",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.4.tgz",
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chromium-bidi": {
"version": "16.0.1",
"resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
"integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/devtools-protocol": {
"version": "0.0.1624250",
"resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
"integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==",
"license": "BSD-3-Clause"
},
"node_modules/docx": {
"version": "9.7.1",
"resolved": "https://registry.npmmirror.com/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/marked": {
"version": "18.0.5",
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/modern-tar": {
"version": "0.7.6",
"resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.7.6.tgz",
"integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/nanoid": {
"version": "5.1.15",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.15.tgz",
"integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/puppeteer": {
"version": "25.1.0",
"resolved": "https://registry.npmmirror.com/puppeteer/-/puppeteer-25.1.0.tgz",
"integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.0.4",
"chromium-bidi": "16.0.1",
"devtools-protocol": "0.0.1624250",
"lilconfig": "^3.1.3",
"puppeteer-core": "25.1.0",
"typed-query-selector": "^2.12.2"
},
"bin": {
"puppeteer": "lib/puppeteer/node/cli.js"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/puppeteer-core": {
"version": "25.1.0",
"resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.1.0.tgz",
"integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.0.4",
"chromium-bidi": "16.0.1",
"devtools-protocol": "0.0.1624250",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.2",
"ws": "^8.21.0"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

7
docs/package.json Normal file
View File

@ -0,0 +1,7 @@
{
"dependencies": {
"docx": "^9.7.1",
"marked": "^18.0.5",
"puppeteer": "^25.1.0"
}
}

26
docs/toPdf.js Normal file
View File

@ -0,0 +1,26 @@
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const dir = 'D:\\projects\\charging-cabinet\\docs';
async function convert(htmlName, pdfName) {
const html = fs.readFileSync(path.join(dir, htmlName), 'utf8');
const browser = await puppeteer.launch({ headless: 'new', executablePath: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.pdf({
path: path.join(dir, pdfName),
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
printBackground: true,
});
await browser.close();
console.log(`OK: ${pdfName}`);
}
(async () => {
await convert('开发合同.html', '开发合同.pdf');
await convert('附件-开发需求规格说明书.html', '附件-开发需求规格说明书.pdf');
console.log('Done');
})();

Binary file not shown.

161
docs/开发合同.html Normal file
View File

@ -0,0 +1,161 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>开发合同</title><style>body{font-family:"Microsoft YaHei",sans-serif;max-width:900px;margin:40px auto;padding:20px;line-height:1.8;color:#333}
h1{text-align:center;font-size:22px;border-bottom:2px solid #333;padding-bottom:10px}
h2{font-size:18px;margin-top:30px;border-left:4px solid #333;padding-left:10px}
h3{font-size:16px;margin-top:20px}h4{font-size:15px;margin-top:15px}
table{width:100%;border-collapse:collapse;margin:15px 0;font-size:14px}
th,td{border:1px solid #999;padding:8px;text-align:left}th{background:#f0f0f0}
p,li{font-size:14px}hr{border:none;border-top:1px solid #ccc;margin:30px 0}
pre{background:#f5f5f5;padding:15px;border-radius:4px;overflow-x:auto;font-size:13px}
code{font-family:Consolas,monospace}
.blank{color:#999;border-bottom:1px solid #999;display:inline-block;min-width:150px}
.sign{margin-top:60px}.sign div{display:inline-block;width:45%;vertical-align:top}</style></head><body><h1>36仓体直流电瓶车充电柜 开发合同</h1>
<p><strong>合同编号:</strong> ________________</p>
<p><strong>签订地点:</strong> ________________</p>
<p><strong>签订日期:</strong> _____年____月____日____</p>
<hr>
<h2>第一条 合同双方</h2>
<p><strong>甲方(委托方):</strong></p>
<ul>
<li>公司名称______________________________</li>
<li>法定代表人________________</li>
<li>地址______________________________</li>
<li>联系人________ 联系电话____________</li>
</ul>
<p><strong>乙方(开发方):</strong></p>
<ul>
<li>公司名称______________________________</li>
<li>法定代表人________________</li>
<li>地址______________________________</li>
<li>联系人________ 联系电话____________</li>
</ul>
<hr>
<h2>第二条 项目概述</h2>
<ol>
<li><strong>项目名称</strong>36仓体直流电瓶车充电柜开发项目</li>
<li><strong>项目目标</strong>:开发充电柜配套的柜控主板、仓控主板、嵌入式固件、后台管理系统及微信小程序</li>
<li><strong>开发周期</strong>合同生效后12周</li>
<li><strong>交付成果</strong>:详见第五条及附件</li>
</ol>
<h2>第三条 开发内容</h2>
<table>
<thead>
<tr>
<th>序号</th>
<th>开发内容</th>
<th>详见附件</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>柜控主板硬件设计开发</td>
<td>附件一</td>
</tr>
<tr>
<td>2</td>
<td>仓控主板硬件设计开发</td>
<td>附件一</td>
</tr>
<tr>
<td>3</td>
<td>嵌入式固件开发(柜控板+仓控板)</td>
<td>附件一</td>
</tr>
<tr>
<td>4</td>
<td>后台管理系统开发</td>
<td>附件二</td>
</tr>
<tr>
<td>5</td>
<td>微信小程序开发</td>
<td>附件二</td>
</tr>
<tr>
<td>6</td>
<td>通讯协议栈开发实现</td>
<td>附件三</td>
</tr>
</tbody></table>
<p>乙方负责主板设计、元器件采购、焊接调试及联调支持。柜体、电源及外部器件由甲方提供。<strong>本合同不交付任何源代码。</strong></p>
<h2>第四条 开发阶段</h2>
<table>
<thead>
<tr>
<th>阶段</th>
<th>时间</th>
<th>交付物</th>
</tr>
</thead>
<tbody><tr>
<td>M1 方案定稿</td>
<td>第1-2周</td>
<td>原理图、PCB、BOM、协议文档</td>
</tr>
<tr>
<td>M2 开发完成</td>
<td>第3-6周</td>
<td>硬件样板、固件、后台、小程序</td>
</tr>
<tr>
<td>M3 联调完成</td>
<td>第7-9周</td>
<td>集成测试报告</td>
</tr>
<tr>
<td>M4 验收交付</td>
<td>第10-12周</td>
<td>完整成品、技术文档</td>
</tr>
</tbody></table>
<p><strong>付款方式:</strong> ________________________________________________________________________</p>
<h2>第五条 交付与验收</h2>
<p>交付物清单及验收标准详见附件三。整机验收时乙方到甲方现场完成联调,验收所需柜体及电源由甲方提供。</p>
<hr>
<h2>第六条 知识产权</h2>
<ol>
<li>乙方在本合同履行过程中产生的全部技术成果(包括但不限于源代码、设计图纸、技术文档等)的知识产权归<strong>甲乙双方共同所有</strong></li>
<li>任何一方均有权在自身业务范围内自由使用上述技术成果,无需征得另一方同意或另行支付费用。</li>
</ol>
<hr>
<h2>第七条 保密条款</h2>
<ol>
<li>双方对合同履行过程中知悉的对方商业秘密、技术秘密负有保密义务。</li>
<li>保密期限自合同生效之日起3年。</li>
<li>未经对方书面同意,不得向第三方披露任何与本合同相关的信息。</li>
</ol>
<hr>
<h2>第八条 质保与维护</h2>
<ol>
<li>硬件质保期自验收通过之日起 <strong>12个月</strong>,质保期内免费维修或更换。</li>
<li>软件质保期自验收通过之日起 <strong>6个月</strong>质保期内免费修复Bug。</li>
<li>质保期外的维护服务费用由双方另行协商。</li>
</ol>
<hr>
<h2>第九条 违约责任</h2>
<ol>
<li>甲方逾期付款的,每逾期一日按应付未付金额的千分之三支付违约金。</li>
<li>乙方逾期交付的,每逾期一日按合同总金额的千分之三支付违约金。</li>
<li>任何一方违反知识产权或保密条款的,应赔偿对方全部损失。</li>
</ol>
<hr>
<h2>第十条 合同总金额</h2>
<p><strong>合同总金额</strong>:人民币 ___________ 元大写____________________</p>
<hr>
<h2>第十一条 争议解决</h2>
<p>本合同履行过程中发生争议的,双方应协商解决;协商不成的,提交合同签订地人民法院诉讼解决。</p>
<hr>
<h2>第十二条 其他</h2>
<ol>
<li>本合同一式两份,甲乙双方各执一份,具有同等法律效力。</li>
<li>本合同附件为合同的组成部分,与本合同具有同等法律效力。</li>
<li>未尽事宜,双方可另行签订补充协议。</li>
</ol>
<hr>
<p><strong>甲方(盖章):</strong> ____________________</p>
<p>授权代表签字________________</p>
<p>日期_____年____月____日</p>
<hr>
<p><strong>乙方(盖章):</strong> ____________________</p>
<p>授权代表签字________________</p>
<p>日期_____年____月____日</p>
</body></html>

127
docs/开发合同.md Normal file
View File

@ -0,0 +1,127 @@
# 36仓体直流电瓶车充电柜 开发合同
**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**签订地点:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**签订日期:** \_\_\_\_\_年\_\_\_\_月\_\_\_\_日\_\_\_\_
---
## 第一条 合同双方
**甲方(委托方):**
- 公司名称:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
- 法定代表人:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
- 地址:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
- 联系人:\_\_\_\_\_\_\_\_ 联系电话:\_\_\_\_\_\_\_\_\_\_\_\_
**乙方(开发方):**
- 公司名称:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
- 法定代表人:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
- 地址:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
- 联系人:\_\_\_\_\_\_\_\_ 联系电话:\_\_\_\_\_\_\_\_\_\_\_\_
---
## 第二条 项目概述
1. **项目名称**36仓体直流电瓶车充电柜开发项目
2. **项目目标**:开发充电柜配套的柜控主板、仓控主板、嵌入式固件、后台管理系统及微信小程序
3. **开发周期**合同生效后12周
4. **交付成果**:详见第五条及附件
## 第三条 开发内容
| 序号 | 开发内容 | 详见附件 |
|------|---------|---------|
| 1 | 柜控主板硬件设计开发 | 附件一 |
| 2 | 仓控主板硬件设计开发 | 附件一 |
| 3 | 嵌入式固件开发(柜控板+仓控板) | 附件一 |
| 4 | 后台管理系统开发 | 附件二 |
| 5 | 微信小程序开发 | 附件二 |
| 6 | 通讯协议栈开发实现 | 附件三 |
乙方负责主板设计、元器件采购、焊接调试及联调支持。柜体、电源及外部器件由甲方提供。**本合同不交付任何源代码。**
## 第四条 开发阶段
| 阶段 | 时间 | 交付物 |
|------|------|--------|
| M1 方案定稿 | 第1-2周 | 原理图、PCB、BOM、协议文档 |
| M2 开发完成 | 第3-6周 | 硬件样板、固件、后台、小程序 |
| M3 联调完成 | 第7-9周 | 集成测试报告 |
| M4 验收交付 | 第10-12周 | 完整成品、技术文档 |
**付款方式:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
## 第五条 交付与验收
交付物清单及验收标准详见附件三。整机验收时乙方到甲方现场完成联调,验收所需柜体及电源由甲方提供。
---
## 第六条 知识产权
1. 乙方在本合同履行过程中产生的全部技术成果(包括但不限于源代码、设计图纸、技术文档等)的知识产权归**甲乙双方共同所有**。
2. 任何一方均有权在自身业务范围内自由使用上述技术成果,无需征得另一方同意或另行支付费用。
---
## 第七条 保密条款
1. 双方对合同履行过程中知悉的对方商业秘密、技术秘密负有保密义务。
2. 保密期限自合同生效之日起3年。
3. 未经对方书面同意,不得向第三方披露任何与本合同相关的信息。
---
## 第八条 质保与维护
1. 硬件质保期自验收通过之日起 **12个月**,质保期内免费维修或更换。
2. 软件质保期自验收通过之日起 **6个月**质保期内免费修复Bug。
3. 质保期外的维护服务费用由双方另行协商。
---
## 第九条 违约责任
1. 甲方逾期付款的,每逾期一日按应付未付金额的千分之三支付违约金。
2. 乙方逾期交付的,每逾期一日按合同总金额的千分之三支付违约金。
3. 任何一方违反知识产权或保密条款的,应赔偿对方全部损失。
---
## 第十条 合同总金额
**合同总金额**:人民币 \_\_\_\_\_\_\_\_\_\_\_ 元(大写:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
---
## 第十一条 争议解决
本合同履行过程中发生争议的,双方应协商解决;协商不成的,提交合同签订地人民法院诉讼解决。
---
## 第十二条 其他
1. 本合同一式两份,甲乙双方各执一份,具有同等法律效力。
2. 本合同附件为合同的组成部分,与本合同具有同等法律效力。
3. 未尽事宜,双方可另行签订补充协议。
---
**甲方(盖章):** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
授权代表签字:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
日期:\_\_\_\_\_年\_\_\_\_月\_\_\_\_日
---
**乙方(盖章):** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
授权代表签字:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
日期:\_\_\_\_\_年\_\_\_\_月\_\_\_\_日

BIN
docs/开发合同.pdf Normal file

Binary file not shown.

935
docs/开发文档.md Normal file
View File

@ -0,0 +1,935 @@
# 36仓体直流电瓶车充电柜 - 开发文档
## 1. 项目概述
### 1.1 项目目标
开发一款36仓体直流电瓶车充电柜配置6个电源每个电源对应6个仓体的独立直流输出口支持每个仓体输出口独立设置电流、电压参数适配星恒BMS Modbus协议与电源1TN RS485协议实现安全、高效的独立充电功能。每个仓体配套状态指示灯通过不同灯光状态反馈仓体工作情况同时支持平台远程禁用仓体。
核心新增定制仓控主板及柜控主板功能:
- 每个仓控主板单独与所属电源通讯
- 仓控板采用拨码开关设置IDID范围1-6
- 柜控板ID固定为128
### 1.2 核心功能矩阵
| 功能模块 | 子功能 | 说明 |
|---------|--------|------|
| 独立充电 | 多电压适配 | 48V/60V/72V直流适配电流5A-20A可调 |
| | 快充模式 | 直流快充1-2小时充满 |
| | 充电控制 | 扫码开门/预约/定时充电 |
| | BMS协议适配 | 支持星恒485一线通、天能485平台可配置 |
| | 无协议充电 | 固定电压电流输出,低电流自动停止 |
| 仓体状态指示 | 绿灯闪烁 | 充电中,继电器闭合,电源正常输出 |
| | 绿灯常亮 | 仓体空闲,可正常启动充电 |
| | 红灯常亮 | 平台仓体禁用,管理员锁定 |
| | 红灯闪烁 | 电池不符合要求或设备告警或门未关 |
| 门锁控制 | 扫码开门 | 用户扫码→平台验证→下发开门指令 |
| | 门磁检测 | 实时监测仓门开/关状态 |
| | 关门超时报警 | 超过设置阈值未关门,上传告警(阈值可配置) |
| 平台仓体控制 | 远程禁用 | 管理员下发禁用指令,强制锁定仓体 |
| | 状态同步 | 禁用后指示灯切换,禁止充电 |
| | 手动解除 | 管理员解除禁用,恢复空闲状态 |
| 协议对接 | 1TN RS485 | 仓控板与电源通讯(电源参数控制) |
| | 星恒BMS Modbus | 仓控板与星恒电池BMS通讯状态监测 |
| | 天能485 | 仓控板与天能电池BMS通讯状态监测 |
| | 仓控-柜控485 | 仓控板ID1-6与柜控板ID128通讯 |
| 安全防护 | 过充/过温/过流/短路/漏电 | 实时监测,自动断电 |
| | 烟感+喷淋 | 烟感触发→断开接触器+打开喷淋电磁阀 |
| | 浸水保护 | 浸水触发→断开接触器切断380V |
| | 远程恢复 | 误报警可平台远程吸合接触器恢复供电 |
| | 6路温控 | 仓体温度实时采集,高温阈值可配置 |
| | 加热风扇 | 环境温度<0°C自动启动加热25°C停止 |
| 固件管理 | OTA升级 | 平台远程升级柜控板和仓控板固件 |
| 定制主板 | 仓控主板 | 6路继电器、门锁驱动、门磁检测、光耦隔离、多通道485、温控 |
| | 柜控主板 | 烟感/浸水接口、喷淋电磁阀、加热风扇、电磁泵、4G模块(TCP长连接) |
## 2. 系统架构
### 2.1 硬件架构
```
┌───────────────────────┐
│ 380V 市电输入 │
└──────────┬────────────┘
┌────┴────┐
│ 漏保 │
└────┬────┘
┌──────────┼──────────┐
│ │ │
┌─────┴─────┐ ┌──┴───┐ ┌───┴──────┐
│ 12V电源 │ │接触器 │ │其他负载 │
│(预接触器) │ │柜控板控│ │(风扇等) │
└─────┬─────┘ └──┬───┘ └──────────┘
│ │
┌─────┴─────┐ ┌──┴───────────┐
│ 柜控板128 │ │ 充电电源×6 │
│ 仓控板×6 │ │ (直流输出) │
│ (常供电) │ └──────────────┘
└─────┬─────┘
┌─────┴─────┐
│ 后备电池 │
│(380V断电备用)│
└───────────┘
```
### 2.2 软件架构
```
┌─────────────────────────────────────────────┐
│ 后台管理平台 │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │
│ │设备管理│ │用户管理│ │数据统计│ │故障管理 │ │
│ │OTA升级│ │ │ │ │ │报警阈值 │ │
│ └──┬───┘ └──┬───┘ └──┬───┘ └────┬─────┘ │
│ ┌──┴──────────┴──────────┴──────────┴────┐ │
│ │ BMS协议配置 · 无协议充电参数 │ │
│ │ 门锁控制 · 仓体控制 · 仓体禁用 │ │
│ └─────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 自定义TCP通讯服务长连接 │ │
│ └────────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 微信小程序 │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │
│ │扫码充电│ │预约充电│ │进度查询│ │消息通知 │ │
│ │扫码开门│ │ │ │ │ │ │ │
│ └──────┘ └──────┘ └──────┘ └──────────┘ │
├─────────────────────────────────────────────┤
│ 柜控主板ID128固件 │
│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ TCP通讯 │ │数据处理 │ │充电控制逻辑 │ │
│ ├──────────┤ ├──────────┤ ├─────────────┤ │
│ │烟感/浸水 │ │485主站 │ │门锁指令转发 │ │
│ │喷淋电磁阀 │ │OTA管理 │ │BMS配置管理 │ │
│ └──────────┘ └──────────┘ └─────────────┘ │
├─────────────────────────────────────────────┤
│ 仓控主板ID1-6固件 │
│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ 485从站 │ │BMS通讯 │ │ 继电器控制 │ │
│ │ │ │多协议适配 │ │ 门锁控制 │ │
│ ├──────────┤ ├──────────┤ ├─────────────┤ │
│ │指示灯控制 │ │温控采集 │ │ 门磁监测 │ │
│ │OTA升级 │ │ │ │ 数据上报 │ │
│ └──────────┘ └──────────┘ └─────────────┘ │
└─────────────────────────────────────────────┘
```
## 3. 通讯协议设计
### 3.1 电源 1TN RS485 协议
#### 3.1.1 协议基础
- 标准号YG20240118-1版本 V1.0
- 制定方:苏州翌工电源科技有限公司
- 适用设备换电柜用锂电池充电器1TN系列多路充电器
- 传输模式Modbus-RTU
- 波特率9600
- 字格式11位1个起始位(0) + 8个数据位 + 1个停止位
- 通讯方式:半双工主从应答(仓控板为主机,电源为从机)
- 地址范围0x01-0x07对应电源3位拨码开关001=0x01 ... 111=0x07
- 校验方式和校验SUM = 所有字节相加取低8位
- 字节序BigEndian网络字节序
#### 3.1.2 信息帧格式
| 字段 | 字节数 | 说明 |
|------|--------|------|
| ADDR | 1 | 电源地址 (0x01-0x07对应拨码开关) |
| CHx | 1 | 通道号高7位通道编号Bit0主/副通道 |
| CMD | 1 | 功能码 |
| LEN | 1 | 数据区长度 |
| DATA | LEN | 数据区BigEndian |
| SUM | 1 | 和校验 = (ADDR+CHx+CMD+LEN+DATA) & 0xFF |
**CHx 通道字节解析:**
| 通道 | Bit1-7 | CHx值(Bit0=0) | CHx值(Bit0=1) |
|------|--------|---------------|---------------|
| CH1 | 0x00 | 0x00 (主通道) | 0x01 (副通道) |
| CH2 | 0x01 | 0x02 (主通道) | 0x03 (副通道) |
| CH3 | 0x02 | 0x04 (主通道) | 0x05 (副通道) |
| CH4 | 0x03 | 0x06 (主通道) | 0x07 (副通道) |
| CH5 | 0x04 | 0x08 (主通道) | 0x09 (副通道) |
| CH6 | 0x05 | 0x0A (主通道) | 0x0B (副通道) |
#### 3.1.3 功能码定义
| 序号 | 命令 | CMD | LEN | 数据区 |
|------|------|-----|-----|--------|
| 1 | 设置通道输出电压/电流 | 0x02 | 0x04 | 电压(2B mV) + 电流(2B 0.1A) |
| 2 | 设置通道开关机 | 0x03 | 0x01 | 0x00=关机, 0x01=开机 |
| 3 | 通道组合指令(开关机+调压+调流) | 0x04 | 0x05 | 开关(1B) + 电压(2B) + 电流(2B) |
| 4 | 电源通道信息查询 | 0x05 | 0x01 | 查询类型 |
| 5 | 供应商/型号/版本号/序列号查询 | 0x12 | 0x01 | 查询类型 |
| 6 | 升级状态查询 | 0x18 | - | 详见升级协议 |
| 7 | OTA远程升级 | 0x19 | - | 详见升级协议 |
**电源回复数据格式:**
| 指令 | 回复LEN | 回复DATA说明 |
|------|---------|-------------|
| 0x02设置V/I | 0x01 | 0x01=设置成功 |
| 0x03开关机 | 0x01 | 0x01=设置成功 |
| 0x04组合指令 | 0x07 | 电压(2B) + 电流(2B) + 故障码(2B) + 环温(1B 0.1℃) |
| 0x05查询 | 0x0D | 设置电压(2B) + 设置电流(2B) + 故障码(2B) + 环温(1B) + 输入电压(2B 0.01V) + 输入电流(2B 0.01A) + 输入功率(2B W) |
| 0x12供应商信息 | 0x0F | 厂商(2B ASCII) + 型号(8B ASCII) + 硬件版本(1B) + 软件版本(1B) + 生产日期(3B) + 序列号(2B) |
#### 3.1.4 故障码定义
**故障字节1 (设备状态)**
| Bit | 含义 | Bit | 含义 |
|-----|------|-----|------|
| Bit0 | 输入欠压 | Bit4 | 输出欠压 |
| Bit1 | 输入过压 | Bit5 | 输出短路 |
| Bit2 | NC | Bit6 | 输出过流 |
| Bit3 | 输出过压 | Bit7 | 过温 |
**故障字节2 (系统状态)**
| Bit | 含义 | Bit | 含义 |
|-----|------|-----|------|
| Bit0 | 硬件故障 | Bit4 | NC |
| Bit1 | PFC故障 | Bit5 | NC |
| Bit2 | 风扇故障 | Bit6 | NC |
| Bit3 | 电池反接故障 | Bit7 | NC |
#### 3.1.5 指令示例
```
设置1#电源 CH1 开启+60.0V+10.0A (组合指令 0x04):
主控 → 电源: ADDR=0x01, CHx=0x00, CMD=0x04, LEN=0x05,
DATA=[0x01, 0x02, 0x58, 0x00, 0x64],
SUM=(0x01+0x00+0x04+0x05+0x01+0x02+0x58+0x00+0x64)=0xC9
电源 → 主控: ADDR=0x01, CHx=0x00, CMD=0x04, LEN=0x07,
DATA=[0x02,0x58, 0x00,0x64, 0x01,0x01, 0x1A],
回复: 当前电压60.0V, 电流10.0A, 故障码0x0101, 环温26℃
读取1#电源 CH1 运行信息 (查询 0x05):
主控 → 电源: ADDR=0x01, CHx=0x00, CMD=0x05, LEN=0x01, DATA=[0x00], SUM=0x07
电源 → 主控: ADDR=0x01, CHx=0x00, CMD=0x05, LEN=0x0D,
DATA=[0x02,0x58, 0x00,0x64, 0x01,0x01, 0x1A,
0x55,0xF0, 0x03,0xE8, 0x08,0x98],
回复: 设定60.0V/10.0A, 故障码0x0101, 环温26℃,
输入电压220.00V, 输入电流10.00A, 输入功率2200W
设置2#电源 CH2 关机 (开关机 0x03):
主控 → 电源: ADDR=0x02, CHx=0x02, CMD=0x03, LEN=0x01, DATA=[0x00], SUM=0x08
供应商信息查询 (0x12):
主控 → 电源: ADDR=0x01, CHx=0x00, CMD=0x12, LEN=0x01, DATA=[0x00], SUM=0x14
电源 → 主控: ADDR=0x01, CHx=0x00, CMD=0x12, LEN=0x0F,
DATA='YGCP1TN-A' + 硬件版本(1B) + 软件版本(1B) +
生产日期(3B: 年/月/日) + 序列号(2B)
```
### 3.2 BMS 协议适配
充电柜支持多协议BMS适配通过平台远程配置当前柜体使用的BMS协议类型。仓控板根据配置选择对应的BMS通讯协议进行电池数据读取。
#### 3.2.1 BMS协议类型配置
| 协议类型 | 说明 | 配置方式 |
|---------|------|---------|
| 星恒485一线通 | 标准Modbus-RTU地址0x16 | 平台配置 |
| 天能485 | 自定义协议 | 平台配置 |
| 无协议充电 | 不读取BMS固定电压电流输出 | 平台配置 |
BMS协议配置存储在柜控板Flash中仓控板上电时从柜控板获取当前BMS协议类型。平台可远程切换协议类型切换后下次充电生效。
#### 3.2.2 星恒 BMS Modbus 协议
**协议基础:**
- 版本V1.1
- 传输模式Modbus-RTU
- 波特率9600数据位8停止位1无校验
- BMS固定地址0x16 (16H)
- 支持功能码03H读保持寄存器、10H写控制指令
**核心寄存器映射:**
| 寄存器地址 | 参数 | 类型 | 单位 | 说明 |
|-----------|------|------|------|------|
| 10000-10002 | BMS软硬件版本/控制 | 只读/读写 | - | 10002: 充电禁止/允许 |
| 10003-10027 | 电池条码 | 只读 | ASCII | 25个寄存器 |
| 10036 | 电芯材料 | 只读 | - | 磷酸铁锂/三元锂/锰酸锂 |
| 10043-10072 | 30串单体电压 | 只读 | mV | 30个寄存器 |
| 10074 | 总电压 | 只读 | mV | 1个寄存器 |
| 10080-10089 | 10路电芯温度 | 只读 | 0.1℃ | 10个寄存器 |
| 10091 | 单体过压告警 | 只读 | BIT | bit0=告警 |
| 10092 | 单体欠压告警 | 只读 | BIT | bit0=告警 |
| 10094 | 电芯不平衡告警 | 只读 | BIT | bit0=告警 |
| 10095 | 过流告警 | 只读 | BIT | bit0=告警 |
| 10096 | 过温告警 | 只读 | BIT | bit0=告警 |
| 10097 | 低温告警 | 只读 | BIT | bit0=告警 |
| 10101 | SOH | 只读 | 0.01 | 健康度0-1 |
| 10102 | SOC | 只读 | 0.1% | 剩余电量 |
| 10103 | 充电请求电压 | 只读 | mV | BMS请求的充电电压 |
| 10104 | 最大充电请求电流 | 只读 | 0.1A | BMS请求的最大电流 |
| 10156 | 电池组唯一编码 | 只读 | - | - |
| 10166 | 总压异常告警 | 只读 | BIT | bit0=告警 |
| 10167 | 容量通量 | 只读 | - | - |
| 10169 | 能量通量 | 只读 | - | - |
| 10172 | 生产日期 | 只读 | - | 年/月/日 |
#### 3.2.3 天能 485 协议
**协议基础:**
- 传输模式自定义RS485协议
- 波特率9600数据位8停止位1无校验
- BMS地址0x01
- 协议细节以天能电池官方提供的BMS通讯协议文档为准仓控板固件中预留协议适配层支持按配置切换协议实现
**适配策略:**
仓控板BMS通讯模块采用分层设计
1. **协议抽象层**:统一的数据接口(电压/电流/SOC/温度/告警等)
2. **协议实现层**星恒Modbus实现、天能自定义协议实现
3. **配置选择层**根据平台下发的BMS类型选择对应的协议实现
#### 3.2.4 无协议充电模式
当配置为无协议充电模式时仓控板不通过485_2总线读取BMS数据充电过程完全由平台下发的固定参数控制
- 平台下发固定充电电压、充电电流
- 仓控板直接控制电源输出设定值
- 通过监测电源输出电流判断是否充满:电流低于设定阈值时自动停止
- 低电流阈值由平台配置,可在充电过程中动态调整
### 3.3 仓控主板 ↔ 柜控主板 485 通讯
#### 3.3.1 通讯参数
- 波特率9600
- 半双工主从方式
- 柜控主板ID128为主站
- 仓控主板ID1-6为从站
- 光耦隔离保护
- 通讯响应延迟 ≤ 0.5s
#### 3.3.2 通讯帧结构
自定义协议帧:
| 字段 | 字节数 | 说明 |
|------|--------|------|
| 帧头 | 2 | 0xAA 0x55 |
| 目标ID | 1 | 0x01-0x06 或 0x80柜控板 |
| 源ID | 1 | 0x01-0x06 或 0x80 |
| 功能码 | 1 | 指令类型 |
| 数据长度 | 2 | BigEndian |
| 数据区 | N | 具体数据 |
| CRC16 | 2 | Modbus CRC16 |
#### 3.3.3 功能码定义
| 功能码 | 指令 | 方向 | 说明 |
|--------|------|------|------|
| 0x01 | 上传BMS数据 | 仓控→柜控 | 电池SOC/电压/温度/告警/BMS协议类型 |
| 0x02 | 上传温控数据 | 仓控→柜控 | 6路仓体温度+环境温度 |
| 0x03 | 上传电源状态 | 仓控→柜控 | 电源运行参数 |
| 0x04 | 上传设备状态 | 仓控→柜控 | 继电器/通讯/门磁/门锁状态 |
| 0x05 | 上传门磁状态 | 仓控→柜控 | 6路仓体门开/关状态 |
| 0x06 | 上传OTA状态 | 仓控→柜控 | OTA升级进度/结果 |
| 0x81 | 继电器控制 | 柜控→仓控 | 充电继电器通断控制 |
| 0x82 | 电源参数设置 | 柜控→仓控 | 电压/电流/开关/无协议充电参数 |
| 0x83 | 指示灯控制 | 柜控→仓控 | 绿灯/红灯/闪烁 |
| 0x84 | 查询指令 | 柜控→仓控 | 查询全量状态 |
| 0x85 | 平台禁用 | 柜控→仓控 | 禁用/解除禁用 |
| 0x86 | 模式切换 | 平台→柜控 | 切换平台模式/柜控模式 |
| 0x87 | 接触器控制 | 平台→柜控 | 合闸/分闸指令 |
| 0x88 | 门锁控制 | 柜控→仓控 | 开门/锁门,指定仓体(位掩码) |
| 0x89 | OTA升级 | 柜控→仓控 | 固件升级数据包/启动升级/查询版本 |
| 0x8A | BMS类型配置 | 柜控→仓控 | 设置当前BMS协议类型(星恒/天能/无协议) |
| 0x8B | 报警阈值配置 | 柜控→仓控 | 设置关门超时/高温报警阈值 |
## 4. 定制主板设计
### 4.1 仓控主板ID1-6
#### 4.1.1 硬件规格
| 模块 | 规格 |
|------|------|
| MCU | 内置MCU控制器独立运行支持OTA固件升级 |
| 继电器 | 6路直流继电器寿命≥10万次 |
| 电子门锁驱动 | 6路MOSFET输出/继电器12V电控锁驱动 |
| 门磁检测 | 6路开关量输入检测仓门开/关状态 |
| 485接口 | 3路隔离4851路接柜控板ID128、1路接BMS6仓体共用总线、1路接所属电源模块 |
| 光耦隔离 | 3路485均做光耦隔离 |
| 温控接口 | 6路适配常用温控传感器NTC/DS18B20 |
| ID设置 | 4位拨码开关设置与柜控板通讯的485从站ID范围1-6 |
| 指示灯接口 | 6路每仓体1路支持绿灯/红灯控制 |
#### 4.1.2 接口定义
```
┌─────────────────────────────────────┐
│ 仓控主板 (ID1-6) │
├─────────────────────────────────────┤
│ [拨码开关] ← 设置485从站ID (1-6) │
│ [485_1] ← 柜控主板 (ID128) │
│ [485_2] ← BMS总线 (6仓体共用) │
│ [485_3] ← 所属电源 (1TN协议) │
│ [TEMP1-6] ← 6路仓体温控 │
│ [RELAY1-6] ← 6路直流继电器 │
│ [DOOR_LK1-6]→ 6路电子门锁驱动 │
│ [DOOR_ST1-6]← 6路门磁检测 │
│ [LED1-6] ← 6路仓体指示灯 │
│ [4PIN] ← 充电线2路正负极+2路485
└─────────────────────────────────────┘
```
#### 4.1.3 固件功能模块
| 模块 | 功能 |
|------|------|
| 485从站模块 | 响应柜控板ID128指令按拨码开关ID寻址 |
| BMS通讯模块 | 按配置协议(星恒/天能/无协议读取BMS数据或跳过 |
| 电源通讯模块 | 通过独立485与所属电源1TN协议交互 |
| 继电器控制模块 | 6路独立通断控制 |
| 指示灯控制模块 | 6路独立LED状态控制关联门锁/充电/告警状态 |
| 温控采集模块 | 6路温度数据采集 |
| ID管理模块 | 拨码开关读取与ID识别 |
| 门锁控制模块 | 6路独立门锁驱动接收柜控板门锁指令开锁 |
| 门磁监测模块 | 6路门磁状态检测上传门状态超时未关门报警 |
| OTA升级模块 | 接收固件升级数据包,校验写入,重启切换 |
#### 4.1.4 核心逻辑流程
```
初始化:
1. 读取拨码开关 → 获取本机ID (1-6)
2. 初始化3路485接口9600, 8, 1, none
3. 初始化继电器 → 全部断开
4. 初始化门锁 → 全部锁闭
5. 初始化指示灯 → 全部绿灯常亮(空闲)
6. 初始化温控接口 → 开始采集
7. 从柜控板获取BMS协议类型配置
8. 从柜控板获取报警阈值配置(关门超时/高温)
9. 注册到柜控板 → 发送上线通知源ID=本机ID, 目标ID=128
主循环:
每个周期100ms
1. 检查柜控板指令485_1
→ 如有指令:解析功能码,执行对应操作
→ 门锁控制(0x88):按位掩码开门/锁门
→ OTA升级(0x89):处理固件升级数据
→ BMS配置(0x8A)切换BMS协议类型
→ 报警阈值配置(0x8B):更新关门超时/高温阈值
2. 按配置的BMS协议类型轮询BMS总线485_2
→ 星恒模式Modbus-RTU读取电池数据
→ 天能模式:自定义协议读取电池数据
→ 无协议模式跳过BMS读取
→ 解析数据,故障时联动继电器+指示灯
3. 读取6路温控数据
→ 超范围时触发保护
→ 温度超过高温报警阈值 → 上传告警
4. 读取6路门磁状态
→ 检测到开门 → 启动计时
→ 超过设置阈值未关门 → 上传告警
5. 通过485_3查询所属电源状态
→ 读取电压/电流/故障码1TN协议
→ 无协议充电模式:监测输出电流,低于阈值通知柜控板停止
6. 上传数据至柜控板ID128
→ BMS数据 + 温控 + 电源状态 + 继电器状态 + 门磁状态
7. 更新指示灯状态
充电启动流程:
1. 检测到仓门关闭(门磁闭合)
2. 按配置协议读取BMS数据无协议模式跳过
3. 上传数据至柜控板ID128等待充电指令
4. 收到允许充电指令 → 闭合继电器
→ 通过485_3发送电源开启+调压调流指令1TN协议
→ 无协议模式:使用平台下发的固定电压/电流值
→ 指示灯切换为绿灯闪烁(充电中)
5. 实时同步数据,动态调整电源参数
→ 有BMS模式根据BMS请求调整
→ 无协议模式:监测电流,低于阈值自动停止
6. 充满/异常 → 断开继电器,关闭电源输出
门开锁流程:
1. 用户扫码 → 平台验证通过
2. 平台下发开门指令至柜控板(TCP)
3. 柜控板转发0x88开门指令至对应仓控板
4. 仓控板解析位掩码 → 驱动对应门锁继电器/MOSFET → 开锁
5. 门锁动作时间到 → 自动复位
6. 用户开门取/放电池
7. 门磁检测到关门 → 上报门已关闭状态
```
### 4.2 柜控主板ID128
#### 4.2.1 硬件规格
| 模块 | 规格 |
|------|------|
| MCU | 内置MCU控制器独立运行支持OTA固件升级 |
| 485接口 | 1路隔离RS485与6个仓控板通讯光耦隔离 |
| 485电表接口 | 1路隔离RS485接DL/T645电表读取耗电量数据 |
| 4G模块 | Air780E自定义TCP协议长连接 |
| 烟感接口 | 1路开关量输入 |
| 浸水接口 | 1路开关量输入 |
| 倾倒检测 | 1路开关量输入 |
| 环境温控传感器 | 1路NTC/DS18B20用于加热风扇自动温控 |
| 接触器控制 | 1路继电器输出控制常开接触器默认断开给信号吸合 |
| 喷淋电磁阀 | 1路继电器输出烟感触发时自动打开喷淋灭火 |
| 加热风扇继电器 | 1路独立继电器输出220V/10A自动温控 |
| 电磁泵继电器 | 1路继电器输出平台远程控制通断 |
| 接触器按钮 | 2个物理按钮合闸按钮吸合接触器、分闸按钮断开接触器用于离线时手动操作 |
| 存储 | Flash存储设备配置参数BMS类型、报警阈值、无协议充电参数本地不留充电数据 |
| 主电源 | 来自12V电源预接触器常供电 |
| 后备电源 | 后备电池输入380V总断电时自动切换维持板子供电与远程通讯 |
| | 平台可远程下发恢复指令 → 吸合接触器恢复充电电源供电 |
#### 4.2.2 接口定义
```
┌─────────────────────────────────────┐
│ 柜控主板 (ID128) │
├─────────────────────────────────────┤
│ [485] ←→ 仓控板ID1-6 │
│ [485_METER]←→ DL/T645电表耗电量采集
│ [4G/TCP] ←→ 后台平台自定义TCP长连接
│ [SMOKE] ← 烟感传感器(开关量) │
│ [WATER] ← 浸水传感器(开关量) │
│ [TILT] ← 倾倒检测(开关量) │
│ [TEMP_AMB] ← 环境温度传感器NTC/DS18B20
│ [CONTACTOR]→ 接触器控制(继电器输出) │
│ [SPRINKLER]→ 喷淋电磁阀控制(继电器输出)│
│ [FAN] → 加热风扇控制(独立继电器输出)│
│ [PUMP] → 电磁泵控制(继电器输出) │
│ [合闸按钮] ← 手动吸合接触器 │
│ [分闸按钮] ← 手动断开接触器 │
│ [PWR_IN] ← 12V电源输入 │
│ [PWR_OUT] → 仓控板供电输出 │
└─────────────────────────────────────┘
```
#### 4.2.3 固件功能模块
| 模块 | 功能 |
|------|------|
| 485主站模块 | 与仓控板ID1-6双向通讯ID寻址 |
| TCP通讯模块 | 与后台平台数据交换自定义TCP协议长连接 |
| 数据处理模块 | BMS/温控/电源/门磁数据解析转发 |
| 充电控制模块 | 双模式充电控制逻辑(平台/本地),支持无协议充电参数管理 |
| 安全监测模块 | 烟感/浸水/倾倒监测触发后断开接触器切断380V + 打开喷淋电磁阀 |
| 接触器控制模块 | 控制380V接触器吸合上电/恢复)/断开(告警/急停),含按钮去抖逻辑 |
| 按钮处理模块 | 合闸→吸合接触器,分闸→断开接触器,离线时手动操控,优先级高于平台指令 |
| 告警联动模块 | 烟感/浸水→断开接触器+打开喷淋→上报平台→记录日志 |
| 加热风扇控制模块 | 自动温控:读取环境温度传感器,<0°C启动加热25°C停止 |
| 电磁泵控制模块 | 接收平台远程指令控制继电器通断 |
| 门锁指令转发模块 | 接收平台开门指令 → 转发对应仓控板0x88 |
| OTA升级模块 | 接收平台固件包 → 分片转发至仓控板或本地升级 |
| BMS配置管理模块 | 存储/切换BMS协议类型新配置下发至仓控板 |
| 电表采集模块 | 通过独立485按DL/T645协议读取电表耗电量定时上报平台 |
#### 4.2.4 核心逻辑流程
```
初始化:
1. 初始化485主站9600, 8, 1, none
2. 初始化4G模块 → 与后台平台建立TCP长连接
3. 初始化烟感/浸水接口(中断模式)
4. 初始化接触器继电器 → 吸合(系统正常上电)
5. 读取存储的配置参数BMS类型、报警阈值、无协议充电参数
6. 轮询所有仓控板ID1-6上线状态
7. 下发BMS类型配置至所有仓控板
8. 下发报警阈值配置至所有仓控板
9. 上传设备上线通知至平台
主循环:
每500ms
1. 轮询仓控板ID1-6数据
→ 发送查询指令 → 接收BMS/温控/电源/门磁/设备状态
→ 按ID分类存储
2. 处理后台下行指令TCP长连接接收
→ 仓体禁用/解除 → 转发对应仓控板
→ 充电控制指令 → 转发对应仓控板
→ 电磁泵控制 → 通/断控制电磁泵继电器
→ 门锁控制 → 开门指令转发对应仓控板0x88
→ OTA升级 → 固件包处理,转发仓控板或本地升级
→ BMS类型配置 → 存储+下发仓控板0x8A
→ 报警阈值配置 → 存储+下发仓控板0x8B
→ 无协议充电参数 → 存储+转发仓控板
→ 配置更新 → 本地存储+生效
3. 安全监测检查(最高优先级)
→ 烟感触发 → 立即断开接触器 + 打开喷淋电磁阀
→ 浸水触发 → 立即断开接触器
→ 倾倒触发 → 立即断开接触器 + 上传告警
→ 发送急停指令至所有仓控板(断开继电器)
→ 板子仍由12V电源正常供电通讯不受影响
→ 上传告警至平台
→ 等待人工/平台远程复位才可重新吸合接触器
4. 电表数据采集
→ 通过485_METER按DL/T645协议读取电表耗电量
→ 定时采集每5分钟缓存累计数据
→ 上报平台
5. 加热风扇自动温控
→ 读取环境温度传感器
→ 温度 < 0°C 闭合加热风扇继电器启动加热
→ 温度 ≥ 25°C → 断开加热风扇继电器(停止加热)
→ 温度在0-25°C之间 → 保持当前状态(滞回控制)
→ 状态变化时上传平台
5. 接触器状态同步
→ 接触器异常断开 → 上报平台
→ 平台远程恢复 → 吸合接触器,恢复充电电源供电
6. 充电控制逻辑
→ 平台模式(默认):等待平台远程指令,需在线
→ 柜控模式:由平台远程切换为本地模式,离线也可正常充电
· 平台下发"启用本地模式"指令 → 柜控板切换为本地控制
· 本地校验项BMS握手成功/无协议充电参数合规、电池参数合规、仓体未被禁用
· 符合条件 → 下发允许充电指令
· 不符合条件 → 下发拒绝指令(红灯闪烁)
→ 平台模式/柜控模式可通过平台远程切换
8. 数据上传
→ 实时上报BMS/温控/状态数据供平台监控
→ 上报加热风扇状态(运行/停止)及环境温度
→ 上报电磁泵状态(通/断)
→ 上报各仓体门磁状态(开/关)及关门超时告警
→ 上报烟感/喷淋状态
→ 上报电表耗电量数据
→ 平台仅记录充电记录(起止时间、电量、费用等)
TCP断连处理
1. 检测到TCP断连 → 触发告警
2. 如已启用柜控模式,离线充电不受影响
3. 如为平台模式 → 充电控制暂停,等待重连
4. 缓存待上传数据(重连后补偿上报)
5. 自动重连间隔递增10s/30s/60s/300s
6. 重连成功 → 补偿上传缓存数据
```
## 5. 指示灯状态控制
### 5.1 状态定义
| 指示灯状态 | 对应状态 | 触发条件 | 继电器 | 电源输出 |
|-----------|---------|---------|--------|---------|
| 绿灯闪烁 | 充电中 | 正常充电BMS通讯正常有协议或电源输出正常无协议 | 闭合 | 正常输出 |
| 绿灯常亮 | 仓体空闲 | 无充电任务、无故障、无禁用、门已关闭 | 断开 | 无输出 |
| 红灯常亮 | 平台禁用 | 管理员下发禁用指令 | 断开 | 无输出 |
| 红灯闪烁 | 电池不符合要求/设备告警/门未关 | BMS校验不通过/故障/门长期未关 | 断开 | 无输出 |
### 5.2 状态切换条件
```
[绿灯常亮] ──扫码/预约充电──→ [绿灯闪烁]
[绿灯闪烁] ──充满/用户停止──→ [绿灯常亮]
[绿灯常亮] ──平台禁用指令──→ [红灯常亮]
[红灯常亮] ──平台解除禁用──→ [绿灯常亮]
[绿灯常亮] ──插入不合规电池──→ [红灯闪烁]
[红灯闪烁] ──拔掉电池──→ [绿灯常亮]
[绿灯闪烁] ──故障告警──→ [红灯闪烁]
[红灯闪烁] ──故障排除──→ [绿灯常亮]
任何状态 ──烟感/浸水触发──→ [红灯闪烁/常亮]
```
### 5.3 控制实现
指示灯控制由仓控主板直接驱动柜控主板通过485下发指示灯控制指令功能码0x83仓控板执行具体GPIO控制
```
绿灯控制GPIO输出PWM闪烁/高电平(常亮)/低电平(灭)
红灯控制GPIO输出PWM闪烁/高电平(常亮)/低电平(灭)
```
## 6. 充电控制逻辑
### 6.1 电池握手流程
```
[用户扫码 → 平台验证 → 下发开门指令]
[仓门打开 → 用户放入电池 → 关闭仓门]
仓控板检测门磁闭合
按配置BMS协议类型读取电池数据
┌─ 星恒模式Modbus 03H读取寄存器
│ - 10000-10002: 版本+控制状态
│ - 10003-10027: 电池条码
│ - 10043-10072: 单体电压30串
│ - 10074: 总电压
│ - 10080-10089: 温度10路
│ - 10091-10099: 告警状态
│ - 10101: SOH
│ - 10102: SOC
│ - 10103-10104: 充电请求参数
├─ 天能模式:自定义协议读取对应数据
└─ 无协议模式跳过BMS读取使用平台下发的固定参数
数据有效性校验SOC 0-100%, 电压40-80V等
打包数据 → 上传至柜控板ID128→ TCP长连接上传平台
等待充电控制指令
```
### 6.2 双模式充电控制
#### 6.2.1 平台控制模式
```
后台平台接收BMS信息 → 管理员判断/自动规则
下发充电允许/禁止指令
柜控板接收 → 转发至对应仓控板
仓控板执行:
允许 → 闭合继电器 → 电源开启+调压调流 → 绿灯闪烁
禁止 → 断开继电器 → 电源关闭 → 红灯闪烁
```
#### 6.2.2 柜控板控制模式
```
后台预设触发条件特殊ID列表、参数规则等
柜控板接收BMS信息 → 自动校验条件
符合条件 → 下发充电允许 → 仓控板执行充电启动
不符合条件 → 下发禁止 → 红灯闪烁
校验结果上传至平台
拔掉电池 → 自动恢复绿灯常亮(空闲)
```
### 6.3 充电过程动态调整
```
每1秒循环
1. 读取BMS实时数据有协议模式/ 读取电源输出电流(无协议模式)
2. SOC ≥ 98% → 满电保护:关闭电源,绿灯常亮
3. 电池温度 > 55℃ 或 < -10 过温保护关闭电源
4. 仓体温度 > 高温报警阈值平台可配置默认55℃→ 高温告警上传
5. 告警状态检查:
- 单体过压/欠压 → 保护动作
- 过流 → 降低电流或断电
- 电芯不平衡 → 预警提示
6. 动态调压调流:
- 有协议模式根据BMS请求电压/电流调整电源输出功能码0x02/0x04
- 无协议模式:保持平台下发的固定电压/电流输出
- 电压偏差 ≥ 0.5V 时重新调节
7. 无协议模式低电流检测:
- 输出电流 < 平台设置的停止阈值 判定充满自动断电
```
### 6.4 无协议充电模式
当配置为无协议充电模式时充电过程不依赖BMS通讯由平台下发固定参数控制
**配置参数(平台设置):**
| 参数 | 说明 | 范围 |
|------|------|------|
| 充电电压 | 固定输出电压 | 40-80V |
| 充电电流 | 固定输出电流 | 5-20A |
| 停止电流阈值 | 低于此值判定充满自动停止 | 0.5-5A |
**充电流程:**
```
[用户放入电池 → 关闭仓门]
仓控板检测门磁闭合
无BMS握手 → 直接上传"仓体就绪"至平台
平台下发充电允许 + 充电参数(电压/电流/停止电流阈值)
仓控板闭合继电器 → 通过485_3设置电源输出固定电压/电流
指示灯切换为绿灯闪烁(充电中)
每1秒监测电源输出电流
输出电流 < 停止电流阈值 判定充满
断开继电器 → 关闭电源输出 → 绿灯常亮
上报充电完成(时长+总输出电量估算)
```
## 7. 软件平台设计
### 7.1 后台管理系统
#### 7.1.1 技术栈建议
```
后端框架Spring Boot / Node.js (Egg.js/Nest.js) / Python (FastAPI)
数据库MySQL + Redis + InfluxDB时序数据
通信协议自定义TCP协议长连接设备通讯+ HTTP/WebSocket前端
前端框架React / Vue3 + Element Plus / Ant Design
```
#### 7.1.2 核心模块
| 模块 | 功能 |
|------|------|
| 设备管理 | 实时监控、远程重启、参数配置、OTA固件升级柜控板+仓控板) |
| 仓体控制 | 独立电流/电压设置、禁用/解除、状态查看、远程开门 |
| BMS协议配置 | 设置当前柜体BMS协议类型星恒/天能/无协议充电) |
| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
| 用户管理 | 注册/登录、权限分配(管理员/普通/骑手) |
| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计 |
| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置、设备告警管理 |
| TCP连接管理 | 连接状态监控、心跳检测、断连告警 |
| 辅助设备控制 | 电磁泵远程通断控制、加热风扇状态监控 |
#### 7.1.3 设备数据模型
```
充电柜 (Cabinet):
- id, 名称, 位置, 状态(在线/离线)
- BMS协议类型(星恒/天能/无协议)
- 无协议充电参数(电压/电流/停止电流阈值)
- 报警阈值(关门超时/高温)
- 6个电源 (Power): 型号, 版本, 状态
- 36个仓体 (Compartment):
- 所属电源, 所属仓控板ID
- 状态(空闲/充电中/禁用/故障/门未关)
- 当前电压/电流设置
- 指示灯状态
- 门磁状态(开/关) · 门锁状态
- 关门超时告警
- 6个仓控板 (CabinBoard):
- ID(1-6), 状态, 固件版本, OTA升级进度
- 6路温控数据
- 6路门磁数据
- 1个柜控板 (CabinetBoard):
- ID(128), 状态, 固件版本, OTA升级进度
- TCP连接状态 · 4G信号强度
- 加热风扇状态(运行/停止) · 环境温度
- 电磁泵状态(通/断)
- 喷淋电磁阀状态(开/关)
- 电表耗电量(kWh) · 电表通讯状态
- 烟感/浸水/喷淋状态
充电记录 (ChargeRecord):
- 仓体ID, 仓控板ID, 电源通道
- 开始时间, 结束时间
- 充电量, 最高温度, 平均电压
- BMS数据快照(SOC/电压/温度)
- 充电状态(正常/异常中断/手动停止)
故障记录 (FaultRecord):
- 仓体ID/主板ID, 故障类型, 故障码
- 发生时间, 处理时间
- 处理状态(待处理/处理中/已解决)
- 故障等级(预警/保护/严重)
```
### 7.2 微信小程序
#### 7.2.1 功能页面
| 页面 | 功能 |
|------|------|
| 首页 | 附近充电柜列表/地图,空闲舱位显示 |
| 扫码充电 | 扫描仓体二维码,启动充电 |
| 预约充电 | 选择仓体,预约时间段 |
| 充电进度 | 实时显示SOC/电压/温度/充电时间 |
| 消息通知 | 充电完成、故障告警、系统通知 |
| 个人中心 | 充电记录、用户信息、反馈 |
#### 7.2.2 仓体状态展示
小程序同步显示仓体指示灯状态及门锁状态:
- 绿灯闪烁 → "充电中"
- 绿灯常亮 + 门锁图标开 → "空闲(门已开)"
- 绿灯常亮 + 门锁图标关 → "空闲(门已关)"
- 红灯 → "禁用/故障"
扫码开门流程:
1. 用户扫描仓体二维码
2. 小程序显示仓体信息和操作按钮
3. 点击"开门"按钮 → 平台验证 → 下发开门指令
4. 门锁打开,用户放入/取出电池
5. 关门后系统自动检测门磁闭合
## 8. 安全防护设计
### 8.1 电气安全
| 防护类型 | 实现方式 | 触发动作 |
|---------|---------|---------|
| 过充保护 | SOC监测 + 电压监测 | SOC≥98%自动断电 |
| 过温保护 | 温控传感器 + BMS温度 | 超过平台设置高温报警阈值(可配置)时告警+断电 |
| 过流保护 | 电源故障码监测 | 超过设定值断电 |
| 短路保护 | 电源故障码监测 | 即时断电 |
| 漏电保护 | 漏保 | 快速切断380V输入 |
| 浪涌保护 | 浪涌保护器件 | 吸收浪涌冲击 |
| 烟感保护 | 开关量烟感传感器 | 断开接触器切断380V + 打开喷淋电磁阀 + 告警 |
| 浸水保护 | 开关量浸水传感器 | 断开接触器切断380V + 告警 |
| 门未关报警 | 门磁传感器+计时 | 超过平台设置关门超时阈值(可配置)时上传告警 |
### 8.2 数据安全
| 安全类型 | 措施 |
|---------|------|
| 存储加密 | 用户信息、BMS数据加密存储 |
| 通讯校验 | 1TN和校验 + Modbus CRC16校验 |
| 网络传输 | 自定义TCP协议加密传输 |
| 权限管控 | 管理员/普通用户分级权限 |
### 8.3 防护联动流程
```
[烟感/浸水触发]
柜控板检测到开关量变化(中断)
烟感触发 → 立即断开接触器切断充电电源380V输入+ 打开喷淋电磁阀(简易喷淋)
浸水触发 → 立即断开接触器切断充电电源380V输入
发送0x81急停指令至所有仓控板(ID1-6):继电器全部断开
柜控板+仓控板仍由12V电源正常供电预接触器4G通讯在线
所有仓体指示灯切换为红灯(告警)
上传告警至后台平台(即时推送)
管理员接收通知 → 判断是否为误报警
误报警 → 平台下发恢复指令 → 关闭喷淋电磁阀 → 柜控板吸合接触器 → 充电电源恢复供电
真实告警 → 现场排查处理 → 灭火后平台关闭喷淋 → 故障排除后恢复
各仓体恢复空闲(绿灯常亮)
```
## 9. 性能指标
| 指标 | 要求 |
|------|------|
| 仓控-柜控通讯延迟 | ≤ 0.5s |
| 仓控-电源通讯延迟 | ≤ 0.3s |
| TCP数据上传延迟 | ≤ 1s |
| 指示灯状态切换延迟 | ≤ 0.5s |
| 平台指令执行延迟 | ≤ 1s |
| 门锁响应延迟(平台→开门) | ≤ 1.5s |
| 门磁检测上报延迟 | ≤ 1s |
| OTA升级成功率 | ≥ 99% |
| 烟感→喷淋联动延迟 | ≤ 0.5s |
| 通讯故障率 | ≤ 0.1% |
| MTBF | ≥ 8000小时 |
| 设备故障率 | ≤ 0.5% |
| 继电器寿命 | ≥ 10万次 |
| 指示灯寿命 | ≥ 50000小时 |
| 工作温度 | -10℃ ~ +70℃ |
| 工作湿度 | 10% ~ 90% |

347
docs/开发计划.md Normal file
View File

@ -0,0 +1,347 @@
# 36仓体直流电瓶车充电柜 - 开发计划
## 1. 项目概述
- **项目名称**36仓体直流电瓶车充电柜
- **开发周期**12周
- **核心交付**充电柜硬件含仓控主板×6、柜控主板×1+ 后台管理系统 + 微信小程序
---
## 2. 阶段划分
### 第1阶段需求确认与方案设计第1-2周
**目标**:完成需求确认、技术调研、方案定稿
| 任务 | 负责人 | 输出物 | 时间 |
|------|--------|--------|------|
| 需求评审与确认 | PM/研发 | 需求规格说明书 | 第1周 |
| 技术调研 | 研发 | 技术调研报告 | 第1周 |
| 系统架构设计 | 架构师 | 系统架构设计文档 | 第2周 |
| 硬件方案设计 | 硬件工程师 | 硬件方案文档 | 第2周 |
| 元器件选型 | 硬件工程师 | 选型清单BOM | 第2周 |
| 仓控主板详细设计 | 硬件工程师 | 仓控主板原理图+PCB设计 | 第2周 |
| 柜控主板详细设计 | 硬件工程师 | 柜控主板原理图+PCB设计 | 第2周 |
| 通讯协议设计 | 嵌入式工程师 | 协议设计文档 | 第2周 |
| 指示灯选型确认 | 硬件工程师 | 指示灯规格确认单 | 第2周 |
| 平台禁用功能方案 | PM/研发 | 功能方案文档 | 第2周 |
| 电池握手方案设计 | 嵌入式工程师 | 握手流程设计文档 | 第2周 |
| 双模式充电控制方案 | 嵌入式工程师 | 控制逻辑设计文档 | 第2周 |
| 接口定义评审 | 全组 | 接口定义确认 | 第2周 |
| 仓控板拨码ID方案 | 硬件工程师 | ID设置方案确认 | 第2周 |
| 仓控-电源独立通讯链路设计 | 嵌入式工程师 | 通讯链路设计文档 | 第2周 |
> **里程碑1第2周末**:需求确认完成,技术方案定稿,元器件选型完成,仓控板与柜控板定制方案明确。
---
### 第2阶段硬件开发与软件开发第3-6周
#### 2.1 硬件开发
| 任务 | 负责人 | 输出物 | 时间 |
|------|--------|--------|------|
| 充电柜本体结构设计 | 结构工程师 | 3D模型+工程图 | 第3-4周 |
| 充电柜本体打样组装 | 工厂 | 样机柜体 | 第4-5周 |
| 电源选型采购 | 采购 | 电源到货 | 第3周 |
| 指示灯选型采购 | 采购 | 指示灯到货 | 第3周 |
| 通讯模块采购 | 采购 | 4G模块到货 | 第3周 |
| 仓控主板PCB打样 | PCB工厂 | 仓控板PCB | 第4周 |
| 柜控主板PCB打样 | PCB工厂 | 柜控板PCB | 第4周 |
| 仓控主板焊接调试 | 硬件工程师 | 仓控板6块功能正常 | 第5周 |
| 柜控主板焊接调试 | 硬件工程师 | 柜控板1块功能正常 | 第5周 |
| 继电器通断测试 | 硬件工程师 | 测试报告 | 第5周 |
| 光耦隔离效果测试 | 硬件工程师 | 测试报告 | 第5周 |
| 485通讯调试 | 硬件工程师 | 通讯功能正常 | 第5周 |
| 温控接口调试 | 硬件工程师 | 6路温控采集正常 | 第5周 |
| 仓控板拨码开关ID测试 | 硬件工程师 | ID识别准确 | 第5-6周 |
| 仓控板门锁/门磁调试 | 硬件工程师 | 6路门锁驱动+门磁检测正常 | 第6周 |
| 仓控-电源独立通讯调试 | 嵌入式工程师 | 通讯链路正常 | 第6周 |
| 4G模块调试 | 嵌入式工程师 | 4G通讯正常 | 第6周 |
| 烟感/浸水接口调试 | 硬件工程师 | 开关量检测正常 | 第6周 |
| 喷淋电磁阀调试 | 硬件工程师 | 烟感联动开启正常 | 第6周 |
| 环境温控传感器调试 | 硬件工程师 | 温度采集正常 | 第6周 |
| 加热风扇继电器调试 | 硬件工程师 | 独立通断正常 | 第6周 |
| 电磁泵继电器调试 | 硬件工程师 | 通断控制正常 | 第6周 |
| 指示灯接线与调试 | 硬件工程师 | 灯光状态正常 | 第6周 |
| 整机装配 | 工厂 | 样机完成 | 第6周 |
#### 2.2 软件开发
| 任务 | 负责人 | 输出物 | 时间 |
|------|--------|--------|------|
| 仓控板固件-485从站通讯 | 嵌入式工程师 | 代码+单元测试 | 第3-4周 |
| 仓控板固件-BMS通讯模块Modbus-RTU | 嵌入式工程师 | 代码+单元测试 | 第3-5周 |
| 仓控板固件-BMS通讯模块天能协议 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
| 仓控板固件-无协议充电模块 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 仓控板固件-电源通讯模块1TN协议 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
| 仓控板固件-继电器控制 | 嵌入式工程师 | 代码+单元测试 | 第4周 |
| 仓控板固件-门锁控制 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
| 仓控板固件-门磁监测 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
| 仓控板固件-指示灯控制 | 嵌入式工程师 | 代码+单元测试 | 第4周 |
| 仓控板固件-温控采集 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
| 仓控板固件-ID管理拨码开关 | 嵌入式工程师 | 代码+单元测试 | 第4周 |
| 仓控板固件-数据上传/指令接收 | 嵌入式工程师 | 代码+单元测试 | 第5周 |
| 仓控板固件-OTA升级 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 仓控板固件-BMS协议适配层 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-485主站通讯ID寻址 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
| 柜控板固件-4G通讯模块 | 嵌入式工程师 | 代码+单元测试 | 第4-6周 |
| 柜控板固件-数据处理与存储 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-充电控制逻辑(双模式) | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-烟感/浸水监测 | 嵌入式工程师 | 代码+单元测试 | 第5周 |
| 柜控板固件-安全监测与联动 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-加热风扇温控 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-电表采集DL/T645 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-电磁泵控制 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-喷淋电磁阀控制 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-门锁指令转发 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-OTA升级管理 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 柜控板固件-BMS配置管理 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
| 后台系统-设备管理模块 | 后端工程师 | 接口+管理界面 | 第3-5周 |
| 后台系统-仓体控制模块 | 后端工程师 | 接口+管理界面 | 第4-6周 |
| 后台系统-用户管理模块 | 后端工程师 | 接口+管理界面 | 第3-4周 |
| 后台系统-数据统计模块 | 后端工程师 | 接口+报表页面 | 第5-6周 |
| 后台系统-故障管理模块 | 后端工程师 | 接口+管理界面 | 第5-6周 |
| 后台系统-BMS协议配置 | 后端工程师 | 接口+配置界面 | 第5-6周 |
| 后台系统-无协议充电参数配置 | 后端工程师 | 接口+配置界面 | 第5-6周 |
| 后台系统-报警阈值管理 | 后端工程师 | 接口+配置界面 | 第5-6周 |
| 后台系统-OTA升级管理 | 后端工程师 | 接口+升级管理界面 | 第5-6周 |
| 后台系统-仓体禁用功能 | 后端工程师 | 接口+管理界面 | 第5-6周 |
| 后台系统-充电控制模式配置 | 后端工程师 | 接口+配置界面 | 第5-6周 |
| 后台系统-4G通讯管理 | 后端工程师 | 接口+监控界面 | 第5-6周 |
| 后台系统-4G MQTT服务端 | 后端工程师 | 服务+接口 | 第4-6周 |
| 小程序-扫码充电 | 前端工程师 | 小程序页面+接口对接 | 第4-6周 |
| 小程序-预约充电 | 前端工程师 | 小程序页面+接口对接 | 第4-6周 |
| 小程序-充电进度查询 | 前端工程师 | 小程序页面+接口对接 | 第5-6周 |
| 小程序-消息通知 | 前端工程师 | 推送功能 | 第5-6周 |
| 小程序-故障反馈 | 前端工程师 | 小程序页面+接口对接 | 第5-6周 |
| 电池握手验证程序 | 嵌入式/后端 | 代码+单元测试 | 第5-6周 |
| 指示灯控制程序联调 | 嵌入式工程师 | 功能验证 | 第6周 |
| 平台仓体禁用功能联调 | 全栈 | 功能验证 | 第6周 |
| 柜控板-仓控板ID寻址通讯程序 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
> **里程碑2第6周末**:硬件开发调试完成,软件开发完成,各模块初步实现。
---
### 第3阶段系统集成与联合调试第7-9周
| 任务 | 负责人 | 时间 |
|------|--------|------|
| 仓控板-柜控板485通讯联调 | 嵌入式工程师 | 第7周 |
| 仓控板-电源独立通讯联调 | 嵌入式工程师 | 第7周 |
| 仓控板-BMS通讯联调 | 嵌入式工程师 | 第7周 |
| 指示灯状态联动测试 | 全组 | 第7周 |
| 平台仓体禁用功能联调 | 全栈 | 第7周 |
| 电池握手验证联调 | 全组 | 第7-8周 |
| 充电双模式控制联调 | 全组 | 第7-8周 |
| 无协议充电联调 | 全组 | 第7-8周 |
| 4G数据上传联调 | 全栈 | 第7-8周 |
| 烟感/浸水+喷淋电磁阀联动联调 | 全组 | 第8周 |
| 门锁控制联调(平台开门) | 全栈 | 第8周 |
| 门磁监测+关门超时报警联调 | 全组 | 第8周 |
| BMS多协议切换联调 | 全组 | 第8周 |
| OTA固件升级联调 | 全组 | 第8周 |
| 加热风扇温控联调 | 全组 | 第8周 |
| 电磁泵远程控制联调 | 全组 | 第8周 |
| 后台管理平台联调 | 全栈 | 第8周 |
| 小程序联调 | 全栈 | 第8周 |
| 仓控板ID寻址准确性测试 | 嵌入式工程师 | 第8周 |
| 整机功能联调 | 全组 | 第8-9周 |
| 异常场景排查与修复 | 全组 | 第8-9周 |
| 性能调优 | 全组 | 第9周 |
| 安全性测试 | 全组 | 第9周 |
| 系统稳定性测试 | 全组 | 第9周 |
> **里程碑3第9周末**:系统集成调试完成,所有功能正常,无重大故障。
---
### 第4阶段试点测试第10-11周
| 任务 | 负责人 | 时间 |
|------|--------|------|
| 36仓体同步运行测试 | 全组 | 第10周 |
| 指示灯状态准确性全面测试 | 测试 | 第10周 |
| 平台禁用功能可靠性测试 | 测试 | 第10周 |
| 主板运行稳定性测试 | 测试 | 第10周 |
| 电池握手与充电控制准确性测试 | 测试 | 第10-11周 |
| 仓控板ID拨码设置准确性测试 | 测试 | 第10周 |
| ID寻址通讯可靠性测试 | 测试 | 第10周 |
| 仓控-电源独立通讯稳定性测试 | 测试 | 第10-11周 |
| 4G通讯稳定性测试 | 测试 | 第10-11周 |
| 烟感/浸水告警有效性测试 | 测试 | 第11周 |
| 过充/过温/过流保护测试 | 测试 | 第11周 |
| 实际使用场景模拟测试 | 全组 | 第11周 |
| 问题收集与整改 | 全组 | 第11周 |
> **里程碑4第11周末**:试点测试完成,所有问题整改完毕。
---
### 第5阶段验收交付第12周
| 任务 | 负责人 | 输出物 | 时间 |
|------|--------|--------|------|
| 开发文档整理 | 研发 | 开发文档定稿 | 第12周 |
| 测试文档整理 | 测试 | 测试报告 | 第12周 |
| 操作手册编写 | PM | 用户操作手册 | 第12周 |
| 运维手册编写 | 运维 | 运维手册 | 第12周 |
| 项目验收评审 | 全组 | 验收报告 | 第12周 |
| 项目交接 | PM | 交接清单 | 第12周 |
> **里程碑5第12周末**:项目验收通过,文档交付完成,正式交接。
---
## 3. 甘特图概览
```
任务\周次 | W1 | W2 | W3 | W4 | W5 | W6 | W7 | W8 | W9 | W10| W11| W12|
--------------|----|----|----|----|----|----|----|----|----|----|----|----|
需求确认 | ██ | ██ | | | | | | | | | | |
方案设计 | ██ | ██ | | | | | | | | | | |
结构设计 | | | ██ | ██ | | | | | | | | |
PCB设计打样 | | | ██ | ██ | | | | | | | | |
元器件采购 | | | ██ | | | | | | | | | |
焊接调试 | | | | ██ | ██ | ██ | | | | | | |
主机固件开发 | | | ██ | ██ | ██ | ██ | | | | | | |
后台开发 | | | ██ | ██ | ██ | ██ | | | | | | |
小程序开发 | | | | ██ | ██ | ██ | | | | | | |
系统联调 | | | | | | | ██ | ██ | ██ | | | |
性能优化 | | | | | | | | ██ | ██ | | | |
试点测试 | | | | | | | | | | ██ | ██ | |
问题整改 | | | | | | | | | | ██ | ██ | |
文档编写 | | | | | | | | | | | ██ | ██ |
验收交付 | | | | | | | | | | | | ██ |
```
---
## 4. 关键里程碑
| 里程碑 | 时间 | 交付物 |
|--------|------|--------|
| M1: 方案定稿 | 第2周末 | 需求规格说明书、技术方案文档、BOM清单、原理图 |
| M2: 开发完成 | 第6周末 | 硬件样机、固件代码、后台系统、小程序 |
| M3: 联调完成 | 第9周末 | 集成测试报告、功能确认书 |
| M4: 试点完成 | 第11周末 | 试点测试报告、问题整改清单 |
| M5: 验收交付 | 第12周末 | 开发文档、测试报告、操作手册、验收报告 |
---
## 5. 资源需求
### 5.1 人员配置
| 角色 | 人数 | 主要职责 |
|------|------|---------|
| 项目经理PM | 1 | 需求管理、进度把控、资源协调 |
| 硬件工程师 | 2 | 仓控/柜控主板设计、调试、测试 |
| 嵌入式工程师 | 2 | 仓控/柜控固件开发、协议对接 |
| 后端工程师 | 2 | 后台系统开发、MQTT服务、数据库 |
| 前端工程师 | 1 | 微信小程序开发 |
| 测试工程师 | 1 | 单元/集成/系统测试 |
| 结构工程师 | 1 | 柜体结构设计 |
| 采购/生产 | 1 | 元器件采购、PCB打样、组装 |
### 5.2 硬件清单(样机)
| 物料 | 数量 | 说明 |
|------|------|------|
| 充电柜柜体 | 1台 | 36仓体结构 |
| 充电电源 | 6台 | 直流充电电源1TN协议 |
| 仓控主板PCB | 10块 | 含备板 |
| 柜控主板PCB | 3块 | 含备板 |
| 4G模块 | 3个 | 含备板 |
| 直流继电器 | 50个 | 含备件 |
| 光耦隔离芯片 | 100个 | 485隔离用 |
| 温控传感器 | 15个 | 含备件 |
| 烟感传感器 | 2个 | 含备件 |
| 浸水传感器 | 2个 | 含备件 |
| 环境温控传感器 | 3个 | 含备件用于加热风扇自动温控NTC/DS18B20 |
| 电子门锁(电控锁) | 40个 | 含备件12V每仓体1个 |
| 门磁传感器 | 40个 | 含备件每仓体1个 |
| 喷淋电磁阀 | 2个 | 含备件,烟感联动喷淋 |
| 加热风扇 | 1台 | 含独立继电器控制 |
| 电磁泵 | 1台 | 含继电器驱动 |
| 状态指示灯 | 50个 | 含备件 |
| 拨码开关 | 10个 | 仓控板ID设置 |
| 连接线束 | 1批 | 4PIN线等 |
### 5.3 软件环境
| 类型 | 环境/工具 |
|------|-----------|
| 嵌入式IDE | Keil MDK / STM32CubeIDE |
| 后台开发 | Spring Boot / Node.js / Python + MySQL + Redis + InfluxDB |
| 小程序开发 | 微信开发者工具 |
| 协议测试 | Modbus Poll / 串口调试助手 |
| 版本管理 | Git |
| 项目管理 | 禅道 / Jira |
---
## 6. 风险与应对
| 风险 | 概率 | 影响 | 应对措施 |
|------|------|------|---------|
| 星恒BMS协议版本不匹配 | 中 | 高 | 提前获取最新协议文档,预留协议适配层 |
| 电源1TN协议兼容性问题 | 中 | 高 | 提前联调电源厂商,准备协议测试环境 |
| 485通讯干扰36舱全开 | 中 | 高 | 光耦隔离+终端电阻+合理布线 |
| 4G信号不稳定 | 中 | 中 | 柜控板本地缓存+断线重连机制 |
| 仓控板ID寻址冲突 | 低 | 高 | 拨码开关硬件防呆+上电自检+重复ID告警 |
| 元器件供货延迟 | 中 | 中 | 提前采购,备选供应商方案 |
| 温控传感器精度不足 | 低 | 中 | 选型测试验证,软件滤波处理 |
| 指示灯亮度/视角不足 | 低 | 中 | 提前打样测试确认 |
| 电池握手失败率偏高 | 中 | 高 | 增加重试机制,完善异常处理 |
| PCB设计缺陷需改版 | 中 | 高 | 预留测试点,第一版预投少量验证 |
| 加热风扇温控失效 | 低 | 中 | 增加硬件超温保护,双路温控冗余 |
| 电磁泵继电器粘连 | 低 | 中 | 选用高品质继电器,定期巡检 |
| 门锁频繁动作损坏 | 中 | 中 | 选用工业级电控锁,预留手动开门方式 |
| BMS多协议兼容性 | 中 | 高 | 协议适配层隔离,各协议独立实现,平台可远程回退 |
| 喷淋电磁阀误触发 | 低 | 中 | 硬件滤波+软件确认延时,防误喷 |
---
## 7. 测试策略
### 7.1 测试阶段对应
| 阶段 | 测试类型 | 说明 |
|------|---------|------|
| 第5-6周 | 单元测试 | 硬件模块+软件模块单独测试 |
| 第7-9周 | 集成测试 | 模块间对接测试,协议联调 |
| 第10-11周 | 系统测试 | 全功能测试,模拟实际场景 |
### 7.2 核心测试用例
| 编号 | 测试项 | 预期结果 |
|------|--------|---------|
| TC01 | 36路独立充电同时运行 | 各舱互不干扰,电源正常输出 |
| TC02 | 指示灯状态切换 | 各状态切换准确延迟≤0.5s |
| TC03 | 平台禁用/解除 | 禁用后红灯常亮,禁止充电;解除后恢复绿灯常亮 |
| TC04 | BMS数据读取 | SOC/电压/温度/告警数据准确 |
| TC05 | 电源参数控制 | 电压/电流设置准确,开关控制正常 |
| TC06 | 电池握手流程 | 插入→读取→上传→校验→充电启动,流程完整 |
| TC07 | 双模式充电控制 | 平台模式/柜控模式均正常 |
| TC08 | 仓控板ID识别 | 拨码ID1-6准确识别通讯寻址无误 |
| TC09 | 仓控-电源独立通讯 | 各仓控板与对应电源通讯稳定 |
| TC10 | 烟感触发保护 | 全局断电,红灯切换,告警上传 |
| TC11 | 浸水触发保护 | 同上 |
| TC12 | 过充保护 | SOC≥98%自动断电 |
| TC13 | 过温保护 | 温度>55℃自动断电 |
| TC14 | 4G断线重连 | 断线后缓存数据,重连后补偿上传 |
| TC15 | 电池不合规红灯闪烁 | 拔掉电池后自动恢复绿灯常亮 |
| TC16 | 加热风扇温控 | 环境温度<0°C自动启动25°C自动停止 |
| TC17 | 电磁泵远程控制 | 平台下发通/断指令,继电器正确响应 |
| TC18 | 加热风扇滞回控制 | 温度在0-25°C之间保持当前状态不抖动 |
| TC19 | 扫码开门 | 平台下发开门指令,门锁动作,门磁反馈正确 |
| TC20 | 关门超时报警 | 开门后超过设置阈值未关,上传告警 |
| TC21 | 烟感联动喷淋 | 烟感触发→接触器断开+喷淋电磁阀打开+告警上传 |
| TC22 | BMS协议切换 | 平台切换星恒/天能/无协议,仓控板正确切换通讯方式 |
| TC23 | 无协议充电 | 固定电压电流输出,低于停止电流阈值自动断电 |
| TC24 | OTA固件升级 | 柜控板+仓控板固件在线升级,升级后正常运行 |
| TC25 | 高温报警阈值可配置 | 平台设置不同阈值,仓体超温触发告警 |
| TC26 | 电表耗电量采集 | 柜控板按DL/T645读取电表数据上报平台显示正确 |

View File

@ -0,0 +1,534 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>附件-开发需求规格说明书</title><style>body{font-family:"Microsoft YaHei",sans-serif;max-width:900px;margin:40px auto;padding:20px;line-height:1.8;color:#333}
h1{text-align:center;font-size:22px;border-bottom:2px solid #333;padding-bottom:10px}
h2{font-size:18px;margin-top:30px;border-left:4px solid #333;padding-left:10px}
h3{font-size:16px;margin-top:20px}h4{font-size:15px;margin-top:15px}
table{width:100%;border-collapse:collapse;margin:15px 0;font-size:14px}
th,td{border:1px solid #999;padding:8px;text-align:left}th{background:#f0f0f0}
p,li{font-size:14px}hr{border:none;border-top:1px solid #ccc;margin:30px 0}
pre{background:#f5f5f5;padding:15px;border-radius:4px;overflow-x:auto;font-size:13px}
code{font-family:Consolas,monospace}
.blank{color:#999;border-bottom:1px solid #999;display:inline-block;min-width:150px}
.sign{margin-top:60px}.sign div{display:inline-block;width:45%;vertical-align:top}</style></head><body><h1>附件:开发需求规格说明书</h1>
<p><strong>所属合同:</strong> 36仓体直流电瓶车充电柜 开发合同
<strong>合同编号:</strong> ________________</p>
<hr>
<h2>第1章 柜控主板ID128硬件规格</h2>
<h3>1.1 基本参数</h3>
<table>
<thead>
<tr>
<th>项目</th>
<th>参数</th>
</tr>
</thead>
<tbody><tr>
<td>板卡数量</td>
<td>3块含1块备板</td>
</tr>
<tr>
<td>MCU</td>
<td>内置MCU控制器支持OTA固件升级</td>
</tr>
<tr>
<td>供电</td>
<td>12V直流输入预接触器常供电</td>
</tr>
<tr>
<td>后备电源</td>
<td>后备电池自动切换可选维持板子供电与4G通讯</td>
</tr>
<tr>
<td>工作温度</td>
<td>-10℃ ~ +70℃</td>
</tr>
<tr>
<td>工作湿度</td>
<td>10% ~ 90%</td>
</tr>
<tr>
<td>PCB</td>
<td>双面板/四层板,根据设计复杂度确定</td>
</tr>
</tbody></table>
<h3>1.2 接口规格</h3>
<table>
<thead>
<tr>
<th>接口</th>
<th>规格</th>
</tr>
</thead>
<tbody><tr>
<td>485接口</td>
<td>1路隔离RS485与6个仓控板通讯光耦隔离保护波特率9600</td>
</tr>
<tr>
<td>485电表接口</td>
<td>1路隔离RS485接DL/T645电表读取耗电量数据</td>
</tr>
<tr>
<td>4G模块</td>
<td>Air780E自定义TCP协议长连接</td>
</tr>
<tr>
<td>烟感接口</td>
<td>1路开关量输入中断触发</td>
</tr>
<tr>
<td>浸水接口</td>
<td>1路开关量输入中断触发</td>
</tr>
<tr>
<td>倾倒检测</td>
<td>1路开关量输入中断触发</td>
</tr>
<tr>
<td>环境温控接口</td>
<td>1路适配NTC/DS18B20</td>
</tr>
<tr>
<td>门锁控制</td>
<td>1路MOS管/三极管驱动输出 + 1路门磁反馈输入控制柜体电控锁并检测锁状态</td>
</tr>
<tr>
<td>接触器控制</td>
<td>1路继电器输出控制常开接触器默认断开给信号吸合</td>
</tr>
<tr>
<td>喷淋电磁阀</td>
<td>1路继电器输出烟感触发时自动打开</td>
</tr>
<tr>
<td>加热风扇继电器</td>
<td>1路独立继电器输出220V/10A</td>
</tr>
<tr>
<td>电磁泵继电器</td>
<td>1路继电器输出</td>
</tr>
<tr>
<td>接触器按钮</td>
<td>2个物理按钮合闸按钮吸合接触器、分闸按钮断开接触器</td>
</tr>
<tr>
<td>存储</td>
<td>Flash存储BMS类型/报警阈值/无协议充电参数</td>
</tr>
</tbody></table>
<h3>1.3 供电时序</h3>
<ul>
<li>主电源12V预接触器接入柜控板常供电</li>
<li>后备电池可选在380V断电时自动切换维持板子供电与远程通讯</li>
<li>接触器默认断开,上电后自动吸合恢复供电</li>
<li>平台可远程下发恢复指令吸合接触器</li>
</ul>
<hr>
<h2>第2章 仓控主板ID1-6硬件规格</h2>
<h3>2.1 基本参数</h3>
<table>
<thead>
<tr>
<th>项目</th>
<th>参数</th>
</tr>
</thead>
<tbody><tr>
<td>板卡数量</td>
<td>10块含4块备板</td>
</tr>
<tr>
<td>MCU</td>
<td>内置MCU控制器支持OTA固件升级</td>
</tr>
<tr>
<td>供电</td>
<td>由柜控板统一供电12V</td>
</tr>
<tr>
<td>工作温度</td>
<td>-10℃ ~ +70℃</td>
</tr>
<tr>
<td>ID设置</td>
<td>4位拨码开关设置485从站ID1-6</td>
</tr>
</tbody></table>
<h3>2.2 接口规格</h3>
<table>
<thead>
<tr>
<th>接口</th>
<th>规格</th>
</tr>
</thead>
<tbody><tr>
<td>485接口×3</td>
<td>全部隔离1路接柜控板ID128、1路接BMS总线6仓体共用、1路接所属电源模块</td>
</tr>
<tr>
<td>光耦隔离</td>
<td>3路485均做光耦隔离保护</td>
</tr>
<tr>
<td>温控接口</td>
<td>6路适配NTC/DS18B20可选</td>
</tr>
<tr>
<td>指示灯接口</td>
<td>6路支持绿灯/红灯控制</td>
</tr>
</tbody></table>
<h3>2.3 物料清单(乙方供)</h3>
<table>
<thead>
<tr>
<th>物料</th>
<th>数量</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>4G模块</td>
<td>3个</td>
<td>Air780E用于柜控板</td>
</tr>
<tr>
<td>光耦隔离芯片</td>
<td>100个</td>
<td>485隔离用</td>
</tr>
<tr>
<td>状态指示灯</td>
<td>50个</td>
<td>红绿双色</td>
</tr>
<tr>
<td>拨码开关</td>
<td>10个</td>
<td>4位</td>
</tr>
<tr>
<td>PCB打样及焊接辅料</td>
<td>1批</td>
<td>含钢网/样板焊接</td>
</tr>
</tbody></table>
<hr>
<h2>第3章 MCU固件需求</h2>
<h3>3.1 柜控板固件1个工程</h3>
<table>
<thead>
<tr>
<th>功能</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>485主站通讯</td>
<td>与仓控板ID1-6双向寻址通讯响应延迟≤0.5s</td>
</tr>
<tr>
<td>4G TCP通讯</td>
<td>与后台平台自定义TCP长连接含心跳/断线重连/数据缓存补偿</td>
</tr>
<tr>
<td>指令转发</td>
<td>接收平台充电控制/门锁/配置等指令,转发至对应仓控板</td>
</tr>
<tr>
<td>安全监测联动</td>
<td>烟感/浸水/倾倒检测,触发后断开接触器+打开喷淋电磁阀</td>
</tr>
<tr>
<td>电表采集</td>
<td>通过独立485接口按DL/T645协议读取电表耗电量定时上报平台</td>
</tr>
<tr>
<td>辅助设备控制</td>
<td>加热风扇自动温控(&lt;0°C开/≥25°C停、电磁泵远程通断</td>
</tr>
<tr>
<td>门锁控制</td>
<td>接收平台开门指令,直接控制柜体电控锁</td>
</tr>
<tr>
<td>OTA升级</td>
<td>接收平台固件包,本地升级或分片转发仓控板</td>
</tr>
<tr>
<td>配置管理</td>
<td>BMS协议类型、报警阈值、无协议充电参数的存储与下发</td>
</tr>
</tbody></table>
<h3>3.2 仓控板固件1个工程</h3>
<table>
<thead>
<tr>
<th>功能</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>485从站通讯</td>
<td>响应柜控板指令按拨码ID1-6寻址</td>
</tr>
<tr>
<td>BMS多协议通讯</td>
<td>按配置协议轮询6仓体电池数据星恒Modbus-RTU/天能自定义/无协议跳过</td>
</tr>
<tr>
<td>1TN电源协议</td>
<td>与所属充电电源独立485通讯电压/电流/开关控制(电源自带通断)</td>
</tr>
<tr>
<td>充电控制逻辑</td>
<td>平台/柜控双模式,含电池握手/充满判断/无协议充电/低电流自动停止</td>
</tr>
<tr>
<td>状态采集与指示</td>
<td>6路红绿LED指示灯控制 + 6路温控采集可选</td>
</tr>
<tr>
<td>OTA升级与ID管理</td>
<td>接收固件包校验写入,拨码开关读取识别</td>
</tr>
</tbody></table>
<hr>
<h2>第4章 后台管理系统需求</h2>
<h3>4.1 功能模块</h3>
<table>
<thead>
<tr>
<th>模块</th>
<th>功能要求</th>
</tr>
</thead>
<tbody><tr>
<td>设备管理</td>
<td>实时监控、远程重启、参数配置、OTA固件升级柜控板+仓控板)</td>
</tr>
<tr>
<td>仓体控制</td>
<td>独立电流/电压设置、禁用/解除、状态查看、远程开门</td>
</tr>
<tr>
<td>BMS协议配置</td>
<td>设置当前柜体BMS协议类型星恒/天能/无协议充电)</td>
</tr>
<tr>
<td>无协议充电参数配置</td>
<td>设置固定充电电压/电流/停止电流阈值</td>
</tr>
<tr>
<td>充电控制模式配置</td>
<td>平台/柜控模式切换、特殊ID管理</td>
</tr>
<tr>
<td>能耗管理</td>
<td>实时显示/统计电表耗电量数据,按日/月/年生成报表</td>
</tr>
<tr>
<td>用户管理</td>
<td>注册/登录、权限分配(管理员/普通/骑手)</td>
</tr>
<tr>
<td>数据统计</td>
<td>充电量、使用率、故障率、温控数据、告警统计、能耗统计</td>
</tr>
<tr>
<td>故障管理</td>
<td>故障记录、工单分配、处理跟踪、解除禁用</td>
</tr>
<tr>
<td>报警阈值管理</td>
<td>关门超时阈值配置、高温报警阈值配置</td>
</tr>
<tr>
<td>TCP连接管理</td>
<td>连接状态监控、心跳检测、断连告警</td>
</tr>
<tr>
<td>辅助设备控制</td>
<td>电磁泵远程通断控制、加热风扇状态监控</td>
</tr>
</tbody></table>
<h3>4.2 数据模型</h3>
<table>
<thead>
<tr>
<th>实体</th>
<th>字段</th>
</tr>
</thead>
<tbody><tr>
<td>充电柜</td>
<td>id/名称/位置/状态/BMS协议类型/无协议充电参数/报警阈值/总耗电量</td>
</tr>
<tr>
<td>仓体</td>
<td>所属电源/所属仓控板ID/状态/电压电流设置/指示灯状态</td>
</tr>
<tr>
<td>仓控板</td>
<td>ID/状态/固件版本/OTA进度/6路温控可选</td>
</tr>
<tr>
<td>柜控板</td>
<td>ID/状态/固件版本/OTA进度/TCP状态/4G信号/加热风扇/电磁泵/喷淋/电表耗电量</td>
</tr>
<tr>
<td>充电记录</td>
<td>仓体ID/开始结束时间/充电量/最高温度/状态</td>
</tr>
<tr>
<td>故障记录</td>
<td>主板ID/故障类型/故障码/时间/处理状态/等级</td>
</tr>
</tbody></table>
<hr>
<h2>第5章 微信小程序需求</h2>
<p>微信小程序为后台管理系统的移动端版本,实现后台核心功能的移动化操作。</p>
<h3>5.1 功能页面</h3>
<table>
<thead>
<tr>
<th>页面</th>
<th>功能要求</th>
</tr>
</thead>
<tbody><tr>
<td>首页</td>
<td>充电柜概览仪表盘:在线状态、总耗电量、今日充电量、待处理告警数</td>
</tr>
<tr>
<td>设备管理</td>
<td>查看柜控板/仓控板状态、仓体列表及状态、温控数据、电表读数</td>
</tr>
<tr>
<td>远程控制</td>
<td>仓体禁用/解除、远程开门、电磁泵通断、接触器合闸/分闸</td>
</tr>
<tr>
<td>告警管理</td>
<td>实时告警推送、告警列表查看与确认处理</td>
</tr>
<tr>
<td>数据统计</td>
<td>充电量/能耗/告警统计图表(日/月/年)</td>
</tr>
<tr>
<td>个人中心</td>
<td>账号信息、修改密码、操作日志</td>
</tr>
</tbody></table>
<hr>
<h2>第6章 通讯协议栈</h2>
<h3>6.1 协议列表</h3>
<table>
<thead>
<tr>
<th>协议</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>电源1TN RS485</td>
<td>与充电电源通讯,电压/电流设置、状态查询、故障读取</td>
</tr>
<tr>
<td>星恒BMS Modbus-RTU</td>
<td>读取电池SOC/电压/温度/告警地址0x16</td>
</tr>
<tr>
<td>天能485协议</td>
<td>适配天能电池自定义RS485协议</td>
</tr>
<tr>
<td>电表DL/T645</td>
<td>读取柜体总耗电量,定时采集上报</td>
</tr>
<tr>
<td>仓控-柜控485协议</td>
<td>自定义帧结构(帧头/ID/功能码/长度/数据/CRC1614个功能码</td>
</tr>
<tr>
<td>柜控-平台TCP协议</td>
<td>自定义TCP长连接含心跳/数据上报/指令下发/OTA</td>
</tr>
</tbody></table>
<h3>6.2 仓控-柜控485功能码表</h3>
<table>
<thead>
<tr>
<th></th>
<th>指令</th>
<th>方向</th>
</tr>
</thead>
<tbody><tr>
<td>0x01</td>
<td>上传BMS数据</td>
<td>仓控→柜控</td>
</tr>
<tr>
<td>0x02</td>
<td>上传温控数据</td>
<td>仓控→柜控</td>
</tr>
<tr>
<td>0x03</td>
<td>上传电源状态</td>
<td>仓控→柜控</td>
</tr>
<tr>
<td>0x06</td>
<td>上传OTA状态</td>
<td>仓控→柜控</td>
</tr>
<tr>
<td>0x82</td>
<td>电源参数设置</td>
<td>柜控→仓控</td>
</tr>
<tr>
<td>0x83</td>
<td>指示灯控制</td>
<td>柜控→仓控</td>
</tr>
<tr>
<td>0x84</td>
<td>查询指令</td>
<td>柜控→仓控</td>
</tr>
<tr>
<td>0x85</td>
<td>平台禁用</td>
<td>柜控→仓控</td>
</tr>
<tr>
<td>0x86</td>
<td>模式切换</td>
<td>平台→柜控</td>
</tr>
<tr>
<td>0x87</td>
<td>接触器控制</td>
<td>平台→柜控</td>
</tr>
<tr>
<td>0x89</td>
<td>OTA升级</td>
<td>柜控→仓控</td>
</tr>
<tr>
<td>0x8A</td>
<td>BMS类型配置</td>
<td>柜控→仓控</td>
</tr>
<tr>
<td>0x8B</td>
<td>报警阈值配置</td>
<td>柜控→仓控</td>
</tr>
</tbody></table>
</body></html>

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,108 @@
# 附件一:硬件规格
**所属合同:** 36仓体直流电瓶车充电柜 开发合同
**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
---
## 第1章 柜控主板ID128硬件规格
### 1.1 基本参数
| 项目 | 参数 |
|------|------|
| 板卡数量 | 3块含1块备板 |
| MCU | 内置MCU控制器支持OTA固件升级 |
| 供电 | 12V直流输入预接触器常供电 |
| 后备电源 | 后备电池自动切换可选维持板子供电与4G通讯 |
| 工作温度 | -10℃ ~ +70℃ |
| 工作湿度 | 10% ~ 90% |
| PCB | 双面板/四层板,根据设计复杂度确定 |
### 1.2 接口规格
| 接口 | 规格 |
|------|------|
| 485接口 | 1路隔离RS485与6个仓控板通讯光耦隔离保护波特率9600 |
| 485电表接口 | 1路隔离RS485接DL/T645电表读取耗电量数据 |
| 4G模块 | Air780E自定义TCP协议长连接 |
| 烟感接口 | 1路开关量输入中断触发 |
| 浸水接口 | 1路开关量输入中断触发 |
| 倾倒检测 | 1路开关量输入中断触发 |
| 环境温控接口 | 1路适配NTC/DS18B20 |
| 门锁控制 | 1路MOS管/三极管驱动输出 + 1路门磁反馈输入控制柜体电控锁并检测锁状态 |
| 接触器控制 | 1路继电器输出控制常开接触器默认断开给信号吸合 |
| 喷淋电磁阀 | 1路继电器输出烟感触发时自动打开 |
| 加热风扇继电器 | 1路独立继电器输出220V/10A |
| 电磁泵继电器 | 1路继电器输出 |
| 接触器按钮 | 2个物理按钮合闸按钮吸合接触器、分闸按钮断开接触器 |
| 存储 | Flash存储BMS类型/报警阈值/无协议充电参数 |
### 1.3 供电时序
- 主电源12V预接触器接入柜控板常供电
- 后备电池可选在380V断电时自动切换维持板子供电与远程通讯
- 接触器默认断开,上电后自动吸合恢复供电
- 平台可远程下发恢复指令吸合接触器
---
## 第2章 仓控主板ID1-6硬件规格
### 2.1 基本参数
| 项目 | 参数 |
|------|------|
| 板卡数量 | 10块含4块备板 |
| MCU | 内置MCU控制器支持OTA固件升级 |
| 供电 | 由柜控板统一供电12V |
| 工作温度 | -10℃ ~ +70℃ |
| ID设置 | 4位拨码开关设置485从站ID1-6 |
### 2.2 接口规格
| 接口 | 规格 |
|------|------|
| 485接口×3 | 全部隔离1路接柜控板ID128、1路接BMS总线6仓体共用、1路接所属电源模块 |
| 光耦隔离 | 3路485均做光耦隔离保护 |
| 温控接口 | 6路适配NTC/DS18B20可选 |
| 指示灯接口 | 6路支持绿灯/红灯控制 |
### 2.3 物料清单(乙方供)
| 物料 | 数量 | 说明 |
|------|------|------|
| 4G模块 | 3个 | Air780E用于柜控板 |
| 光耦隔离芯片 | 100个 | 485隔离用 |
| 状态指示灯 | 50个 | 红绿双色 |
| 拨码开关 | 10个 | 4位 |
| PCB打样及焊接辅料 | 1批 | 含钢网/样板焊接 |
---
## 第3章 MCU固件需求
### 3.1 柜控板固件1个工程
| 功能 | 说明 |
|------|------|
| 485主站通讯 | 与仓控板ID1-6双向寻址通讯响应延迟≤0.5s |
| 4G TCP通讯 | 与后台平台自定义TCP长连接含心跳/断线重连/数据缓存补偿 |
| 指令转发 | 接收平台充电控制/门锁/配置等指令,转发至对应仓控板 |
| 安全监测联动 | 烟感/浸水/倾倒检测,触发后断开接触器+打开喷淋电磁阀 |
| 电表采集 | 通过独立485接口按DL/T645协议读取电表耗电量定时上报平台 |
| 辅助设备控制 | 加热风扇自动温控(<0°C开/25°C停电磁泵远程通断 |
| 门锁控制 | 接收平台开门指令,直接控制柜体电控锁 |
| OTA升级 | 接收平台固件包,本地升级或分片转发仓控板 |
| 配置管理 | BMS协议类型、报警阈值、无协议充电参数的存储与下发 |
### 3.2 仓控板固件1个工程
| 功能 | 说明 |
|------|------|
| 485从站通讯 | 响应柜控板指令按拨码ID1-6寻址 |
| BMS多协议通讯 | 按配置协议轮询6仓体电池数据星恒Modbus-RTU/天能自定义/无协议跳过 |
| 1TN电源协议 | 与所属充电电源独立485通讯电压/电流/开关控制(电源自带通断) |
| 充电控制逻辑 | 平台/柜控双模式,含电池握手/充满判断/无协议充电/低电流自动停止 |
| 状态采集与指示 | 6路红绿LED指示灯控制 + 6路温控采集可选 |
| OTA升级与ID管理 | 接收固件包校验写入,拨码开关读取识别 |

Binary file not shown.

View File

@ -0,0 +1,62 @@
# 附件三:交付与验收标准
**所属合同:** 36仓体直流电瓶车充电柜 开发合同
**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
---
## 1. 交付物清单
| 编号 | 交付物 | 形式 | 数量 |
|------|--------|------|------|
| 1 | 柜控主板(含固件) | 硬件成品 | 3块含1块备板 |
| 2 | 仓控主板(含固件) | 硬件成品 | 10块含4块备板 |
| 3 | 后台管理系统 | 编译部署包 | 1份 |
| 4 | 微信小程序 | 编译发布包 | 1份 |
| 5 | 技术开发文档 | 电子版 | 1份 |
| 6 | 用户操作手册 | 电子版 | 1份 |
| 7 | 运维手册 | 电子版 | 1份 |
| 8 | 测试报告 | 电子版 | 1份 |
注:充电柜柜体、充电电源及外部器件(风扇/泵/阀/锁/传感器等)由甲方提供,不在乙方交付范围内。**本合同不交付任何源代码。**
## 2. 验收标准
| 验收项 | 标准 |
|--------|------|
| 硬件验收 | 各接口功能正常,通讯稳定,电气参数符合设计要求 |
| 固件验收 | 所有功能码响应正确通讯延迟≤0.5s,无死机/异常重启 |
| 后台验收 | 各管理功能正常数据展示准确操作响应≤2s |
| 小程序验收 | 扫码充电流程完整,状态展示与硬件一致 |
| 整机验收 | 乙方到甲方现场将板子安装至甲方提供的柜体中完成36仓同步充电测试及安全防护功能测试 |
## 3. 通讯协议栈
### 3.1 协议列表
| 协议 | 说明 |
|------|------|
| 电源1TN RS485 | 与充电电源通讯,电压/电流设置、状态查询、故障读取 |
| 星恒BMS Modbus-RTU | 读取电池SOC/电压/温度/告警地址0x16 |
| 天能485协议 | 适配天能电池自定义RS485协议 |
| 电表DL/T645 | 读取柜体总耗电量,定时采集上报 |
| 仓控-柜控485协议 | 自定义帧结构(帧头/ID/功能码/长度/数据/CRC1614个功能码 |
| 柜控-平台TCP协议 | 自定义TCP长连接含心跳/数据上报/指令下发/OTA |
### 3.2 仓控-柜控485功能码表
| 码 | 指令 | 方向 |
|----|------|------|
| 0x01 | 上传BMS数据 | 仓控→柜控 |
| 0x02 | 上传温控数据 | 仓控→柜控 |
| 0x03 | 上传电源状态 | 仓控→柜控 |
| 0x06 | 上传OTA状态 | 仓控→柜控 |
| 0x82 | 电源参数设置 | 柜控→仓控 |
| 0x83 | 指示灯控制 | 柜控→仓控 |
| 0x84 | 查询指令 | 柜控→仓控 |
| 0x85 | 平台禁用 | 柜控→仓控 |
| 0x86 | 模式切换 | 平台→柜控 |
| 0x87 | 接触器控制 | 平台→柜控 |
| 0x89 | OTA升级 | 柜控→仓控 |
| 0x8A | BMS类型配置 | 柜控→仓控 |
| 0x8B | 报警阈值配置 | 柜控→仓控 |

Binary file not shown.

View File

@ -0,0 +1,53 @@
# 附件二:软件规格
**所属合同:** 36仓体直流电瓶车充电柜 开发合同
**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
---
## 第1章 后台管理系统需求
### 1.1 功能模块
| 模块 | 功能要求 |
|------|---------|
| 设备管理 | 实时监控、远程重启、参数配置、OTA固件升级柜控板+仓控板) |
| 仓体控制 | 独立电流/电压设置、禁用/解除、状态查看、远程开门 |
| BMS协议配置 | 设置当前柜体BMS协议类型星恒/天能/无协议充电) |
| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
| 能耗管理 | 实时显示/统计电表耗电量数据,按日/月/年生成报表 |
| 用户管理 | 注册/登录、权限分配(管理员/普通/骑手) |
| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计、能耗统计 |
| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置 |
| TCP连接管理 | 连接状态监控、心跳检测、断连告警 |
| 辅助设备控制 | 电磁泵远程通断控制、加热风扇状态监控 |
### 1.2 数据模型
| 实体 | 字段 |
|------|------|
| 充电柜 | id/名称/位置/状态/BMS协议类型/无协议充电参数/报警阈值/总耗电量 |
| 仓体 | 所属电源/所属仓控板ID/状态/电压电流设置/指示灯状态 |
| 仓控板 | ID/状态/固件版本/OTA进度/6路温控可选 |
| 柜控板 | ID/状态/固件版本/OTA进度/TCP状态/4G信号/加热风扇/电磁泵/喷淋/电表耗电量 |
| 充电记录 | 仓体ID/开始结束时间/充电量/最高温度/状态 |
| 故障记录 | 主板ID/故障类型/故障码/时间/处理状态/等级 |
---
## 第2章 微信小程序需求
微信小程序为后台管理系统的移动端版本。
### 2.1 功能页面
| 页面 | 功能要求 |
|------|---------|
| 首页 | 充电柜概览仪表盘:在线状态、总耗电量、今日充电量、待处理告警数 |
| 设备管理 | 查看柜控板/仓控板状态、仓体列表及状态、温控数据、电表读数 |
| 远程控制 | 仓体禁用/解除、远程开门、电磁泵通断、接触器合闸/分闸 |
| 告警管理 | 实时告警推送、告警列表查看与确认处理 |
| 数据统计 | 充电量/能耗/告警统计图表(日/月/年) |
| 个人中心 | 账号信息、修改密码、操作日志 |

3118
software/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
# MySQL 连接pms_dev 库由迁移自动创建)
DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms_dev
# Redis 连接(生产 db5
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
# HTTP 服务
HOST=0.0.0.0
PORT=3000
# JWT
JWT_SECRET=pms-dev-secret-change-me-in-production
# 节点心跳超时(秒)
NODE_HEARTBEAT_TIMEOUT=180

View File

@ -0,0 +1,15 @@
# 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
# JWT
JWT_SECRET=pms-test-secret
# 节点心跳超时(秒)
NODE_HEARTBEAT_TIMEOUT=180

2695
software/api-server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
[package]
name = "api-server"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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"] }
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
tokio-util = { version = "0.7", features = ["codec"] }
bytes = "1"
futures = "0.3"
rand = "0.8"
hex = "0.4"
rust_xlsxwriter = "0.80"
jsonwebtoken = "9"
argon2 = "0.5"
dotenvy = "0.15"

View File

@ -0,0 +1,301 @@
//! 设备指令下发 — 通过 Redis 队列向设备发送指令
//!
//! API 服务不直接持有 TCP 连接,而是通过 Redis LIST `device:cmd:{imei}`
//! 推送指令,由设备服务消费并发送到设备。指令响应通过 `device:reply:{imei}`
//! 队列返回API 服务通过 `PendingCommands` 匹配 msg_id 完成异步等待。
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::{oneshot, Mutex};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::protocol::ServerResponse;
/// msg_id → oneshot Sender 映射表
#[derive(Clone)]
pub struct PendingCommands {
inner: Arc<Mutex<HashMap<u32, oneshot::Sender<Value>>>>,
}
impl PendingCommands {
pub fn new() -> Self {
Self { inner: Arc::new(Mutex::new(HashMap::new())) }
}
/// 注册等待中的指令
#[allow(dead_code)]
pub async fn insert(&self, msg_id: u32, tx: oneshot::Sender<Value>) {
self.inner.lock().await.insert(msg_id, tx);
}
/// 移除并返回指定 msg_id 的 sender消费一次
pub async fn remove(&self, msg_id: u32) -> Option<oneshot::Sender<Value>> {
self.inner.lock().await.remove(&msg_id)
}
}
/// 设备服务节点信息
#[derive(Debug, Clone)]
pub struct NodeInfo {
pub node_id: String,
pub ip: String,
pub tcp_port: u16,
/// RFC3339 格式的最后心跳时间,从 Redis 读取
pub last_heartbeat: Option<String>,
}
/// 节点注册表Redis 存储API 服务从 Redis 读取节点信息)
///
/// 设备服务通过 HSET 写入 `node:{node_id}` hash
/// API 服务通过此模块读取。Redis 是节点信息的单一数据源。
#[derive(Clone)]
pub struct NodeRegistry {
redis: redis::aio::ConnectionManager,
}
impl NodeRegistry {
pub fn new(redis: redis::aio::ConnectionManager) -> Self {
Self { redis }
}
fn node_key(node_id: &str) -> String {
format!("node:{}", node_id)
}
/// 注册节点(写入 Redis HSET
pub async fn register(&self, node_id: &str, ip: &str, tcp_port: u16) {
let key = Self::node_key(node_id);
let now = chrono::Utc::now().to_rfc3339();
let mut conn = self.redis.clone();
let _: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("node_id").arg(node_id)
.arg("ip").arg(ip)
.arg("tcp_port").arg(tcp_port.to_string())
.arg("status").arg("online")
.arg("last_heartbeat").arg(&now)
.query_async(&mut conn)
.await;
// ponytail: HSET 不支持 EX用 EXPIRE 设置 TTL
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(300_u64)
.query_async(&mut conn)
.await;
}
/// 移除节点(从 Redis 删除)
pub async fn remove(&self, node_id: &str) {
let key = Self::node_key(node_id);
let mut conn = self.redis.clone();
let _: Result<(), _> = redis::cmd("DEL")
.arg(&key)
.query_async(&mut conn)
.await;
}
/// 更新心跳时间戳
pub async fn heartbeat(&self, node_id: &str) -> bool {
let key = Self::node_key(node_id);
let mut conn = self.redis.clone();
let result: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("last_heartbeat")
.arg(chrono::Utc::now().to_rfc3339())
.query_async(&mut conn)
.await;
// 心跳同时刷新 TTL
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(300_u64)
.query_async(&mut conn)
.await;
result.is_ok()
}
/// 列出所有节点(从 Redis 读取,使用 SCAN 替代 KEYS 避免阻塞)
pub async fn list(&self) -> Vec<NodeInfo> {
let mut conn = self.redis.clone();
let keys = self.scan_keys(&mut conn, "node:*").await;
let mut nodes = Vec::with_capacity(keys.len());
for key in keys {
let mut conn = self.redis.clone();
let info: HashMap<String, String> = redis::cmd("HGETALL")
.arg(&key)
.query_async(&mut conn)
.await
.unwrap_or_default();
let node_id = info.get("node_id").cloned().unwrap_or_default();
if node_id.is_empty() {
continue; // ponytail: 跳过空记录
}
nodes.push(NodeInfo {
node_id,
ip: info.get("ip").cloned().unwrap_or_default(),
tcp_port: info.get("tcp_port").and_then(|p| p.parse().ok()).unwrap_or(0),
last_heartbeat: info.get("last_heartbeat").cloned(),
});
}
nodes
}
/// 使用 SCAN 命令安全地获取所有匹配模式的键(避免 KEYS 阻塞)
async fn scan_keys(&self, conn: &mut redis::aio::ConnectionManager, pattern: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut cursor = 0i64;
loop {
let (next_cursor, batch): (i64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(conn)
.await
.unwrap_or_default();
keys.extend(batch);
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
keys
}
/// 检查设备是否在线(通过 Redis 中的在线标记)
pub async fn is_device_online(&self, imei: &str) -> bool {
let key = format!("device:online:{}", imei);
let mut conn = self.redis.clone();
let exists: Result<bool, _> = redis::cmd("EXISTS")
.arg(&key)
.query_async(&mut conn)
.await;
exists.unwrap_or(false)
}
}
/// 应用共享状态
#[derive(Clone)]
#[allow(dead_code)]
pub struct AppState {
pub redis: redis::aio::ConnectionManager,
pub mysql: sqlx::MySqlPool,
pub pending_commands: PendingCommands,
pub nodes: NodeRegistry,
}
/// 指令下发请求体
#[derive(Debug, Deserialize)]
pub struct CommandRequest {
pub act: String,
#[serde(default)]
pub msg_id: u32,
pub sub_device_id: Option<u8>,
#[serde(flatten)]
pub params: Option<Value>,
}
/// 设备指令结构(序列化后推入 Redis
#[derive(Debug, Clone, serde::Serialize, serde::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 序列化不应失败")
}
}
/// 向设备发送指令(通过 Redis 队列)
pub async fn send_device_command(
redis: &redis::aio::ConnectionManager,
_dev_id: &str,
cmd: &DeviceCommand,
) -> Result<(), String> {
let json_str = cmd.to_json();
let mut conn = redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| format!("Redis 推入失败: {}", e))?;
Ok(())
}
/// 向设备发送响应(通过 Redis 队列)
#[allow(dead_code)]
pub async fn send_device_response(
redis: &redis::aio::ConnectionManager,
_dev_id: &str,
resp: &ServerResponse,
) -> Result<(), String> {
let json_str = resp.to_json();
let mut conn = redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| format!("Redis 推入失败: {}", e))?;
Ok(())
}
/// POST /api/devices/:dev_id/command — 向指定设备下发指令
pub async fn send_command(
user: CurrentUser,
State(state): State<AppState>,
Path(dev_id): Path<String>,
Json(req): Json<CommandRequest>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
let cmd = DeviceCommand {
act: req.act,
msg_id: req.msg_id,
dev_id: Some(dev_id.clone()),
sub_device_id: req.sub_device_id,
params: req.params,
};
send_device_command(&state.redis, &dev_id, &cmd)
.await
.map_err(AppError::Internal)?;
tracing::info!("[command] 已推入队列 dev_id={} act={}", dev_id, cmd.act);
Ok(Json(json!({ "suc": 1 })))
}
/// GET /api/devices/stats — 在线设备统计
pub async fn device_stats(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let nodes = state.nodes.list().await;
Ok(Json(json!({
"node_count": nodes.len(),
})))
}

View File

@ -0,0 +1,59 @@
//! 应用配置管理
//!
//! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。
/// 默认 JWT 密钥(开发环境用,生产环境必须通过环境变量覆盖)
pub const JWT_SECRET_DEFAULT: &str = "pms-dev-secret-change-me-in-production";
/// 用户默认密码(创建用户或重置密码时使用)
pub const DEFAULT_PASSWORD: &str = "123456";
/// 应用配置
#[allow(dead_code)]
pub struct Config {
pub database_url: String,
pub redis_url: String,
pub host: String,
pub port: u16,
/// JWT 签名密钥
pub jwt_secret: String,
/// 导出目录
pub export_dir: String,
/// 节点心跳超时(秒)
pub node_heartbeat_timeout: u64,
}
impl Config {
/// 从环境变量加载配置,优先读取 `.env` 文件
pub fn from_env() -> Self {
let _ = dotenvy::dotenv();
Self {
database_url: std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set"),
redis_url: std::env::var("REDIS_URL")
.expect("REDIS_URL must be set"),
host: std::env::var("HOST")
.unwrap_or_else(|_| "0.0.0.0".to_string()),
port: std::env::var("PORT")
.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()),
export_dir: std::env::var("EXPORT_DIR")
.unwrap_or_else(|_| "./exports".to_string()),
node_heartbeat_timeout: std::env::var("NODE_HEARTBEAT_TIMEOUT")
.unwrap_or_else(|_| "180".to_string())
.parse()
.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

@ -0,0 +1,406 @@
//! 数据库迁移 — 自动建表
use sqlx::MySqlPool;
/// 执行数据库迁移,创建所有必要的数据表
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let sql = r#"
CREATE TABLE IF NOT EXISTS organizations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '',
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 projects (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
organization_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabinets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT,
abstract_id VARCHAR(20) NOT NULL UNIQUE COMMENT 'ID10-00000000',
imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
auth_str VARCHAR(8) COMMENT '',
name VARCHAR(100) COMMENT '',
status TINYINT DEFAULT 0 COMMENT '0线 1线',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabin_boards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
board_id TINYINT NOT NULL COMMENT 'ID 1-6',
status TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS compartments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabin_board_id BIGINT NOT NULL,
channel_id TINYINT NOT NULL COMMENT 'ID 1-6',
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,
FOREIGN KEY (cabin_board_id) REFERENCES cabin_boards(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '',
name VARCHAR(50) COMMENT '',
password VARCHAR(255) NOT NULL,
role TINYINT DEFAULT 0 COMMENT '0 1 2',
organization_id BIGINT,
status TINYINT DEFAULT 1 COMMENT '0 1',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS charge_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
compartment_id BIGINT NOT NULL,
cabinet_id BIGINT NOT NULL,
start_time TIMESTAMP,
end_time TIMESTAMP,
energy DECIMAL(10,2) COMMENT 'kWh',
max_temperature DECIMAL(5,1) COMMENT '',
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS device_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
log_type TINYINT NOT NULL COMMENT '1 2 3 4',
content TEXT COMMENT 'JSON',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS energy_stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
total_energy DECIMAL(10,2) COMMENT 'kWh',
charge_count INT DEFAULT 0 COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
UNIQUE KEY uk_cabinet_date (cabinet_id, stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS operation_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
action VARCHAR(100) NOT NULL COMMENT '',
target_type VARCHAR(50) COMMENT '',
target_id BIGINT COMMENT 'ID',
detail TEXT COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE COMMENT '',
description VARCHAR(200) COMMENT '',
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 permissions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE COMMENT 'device:view',
name VARCHAR(100) NOT NULL COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS role_permissions (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_scopes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
scope_type TINYINT NOT NULL COMMENT '1 2 3',
scope_id BIGINT NOT NULL COMMENT '//ID',
permission_type TINYINT NOT NULL COMMENT '1 2',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS download_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
task_type VARCHAR(50) NOT NULL COMMENT 'charge_records/energy_stats/device_logs',
status TINYINT DEFAULT 0 COMMENT '0 1 2 3',
file_path VARCHAR(500) COMMENT '',
file_name VARCHAR(200) COMMENT '',
progress INT DEFAULT 0 COMMENT '',
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"#;
// 逐条执行,避免多语句执行问题
for statement in sql.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
sqlx::query(statement).execute(pool).await?;
}
// 创建关键索引IF NOT EXISTS 避免重复)
create_indexes(pool).await?;
// 初始化默认权限码INSERT IGNORE 避免重复插入)
seed_permissions(pool).await?;
// 初始化默认角色
seed_roles(pool).await?;
tracing::info!("数据库迁移完成");
Ok(())
}
/// 创建关键查询索引(幂等,已存在则跳过)
async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> {
/// 辅助:检查索引是否存在,不存在则创建
async fn ensure_index(
pool: &MySqlPool,
table: &str,
index: &str,
columns: &str,
) -> Result<(), sqlx::Error> {
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM information_schema.statistics \
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
)
.bind(table)
.bind(index)
.fetch_one(pool)
.await?;
if exists.0 == 0 {
let ddl = format!("CREATE INDEX {} ON {}({})", index, table, columns);
sqlx::query(&ddl).execute(pool).await?;
}
Ok(())
}
ensure_index(pool, "cabinets", "idx_cabinets_project", "project_id").await?;
ensure_index(pool, "cabin_boards", "idx_cabin_boards_cabinet", "cabinet_id").await?;
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?;
tracing::info!("数据库索引创建完成");
Ok(())
}
/// 插入默认权限码
async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let perms: Vec<(&str, &str)> = vec![
("device:view", "查看设备"),
("device:operate", "操作设备"),
("device:create", "添加设备"),
("device:edit", "编辑设备"),
("device:delete", "删除设备"),
("charge:start", "开始充电"),
("charge:stop", "停止充电"),
("charge:open_door", "开门"),
("charge_record:view", "查看充电记录"),
("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", "删除组织"),
// 项目管理
("project:view", "查看项目"),
("project:create", "创建项目"),
("project:edit", "编辑项目"),
("project:delete", "删除项目"),
];
for (code, name) in perms {
sqlx::query(
"INSERT IGNORE INTO permissions (code, name) VALUES (?, ?)"
)
.bind(code)
.bind(name)
.execute(pool)
.await?;
}
tracing::info!("默认权限码初始化完成");
Ok(())
}
/// 插入默认角色并分配权限
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 创建总管理员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("super_admin")
.bind("总管理员 — 全部权限")
.execute(pool)
.await?;
// 创建企业管理员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("org_admin")
.bind("企业管理员 — 管理本组织下项目/设备/人员")
.execute(pool)
.await?;
// 创建普通用户角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("normal_user")
.bind("普通用户 — 查看/操作指定范围设备")
.execute(pool)
.await?;
// 为总管理员分配所有权限
let super_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'super_admin'"
)
.fetch_optional(pool)
.await?;
if let Some((role_id,)) = super_admin_id {
// 查询所有权限ID
let perm_ids: Vec<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions"
)
.fetch_all(pool)
.await?;
for (perm_id,) in perm_ids {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
// 为企业管理员分配非系统管理权限
let org_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'org_admin'"
)
.fetch_optional(pool)
.await?;
let org_perm_codes = [
"device:view", "device:operate", "device:create", "device:edit",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "charge_record:export",
"device_log:view", "energy:view", "energy:export",
"user:view", "user:edit",
"operation_log:view",
// 组织管理
"org:view", "org:create", "org:edit", "org:delete",
// 项目管理
"project:view", "project:create", "project:edit", "project:delete",
];
if let Some((role_id,)) = org_admin_id {
for code in &org_perm_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
}
// 为普通用户分配查看和操作权限
let normal_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'normal_user'"
)
.fetch_optional(pool)
.await?;
let normal_perm_codes = [
"device:view", "device:operate",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "device_log:view", "energy:view",
];
if let Some((role_id,)) = normal_id {
for code in &normal_perm_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
}
tracing::info!("默认角色及权限分配完成");
Ok(())
}

View File

@ -0,0 +1,17 @@
//! MySQL 数据库连接池
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
pub mod migrate;
/// 初始化 MySQL 连接池
pub async fn init_pool(database_url: &str) -> Result<MySqlPool, sqlx::Error> {
let pool = MySqlPoolOptions::new()
.max_connections(20)
.connect(database_url)
.await?;
tracing::info!("MySQL 连接池已建立");
Ok(pool)
}

View File

@ -0,0 +1,66 @@
//! 统一错误处理
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// 应用统一错误类型
#[derive(thiserror::Error, Debug)]
#[allow(dead_code)]
pub enum AppError {
#[error("数据库错误: {0}")]
Database(#[from] sqlx::Error),
#[error("Redis错误: {0}")]
Redis(#[from] redis::RedisError),
#[error("未找到: {0}")]
NotFound(String),
#[error("参数错误: {0}")]
BadRequest(String),
#[error("未授权: {0}")]
Unauthorized(String),
#[error("无权限: {0}")]
Forbidden(String),
#[error("请求过于频繁: {0}")]
TooManyRequests(String),
#[error("内部错误: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::Database(e) => {
tracing::error!("数据库错误: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "DATABASE_ERROR", "服务器内部错误")
}
AppError::Redis(e) => {
tracing::error!("Redis错误: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "REDIS_ERROR", "缓存服务错误")
}
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.as_str()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.as_str()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.as_str()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.as_str()),
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS", msg.as_str()),
AppError::Internal(msg) => {
tracing::error!("内部错误: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误")
}
};
let body = json!({
"code": code,
"message": message,
});
(status, Json(body)).into_response()
}
}

View File

@ -0,0 +1,97 @@
//! 充电柜矩阵系统 — API 服务入口
//!
//! 提供 HTTP API后台管理 + H5处理后端业务逻辑。
//! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。
mod commands;
mod config;
mod db;
mod error;
mod middleware;
mod nodes;
mod protocol;
mod redis;
mod routes;
mod workers;
use std::net::SocketAddr;
use tracing_subscriber::EnvFilter;
use commands::{AppState, NodeRegistry, PendingCommands};
#[tokio::main]
async fn main() {
// 初始化日志
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
// 加载配置
let cfg = config::Config::from_env();
cfg.validate();
tracing::info!("配置加载完成");
// 初始化 MySQL 连接池
let db_pool = db::init_pool(&cfg.database_url)
.await
.expect("MySQL 连接失败");
tracing::info!("数据库连接成功");
// 执行自动迁移
db::migrate::run(&db_pool)
.await
.expect("数据库迁移失败");
tracing::info!("数据库迁移完成");
// 初始化 Redis 连接
let redis_conn = redis::init_connection(&cfg.redis_url)
.await
.expect("Redis 连接失败");
tracing::info!("Redis 连接成功");
// 初始化共享状态
let pending_commands = PendingCommands::new();
let nodes = NodeRegistry::new(redis_conn.clone());
let state = AppState {
redis: redis_conn.clone(),
mysql: db_pool.clone(),
pending_commands: pending_commands.clone(),
nodes: nodes.clone(),
};
// 启动后台协程 — 处理设备上报
let report_redis = redis_conn.clone();
let report_mysql = db_pool.clone();
tokio::spawn(async move {
workers::report_worker::start(report_redis, report_mysql).await;
});
// 启动后台协程 — 处理指令响应
let reply_redis = redis_conn.clone();
tokio::spawn(async move {
workers::reply_worker::start(reply_redis, pending_commands).await;
});
// 启动后台协程 — 节点心跳超时检测
let checker_nodes = nodes.clone();
let heartbeat_timeout = cfg.node_heartbeat_timeout;
tokio::spawn(async move {
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
});
// 构建 HTTP 路由
let app = routes::build(state);
// 启动 HTTP 服务
let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
.parse()
.expect("无效的监听地址");
tracing::info!("HTTP 服务器启动于 http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("端口绑定失败");
axum::serve(listener, app)
.await
.expect("服务器运行失败");
}

View File

@ -0,0 +1,286 @@
//! 认证与权限校验中间件
//!
//! 提供 JWT 令牌解析和 RBAC 权限校验功能:
//! - `auth_middleware`:从请求头提取 JWT解析用户信息并注入请求扩展
//! - `check_permission`:校验用户是否拥有指定权限码
//! - `CurrentUser`从请求扩展中提取当前登录用户信息Axum Extractor
//! - `generate_token`:签发 JWT token
//! - `hash_password` / `verify_password`:密码哈希与验证
use std::collections::HashSet;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::body::Body;
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::http::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
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 {
/// 用户IDsubject
pub sub: i64,
/// 手机号
pub phone: String,
/// 过期时间Unix 时间戳,秒)
pub exp: u64,
/// 签发时间Unix 时间戳,秒)
pub iat: u64,
}
/// 当前登录用户信息(从请求扩展中提取)
#[derive(Debug, Clone)]
pub struct CurrentUser {
/// 用户ID
pub user_id: i64,
/// 手机号
#[allow(dead_code)]
pub phone: String,
/// 用户拥有的权限码集合
pub permissions: HashSet<String>,
/// 角色等级0普通 1企业管理员 2总管理员
pub role_level: i32,
/// 所属组织ID总管理员为 None
pub organization_id: Option<i64>,
}
/// 认证中间件 — 从请求头提取 JWT解析用户信息并注入请求扩展
///
/// 配合 `axum::middleware::from_fn_with_state(state, auth_middleware)` 使用
pub async fn auth_middleware(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
let pool = &state.mysql;
// 从 Authorization 头提取 Bearer token
let auth_header = req.headers().get("Authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let token = match auth_header.strip_prefix("Bearer ") {
Some(t) => t.trim().to_string(),
None => {
return AppError::Unauthorized("缺少认证令牌".into()).into_response();
}
};
// 获取 JWT 密钥
let secret = std::env::var(JWT_SECRET_ENV)
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
// 解码并验证 JWT
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
let token_data = match decode::<Claims>(
&token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
) {
Ok(data) => data,
Err(_) => {
return AppError::Unauthorized("令牌无效或已过期".into()).into_response();
}
};
let claims = token_data.claims;
// 查询用户权限码集合
let permissions = match load_user_permissions(pool, claims.sub).await {
Ok(perms) => perms,
Err(e) => {
tracing::error!("查询用户权限失败: {}", e);
return AppError::Internal("权限查询失败".into()).into_response();
}
};
// 查询用户角色等级和组织ID
let (role_level, organization_id) = match load_user_role_and_org(pool, claims.sub).await {
Ok(data) => data,
Err(e) => {
tracing::error!("查询用户角色失败: {}", e);
return AppError::Internal("角色查询失败".into()).into_response();
}
};
// 构建当前用户信息并注入请求扩展
let current_user = CurrentUser {
user_id: claims.sub,
phone: claims.phone,
permissions,
role_level,
organization_id,
};
// 拆分请求,插入扩展,重新组合
let (mut parts, body) = req.into_parts();
parts.extensions.insert(current_user);
let req = Request::from_parts(parts, body);
next.run(req).await
}
/// 从请求中提取当前用户Axum Extractor 实现)
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
parts.extensions.get::<CurrentUser>().cloned().ok_or_else(|| {
AppError::Unauthorized("未认证".into()).into_response()
})
}
}
/// 权限校验 — 检查用户是否拥有指定权限码
///
/// 总管理员role_level >= 2自动拥有全部权限
pub fn check_permission(user: &CurrentUser, code: &str) -> Result<(), AppError> {
if user.role_level >= 2 {
return Ok(());
}
if user.permissions.contains(code) {
Ok(())
} else {
Err(AppError::Forbidden(format!("缺少权限: {}", code)))
}
}
/// 加载用户权限码集合(公开接口,供路由处理器调用)
pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result<HashSet<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT DISTINCT p.code
FROM permissions p
INNER JOIN role_permissions rp ON rp.permission_id = p.id
INNER JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = ?
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(code,)| code).collect())
}
/// 加载用户最高角色等级和组织ID
async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32, Option<i64>), sqlx::Error> {
let result: Option<(i32, Option<i64>)> = sqlx::query_as(
"SELECT COALESCE(MAX(role), 0), organization_id FROM users WHERE id = ?",
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(result.unwrap_or((0, None)))
}
/// 生成 JWT token7天有效期
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());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| AppError::Internal(format!("时间错误: {}", e)))?
.as_secs();
let claims = Claims {
sub: user_id,
phone: phone.to_string(),
exp: now + 86400 * 7,
iat: now,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| AppError::Internal(format!("Token生成失败: {}", e)))
}
// ---------------------------------------------------------------------------
// 密码哈希
// ---------------------------------------------------------------------------
/// 使用 argon2 对密码进行哈希
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(format!("密码哈希失败: {}", e)))?;
Ok(hash.to_string())
}
/// 验证密码与 argon2 哈希是否匹配
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| AppError::Internal(format!("密码哈希解析失败: {}", e)))?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
// ---------------------------------------------------------------------------
// 组织数据过滤
// ---------------------------------------------------------------------------
/// 返回角色等级 >= 2 时「总管理员」看全部
/// 否则按 organization_id 过滤(子查询方式,通过 cabinet → project → org 链路)
pub fn org_condition(user: &CurrentUser, cabinet_table: &str) -> (String, Vec<String>) {
if user.role_level >= 2 {
return (String::new(), vec![]);
}
if let Some(org_id) = user.organization_id {
(
format!(
" AND {}.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)",
cabinet_table
),
vec![org_id.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
}
}
/// 操作日志的组织过滤(通过 users.organization_id 链路)
pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, Vec<String>) {
if user.role_level >= 2 {
return (String::new(), vec![]);
}
if let Some(org_id) = user.organization_id {
(
format!(
" AND {}.user_id IN (SELECT id FROM users WHERE organization_id = ?)",
log_table
),
vec![org_id.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
}
}

View File

@ -0,0 +1,3 @@
//! 中间件模块
pub mod auth;

View File

@ -0,0 +1,70 @@
//! 设备服务节点管理 API
//!
//! 设备服务启动时注册到 API 服务,定期发送心跳。
//! API 服务通过节点健康状况判断设备服务是否在线。
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::commands::AppState;
use crate::error::AppError;
/// 节点注册请求体
#[derive(Debug, Deserialize)]
pub struct NodeRegisterRequest {
pub node_id: String,
pub ip: String,
pub tcp_port: u16,
}
/// POST /api/nodes/register — 设备服务注册
pub async fn register_node(
State(state): State<AppState>,
Json(req): Json<NodeRegisterRequest>,
) -> Result<Json<Value>, AppError> {
state.nodes.register(&req.node_id, &req.ip, req.tcp_port).await;
tracing::info!("[节点注册] node_id={}", req.node_id);
Ok(Json(json!({"suc": 1})))
}
/// POST /api/nodes/:node_id/heartbeat — 节点心跳
pub async fn node_heartbeat(
State(state): State<AppState>,
Path(node_id): Path<String>,
) -> Result<Json<Value>, AppError> {
if state.nodes.heartbeat(&node_id).await {
Ok(Json(json!({"suc": 1})))
} else {
Ok(Json(json!({"suc": 0, "err": "节点未注册"})))
}
}
/// POST /api/nodes/:node_id/deregister — 节点注销
pub async fn deregister_node(
State(state): State<AppState>,
Path(node_id): Path<String>,
) -> Result<Json<Value>, AppError> {
state.nodes.remove(&node_id).await;
tracing::info!("[节点注销] node_id={}", node_id);
Ok(Json(json!({"suc": 1})))
}
/// GET /api/nodes — 节点列表
pub async fn list_nodes(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let nodes = state.nodes.list().await;
let list: Vec<Value> = nodes
.into_iter()
.map(|n| {
json!({
"node_id": n.node_id,
"ip": n.ip,
"tcp_port": n.tcp_port,
})
})
.collect();
Ok(Json(json!({"nodes": list, "count": list.len()})))
}

View File

@ -0,0 +1,61 @@
//! 设备通讯协议消息类型定义与序列化。
//!
//! 设备与平台间采用 JSON 格式通讯LF (`\n`) 作为消息边界。
//! 所有 JSON 键名使用蛇形命名snake_case
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>,
}
/// 平台回复设备的消息。
#[derive(Debug, Clone, Serialize, Deserialize)]
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>,
}
impl ServerResponse {
/// 创建成功响应。
pub fn success(msg_id: Option<u32>) -> Self {
Self { suc: 1, msg_id, err: None, auth_str: 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 }
}
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
}
}

View File

@ -0,0 +1,12 @@
//! Redis 连接管理
use redis::aio::ConnectionManager;
use redis::Client;
/// 建立 Redis 连接管理器
pub async fn init_connection(redis_url: &str) -> Result<ConnectionManager, redis::RedisError> {
let client = Client::open(redis_url)?;
let conn = ConnectionManager::new(client).await?;
tracing::info!("Redis 连接已建立");
Ok(conn)
}

View File

@ -0,0 +1,118 @@
//! 认证路由 — 登录/登出/当前用户信息
//!
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
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,
}
/// POST /api/auth/login — 用户登录
pub async fn login(
State(state): State<AppState>,
Json(input): Json<LoginInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
if input.phone.is_empty() || input.password.is_empty() {
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
}
// 查询用户(含密码比对)
let user_row: Option<(i64, Option<String>, String, String, i32, i32)> = sqlx::query_as(
"SELECT id, name, phone, password, role, status FROM users WHERE phone = ?"
)
.bind(&input.phone)
.fetch_optional(pool)
.await?;
let (id, name, phone, stored_password, role, status) =
user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
// 检查账户状态
if status == 0 {
return Err(AppError::Forbidden("账户已被禁用".into()));
}
// 密码校验argon2 哈希比对)
let valid = auth::verify_password(&input.password, &stored_password)
.map_err(|_| AppError::Internal("密码验证失败".into()))?;
if !valid {
return Err(AppError::Unauthorized("手机号或密码错误".into()));
}
// 生成 JWT token
let token = auth::generate_token(id, &phone)?;
// 查询用户权限码
let permissions = auth::load_user_permissions(pool, 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": role,
"permissions": perm_list,
}
},
"message": "登录成功"
})))
}
/// POST /api/auth/logout — 用户登出
pub async fn logout() -> Json<Value> {
// JWT 无状态,客户端清除 token 即可
Json(json!({ "code": 0, "data": null, "message": "登出成功" }))
}
/// GET /api/auth/me — 获取当前用户信息及权限
#[allow(clippy::type_complexity)]
pub async fn me(
user: CurrentUser,
State(state): State<AppState>,
) -> 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 = ?"
)
.bind(user.user_id)
.fetch_optional(pool)
.await?;
let (id, name, phone, role, org_id) =
row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
let perm_list: Vec<String> = user.permissions.into_iter().collect();
Ok(Json(json!({
"code": 0,
"data": {
"id": id,
"name": name,
"phone": phone,
"role": role,
"organization_id": org_id,
"permissions": perm_list,
},
"message": "ok"
})))
}

View File

@ -0,0 +1,113 @@
//! 组织树 API
//!
//! 返回组织->项目->设备完整树状结构。
//! 根据用户角色进行数据隔离:总管理员看全部,企业管理员只看本组织。
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
/// GET /api/organization-tree — 返回组织->项目->设备完整树
///
/// 需要 `device:view` 权限。
/// 数据隔离:总管理员看所有组织,企业管理员只看本组织下的数据。
pub async fn organization_tree(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
// 根据角色过滤组织:总管理员看所有,企业管理员只看本组织
let orgs: Vec<OrganizationRow> = if user.role_level >= 2 {
sqlx::query_as(
r#"SELECT id, name FROM organizations ORDER BY id"#,
)
.fetch_all(db)
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as(
r#"SELECT id, name FROM organizations WHERE id = ? ORDER BY id"#,
)
.bind(org_id)
.fetch_all(db)
.await?
} else {
// 无组织关联的用户看不到任何数据
return Ok(Json(json!([])));
};
let projects: Vec<ProjectRow> = sqlx::query_as(
r#"SELECT id, organization_id, name FROM projects ORDER BY id"#,
)
.fetch_all(db)
.await?;
let cabinets: Vec<CabinetRow> = sqlx::query_as(
r#"SELECT id, project_id, abstract_id, imei, name, status
FROM cabinets ORDER BY id"#,
)
.fetch_all(db)
.await?;
// 构建树
let tree: Vec<TreeNode> = orgs
.into_iter()
.map(|org| {
let org_projects: Vec<ProjectRow> = projects
.iter()
.filter(|p| p.organization_id == Some(org.id))
.cloned()
.collect();
let children: Vec<TreeNode> = org_projects
.into_iter()
.map(|proj| {
let proj_cabinets: Vec<CabinetRow> = cabinets
.iter()
.filter(|c| c.project_id == Some(proj.id))
.cloned()
.collect();
let cab_nodes: Vec<TreeNode> = proj_cabinets
.into_iter()
.map(|c| TreeNode {
id: c.id,
name: c.name.unwrap_or_else(|| c.abstract_id.clone()),
children: None,
abstract_id: Some(c.abstract_id),
imei: Some(c.imei),
status: Some(c.status),
})
.collect();
TreeNode {
id: proj.id,
name: proj.name,
children: Some(cab_nodes),
abstract_id: None,
imei: None,
status: None,
}
})
.collect();
TreeNode {
id: org.id,
name: org.name,
children: Some(children),
abstract_id: None,
imei: None,
status: None,
}
})
.collect();
Ok(Json(json!(tree)))
}

View File

@ -0,0 +1,425 @@
//! 设备管理 API — CRUD + 详情 + 级联下拉
//!
//! 提供柜子的增删改查、详情查询、安全码重新生成,
//! 以及柜子/仓板/仓体的级联下拉选项。
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::MySqlPool;
use super::organizations::{
generate_abstract_id, generate_auth_str,
CabinetDetail, CabinetRow, CabinBoardRow, CabinBoardWithCompartments,
CompartmentRow, CreateCabinetBody, UpdateCabinetBody,
};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
// ---------------------------------------------------------------------------
// 项目访问权限验证
// ---------------------------------------------------------------------------
/// 验证用户是否有权访问指定项目
///
/// 总管理员role_level >= 2可访问所有项目
/// 企业管理员只能访问本组织下的项目。
async fn check_project_access(
user: &CurrentUser,
pool: &MySqlPool,
project_id: i64,
) -> Result<(), AppError> {
// 总管理员可访问所有项目
if user.role_level >= 2 {
return Ok(());
}
if let Some(org_id) = user.organization_id {
// 验证项目是否属于用户的组织
let project: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM projects WHERE id = ? AND organization_id = ?",
)
.bind(project_id)
.bind(org_id)
.fetch_optional(pool)
.await?;
if project.is_none() {
return Err(AppError::Forbidden("无权访问该项目".into()));
}
} else {
return Err(AppError::Forbidden("无组织关联".into()));
}
Ok(())
}
// ---------------------------------------------------------------------------
// 设备 CRUD
// ---------------------------------------------------------------------------
/// GET /api/projects/:project_id/cabinets — 按项目查询柜子列表
pub async fn list_cabinets(
user: CurrentUser,
State(state): State<super::AppState>,
Path(project_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
check_project_access(&user, &state.mysql, project_id).await?;
let db = &state.mysql;
let rows = sqlx::query_as::<_, CabinetRow>(
r#"SELECT id, project_id, abstract_id, imei, name, status
FROM cabinets WHERE project_id = ? ORDER BY id"#,
)
.bind(project_id)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
}
/// POST /api/cabinets — 批量添加柜子
///
/// 每个 IMEI 创建一个柜子,同时生成默认仓控板和 6 个仓体。
pub async fn create_cabinet(
user: CurrentUser,
State(state): State<super::AppState>,
Json(body): Json<CreateCabinetBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:create")?;
let db = &state.mysql;
if body.imeis.is_empty() {
return Err(AppError::BadRequest("IMEI 列表不能为空".into()));
}
let mut unique_imeis: Vec<String> = Vec::new();
for imei in &body.imeis {
let trimmed = imei.trim().to_string();
if trimmed.len() != 15 {
return Err(AppError::BadRequest(
format!("IMEI 必须为15位数字: {}", trimmed),
));
}
if !unique_imeis.contains(&trimmed) {
unique_imeis.push(trimmed);
}
}
let mut created_ids: Vec<i64> = Vec::new();
for imei in &unique_imeis {
let abstract_id = generate_abstract_id(imei);
let auth_str = generate_auth_str();
let mut tx = db.begin().await.map_err(AppError::Database)?;
let result = sqlx::query(
r#"INSERT INTO cabinets (project_id, abstract_id, imei, auth_str)
VALUES (?, ?, ?, ?)"#,
)
.bind(body.project_id)
.bind(&abstract_id)
.bind(imei)
.bind(&auth_str)
.execute(&mut *tx)
.await?;
let cabinet_id = result.last_insert_id() as i64;
let board_result = sqlx::query(
r#"INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (?, 1)"#,
)
.bind(cabinet_id)
.execute(&mut *tx)
.await?;
let board_pk = board_result.last_insert_id() as i64;
for ch in 1..=6 {
sqlx::query(
r#"INSERT INTO compartments (cabin_board_id, channel_id) VALUES (?, ?)"#,
)
.bind(board_pk)
.bind(ch)
.execute(&mut *tx)
.await?;
}
tx.commit().await.map_err(AppError::Database)?;
created_ids.push(cabinet_id);
tracing::info!("创建柜子 id={} abstract_id={} imei={}", cabinet_id, abstract_id, imei);
}
Ok(Json(json!({ "ids": created_ids })))
}
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
pub async fn update_cabinet(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<UpdateCabinetBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
let db = &state.mysql;
let rows = sqlx::query(
r#"UPDATE cabinets SET
project_id = COALESCE(?, project_id),
name = COALESCE(?, name)
WHERE id = ?"#,
)
.bind(body.project_id)
.bind(&body.name)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("更新柜子 id={}", id);
Ok(Json(json!({})))
}
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
pub async fn delete_cabinet(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:delete")?;
let db = &state.mysql;
let mut tx = db.begin().await.map_err(AppError::Database)?;
sqlx::query(
r#"DELETE compartments FROM compartments
INNER JOIN cabin_boards ON compartments.cabin_board_id = cabin_boards.id
WHERE cabin_boards.cabinet_id = ?"#,
)
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query(r#"DELETE FROM cabin_boards WHERE cabinet_id = ?"#)
.bind(id)
.execute(&mut *tx)
.await?;
let rows = sqlx::query(r#"DELETE FROM cabinets WHERE id = ?"#)
.bind(id)
.execute(&mut *tx)
.await?
.rows_affected();
tx.commit().await.map_err(AppError::Database)?;
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("删除柜子 id={}", id);
Ok(Json(json!({})))
}
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
pub async fn regenerate_auth(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
let db = &state.mysql;
let auth_str = generate_auth_str();
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ? WHERE id = ?"#)
.bind(&auth_str)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("重新生成 auth_str id={}", id);
Ok(Json(json!({ "auth_str": auth_str })))
}
// ---------------------------------------------------------------------------
// 柜子详情(含仓控板 + 仓体)
// ---------------------------------------------------------------------------
/// GET /api/cabinets/:id — 柜子详情,包含仓控板和仓体
pub async fn get_cabinet_detail(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<CabinetDetail>, AppError> {
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
let cabinet = sqlx::query_as::<_, CabinetRow>(
r#"SELECT id, project_id, abstract_id, imei, name, status
FROM cabinets WHERE id = ?"#,
)
.bind(id)
.fetch_optional(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("柜子 {} 不存在", id)))?;
// 验证用户是否有权访问该柜子所属的项目
if let Some(pid) = cabinet.project_id {
check_project_access(&user, db, pid).await?;
}
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)
.fetch_all(db)
.await?;
let mut cabin_boards = Vec::new();
for board in boards {
let compartments: Vec<CompartmentRow> = sqlx::query_as(
r#"SELECT id, cabin_board_id, channel_id, status
FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"#,
)
.bind(board.id)
.fetch_all(db)
.await?;
cabin_boards.push(CabinBoardWithCompartments {
id: board.id,
board_id: board.board_id,
status: board.status,
compartments,
});
}
Ok(Json(CabinetDetail {
id: cabinet.id,
project_id: cabinet.project_id,
abstract_id: cabinet.abstract_id,
imei: cabinet.imei,
name: cabinet.name,
status: cabinet.status,
cabin_boards,
}))
}
// ---------------------------------------------------------------------------
// 级联下拉选项
// ---------------------------------------------------------------------------
/// 柜子下拉查询参数
#[derive(Debug, Deserialize)]
pub struct CabinetListQuery {
pub project_id: Option<i64>,
}
/// 仓板下拉查询参数
#[derive(Debug, Deserialize)]
pub struct CabinBoardListQuery {
pub cabinet_id: i64,
}
/// 仓体下拉查询参数
#[derive(Debug, Deserialize)]
pub struct CompartmentListQuery {
pub cabin_board_id: i64,
}
/// 柜子下拉选项
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CabinetOption {
pub id: i64,
pub name: Option<String>,
pub abstract_id: String,
}
/// 仓板下拉选项
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CabinBoardOption {
pub id: i64,
pub board_id: i8,
}
/// 仓体下拉选项
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CompartmentOption {
pub id: i64,
pub channel_id: i8,
}
/// GET /api/cabinets/options — 柜子下拉列表
pub async fn list_cabinet_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let rows = if let Some(pid) = params.project_id {
sqlx::query_as::<_, CabinetOption>(
"SELECT id, name, abstract_id FROM cabinets WHERE project_id = ? ORDER BY id"
)
.bind(pid)
.fetch_all(&state.mysql)
.await?
} else {
sqlx::query_as::<_, CabinetOption>(
"SELECT id, name, abstract_id FROM cabinets ORDER BY id"
)
.fetch_all(&state.mysql)
.await?
};
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}
/// GET /api/cabin-boards/options — 仓板下拉列表
pub async fn list_cabin_board_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CabinBoardListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let rows = sqlx::query_as::<_, CabinBoardOption>(
"SELECT id, board_id FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"
)
.bind(params.cabinet_id)
.fetch_all(&state.mysql)
.await?;
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}
/// GET /api/compartments/options — 仓体下拉列表
pub async fn list_compartment_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CompartmentListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let rows = sqlx::query_as::<_, CompartmentOption>(
"SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"
)
.bind(params.cabin_board_id)
.fetch_all(&state.mysql)
.await?;
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}

View File

@ -0,0 +1,397 @@
//! 充电记录 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};
/// 充电记录列表查询参数
#[derive(Debug, Deserialize)]
pub struct ChargeRecordQuery {
pub cabinet_id: Option<i64>,
pub cabin_board_id: Option<i64>,
pub compartment_id: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_page_size")]
pub page_size: i64,
}
/// 充电记录导出查询参数
#[derive(Debug, Deserialize)]
pub struct ChargeRecordExportQuery {
pub cabinet_id: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
/// 充电记录列表项
#[derive(Debug, Serialize)]
pub struct ChargeRecordItem {
pub id: i64,
pub cabinet_id: i64,
pub cabinet_name: Option<String>,
pub cabin_board_id: i64,
pub board_id: i8,
pub compartment_id: i64,
pub channel_id: i8,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub energy: Option<f64>,
pub max_temperature: Option<f64>,
pub status: i8,
pub status_text: String,
}
fn default_page() -> i64 { 1 }
fn default_page_size() -> i64 { 20 }
/// 状态码转中文描述
fn status_text(status: i8) -> &'static str {
match status {
0 => "正常",
1 => "异常",
2 => "手动停止",
_ => "未知",
}
}
/// 数据库行映射 — DECIMAL 列通过 CAST 转为 f64
#[derive(Debug, sqlx::FromRow)]
struct ChargeRecordRow {
id: i64,
cabinet_id: i64,
cabinet_name: Option<String>,
compartment_id: i64,
cabin_board_id: Option<i64>,
board_id: Option<i8>,
channel_id: Option<i8>,
start_time: Option<NaiveDateTime>,
end_time: Option<NaiveDateTime>,
energy: Option<f64>,
max_temperature: Option<f64>,
status: i8,
}
/// 构建 WHERE 条件子句及绑定值
fn build_where_clause(
cabinet_id: Option<i64>,
cabin_board_id: Option<i64>,
compartment_id: Option<i64>,
start_time: Option<&str>,
end_time: Option<&str>,
) -> (String, Vec<String>) {
let mut conditions = Vec::new();
let mut bind_values = Vec::new();
if let Some(id) = cabinet_id {
conditions.push("cr.cabinet_id = ?");
bind_values.push(id.to_string());
}
if let Some(id) = cabin_board_id {
conditions.push("cb.id = ?");
bind_values.push(id.to_string());
}
if let Some(id) = compartment_id {
conditions.push("cr.compartment_id = ?");
bind_values.push(id.to_string());
}
if let Some(t) = start_time {
conditions.push("cr.start_time >= ?");
bind_values.push(t.to_string());
}
if let Some(t) = end_time {
conditions.push("cr.start_time <= ?");
bind_values.push(t.to_string());
}
let clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(clause, bind_values)
}
/// 查询充电记录行(复用 SQL 模板)
async fn fetch_charge_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<ChargeRecordRow>, sqlx::Error> {
let limit_clause = match (limit, offset) {
(Some(l), Some(o)) => format!("LIMIT {} OFFSET {}", l, o),
(Some(l), None) => format!("LIMIT {}", l),
_ => String::new(),
};
let sql = format!(
"SELECT cr.id, cr.cabinet_id, c.name AS cabinet_name, \
cr.compartment_id, cb.id AS cabin_board_id, cb.board_id, comp.channel_id, \
cr.start_time, cr.end_time, \
CAST(cr.energy AS DOUBLE) AS energy, \
CAST(cr.max_temperature AS DOUBLE) AS max_temperature, \
cr.status \
FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{} ORDER BY cr.start_time DESC {}",
where_clause, limit_clause
);
let mut query = sqlx::query_as::<_, ChargeRecordRow>(&sql);
for val in bind_values {
query = query.bind(val);
}
query.fetch_all(pool).await
}
/// GET /api/charge-records — 充电记录分页列表
pub async fn list_charge_records(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<ChargeRecordQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:view")?;
let offset = (params.page - 1) * params.page_size;
let (mut where_clause, mut bind_values) = build_where_clause(
params.cabinet_id,
params.cabin_board_id,
params.compartment_id,
params.start_time.as_deref(),
params.end_time.as_deref(),
);
// 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "cr");
if !org_cond.is_empty() {
if where_clause.is_empty() {
where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
} else {
where_clause.push_str(&org_cond);
}
bind_values.extend(org_binds);
}
// 查询总数(需要与数据查询保持一致的 JOIN 链路)
let count_sql = format!(
"SELECT COUNT(*) FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(&state.mysql).await?;
// 查询列表
let rows = fetch_charge_rows(
&state.mysql,
&where_clause,
&bind_values,
Some(params.page_size),
Some(offset),
)
.await?;
let list: Vec<ChargeRecordItem> = rows
.into_iter()
.map(|r| ChargeRecordItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
cabin_board_id: r.cabin_board_id.unwrap_or(0),
board_id: r.board_id.unwrap_or(0),
compartment_id: r.compartment_id,
channel_id: r.channel_id.unwrap_or(0),
start_time: r.start_time.map(|t| t.to_string()),
end_time: r.end_time.map(|t| t.to_string()),
energy: r.energy,
max_temperature: r.max_temperature,
status: r.status,
status_text: status_text(r.status).to_string(),
})
.collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": list,
"total": total,
"page": params.page,
"page_size": params.page_size,
},
"message": "ok"
})))
}
/// GET /api/charge-records/export — 创建异步导出任务
pub async fn export_charge_records(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<ChargeRecordExportQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
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,
}))
.unwrap_or_default();
// 插入下载任务error_message 临时传递查询参数)
let result = sqlx::query(
"INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
VALUES (?, ?, 0, ?, ?)"
)
.bind(user_id)
.bind("charge_records")
.bind(&file_name)
.bind(&query_json)
.execute(&state.mysql)
.await?;
let task_id = result.last_insert_id() as i64;
// 启动后台异步生成 Excel
let pool = state.mysql.clone();
tokio::spawn(async move {
if let Err(e) = generate_charge_records_excel(pool, task_id).await {
tracing::error!("导出充电记录失败 task_id={}: {}", task_id, e);
}
});
Ok(Json(json!({
"code": 0,
"data": { "task_id": task_id },
"message": "导出任务已创建"
})))
}
/// 生成导出文件名充电记录_柜名_时间范围.xlsx
fn generate_export_filename(params: &ChargeRecordExportQuery) -> String {
let start = params
.start_time
.as_ref()
.map(|s| s.replace(['-', ':', ' '], ""))
.unwrap_or_default();
let end = params
.end_time
.as_ref()
.map(|s| s.replace(['-', ':', ' '], ""))
.unwrap_or_default();
let cabinet_part = params
.cabinet_id
.map(|id| format!("{}", id))
.unwrap_or_default();
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
}
/// 后台异步生成 Excel 文件
async fn generate_charge_records_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(&pool)
.await?;
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_where_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
None,
None,
params.get("start_time").and_then(|v| v.as_str()),
params.get("end_time").and_then(|v| v.as_str()),
);
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("充电记录")?;
let headers = [
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
"充电量(kWh)", "最高温度(°C)", "状态",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
}
for (row_idx, record) in rows.iter().enumerate() {
let row = (row_idx + 1) as u32;
sheet.write_number(row, 0, record.id as f64)?;
sheet.write_string(row, 1, record.cabinet_name.as_deref().unwrap_or("-"))?;
sheet.write_number(row, 2, record.board_id.unwrap_or(0) as f64)?;
sheet.write_number(row, 3, record.channel_id.unwrap_or(0) as f64)?;
sheet.write_string(
row, 4,
record.start_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
)?;
sheet.write_string(
row, 5,
record.end_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
)?;
sheet.write_number(row, 6, record.energy.unwrap_or(0.0))?;
sheet.write_number(row, 7, record.max_temperature.unwrap_or(0.0))?;
sheet.write_string(row, 8, status_text(record.status))?;
}
// 保存文件
let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?;
let file_path = format!("{}/{}", file_dir, file_name);
workbook.save(&file_path)?;
// 更新任务状态为完成
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
Ok(())
}

View File

@ -0,0 +1,157 @@
//! 设备日志 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};
/// 设备日志查询参数
#[derive(Debug, Deserialize)]
pub struct DeviceLogQuery {
pub cabinet_id: Option<i64>,
pub log_type: Option<i8>,
pub start_time: Option<String>,
pub end_time: Option<String>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_page_size")]
pub page_size: i64,
}
/// 设备日志列表项
#[derive(Debug, Serialize)]
pub struct DeviceLogItem {
pub id: i64,
pub cabinet_id: i64,
pub cabinet_name: Option<String>,
pub log_type: i8,
pub log_type_text: String,
pub content: Option<String>,
pub created_at: Option<String>,
}
fn default_page() -> i64 { 1 }
fn default_page_size() -> i64 { 20 }
/// 日志类型码转中文
fn log_type_text(log_type: i8) -> &'static str {
match log_type {
1 => "状态上报",
2 => "故障",
3 => "告警",
4 => "停电",
_ => "未知",
}
}
/// 数据库行映射
#[derive(Debug, sqlx::FromRow)]
struct DeviceLogRow {
id: i64,
cabinet_id: i64,
cabinet_name: Option<String>,
log_type: i8,
content: Option<String>,
created_at: Option<NaiveDateTime>,
}
/// GET /api/device-logs — 设备日志分页列表
pub async fn list_device_logs(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DeviceLogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device_log:view")?;
let offset = (params.page - 1) * params.page_size;
// 构建 WHERE 条件
let mut conditions = Vec::new();
let mut bind_values: Vec<String> = Vec::new();
if let Some(cabinet_id) = params.cabinet_id {
conditions.push("dl.cabinet_id = ?");
bind_values.push(cabinet_id.to_string());
}
if let Some(log_type) = params.log_type {
conditions.push("dl.log_type = ?");
bind_values.push(log_type.to_string());
}
if let Some(ref start_time) = params.start_time {
conditions.push("dl.created_at >= ?");
bind_values.push(start_time.clone());
}
if let Some(ref end_time) = params.end_time {
conditions.push("dl.created_at <= ?");
bind_values.push(end_time.clone());
}
// 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "dl");
if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND "));
bind_values.extend(org_binds);
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// 查询总数
let count_sql = format!(
"SELECT COUNT(*) FROM device_logs dl {}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(&state.mysql).await?;
// 查询列表
let list_sql = format!(
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
dl.log_type, dl.content, dl.created_at \
FROM device_logs dl \
LEFT JOIN cabinets c ON dl.cabinet_id = c.id \
{} ORDER BY dl.created_at DESC LIMIT ? OFFSET ?",
where_clause
);
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
for val in &bind_values {
list_query = list_query.bind(val);
}
list_query = list_query.bind(params.page_size).bind(offset);
let rows = list_query.fetch_all(&state.mysql).await?;
let list: Vec<DeviceLogItem> = rows
.into_iter()
.map(|r| DeviceLogItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
log_type: r.log_type,
log_type_text: log_type_text(r.log_type).to_string(),
content: r.content,
created_at: r.created_at.map(|t| t.to_string()),
})
.collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": list,
"total": total,
"page": params.page,
"page_size": params.page_size,
},
"message": "ok"
})))
}

View File

@ -0,0 +1,280 @@
//! 下载中心 API — 任务列表、下载链接、文件下载、过期清理
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::Response;
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};
/// 下载任务列表查询参数
#[derive(Debug, Deserialize)]
pub struct DownloadListQuery {
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_page_size")]
pub page_size: i64,
}
/// 清理请求参数
#[derive(Debug, Deserialize)]
pub struct CleanupRequest {
/// 保留天数默认7天
#[serde(default = "default_retain_days")]
pub retain_days: i32,
}
/// 下载任务列表项
#[derive(Debug, Serialize)]
pub struct DownloadTaskItem {
pub id: i64,
pub task_type: String,
pub status: i8,
pub status_text: String,
pub file_name: Option<String>,
pub progress: i32,
pub error_message: Option<String>,
pub created_at: Option<String>,
pub completed_at: Option<String>,
}
fn default_page() -> i64 { 1 }
fn default_page_size() -> i64 { 20 }
fn default_retain_days() -> i32 { 7 }
/// 状态码转文本
fn download_status_text(status: i8) -> &'static str {
match status {
0 => "排队中",
1 => "处理中",
2 => "已完成",
3 => "失败",
_ => "未知",
}
}
/// 数据库行映射
#[derive(Debug, sqlx::FromRow)]
struct DownloadTaskRow {
id: i64,
task_type: String,
status: i8,
file_name: Option<String>,
file_path: Option<String>,
progress: i32,
error_message: Option<String>,
created_at: Option<NaiveDateTime>,
completed_at: Option<NaiveDateTime>,
}
/// 将数据库行转为响应对象
fn row_to_item(row: DownloadTaskRow) -> DownloadTaskItem {
DownloadTaskItem {
id: row.id,
task_type: row.task_type,
status: row.status,
status_text: download_status_text(row.status).to_string(),
file_name: row.file_name,
progress: row.progress,
error_message: if row.status == 3 { row.error_message } else { None },
created_at: row.created_at.map(|t| t.to_string()),
completed_at: row.completed_at.map(|t| t.to_string()),
}
}
/// GET /api/downloads — 下载任务列表
pub async fn list_downloads(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DownloadListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let user_id = user.user_id;
let offset = (params.page - 1) * params.page_size;
// 查询总数
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM download_tasks WHERE user_id = ?"
)
.bind(user_id)
.fetch_one(&state.mysql)
.await?;
// 查询列表
let rows = sqlx::query_as::<_, DownloadTaskRow>(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE user_id = ? \
ORDER BY created_at DESC LIMIT ? OFFSET ?"
)
.bind(user_id)
.bind(params.page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?;
let list: Vec<DownloadTaskItem> = rows.into_iter().map(row_to_item).collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": list,
"total": total,
"page": params.page,
"page_size": params.page_size,
},
"message": "ok"
})))
}
/// GET /api/downloads/:id — 获取下载链接
pub async fn get_download(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let row: Option<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE id = ?"
)
.bind(id)
.fetch_optional(&state.mysql)
.await?;
let row = row.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
if row.status != 2 {
return Err(AppError::BadRequest("任务尚未完成".into()));
}
let file_path = row.file_path.unwrap_or_default();
// 检查文件是否存在
if !std::path::Path::new(&file_path).exists() {
return Err(AppError::NotFound("文件不存在或已清理".into()));
}
// 返回文件下载路径后续可改为签名URL
let download_url = format!("/api/downloads/{}/file", id);
Ok(Json(json!({
"code": 0,
"data": {
"id": row.id,
"file_name": row.file_name,
"download_url": download_url,
},
"message": "ok"
})))
}
/// GET /api/downloads/:id/file — 下载文件(二进制流)
pub async fn download_file(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Response<Body>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let db = &state.mysql;
// 查询下载任务(限定本人)
let task: Option<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE id = ? AND user_id = ?",
)
.bind(id)
.bind(user.user_id)
.fetch_optional(db)
.await?;
let task = task.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
if task.status != 2 {
return Err(AppError::BadRequest("文件未生成完成".into()));
}
let file_path = task
.file_path
.ok_or_else(|| AppError::NotFound("文件路径不存在".into()))?;
// 读取文件内容
let file_content = tokio::fs::read(&file_path).await.map_err(|e| {
tracing::error!("读取下载文件失败 path={} err={}", file_path, e);
AppError::Internal(format!("读取文件失败: {}", e))
})?;
// 推断文件名
let file_name = task.file_name.unwrap_or_else(|| "download.xlsx".to_string());
// 构建文件下载响应
let response = Response::builder()
.status(200)
.header(
header::CONTENT_TYPE,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", file_name),
)
.body(Body::from(file_content))
.map_err(|e| AppError::Internal(format!("构建响应失败: {}", e)))?;
Ok(response)
}
/// POST /api/downloads/cleanup — 清理过期文件
pub async fn cleanup_downloads(
user: CurrentUser,
State(state): State<super::AppState>,
Json(req): Json<CleanupRequest>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
// 查询过期任务(状态为完成且超过保留天数)
let expired: Vec<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks \
WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
)
.bind(req.retain_days)
.fetch_all(&state.mysql)
.await?;
let mut cleaned = 0u32;
for task in &expired {
// 删除物理文件
if let Some(ref path) = task.file_path {
let _ = tokio::fs::remove_file(path).await;
}
// 更新数据库记录
let _ = sqlx::query(
"UPDATE download_tasks SET file_path = NULL, status = 3, \
error_message = '' WHERE id = ?"
)
.bind(task.id)
.execute(&state.mysql)
.await;
cleaned += 1;
}
tracing::info!("清理过期下载文件 {} 个", cleaned);
Ok(Json(json!({
"code": 0,
"data": { "cleaned": cleaned },
"message": format!("已清理 {} 个过期文件", cleaned)
})))
}

View File

@ -0,0 +1,553 @@
//! 能耗管理 API — 总览、排行、趋势、导出
//!
//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。
//! 电费估算按 ¥0.80/kWh 计算(可配置)。
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 电费单价(元/kWh
const COST_PER_KWH: f64 = 0.80;
/// 趋势查询参数
#[derive(Debug, Deserialize)]
pub struct TrendQuery {
/// day | week | month
#[serde(default = "default_granularity")]
pub granularity: String,
/// 天数范围默认30天
#[serde(default = "default_range")]
pub range: i64,
}
/// 导出查询参数
#[derive(Debug, Deserialize)]
pub struct ExportQuery {
pub cabinet_id: Option<i64>,
pub project_id: Option<i64>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
/// 柜子排行行
#[derive(Debug, sqlx::FromRow)]
struct CabinetRankRow {
id: i64,
abstract_id: String,
name: Option<String>,
today_energy: Option<f64>,
month_energy: Option<f64>,
}
/// 项目统计行
#[derive(Debug, sqlx::FromRow)]
struct ProjectStatRow {
id: i64,
name: String,
cabinet_count: i64,
today_energy: Option<f64>,
month_energy: Option<f64>,
}
/// 趋势数据行
#[derive(Debug, sqlx::FromRow)]
struct TrendRow {
period: String, // 日期或周/月标识
energy: Option<f64>,
charge_count: Option<i64>,
}
/// 能耗明细行(用于导出)
#[derive(Debug, sqlx::FromRow)]
struct EnergyDetailRow {
stat_date: chrono::NaiveDate,
cabinet_id: i64,
cabinet_name: Option<String>,
project_name: Option<String>,
total_energy: Option<f64>,
charge_count: Option<i64>,
}
fn default_granularity() -> String { "day".to_string() }
fn default_range() -> i64 { 30 }
/// GET /api/energy-stats/summary — 总览
///
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
pub async fn get_summary(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
// 组织过滤子查询
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(),
vec![oid.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
};
// 今日总能耗
let today_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es WHERE stat_date = CURDATE(){}",
org_filter
);
let mut today_q = sqlx::query_scalar::<_, f64>(&today_sql);
for v in &org_binds { today_q = today_q.bind(v); }
let today_energy: f64 = today_q.fetch_one(pool).await?;
// 本月总能耗
let month_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}",
org_filter
);
let mut month_q = sqlx::query_scalar::<_, f64>(&month_sql);
for v in &org_binds { month_q = month_q.bind(v); }
let month_energy: f64 = month_q.fetch_one(pool).await?;
// 柜子总数(含组织过滤)
let cabinet_count: i64 = if user.role_level >= 2 {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets")
.fetch_one(pool).await?
} else if let Some(oid) = user.organization_id {
sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"
)
.bind(oid)
.fetch_one(pool).await?
} else {
0
};
// 在线柜子数
let online_count: i64 = if user.role_level >= 2 {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
.fetch_one(pool).await?
} else if let Some(oid) = user.organization_id {
sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE c.status = 1 AND p.organization_id = ?"
)
.bind(oid)
.fetch_one(pool).await?
} else {
0
};
let today = today_energy;
let month = month_energy;
Ok(Json(json!({
"code": 0,
"data": {
"today_energy": (today * 100.0).round() / 100.0,
"month_energy": (month * 100.0).round() / 100.0,
"estimated_cost": ((month * COST_PER_KWH) * 100.0).round() / 100.0,
"cabinet_count": cabinet_count,
"online_count": online_count,
},
"message": "ok"
})))
}
/// GET /api/energy-stats/cabinets — 柜子能耗排行
///
/// 按今日能耗降序排列,返回柜子列表及今日/本月能耗。
pub async fn get_cabinet_ranking(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
let (org_join, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" JOIN projects p ON c.project_id = p.id AND p.organization_id = ?".to_string(),
vec![oid.to_string()],
)
} else {
(" JOIN projects p ON 1=0".to_string(), vec![])
};
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 \
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') \
{} \
ORDER BY today_energy DESC, c.id ASC",
org_join
);
let mut query = sqlx::query_as::<_, CabinetRankRow>(&sql);
for v in &org_binds { query = query.bind(v); }
let rows = query.fetch_all(pool).await?;
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"cabinet_id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// GET /api/energy-stats/projects — 项目能耗统计
///
/// 按今日能耗降序排列,返回项目级聚合数据。
pub async fn get_project_stats(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(" WHERE p.organization_id = ?".to_string(), vec![oid.to_string()])
} else {
(" WHERE 1=0".to_string(), vec![])
};
let sql = format!(
"SELECT p.id, p.name, \
COUNT(DISTINCT c.id) AS cabinet_count, \
COALESCE(SUM(es_today.total_energy), 0) AS today_energy, \
COALESCE(SUM(es_month.total_energy), 0) 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') \
{} \
GROUP BY p.id, p.name \
ORDER BY today_energy DESC, p.id ASC",
org_filter
);
let mut query = sqlx::query_as::<_, ProjectStatRow>(&sql);
for v in &org_binds { query = query.bind(v); }
let rows = query.fetch_all(pool).await?;
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"project_id": r.id,
"name": r.name,
"cabinet_count": r.cabinet_count,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// GET /api/energy-stats/trend — 趋势数据
///
/// granularity: day | week | month, 默认 day
/// range: 天数范围, 默认 30
pub async fn get_trend(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<TrendQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
// 组织过滤es.cabinet_id → cabinets.project_id → projects.organization_id
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(),
vec![oid.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
};
let rows = match params.granularity.as_str() {
"week" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
"month" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
_ => {
// day
let sql = format!(
"SELECT stat_date AS period, \
total_energy AS energy, charge_count AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
ORDER BY stat_date ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
};
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"period": r.period,
"energy": (r.energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"charge_count": r.charge_count.unwrap_or(0),
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": {
"granularity": params.granularity,
"list": list,
},
"message": "ok"
})))
}
/// GET /api/energy-stats/export — 异步导出能耗报表
pub async fn export_energy_stats(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<ExportQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:export")?;
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,
}))
.unwrap_or_default();
// 插入下载任务
let result = sqlx::query(
"INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
VALUES (?, ?, 0, ?, ?)"
)
.bind(user_id)
.bind("energy_stats")
.bind(&file_name)
.bind(&query_json)
.execute(&state.mysql)
.await?;
let task_id = result.last_insert_id() as i64;
// 后台异步生成 Excel
let pool = state.mysql.clone();
tokio::spawn(async move {
if let Err(e) = generate_energy_excel(pool, task_id).await {
tracing::error!("导出能耗统计失败 task_id={}: {}", task_id, e);
}
});
Ok(Json(json!({
"code": 0,
"data": { "task_id": task_id },
"message": "导出任务已创建"
})))
}
/// 生成导出文件名
fn generate_export_filename(params: &ExportQuery) -> String {
let start = params
.start_date
.as_ref()
.map(|s| s.replace('-', ""))
.unwrap_or_default();
let end = params
.end_date
.as_ref()
.map(|s| s.replace('-', ""))
.unwrap_or_default();
format!("能耗统计_{}-{}.xlsx", start, end)
}
/// 构建导出条件
fn build_export_clause(
cabinet_id: Option<i64>,
project_id: Option<i64>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> (String, Vec<String>) {
let mut conditions = Vec::new();
let mut bind_values = Vec::new();
if let Some(id) = cabinet_id {
conditions.push("es.cabinet_id = ?");
bind_values.push(id.to_string());
}
if let Some(id) = project_id {
conditions.push("c.project_id = ?");
bind_values.push(id.to_string());
}
if let Some(d) = start_date {
conditions.push("es.stat_date >= ?");
bind_values.push(d.to_string());
}
if let Some(d) = end_date {
conditions.push("es.stat_date <= ?");
bind_values.push(d.to_string());
}
let clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(clause, bind_values)
}
/// 后台异步生成能耗 Excel 文件
async fn generate_energy_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(&pool)
.await?;
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(
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 sql = format!(
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
p.name AS project_name, \
CAST(es.total_energy AS DOUBLE) AS total_energy, \
es.charge_count \
FROM energy_stats es \
LEFT JOIN cabinets c ON es.cabinet_id = c.id \
LEFT JOIN projects p ON c.project_id = p.id \
{} ORDER BY es.stat_date DESC, es.cabinet_id ASC",
where_clause
);
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
for val in &bind_values {
query = query.bind(val);
}
let rows = query.fetch_all(&pool).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("能耗统计")?;
let headers = [
"日期", "柜子ID", "柜子名称", "所属项目", "能耗(kWh)", "充电次数",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
}
for (row_idx, record) in rows.iter().enumerate() {
let row = (row_idx + 1) as u32;
sheet.write_string(row, 0, record.stat_date.to_string())?;
sheet.write_number(row, 1, record.cabinet_id as f64)?;
sheet.write_string(row, 2, record.cabinet_name.as_deref().unwrap_or("-"))?;
sheet.write_string(row, 3, record.project_name.as_deref().unwrap_or("-"))?;
sheet.write_number(row, 4, record.total_energy.unwrap_or(0.0))?;
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
}
// 保存文件
let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?;
let file_path = format!("{}/{}", file_dir, file_name);
workbook.save(&file_path)?;
// 更新任务状态为完成
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
Ok(())
}

View File

@ -0,0 +1,990 @@
//! H5 用户端 API
//!
//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
//! - POST /api/h5/auth/login — H5 手机号登录
//! - GET /api/h5/dashboard — 首页仪表盘数据
//! - GET /api/h5/projects — 项目列表
//! - GET /api/h5/cabinets — 设备列表(按项目过滤)
//! - GET /api/h5/cabinets/:id — 设备详情(含仓板+仓体状态)
//! - GET /api/h5/cabinets/abstract/:aid — 通过抽象ID查设备扫码入口
//! - GET /api/h5/compartments/:id — 仓体/通道详情
//! - POST /api/h5/charge/start — 开始充电
//! - POST /api/h5/charge/stop — 停止充电
//! - POST /api/h5/door/open — 开门
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::commands::{AppState, DeviceCommand};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
// ---------------------------------------------------------------------------
// 请求体定义
// ---------------------------------------------------------------------------
/// H5 登录请求体
#[derive(Debug, Deserialize)]
pub(crate) struct H5LoginInput {
phone: String,
password: String,
}
/// 充电控制请求体(开始/停止充电、开门共用)
#[derive(Debug, Deserialize)]
pub(crate) struct CompartmentActionInput {
compartment_id: i64,
}
/// 设备列表查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct CabinetListQuery {
project_id: Option<i64>,
}
// ---------------------------------------------------------------------------
// 行映射结构体
// ---------------------------------------------------------------------------
/// 项目简要信息行
#[derive(Debug, sqlx::FromRow)]
struct ProjectRow {
id: i64,
name: String,
}
/// 柜子列表行
#[derive(Debug, sqlx::FromRow)]
struct H5CabinetRow {
id: i64,
abstract_id: String,
name: Option<String>,
status: i8,
project_id: Option<i64>,
}
/// 仓控板行
#[derive(Debug, sqlx::FromRow)]
struct H5CabinBoardRow {
id: i64,
board_id: i8,
status: i8,
}
/// 仓体行
#[derive(Debug, sqlx::FromRow)]
struct H5CompartmentRow {
id: i64,
channel_id: i8,
status: i8,
}
/// 充电记录简要行
#[derive(Debug, sqlx::FromRow)]
struct ChargeRecordBriefRow {
id: i64,
start_time: Option<chrono::NaiveDateTime>,
end_time: Option<chrono::NaiveDateTime>,
energy: Option<f64>,
}
/// 柜子 IMEI 查询行(用于指令下发)
#[derive(Debug, sqlx::FromRow)]
struct CabinetImeiRow {
imei: String,
}
// ---------------------------------------------------------------------------
// H5 登录
// ---------------------------------------------------------------------------
/// POST /api/h5/auth/login — H5 用户登录(复用 users 表)
pub async fn h5_login(
State(state): State<AppState>,
Json(input): Json<H5LoginInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
if input.phone.is_empty() || input.password.is_empty() {
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
}
// 查询用户
let user_row: Option<(i64, Option<String>, String, String, i32)> = sqlx::query_as(
"SELECT id, name, phone, password, status FROM users WHERE phone = ?",
)
.bind(&input.phone)
.fetch_optional(pool)
.await?;
let (id, name, phone, stored_password, status) =
user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
if status == 0 {
return Err(AppError::Forbidden("账户已被禁用".into()));
}
let valid = auth::verify_password(&input.password, &stored_password)
.map_err(|_| AppError::Internal("密码验证失败".into()))?;
if !valid {
return Err(AppError::Unauthorized("手机号或密码错误".into()));
}
let token = auth::generate_token(id, &phone)?;
Ok(Json(json!({
"code": 0,
"data": {
"token": token,
"user": {
"id": id,
"phone": phone,
"name": name,
}
},
"message": "登录成功"
})))
}
// ---------------------------------------------------------------------------
// 仪表盘
// ---------------------------------------------------------------------------
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离)
pub async fn get_dashboard(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 根据角色确定组织过滤条件
let org_id = user.organization_id;
let is_super = user.role_level >= 2;
// 统计在线柜子数(带组织隔离)
let online_cabinets = count_online_cabinets(pool, is_super, org_id).await?;
// 统计充电中仓体数(带组织隔离)
let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?;
// 统计空闲仓体数(带组织隔离)
let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?;
// 统计故障仓体数(带组织隔离)
let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?;
// 今日充电次数(带组织隔离)
let today_charge_count = count_today_charges(pool, is_super, org_id).await?;
// 今日充电量kWh带组织隔离
let today_energy = sum_today_energy(pool, is_super, org_id).await?;
// 今日充电时长(小时,带组织隔离)
let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?;
// 故障告警取最近10条故障仓体返回项目名+柜名)
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
// 项目摘要
let projects = fetch_project_summaries(pool, &user).await?;
Ok(Json(json!({
"code": 0,
"data": {
"online_cabinets": online_cabinets,
"charging_count": charging_count,
"idle_channels": idle_channels,
"fault_channels": fault_channels,
"today_charge_count": today_charge_count,
"today_energy_kwh": today_energy,
"today_charge_hours": today_charge_hours,
"alerts": alerts,
"projects": projects,
},
"message": "ok"
})))
}
/// 统计在线柜子数(带组织隔离)
async fn count_online_cabinets(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.status = 1 AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 按状态统计仓体数(带组织隔离,通过 cabin_boards → cabinets → projects 链路)
async fn count_compartments_by_status(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
status: i8,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar("SELECT COUNT(*) FROM compartments WHERE status = ?")
.bind(status)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
INNER JOIN cabinets cab ON cb.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE comp.status = ? AND p.organization_id = ?"#,
)
.bind(status)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电次数(带组织隔离)
async fn count_today_charges(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar(
"SELECT COUNT(*) FROM charge_records WHERE DATE(start_time) = CURDATE()",
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电量kWh带组织隔离
async fn sum_today_energy(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<f64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0.0);
}
let energy: Option<f64> = if is_super {
sqlx::query_scalar(
"SELECT CAST(SUM(energy) AS DOUBLE) FROM charge_records WHERE DATE(start_time) = CURDATE()",
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT CAST(SUM(cr.energy) AS DOUBLE) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(energy.unwrap_or(0.0))
}
/// 今日充电时长(小时,带组织隔离)
async fn sum_today_charge_hours(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<f64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0.0);
}
// 计算总秒数(未结束的记录用 NOW() 代替 end_time
let total_seconds: f64 = if is_super {
sqlx::query_scalar(
r#"SELECT COALESCE(SUM(
TIMESTAMPDIFF(SECOND, start_time, COALESCE(end_time, NOW()))
), 0) FROM charge_records
WHERE DATE(start_time) = CURDATE()"#,
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COALESCE(SUM(
TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW()))
), 0) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
// 秒转小时保留1位小数
Ok((total_seconds / 3600.0 * 10.0).round() / 10.0)
}
/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离)
async fn fetch_fault_alerts(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<Vec<Value>, AppError> {
if !is_super && org_id.is_none() {
return Ok(vec![]);
}
let base_sql = if is_super {
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
comp.channel_id
FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
LEFT JOIN projects p ON cab.project_id = p.id
WHERE comp.status = 2
ORDER BY comp.updated_at DESC
LIMIT 10"#
} else {
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
comp.channel_id
FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE comp.status = 2 AND p.organization_id = ?
ORDER BY comp.updated_at DESC
LIMIT 10"#
};
let rows: Vec<(String, String, i8)> = if is_super {
sqlx::query_as(base_sql).fetch_all(pool).await?
} else {
sqlx::query_as(base_sql)
.bind(org_id.unwrap())
.fetch_all(pool)
.await?
};
Ok(rows
.into_iter()
.map(|(project, cabinet, channel)| {
json!({
"project": project,
"cabinet": cabinet,
"channel": channel,
"alert_type": "fault",
})
})
.collect())
}
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
async fn fetch_project_summaries(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
) -> Result<Vec<Value>, AppError> {
// 获取用户可见的项目
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
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",
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let mut result = Vec::with_capacity(projects.len());
for p in &projects {
let cabinet_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets WHERE project_id = ?",
)
.bind(p.id)
.fetch_one(pool)
.await?;
let charging: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 1"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
let idle: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 0"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
let fault: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 2"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
result.push(json!({
"id": p.id,
"name": p.name,
"cabinet_count": cabinet_count,
"charging": charging,
"idle": idle,
"fault": fault,
}));
}
Ok(result)
}
// ---------------------------------------------------------------------------
// 项目列表
// ---------------------------------------------------------------------------
/// GET /api/h5/projects — H5 项目列表(简要)
pub async fn get_projects(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
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",
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let list: Vec<Value> = projects
.into_iter()
.map(|p| json!({ "id": p.id, "name": p.name }))
.collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备列表
// ---------------------------------------------------------------------------
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
pub async fn get_cabinets(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
let rows: Vec<H5CabinetRow> = if let Some(pid) = query.project_id {
// 按项目过滤(同时验证组织权限)
if user.role_level >= 2 {
sqlx::query_as::<_, H5CabinetRow>(
"SELECT id, abstract_id, name, status, project_id FROM cabinets WHERE project_id = ?",
)
.bind(pid)
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT id, abstract_id, name, status, project_id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.project_id = ? AND p.organization_id = ?"#,
)
.bind(pid)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
}
} else if user.role_level >= 2 {
// 总管理员看全部
sqlx::query_as::<_, H5CabinetRow>(
"SELECT id, abstract_id, name, status, project_id FROM cabinets",
)
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT id, abstract_id, name, status, project_id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE p.organization_id = ?"#,
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"status": r.status,
"project_id": r.project_id,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备详情
// ---------------------------------------------------------------------------
/// GET /api/h5/cabinets/:id — H5 设备详情(含仓控板+仓体,带权限校验)
pub async fn get_cabinet_detail(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
// 组织隔离:验证用户是否有权访问该设备
verify_cabinet_access(&user, id, pool).await?;
// 查询柜子基本信息
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
let (cab_id, abstract_id, imei, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 查询仓控板
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",
)
.bind(cab_id)
.fetch_all(pool)
.await?;
// 查询每个仓控板下的仓体
let mut cabin_boards = Vec::with_capacity(boards.len());
for board in &boards {
let comps: Vec<H5CompartmentRow> = sqlx::query_as::<_, H5CompartmentRow>(
"SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id",
)
.bind(board.id)
.fetch_all(pool)
.await?;
let compartments: Vec<Value> = comps
.into_iter()
.map(|c| {
json!({
"id": c.id,
"channel_id": c.channel_id,
"status": c.status,
})
})
.collect();
cabin_boards.push(json!({
"id": board.id,
"board_id": board.board_id,
"status": board.status,
"compartments": compartments,
}));
}
Ok(Json(json!({
"code": 0,
"data": {
"id": cab_id,
"abstract_id": abstract_id,
"imei": imei,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
/// GET /api/h5/cabinets/abstract/:abstract_id — 通过抽象ID查设备扫码入口带权限校验
pub async fn get_cabinet_by_abstract_id(
user: CurrentUser,
State(state): State<AppState>,
Path(abstract_id): Path<String>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
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, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 组织隔离:验证用户是否有权访问该设备
verify_cabinet_access(&user, cab_id, pool).await?;
// 查询仓控板+仓体
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",
)
.bind(cab_id)
.fetch_all(pool)
.await?;
let mut cabin_boards = Vec::with_capacity(boards.len());
for board in &boards {
let comps: Vec<H5CompartmentRow> = sqlx::query_as::<_, H5CompartmentRow>(
"SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id",
)
.bind(board.id)
.fetch_all(pool)
.await?;
let compartments: Vec<Value> = comps
.into_iter()
.map(|c| json!({ "id": c.id, "channel_id": c.channel_id, "status": c.status }))
.collect();
cabin_boards.push(json!({
"id": board.id,
"board_id": board.board_id,
"status": board.status,
"compartments": compartments,
}));
}
Ok(Json(json!({
"code": 0,
"data": {
"id": cab_id,
"abstract_id": abs_id,
"imei": imei,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 仓体/通道详情
// ---------------------------------------------------------------------------
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
pub async fn get_compartment_detail(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
// 查询仓体基本信息
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
let (comp_id, channel_id, status, cabin_board_id) =
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
let cabinet_id: Option<(i64,)> = sqlx::query_as(
"SELECT cabinet_id FROM cabin_boards WHERE id = ?",
)
.bind(cabin_board_id)
.fetch_optional(pool)
.await?;
if let Some((cab_id,)) = cabinet_id {
verify_cabinet_access(&user, cab_id, pool).await?;
}
// 从 Redis 获取实时数据
let redis_key = format!("compartment:status:{}", comp_id);
let mut conn = state.redis.clone();
let realtime_data: Option<String> = redis::cmd("GET")
.arg(&redis_key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let realtime: Value = realtime_data
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(json!({}));
// 查询最近充电记录
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
r#"SELECT id, start_time, end_time, CAST(energy AS DOUBLE) AS energy
FROM charge_records
WHERE compartment_id = ?
ORDER BY start_time DESC LIMIT 10"#,
)
.bind(comp_id)
.fetch_all(pool)
.await?;
let charge_records: Vec<Value> = records
.into_iter()
.map(|r| {
json!({
"id": r.id,
"start_time": r.start_time.map(|t| t.to_string()),
"end_time": r.end_time.map(|t| t.to_string()),
"energy_kwh": r.energy.unwrap_or(0.0),
"cost": 0,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": {
"id": comp_id,
"channel_id": channel_id,
"status": status,
"cabin_board_id": cabin_board_id,
"voltage": realtime.get("voltage").and_then(|v| v.as_f64()).unwrap_or(0.0),
"current": realtime.get("current").and_then(|v| v.as_f64()).unwrap_or(0.0),
"power": realtime.get("power").and_then(|v| v.as_f64()).unwrap_or(0.0),
"energy": realtime.get("energy").and_then(|v| v.as_f64()).unwrap_or(0.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!([])),
"charge_records": charge_records,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 充电控制
// ---------------------------------------------------------------------------
/// POST /api/h5/charge/start — 开始充电
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
}
/// POST /api/h5/charge/stop — 停止充电
pub async fn stop_charge(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:stop")?;
send_compartment_command(&state, body.compartment_id, "off").await
}
/// POST /api/h5/door/open — 开门
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
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
/// 根据仓体ID查找所属柜子的IMEI并通过 Redis 下发指令
async fn send_compartment_command(
state: &AppState,
compartment_id: i64,
action: &str,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 通过 compartment → cabin_board → cabinet 链路查找 IMEI 和 sub_device_id
let row: Option<CabinetImeiRow> = sqlx::query_as(
r#"SELECT cab.imei 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(compartment_id)
.fetch_optional(pool)
.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 = ?"#,
)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
let sub_device_id = sub_row.map(|(bid,)| bid as u8);
// 检查是否有设备节点在线
if !state.nodes.is_device_online(imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 构建指令并通过 Redis 下发
let cmd = DeviceCommand {
act: action.to_string(),
msg_id: 0,
dev_id: Some(imei.clone()),
sub_device_id,
params: Some(json!({})),
};
let json_str = cmd.to_json();
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!(
"[h5] 指令已推入队列 imei={} action={} compartment_id={}",
imei, action, compartment_id
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "指令已下发"
})))
}
/// 验证用户是否有权访问指定柜子(组织数据隔离)
///
/// 总管理员role_level >= 2可访问所有柜子
/// 企业管理员只能访问本组织下项目关联的柜子;
/// 无组织关联的用户无权访问任何柜子。
async fn verify_cabinet_access(
user: &CurrentUser,
cabinet_id: i64,
pool: &sqlx::MySqlPool,
) -> Result<(), AppError> {
// 总管理员无需校验
if user.role_level >= 2 {
return Ok(());
}
let org_id = user
.organization_id
.ok_or_else(|| AppError::Forbidden("无组织关联,无权访问设备".into()))?;
// 通过 cabinet → project → organization 链路验证归属
let access: Option<(i64,)> = sqlx::query_as(
r#"SELECT cab.id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.id = ? AND p.organization_id = ?"#,
)
.bind(cabinet_id)
.bind(org_id)
.fetch_optional(pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
Ok(())
}

View File

@ -0,0 +1,11 @@
//! 健康检查接口
use axum::Json;
use serde_json::{json, Value};
/// GET /api/health — 服务健康检查
pub async fn health_check() -> Json<Value> {
Json(json!({
"status": "ok"
}))
}

View File

@ -0,0 +1,112 @@
//! 路由注册 — 聚合所有子路由
//!
//! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。
//! 权限校验在各路由处理器内部通过 `check_permission` 完成。
use axum::middleware;
use axum::routing::{get, post, put};
use axum::Router;
use crate::commands::AppState;
use crate::middleware::auth::auth_middleware;
mod auth;
mod cabinets;
mod cabinet_tree;
mod charge_records;
mod device_logs;
mod downloads;
mod energy_stats;
mod h5;
mod health;
mod operation_logs;
mod organizations;
mod projects;
mod roles;
mod users;
mod users_perm;
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
pub fn build(state: AppState) -> Router {
let protected = Router::<AppState>::new()
// 认证相关
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
// 设备指令下发
.route("/api/devices/:dev_id/command", post(crate::commands::send_command))
.route("/api/devices/stats", get(crate::commands::device_stats))
// 节点管理
.route("/api/nodes/register", post(crate::nodes::register_node))
.route("/api/nodes/:node_id/heartbeat", post(crate::nodes::node_heartbeat))
.route("/api/nodes/:node_id/deregister", post(crate::nodes::deregister_node))
.route("/api/nodes", get(crate::nodes::list_nodes))
// 角色管理
.route("/api/roles", get(roles::list_roles).post(roles::create_role))
.route("/api/roles/:id", put(roles::update_role).delete(roles::delete_role))
.route("/api/roles/:id/permissions", put(roles::set_role_permissions))
// 权限码列表
.route("/api/permissions", get(roles::list_permissions))
// 用户权限管理
.route("/api/users/:id/permissions", get(users_perm::get_user_permissions))
.route("/api/users/:id/roles", put(users_perm::set_user_roles))
.route("/api/users/:id/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes))
// 组织管理
.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/:org_id/projects", get(projects::list_projects).post(projects::create_project))
.route("/api/projects/:id", put(projects::update_project).delete(projects::delete_project))
// 设备管理
.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/regenerate-auth", post(cabinets::regenerate_auth))
// 组织树
.route("/api/organization-tree", get(cabinet_tree::organization_tree))
// 充电记录
.route("/api/charge-records", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_charge_records))
// 设备日志
.route("/api/device-logs", get(device_logs::list_device_logs))
// 下载中心
.route("/api/downloads", get(downloads::list_downloads))
.route("/api/downloads/:id", get(downloads::get_download))
.route("/api/downloads/:id/file", get(downloads::download_file))
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
// 设备级联下拉
.route("/api/cabinets/options", get(cabinets::list_cabinet_options))
.route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options))
.route("/api/compartments/options", get(cabinets::list_compartment_options))
// 能耗管理
.route("/api/energy-stats/summary", get(energy_stats::get_summary))
.route("/api/energy-stats/cabinets", get(energy_stats::get_cabinet_ranking))
.route("/api/energy-stats/projects", get(energy_stats::get_project_stats))
.route("/api/energy-stats/trend", get(energy_stats::get_trend))
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
// 用户管理
.route("/api/users", get(users::list_users).post(users::create_user))
.route("/api/users/:id", put(users::update_user).delete(users::delete_user))
.route("/api/users/:id/reset-password", put(users::reset_password))
.route("/api/users/:id/status", put(users::set_user_status))
// 操作日志
.route("/api/operation-logs", get(operation_logs::list_operation_logs))
// H5 用户端接口(需认证)
.route("/api/h5/dashboard", get(h5::get_dashboard))
.route("/api/h5/projects", get(h5::get_projects))
.route("/api/h5/cabinets", get(h5::get_cabinets))
.route("/api/h5/cabinets/abstract/:abstract_id", get(h5::get_cabinet_by_abstract_id))
.route("/api/h5/cabinets/:id", get(h5::get_cabinet_detail))
.route("/api/h5/compartments/:id", get(h5::get_compartment_detail))
.route("/api/h5/charge/start", post(h5::start_charge))
.route("/api/h5/charge/stop", post(h5::stop_charge))
.route("/api/h5/door/open", post(h5::open_door))
// 认证中间件层
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
Router::<AppState>::new()
.route("/api/health", get(health::health_check))
.route("/api/auth/login", post(auth::login))
.route("/api/h5/auth/login", post(h5::h5_login))
.merge(protected)
.with_state(state)
}

View File

@ -0,0 +1,144 @@
//! 操作日志 API
//!
//! 提供操作日志查询接口,支持按用户、操作类型、时间范围过滤和分页:
//! - GET /api/operation-logs — 操作日志列表
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
/// 操作日志查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct LogQuery {
#[allow(dead_code)]
user_id: Option<i64>,
action: Option<String>,
start_time: Option<String>,
end_time: Option<String>,
page: Option<u32>,
page_size: Option<u32>,
}
/// GET /api/operation-logs — 操作日志列表(分页)
///
/// 支持 user_id / action / start_time / end_time 过滤。
pub async fn list_operation_logs(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<LogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
let mut conditions: Vec<String> = Vec::new();
let mut bind_values: Vec<String> = Vec::new();
if let Some(uid) = query.user_id {
conditions.push("o.user_id = ?".to_string());
bind_values.push(uid.to_string());
}
if let Some(ref act) = query.action
&& !act.is_empty()
{
conditions.push("o.action = ?".to_string());
bind_values.push(act.clone());
}
if let Some(ref t) = query.start_time
&& !t.is_empty()
{
conditions.push("o.created_at >= ?".to_string());
bind_values.push(t.clone());
}
if let Some(ref t) = query.end_time
&& !t.is_empty()
{
conditions.push("o.created_at <= ?".to_string());
bind_values.push(t.clone());
}
// 追加组织过滤
let (org_cond, org_binds) = auth::org_condition_for_logs(&user, "o");
if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND ").to_string());
bind_values.extend(org_binds);
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// 查询总数
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(pool).await?;
// 查询列表(关联用户姓名)
let list_sql = format!(
r#"SELECT o.id, o.user_id, u.name AS user_name, o.action, o.target_type, o.target_id, o.detail, o.created_at
FROM operation_logs o
LEFT JOIN users u ON u.id = o.user_id
{}
ORDER BY o.id DESC
LIMIT ? OFFSET ?"#,
where_clause
);
#[derive(sqlx::FromRow)]
struct LogRow {
id: i64,
user_id: i64,
user_name: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<i64>,
detail: Option<String>,
created_at: String,
}
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
for val in &bind_values {
list_query = list_query.bind(val);
}
let rows = list_query
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?;
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"user_id": r.user_id,
"user_name": r.user_name.unwrap_or_else(|| format!("#{}", r.user_id)),
"action": r.action,
"target_type": r.target_type,
"target_id": r.target_id,
"detail": r.detail,
"created_at": r.created_at,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": { "list": list, "total": total },
"message": "ok"
})))
}

View File

@ -0,0 +1,255 @@
//! 组织 CRUD API + 共享类型
//!
//! 提供组织的增删改查,以及跨模块共享的请求体/行结构体。
//! 项目、设备、树状结构分别拆分到 projects / cabinets / cabinet_tree 模块。
use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
// ---------------------------------------------------------------------------
// 共享请求体
// ---------------------------------------------------------------------------
/// 通用名称请求体(组织/项目共用)
#[derive(Debug, Deserialize)]
pub struct NameBody {
pub name: String,
}
/// 批量创建柜子请求体
#[derive(Debug, Deserialize)]
pub struct CreateCabinetBody {
pub imeis: Vec<String>,
pub project_id: i64,
}
/// 更新柜子请求体
#[derive(Debug, Deserialize)]
pub struct UpdateCabinetBody {
pub project_id: Option<i64>,
pub name: Option<String>,
}
// ---------------------------------------------------------------------------
// 共享行结构体
// ---------------------------------------------------------------------------
/// 组织表行
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct OrganizationRow {
pub id: i64,
pub name: String,
}
/// 项目表行
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct ProjectRow {
pub id: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization_id: Option<i64>,
pub name: String,
}
/// 柜子表行
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct CabinetRow {
pub id: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<i64>,
pub abstract_id: String,
pub imei: String,
pub name: Option<String>,
pub status: i8,
}
/// 树节点
#[derive(Debug, Serialize)]
pub struct TreeNode {
pub id: i64,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<TreeNode>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub abstract_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub imei: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<i8>,
}
/// 仓控板行
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CabinBoardRow {
pub id: i64,
pub cabinet_id: i64,
pub board_id: i8,
pub status: i8,
}
/// 仓体行
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CompartmentRow {
pub id: i64,
pub cabin_board_id: i64,
pub channel_id: i8,
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>,
pub abstract_id: String,
pub imei: String,
pub name: Option<String>,
pub status: i8,
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;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
(0..8).map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char).collect()
}
// ---------------------------------------------------------------------------
// 组织 CRUD
// ---------------------------------------------------------------------------
/// GET /api/organizations — 组织列表
pub async fn list_organizations(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:view")?;
let db = &state.mysql;
let rows = sqlx::query_as::<_, OrganizationRow>(
r#"SELECT id, name FROM organizations ORDER BY id"#,
)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
}
/// POST /api/organizations — 创建组织
pub async fn create_organization(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:create")?;
let db = &state.mysql;
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("组织名称不能为空".into()));
}
let result = sqlx::query(r#"INSERT INTO organizations (name) VALUES (?)"#)
.bind(name)
.execute(db)
.await?;
let id = result.last_insert_id() as i64;
tracing::info!("创建组织 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
}
/// PUT /api/organizations/:id — 更新组织
pub async fn update_organization(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:edit")?;
let db = &state.mysql;
let name = body.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("组织名称不能为空".into()));
}
let rows = sqlx::query(r#"UPDATE organizations SET name = ? WHERE id = ?"#)
.bind(&name)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("组织 {} 不存在", id)));
}
tracing::info!("更新组织 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
}
/// DELETE /api/organizations/:id — 删除组织
pub async fn delete_organization(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:delete")?;
let db = &state.mysql;
let count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM projects WHERE organization_id = ?"#,
)
.bind(id)
.fetch_one(db)
.await?;
if count.0 > 0 {
return Err(AppError::BadRequest(
format!("组织 {} 下仍有项目,无法删除", id),
));
}
let rows = sqlx::query(r#"DELETE FROM organizations WHERE id = ?"#)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("组织 {} 不存在", id)));
}
tracing::info!("删除组织 id={}", id);
Ok(Json(json!({})))
}

View File

@ -0,0 +1,135 @@
//! 项目 CRUD API
//!
//! 提供项目的增删改查,从 organizations 模块拆分而来。
use axum::extract::{Path, State};
use axum::Json;
use serde_json::{json, Value};
use super::organizations::{NameBody, ProjectRow};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
/// GET /api/organizations/:org_id/projects — 按组织查询项目列表
pub async fn list_projects(
user: CurrentUser,
State(state): State<AppState>,
Path(org_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:view")?;
let db = &state.mysql;
let rows = sqlx::query_as::<_, ProjectRow>(
r#"SELECT id, organization_id, name FROM projects WHERE organization_id = ? ORDER BY id"#,
)
.bind(org_id)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
}
/// POST /api/organizations/:org_id/projects — 创建项目
pub async fn create_project(
user: CurrentUser,
State(state): State<AppState>,
Path(org_id): Path<i64>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:create")?;
let db = &state.mysql;
let name = body.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("项目名称不能为空".into()));
}
let org_exists: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM organizations WHERE id = ?"#,
)
.bind(org_id)
.fetch_one(db)
.await?;
if org_exists.0 == 0 {
return Err(AppError::NotFound(format!("组织 {} 不存在", org_id)));
}
let result = sqlx::query(r#"INSERT INTO projects (organization_id, name) VALUES (?, ?)"#)
.bind(org_id)
.bind(&name)
.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 })))
}
/// PUT /api/projects/:id — 更新项目
pub async fn update_project(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:edit")?;
let db = &state.mysql;
let name = body.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("项目名称不能为空".into()));
}
let rows = sqlx::query(r#"UPDATE projects SET name = ? WHERE id = ?"#)
.bind(&name)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
}
tracing::info!("更新项目 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
}
/// DELETE /api/projects/:id — 删除项目
pub async fn delete_project(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:delete")?;
let db = &state.mysql;
let count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM cabinets WHERE project_id = ?"#,
)
.bind(id)
.fetch_one(db)
.await?;
if count.0 > 0 {
return Err(AppError::BadRequest(
format!("项目 {} 下仍有设备,无法删除", id),
));
}
let rows = sqlx::query(r#"DELETE FROM projects WHERE id = ?"#)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
}
tracing::info!("删除项目 id={}", id);
Ok(Json(json!({})))
}

View File

@ -0,0 +1,248 @@
//! 角色管理 API
//!
//! 提供角色的 CRUD 操作及角色权限分配接口:
//! - GET /api/roles — 角色列表
//! - POST /api/roles — 创建角色
//! - PUT /api/roles/:id — 更新角色
//! - DELETE /api/roles/:id — 删除角色
//! - PUT /api/roles/:id/permissions — 设置角色权限
//! - GET /api/permissions — 权限码列表
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::CurrentUser;
use crate::commands::AppState;
/// 创建/更新角色请求体
#[derive(Debug, Deserialize)]
pub(crate) struct RoleInput {
name: String,
description: Option<String>,
}
/// 设置角色权限请求体
#[derive(Debug, Deserialize)]
pub(crate) struct SetPermissionsInput {
/// 权限码列表
permission_codes: Vec<String>,
}
/// GET /api/roles — 获取所有角色及其权限码
pub async fn list_roles(
_user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 查询所有角色
let roles: Vec<(i64, String, Option<String>)> = sqlx::query_as(
"SELECT id, name, description FROM roles ORDER BY id"
)
.fetch_all(pool)
.await?;
let mut result = Vec::new();
for (id, name, description) in roles {
// 查询该角色的权限码
let perm_codes: Vec<(String,)> = sqlx::query_as(
r#"
SELECT p.code FROM permissions p
INNER JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = ?
ORDER BY p.code
"#
)
.bind(id)
.fetch_all(pool)
.await?;
result.push(json!({
"id": id,
"name": name,
"description": description,
"permission_codes": perm_codes.into_iter().map(|(c,)| c).collect::<Vec<_>>(),
}));
}
Ok(Json(json!({ "code": 0, "data": result, "message": "ok" })))
}
/// POST /api/roles — 创建角色
pub async fn create_role(
user: CurrentUser,
State(state): State<AppState>,
Json(input): Json<RoleInput>,
) -> Result<(StatusCode, Json<Value>), AppError> {
let pool = &state.mysql;
// 校验权限
crate::middleware::auth::check_permission(&user, "role:create")?;
if input.name.is_empty() || input.name.len() > 50 {
return Err(AppError::BadRequest("角色名长度需在1-50字符之间".into()));
}
let result = sqlx::query(
"INSERT INTO roles (name, description) VALUES (?, ?)"
)
.bind(&input.name)
.bind(&input.description)
.execute(pool)
.await;
match result {
Ok(r) => {
let id = r.last_insert_id() as i64;
Ok((StatusCode::CREATED, Json(json!({
"code": 0,
"data": { "id": id, "name": input.name, "description": input.description },
"message": "创建成功"
}))))
}
Err(e) => {
if e.to_string().contains("Duplicate") {
Err(AppError::BadRequest(format!("角色名 '{}' 已存在", input.name)))
} else {
Err(AppError::Database(e))
}
}
}
}
/// PUT /api/roles/:id — 更新角色
pub async fn update_role(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<RoleInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
crate::middleware::auth::check_permission(&user, "role:edit")?;
if input.name.is_empty() || input.name.len() > 50 {
return Err(AppError::BadRequest("角色名长度需在1-50字符之间".into()));
}
let result = sqlx::query(
"UPDATE roles SET name = ?, description = ? WHERE id = ?"
)
.bind(&input.name)
.bind(&input.description)
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("角色不存在".into()));
}
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
}
/// DELETE /api/roles/:id — 删除角色
pub async fn delete_role(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
crate::middleware::auth::check_permission(&user, "role:delete")?;
// 检查是否有用户正在使用该角色
let count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM user_roles WHERE role_id = ?"
)
.bind(id)
.fetch_one(pool)
.await?;
if count.0 > 0 {
return Err(AppError::BadRequest(
format!("该角色下还有 {} 个用户,请先移除", count.0)
));
}
let result = sqlx::query("DELETE FROM roles WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("角色不存在".into()));
}
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
}
/// PUT /api/roles/:id/permissions — 设置角色权限(全量替换)
pub async fn set_role_permissions(
user: CurrentUser,
State(state): State<AppState>,
Path(role_id): Path<i64>,
Json(input): Json<SetPermissionsInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
crate::middleware::auth::check_permission(&user, "role:edit")?;
// 检查角色是否存在
let role_exists: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE id = ?"
)
.bind(role_id)
.fetch_optional(pool)
.await?;
if role_exists.is_none() {
return Err(AppError::NotFound("角色不存在".into()));
}
// 删除旧权限
sqlx::query("DELETE FROM role_permissions WHERE role_id = ?")
.bind(role_id)
.execute(pool)
.await?;
// 批量插入新权限
for code in &input.permission_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
Ok(Json(json!({ "code": 0, "data": null, "message": "权限设置成功" })))
}
/// GET /api/permissions — 获取所有权限码列表
pub async fn list_permissions(
_user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let perms: Vec<(i64, String, String)> = sqlx::query_as(
"SELECT id, code, name FROM permissions ORDER BY code"
)
.fetch_all(pool)
.await?;
let data: Vec<Value> = perms.into_iter().map(|(id, code, name)| {
json!({ "id": id, "code": code, "name": name })
}).collect();
Ok(Json(json!({ "code": 0, "data": data, "message": "ok" })))
}

View File

@ -0,0 +1,564 @@
//! 用户管理 API
//!
//! 提供用户的 CRUD 操作、密码重置和状态管理:
//! - GET /api/users — 用户列表(分页,支持关键字搜索)
//! - POST /api/users — 创建用户(手机号+姓名+角色)
//! - PUT /api/users/:id — 编辑用户
//! - PUT /api/users/:id/reset-password — 重置密码
//! - PUT /api/users/:id/status — 启用/禁用
//! - DELETE /api/users/:id — 删除用户
use axum::extract::{Path, Query, State};
use axum::Json;
use chrono::NaiveDateTime;
use serde::Deserialize;
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)]
pub(crate) struct UserListQuery {
page: Option<u32>,
page_size: Option<u32>,
keyword: Option<String>,
}
/// 用户列表数据库行映射替代7元素元组sqlx 不支持超过6元素的元组
#[derive(Debug, sqlx::FromRow)]
struct UserRow {
id: i64,
phone: String,
name: Option<String>,
role: i32,
organization_id: Option<i64>,
status: i32,
created_at: NaiveDateTime,
}
/// 创建用户请求体
#[derive(Debug, Deserialize)]
pub(crate) struct CreateUserInput {
phone: String,
name: Option<String>,
role: Option<i32>,
organization_id: Option<i64>,
password: Option<String>,
}
/// 编辑用户请求体
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateUserInput {
name: Option<String>,
role: Option<i32>,
organization_id: Option<i64>,
}
/// 重置密码请求体
#[derive(Debug, Deserialize)]
pub(crate) struct ResetPasswordInput {
password: Option<String>,
}
/// 状态变更请求体
#[derive(Debug, Deserialize)]
pub(crate) struct StatusInput {
status: i32,
}
// ---------------------------------------------------------------------------
// 辅助函数: 记录操作日志
// ---------------------------------------------------------------------------
/// 记录一条操作日志到 operation_logs 表
async fn log_operation(
pool: &sqlx::MySqlPool,
user_id: i64,
action: &str,
target_type: &str,
target_id: Option<i64>,
detail: &str,
) {
let result = sqlx::query(
"INSERT INTO operation_logs (user_id, action, target_type, target_id, detail) VALUES (?, ?, ?, ?, ?)"
)
.bind(user_id)
.bind(action)
.bind(target_type)
.bind(target_id)
.bind(detail)
.execute(pool)
.await;
if let Err(e) = result {
tracing::error!("记录操作日志失败: {}", e);
}
}
// ---------------------------------------------------------------------------
// 用户列表
// ---------------------------------------------------------------------------
/// GET /api/users — 用户列表(分页)
///
/// 支持 keyword 搜索(匹配手机号/姓名page 从 1 开始。
pub async fn list_users(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<UserListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "user:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
let keyword = query.keyword.as_deref().unwrap_or("");
// 构建查询条件
let has_keyword = !keyword.is_empty();
let like_pattern = format!("%{}%", keyword);
// 组织隔离:总管理员看所有用户,企业管理员只看本组织用户
let org_filter = if user.role_level >= 2 {
None // 总管理员不过滤
} else {
user.organization_id // 企业管理员按组织过滤,无组织则返回空
};
// 无组织关联的非总管理员用户看不到任何人
if user.role_level < 2 && org_filter.is_none() {
return Ok(Json(json!({
"code": 0,
"data": { "list": [], "total": 0 },
"message": "ok"
})));
}
// 查询总数
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);
}
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>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at
FROM users u
WHERE u.phone LIKE ? OR u.name LIKE ?
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
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);
}
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
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
FROM users u
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
};
// 收集组织ID并批量查询组织名称
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
let org_names = if org_ids.is_empty() {
vec![]
} else {
// 使用 IN 查询, 构造占位符
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT id, name FROM organizations WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
for id in &org_ids {
q = q.bind(id);
}
q.fetch_all(pool).await.unwrap_or_default()
};
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
// 构建角色名称映射
let role_names: [&str; 3] = ["普通用户", "企业管理员", "总管理员"];
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
json!({
"id": r.id,
"phone": r.phone,
"name": r.name,
"role": r.role,
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
"organization_id": r.organization_id,
"organization_name": org_name,
"status": r.status,
"created_at": r.created_at.to_string(),
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": { "list": list, "total": total.0 },
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 创建用户
// ---------------------------------------------------------------------------
/// POST /api/users — 创建用户
pub async fn create_user(
current_user: CurrentUser,
State(state): State<AppState>,
Json(input): Json<CreateUserInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:create")?;
let pool = &state.mysql;
// 校验手机号
let phone = input.phone.trim();
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
}
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()));
}
// 企业管理员必须指定所属组织
if role == 1 && input.organization_id.is_none() {
return Err(AppError::BadRequest("企业管理员必须指定所属组织".into()));
}
let org_id = input.organization_id;
// 校验组织存在
if let Some(oid) = org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
}
let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
let password = auth::hash_password(&plain_password)?;
// 检查手机号是否已存在
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM users WHERE phone = ?"
)
.bind(phone)
.fetch_one(pool)
.await?;
if exists.0 > 0 {
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
}
let result = sqlx::query(
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
)
.bind(phone)
.bind(&name)
.bind(&password)
.bind(role)
.bind(org_id)
.execute(pool)
.await?;
let user_id = result.last_insert_id() as i64;
// 记录操作日志
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);
Ok(Json(json!({
"code": 0,
"data": { "id": user_id },
"message": "创建成功"
})))
}
// ---------------------------------------------------------------------------
// 编辑用户
// ---------------------------------------------------------------------------
/// PUT /api/users/:id — 编辑用户
pub async fn update_user(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<UpdateUserInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:edit")?;
let pool = &state.mysql;
// 检查用户存在
let old: Option<(String, Option<String>, i32, Option<i64>)> = sqlx::query_as(
"SELECT phone, name, role, organization_id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let (phone, old_name, old_role, old_org_id) = old
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
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()));
}
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()));
}
// 校验组织存在
if let Some(oid) = new_org_id
&& Some(oid) != old_org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
}
let rows = sqlx::query(
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
)
.bind(&new_name)
.bind(new_role)
.bind(new_org_id)
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
// 记录操作日志
let detail = format!(
"编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}",
phone,
old_name.as_deref().unwrap_or("-"),
new_name.as_deref().unwrap_or("-"),
old_role, new_role
);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("编辑用户 id={}", user_id);
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
}
// ---------------------------------------------------------------------------
// 重置密码
// ---------------------------------------------------------------------------
/// PUT /api/users/:id/reset-password — 重置密码
pub async fn reset_password(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<ResetPasswordInput>,
) -> Result<Json<Value>, AppError> {
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());
let new_password = auth::hash_password(&plain_password)?;
let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?")
.bind(&new_password)
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
// 记录操作日志
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);
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
}
// ---------------------------------------------------------------------------
// 启用/禁用
// ---------------------------------------------------------------------------
/// PUT /api/users/:id/status — 启用/禁用用户
pub async fn set_user_status(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<StatusInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:edit")?;
let pool = &state.mysql;
// 不能禁用自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能修改自己的状态".into()));
}
if ![0, 1].contains(&input.status) {
return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into()));
}
let rows = sqlx::query("UPDATE users SET status = ? WHERE id = ?")
.bind(input.status)
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
let status_text = if input.status == 1 { "启用" } else { "禁用" };
// 记录操作日志
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);
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
}
// ---------------------------------------------------------------------------
// 删除用户
// ---------------------------------------------------------------------------
/// DELETE /api/users/:id — 删除用户
pub async fn delete_user(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:delete")?;
let pool = &state.mysql;
// 不能删除自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能删除自己的账户".into()));
}
// 查询用户信息用于日志
let user_info: Option<(String,)> = sqlx::query_as(
"SELECT phone FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let phone = user_info.map(|(p,)| p).unwrap_or_default();
let rows = sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
// 记录操作日志
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);
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
}

View File

@ -0,0 +1,245 @@
//! 用户权限管理 API
//!
//! 提供用户角色分配和权限范围配置:
//! - GET /api/users/:id/permissions — 获取用户权限(含角色和权限码)
//! - PUT /api/users/:id/roles — 设置用户角色
//! - PUT /api/users/:id/scopes — 设置用户权限范围(组织/项目/设备级)
//! - GET /api/users/:id/scopes — 获取用户权限范围
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
/// 设置用户角色请求体
#[derive(Debug, Deserialize)]
pub(crate) struct SetRolesInput {
/// 角色ID列表
role_ids: Vec<i64>,
}
/// 权限范围条目
#[derive(Debug, Deserialize)]
pub(crate) struct ScopeEntry {
/// 范围类型1组织 2项目 3设备
scope_type: i8,
/// 对应的组织/项目/设备ID
scope_id: i64,
/// 权限类型1查看 2操作
permission_type: i8,
}
/// 设置用户权限范围请求体
#[derive(Debug, Deserialize)]
pub(crate) struct SetScopesInput {
scopes: Vec<ScopeEntry>,
}
/// GET /api/users/:id/permissions — 获取用户权限详情
#[allow(clippy::type_complexity)]
pub async fn get_user_permissions(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:view")?;
// 查询用户基本信息
let user_row: Option<(i64, String, Option<String>, i32, Option<i64>)> = sqlx::query_as(
"SELECT id, name, phone, role, organization_id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let user_row = user_row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
// 查询用户角色
let roles: Vec<(i64, String, Option<String>)> = sqlx::query_as(
r#"
SELECT r.id, r.name, r.description
FROM roles r
INNER JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = ?
ORDER BY r.id
"#
)
.bind(user_id)
.fetch_all(pool)
.await?;
// 查询用户权限码
let perm_codes = auth::load_user_permissions(pool, user_id).await?;
// 查询用户权限范围
let scopes: Vec<(i64, i8, i64, i8)> = sqlx::query_as(
"SELECT id, scope_type, scope_id, permission_type FROM user_scopes WHERE user_id = ?"
)
.bind(user_id)
.fetch_all(pool)
.await?;
let roles_data: Vec<Value> = roles.into_iter().map(|(id, name, desc)| {
json!({ "id": id, "name": name, "description": desc })
}).collect();
let scopes_data: Vec<Value> = scopes.into_iter().map(|(id, st, sid, pt)| {
json!({
"id": id,
"scope_type": st,
"scope_id": sid,
"permission_type": pt,
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": {
"user_id": user_row.0,
"name": user_row.1,
"phone": user_row.2,
"role_level": user_row.3,
"organization_id": user_row.4,
"roles": roles_data,
"permission_codes": perm_codes,
"scopes": scopes_data,
},
"message": "ok"
})))
}
/// PUT /api/users/:id/roles — 设置用户角色(全量替换)
pub async fn set_user_roles(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<SetRolesInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:edit")?;
// 不允许修改自己的角色
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能修改自己的角色".into()));
}
// 检查用户是否存在
let user_exists: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
if user_exists.is_none() {
return Err(AppError::NotFound("用户不存在".into()));
}
// 删除旧角色
sqlx::query("DELETE FROM user_roles WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await?;
// 插入新角色
for role_id in &input.role_ids {
// 验证角色存在
let role_exists: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE id = ?"
)
.bind(role_id)
.fetch_optional(pool)
.await?;
if role_exists.is_some() {
sqlx::query(
"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)"
)
.bind(user_id)
.bind(role_id)
.execute(pool)
.await?;
}
}
Ok(Json(json!({ "code": 0, "data": null, "message": "角色设置成功" })))
}
/// PUT /api/users/:id/scopes — 设置用户权限范围(全量替换)
pub async fn set_user_scopes(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<SetScopesInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:edit")?;
// 校验 scope_type 和 permission_type 范围
for scope in &input.scopes {
if ![1, 2, 3].contains(&scope.scope_type) {
return Err(AppError::BadRequest(
"scope_type 必须为 1(组织)/2(项目)/3(设备)".into()
));
}
if ![1, 2].contains(&scope.permission_type) {
return Err(AppError::BadRequest(
"permission_type 必须为 1(查看)/2(操作)".into()
));
}
}
// 删除旧范围
sqlx::query("DELETE FROM user_scopes WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await?;
// 插入新范围
for scope in &input.scopes {
sqlx::query(
"INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (?, ?, ?, ?)"
)
.bind(user_id)
.bind(scope.scope_type)
.bind(scope.scope_id)
.bind(scope.permission_type)
.execute(pool)
.await?;
}
Ok(Json(json!({ "code": 0, "data": null, "message": "权限范围设置成功" })))
}
/// GET /api/users/:id/scopes — 获取用户权限范围
pub async fn get_user_scopes(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:view")?;
let scopes: Vec<(i64, i8, i64, i8)> = sqlx::query_as(
"SELECT id, scope_type, scope_id, permission_type FROM user_scopes WHERE user_id = ?"
)
.bind(user_id)
.fetch_all(pool)
.await?;
let data: Vec<Value> = scopes.into_iter().map(|(id, st, sid, pt)| {
json!({
"id": id,
"scope_type": st,
"scope_id": sid,
"permission_type": pt,
})
}).collect();
Ok(Json(json!({ "code": 0, "data": data, "message": "ok" })))
}

View File

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

View File

@ -0,0 +1,49 @@
//! 节点心跳超时检测协程
//!
//! 定期检查设备服务节点的心跳状态,移除超时节点。
//! 心跳时间从 Redis 读取RFC3339 字符串),与 device-server 写入的格式一致。
use std::time::Duration;
use crate::commands::NodeRegistry;
/// 启动节点心跳超时检测
///
/// 每 30 秒检查一次,移除超过 `timeout_secs` 未发送心跳的节点。
pub async fn run(nodes: NodeRegistry, timeout_secs: u64) {
let timeout = Duration::from_secs(timeout_secs);
let check_interval = Duration::from_secs(30);
loop {
tokio::time::sleep(check_interval).await;
let node_list = nodes.list().await;
let now = chrono::Utc::now();
for node in &node_list {
let last_hb = match &node.last_heartbeat {
Some(hb) => match chrono::DateTime::parse_from_rfc3339(hb) {
Ok(dt) => dt.with_timezone(&chrono::Utc),
Err(_) => {
tracing::warn!("[node_checker] 无效的心跳时间格式 node_id={}", node.node_id);
continue;
}
},
None => {
tracing::warn!("[node_checker] 节点缺少心跳时间 node_id={}", node.node_id);
continue;
}
};
let elapsed = now - last_hb;
if elapsed > chrono::Duration::from_std(timeout).unwrap_or_default() {
tracing::warn!(
"[node_checker] 节点心跳超时 node_id={} 距最后心跳={}秒",
node.node_id,
elapsed.num_seconds()
);
nodes.remove(&node.node_id).await;
}
}
}
}

View File

@ -0,0 +1,47 @@
//! 指令响应处理协程
//!
//! 从 Redis LIST `device:reply:{imei}` 读取设备指令响应,
//! 匹配 msg_id 并通过 oneshot channel 通知等待中的 HTTP 请求。
use redis::aio::ConnectionManager;
use crate::commands::PendingCommands;
/// 启动指令响应处理协程
///
/// 使用 BRPOP 阻塞等待所有 `device:reply:*` 队列的响应消息。
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;
match result {
Ok((_key, 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;
let pending = pending.clone();
tokio::spawn(async move {
if let Some(tx) = pending.remove(msg_id).await {
let _ = tx.send(reply);
tracing::debug!("[reply_worker] msg_id={} 已匹配", msg_id);
} else {
tracing::debug!("[reply_worker] msg_id={} 无等待者(可能已超时)", msg_id);
}
});
}
}
Err(e) => {
tracing::error!("[reply_worker] BRPOP 错误: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
}

View File

@ -0,0 +1,397 @@
//! 设备上报处理协程
//!
//! 从 Redis LIST `device:reports` 读取设备上报消息,
//! 执行业务逻辑:签名验证、状态持久化、充电事件处理、告警判断等。
//! 需要回复设备的消息通过 `device:commands` 队列发回。
use rand::Rng;
use redis::aio::ConnectionManager;
use sha2::{Digest, Sha256};
use sqlx::MySqlPool;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::protocol::ServerResponse;
/// Redis key 前缀
const REDIS_KEY_AUTH_STR: &str = "auth_str:";
/// 签名截取范围
const SIGN_START: usize = 56;
const SIGN_END: usize = 63;
/// 签名校验窗口±60 秒
const TIMESTAMP_TOLERANCE_SECS: i64 = 60;
// ---------------------------------------------------------------------------
// 频率限制器
// ---------------------------------------------------------------------------
struct RateLimiter {
attempts: Mutex<HashMap<String, Vec<Instant>>>,
max_attempts: usize,
window: Duration,
}
impl RateLimiter {
fn new(max_attempts: usize, window: Duration) -> Self {
Self { attempts: Mutex::new(HashMap::new()), max_attempts, window }
}
async fn check(&self, key: &str) -> bool {
let mut map = self.attempts.lock().await;
let now = Instant::now();
let window_start = now - self.window;
let entry = map.entry(key.to_string()).or_default();
entry.retain(|t| *t > window_start);
if entry.len() >= self.max_attempts {
return false;
}
entry.push(now);
true
}
}
/// 全局 auth_str 频率限制器5次/5分钟
static AUTH_STR_LIMITER: LazyLock<RateLimiter> = LazyLock::new(|| {
RateLimiter::new(5, Duration::from_secs(300))
});
/// 启动上报处理协程
pub async fn start(redis: ConnectionManager, mysql: MySqlPool) {
tracing::info!("[report_worker] 启动");
loop {
let mut conn = redis.clone();
let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP")
.arg("device:reports")
.arg(0)
.query_async(&mut conn)
.await;
match result {
Ok((_key, msg_json)) => {
let mysql = mysql.clone();
let redis = redis.clone();
tokio::spawn(async move {
if let Err(e) = process_report(msg_json, &mysql, &redis).await {
tracing::error!("[report_worker] 处理失败: {}", e);
}
});
}
Err(e) => {
tracing::error!("[report_worker] BRPOP 错误: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
/// 处理单条设备上报
async fn process_report(
msg_json: String,
mysql: &MySqlPool,
redis: &ConnectionManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let msg: serde_json::Value = serde_json::from_str(&msg_json)?;
let act = msg["act"].as_str().unwrap_or("unknown");
let dev_id = msg["dev_id"].as_str().unwrap_or("");
tracing::debug!("[report] 处理 dev_id={} act={}", dev_id, act);
match act {
"auth_str" => handle_auth_str(&msg, dev_id, redis).await?,
"login" => handle_login(&msg, dev_id, redis).await?,
"status_post" => handle_status_post(&msg, dev_id, mysql, redis).await?,
"pow_fail" => handle_pow_fail(&msg, dev_id, mysql).await?,
"bat_in" => handle_bat_in(&msg, dev_id, mysql).await?,
"bat_out" => handle_bat_out(&msg, dev_id, mysql).await?,
"off" => handle_device_off(&msg, dev_id, mysql).await?,
other => {
tracing::warn!("[report] 未知上报类型 dev_id={} act={}", dev_id, other);
}
}
Ok(())
}
/// 向设备发送响应(通过 Redis commands 队列)
async fn send_response_to_device(
redis: &ConnectionManager,
dev_id: &str,
resp: &ServerResponse,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let json_str = resp.to_json();
let mut conn = redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await?;
tracing::debug!("[响应] 已推入队列 dev_id={}", dev_id);
Ok(())
}
/// 处理 `auth_str`:生成 8 位随机安全码并存入 Redis
async fn handle_auth_str(
msg: &serde_json::Value,
dev_id: &str,
redis: &ConnectionManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let msg_id = msg["msg_id"].as_u64().map(|v| v as u32);
if !AUTH_STR_LIMITER.check(dev_id).await {
tracing::warn!("[auth_str] 频率超限 dev_id={}", dev_id);
let resp = ServerResponse::error(msg_id, "请求过于频繁,请稍后再试");
send_response_to_device(redis, dev_id, &resp).await?;
return Ok(());
}
let auth_str: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(8)
.map(char::from)
.collect();
let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}");
let mut conn = redis.clone();
let _: Result<(), _> = redis::cmd("SET")
.arg(&key)
.arg(&auth_str)
.query_async::<()>(&mut conn)
.await;
tracing::info!("[auth_str] 已生成安全码 dev_id={}", dev_id);
let mut resp = ServerResponse::success(msg_id);
resp.auth_str = Some(auth_str);
send_response_to_device(redis, dev_id, &resp).await?;
Ok(())
}
/// 处理 `login`:验证签名
async fn handle_login(
msg: &serde_json::Value,
dev_id: &str,
redis: &ConnectionManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let msg_id = msg["msg_id"].as_u64().map(|v| v as u32);
let timestamp = msg["timestamp"].as_u64().unwrap_or(0);
let sign = msg["sign"].as_str().unwrap_or("");
// 校验时间窗口 ±60 秒
let now = chrono::Utc::now().timestamp();
let ts = timestamp as i64;
if (now - ts).abs() > TIMESTAMP_TOLERANCE_SECS {
tracing::warn!("[login] 时间戳超限 dev_id={} timestamp={}", dev_id, timestamp);
let resp = ServerResponse::error(msg_id, "时间戳无效");
send_response_to_device(redis, dev_id, &resp).await?;
return Ok(());
}
// 从 Redis 获取安全码
let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}");
let mut conn = redis.clone();
let auth_str: Option<String> = redis::cmd("GET")
.arg(&key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let auth_str = match auth_str {
Some(s) => s,
None => {
let resp = ServerResponse::error(msg_id, "未获取安全码,请先调用 auth_str");
send_response_to_device(redis, dev_id, &resp).await?;
return Ok(());
}
};
// 计算期望签名
let input = format!("{}{}{}", dev_id, auth_str, timestamp);
let hash = Sha256::digest(input.as_bytes());
let hex_hash = hex::encode(hash);
let expected_sign = &hex_hash[SIGN_START..SIGN_END];
if sign == expected_sign {
// 标记设备在线5分钟 TTL
let online_key = format!("device:online:{}", dev_id);
let mut conn = redis.clone();
let _: Result<(), _> = redis::cmd("SET")
.arg(&online_key)
.arg("1")
.arg("EX")
.arg(300_u32)
.query_async::<()>(&mut conn)
.await;
tracing::info!("[login] 成功 dev_id={}", dev_id);
let resp = ServerResponse::success(msg_id);
send_response_to_device(redis, dev_id, &resp).await?;
} else {
tracing::warn!("[login] 签名不匹配 dev_id={}", dev_id);
let resp = ServerResponse::error(msg_id, "签名错误");
send_response_to_device(redis, dev_id, &resp).await?;
}
Ok(())
}
/// 处理状态上报 — 持久化到 MySQL device_logs
async fn handle_status_post(
msg: &serde_json::Value,
dev_id: &str,
mysql: &MySqlPool,
redis: &ConnectionManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 存入 Redis 实时状态
let redis_key = format!("device:status:{}", dev_id);
let mut conn = redis.clone();
let _: Result<(), _> = redis::cmd("SET")
.arg(&redis_key)
.arg(serde_json::to_string(msg).unwrap_or_default())
.query_async::<()>(&mut conn)
.await;
// 更新设备在线标记5分钟 TTL
let online_key = format!("device:online:{}", dev_id);
let _: Result<(), _> = redis::cmd("SET")
.arg(&online_key)
.arg("1")
.arg("EX")
.arg(300_u32)
.query_async::<()>(&mut conn)
.await;
// 持久化到 MySQL
let content = serde_json::json!({
"dev_id": dev_id,
"sub_device_id": msg["sub_device_id"],
"status_hex": msg["status"],
"rssi": msg["rssi"],
"timestamp": msg["timestamp"],
});
sqlx::query(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (
(SELECT id FROM cabinets WHERE imei = ?), 1, ?
)",
)
.bind(dev_id)
.bind(content.to_string())
.execute(mysql)
.await?;
tracing::debug!("[status_post] 已持久化 dev_id={}", dev_id);
Ok(())
}
/// 处理停电上报
async fn handle_pow_fail(
msg: &serde_json::Value,
dev_id: &str,
mysql: &MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let content = serde_json::json!({
"dev_id": dev_id,
"timestamp": msg["timestamp"],
"sub_device_id": msg["sub_device_id"],
});
sqlx::query(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (
(SELECT id FROM cabinets WHERE imei = ?), 4, ?
)",
)
.bind(dev_id)
.bind(content.to_string())
.execute(mysql)
.await?;
tracing::info!("[pow_fail] 停电上报 dev_id={}", dev_id);
Ok(())
}
/// 处理电池插入
async fn handle_bat_in(
msg: &serde_json::Value,
dev_id: &str,
mysql: &MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!("[bat_in] 电池插入 dev_id={}", dev_id);
let content = serde_json::json!({
"dev_id": dev_id,
"id": msg["id"],
"timestamp": msg["timestamp"],
"bms": msg.get("bms"),
});
sqlx::query(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (
(SELECT id FROM cabinets WHERE imei = ?), 2, ?
)",
)
.bind(dev_id)
.bind(content.to_string())
.execute(mysql)
.await?;
Ok(())
}
/// 处理电池拔出
async fn handle_bat_out(
msg: &serde_json::Value,
dev_id: &str,
mysql: &MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!("[bat_out] 电池拔出 dev_id={}", dev_id);
let content = serde_json::json!({
"dev_id": dev_id,
"id": msg["id"],
"timestamp": msg["timestamp"],
});
sqlx::query(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (
(SELECT id FROM cabinets WHERE imei = ?), 2, ?
)",
)
.bind(dev_id)
.bind(content.to_string())
.execute(mysql)
.await?;
Ok(())
}
/// 处理设备主动上报 off充满自动停止
async fn handle_device_off(
msg: &serde_json::Value,
dev_id: &str,
mysql: &MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!("[off] 设备上报停止充电 dev_id={}", dev_id);
let content = serde_json::json!({
"dev_id": dev_id,
"id": msg["id"],
"msg_id": msg["msg_id"],
"timestamp": msg["timestamp"],
});
sqlx::query(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (
(SELECT id FROM cabinets WHERE imei = ?), 2, ?
)",
)
.bind(dev_id)
.bind(content.to_string())
.execute(mysql)
.await?;
Ok(())
}

View File

@ -0,0 +1,17 @@
# Redis 连接(生产 db5
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
# TCP 设备通讯
TCP_PORT=3002
# 节点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

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

1229
software/device-server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
[package]
name = "pms-device-server"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["full"] }
redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"
tokio-util = { version = "0.7", features = ["codec"] }
bytes = "1"
futures = "0.3"
dotenvy = "0.15"
chrono = { version = "0.4", features = ["serde"] }

View File

@ -0,0 +1,41 @@
//! 设备服务配置管理
//!
//! 从环境变量加载配置,回退到默认值。
/// 设备服务配置
pub struct Config {
pub host: String,
pub tcp_port: u16,
pub node_id: String,
pub redis_url: String,
pub heartbeat_interval: u64,
pub register_ttl: u64,
}
impl Config {
/// 从环境变量加载配置
pub fn from_env() -> Self {
let _ = dotenvy::dotenv();
Self {
host: std::env::var("HOST")
.unwrap_or_else(|_| "0.0.0.0".to_string()),
tcp_port: std::env::var("TCP_PORT")
.unwrap_or_else(|_| "3002".to_string())
.parse()
.expect("TCP_PORT must be a valid number"),
node_id: std::env::var("NODE_ID")
.unwrap_or_else(|_| "node-1".to_string()),
redis_url: std::env::var("REDIS_URL")
.expect("REDIS_URL must be set"),
heartbeat_interval: std::env::var("HEARTBEAT_INTERVAL")
.unwrap_or_else(|_| "60".to_string())
.parse()
.expect("HEARTBEAT_INTERVAL must be a valid number"),
register_ttl: std::env::var("REGISTER_TTL")
.unwrap_or_else(|_| "300".to_string())
.parse()
.expect("REGISTER_TTL must be a valid number"),
}
}
}

View File

@ -0,0 +1,177 @@
//! 充电柜矩阵系统 — 设备服务入口
//!
//! 轻量化 TCP 服务,只处理设备长连接通信和指令解析。
//! 不存储 DB、不验证业务逻辑所有设备上报通过 Redis 转发给 API 服务。
//!
//! 启动流程:
//! 1. 连接 Redis
//! 2. 通过 Redis 注册到 API 服务(节点信息写入 Redis
//! 3. 启动心跳协程(定期更新 Redis 中的心跳时间戳)
//! 4. 启动指令消费协程(从 Redis 读取平台指令并发送到设备)
//! 5. 启动 TCP 监听
mod config;
mod redis_conn;
mod tcp;
use std::time::Duration;
use config::Config;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
// 加载配置
let cfg = Config::from_env();
tracing::info!("配置加载完成 node_id={}", cfg.node_id);
// 连接 Redis
let redis_conn = redis_conn::init_connection(&cfg.redis_url)
.await
.expect("Redis 连接失败");
tracing::info!("Redis 连接成功");
// 初始化设备连接池
let device_pool = tcp::connection::ConnectionPool::new();
// 通过 Redis 注册节点信息
register_node(&redis_conn, &cfg).await;
// 启动心跳协程(通过 Redis 更新心跳时间戳)
let hb_redis = redis_conn.clone();
let hb_node_id = cfg.node_id.clone();
let hb_interval = cfg.heartbeat_interval;
let hb_ttl = cfg.register_ttl;
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(hb_interval));
loop {
interval.tick().await;
heartbeat(&hb_redis, &hb_node_id, hb_ttl).await;
}
});
// 启动指令消费协程(从 Redis 读取平台指令并发送到设备)
let cmd_redis = redis_conn.clone();
let cmd_pool = device_pool.clone();
tokio::spawn(async move {
consume_commands(cmd_redis, cmd_pool).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;
}
/// 通过 Redis 注册节点信息HSET hash 结构,与 heartbeat 保持一致)
async fn register_node(redis: &redis::aio::ConnectionManager, cfg: &Config) {
let key = format!("node:{}", cfg.node_id);
let now = chrono::Utc::now().to_rfc3339();
let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("node_id")
.arg(&cfg.node_id)
.arg("ip")
.arg(&cfg.host)
.arg("tcp_port")
.arg(cfg.tcp_port.to_string())
.arg("status")
.arg("online")
.arg("registered_at")
.arg(&now)
.arg("last_heartbeat")
.arg(&now)
.query_async(&mut conn)
.await;
// ponytail: HSET 后单独设置 TTLredis-rs 的 HSET 不支持 EX 选项)
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(cfg.register_ttl)
.query_async(&mut conn)
.await;
match result {
Ok(_) => tracing::info!("已通过 Redis 注册节点 node_id={}", cfg.node_id),
Err(e) => tracing::error!("节点注册失败: {}", e),
}
}
/// 通过 Redis 更新心跳时间戳,同时刷新 TTL
async fn heartbeat(redis: &redis::aio::ConnectionManager, node_id: &str, register_ttl: u64) {
let key = format!("node:{}", node_id);
let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("last_heartbeat")
.arg(chrono::Utc::now().to_rfc3339())
.query_async(&mut conn)
.await;
// ponytail: 心跳同时刷新 TTL避免节点被过期清理
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(register_ttl)
.query_async(&mut conn)
.await;
match result {
Ok(_) => tracing::debug!("心跳已更新 node_id={}", node_id),
Err(e) => tracing::warn!("心跳更新失败: {}", e),
}
}
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 读取平台指令并发送到设备
async fn consume_commands(
redis: redis::aio::ConnectionManager,
pool: tcp::connection::ConnectionPool,
) {
tracing::info!("[cmd_consumer] 启动");
loop {
let mut conn = redis.clone();
let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP")
.arg("device:commands")
.arg(0)
.query_async(&mut conn)
.await;
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));
let dev_id = match dev_id {
Some(id) if !id.is_empty() => id,
_ => {
tracing::warn!("[cmd_consumer] 指令缺少 dev_id丢弃");
continue;
}
};
// 发送到设备
match pool.send_command(&dev_id, &cmd_json).await {
Ok(()) => {
tracing::debug!("[cmd] 已发送到设备 dev_id={}", dev_id);
}
Err(e) => {
tracing::warn!("[cmd] 发送失败 dev_id={} err={}", dev_id, e);
}
}
}
Err(e) => {
tracing::error!("[cmd_consumer] BRPOP 错误: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}

View File

@ -0,0 +1,12 @@
//! Redis 连接管理
use redis::aio::ConnectionManager;
use redis::Client;
/// 建立 Redis 连接管理器
pub async fn init_connection(redis_url: &str) -> Result<ConnectionManager, redis::RedisError> {
let client = Client::open(redis_url)?;
let conn = ConnectionManager::new(client).await?;
tracing::info!("Redis 连接已建立");
Ok(conn)
}

View File

@ -0,0 +1,113 @@
#![allow(dead_code)]
//! 设备连接池管理。
//!
//! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。
//! `ConnectionPool` 以 `dev_id`IMEI为键支持并发读写。
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{mpsc, RwLock};
/// 单台设备的连接状态。
#[allow(dead_code)]
pub struct DeviceConnection {
pub dev_id: String,
/// 向设备发送指令的通道。
pub tx: mpsc::UnboundedSender<String>,
/// 连接建立时间。
pub connected_at: Instant,
/// 最后活跃时间(收到任意消息时更新)。
pub last_seen: Instant,
/// 是否已完成登录验证。
pub authenticated: bool,
}
impl DeviceConnection {
pub fn new(dev_id: String, tx: mpsc::UnboundedSender<String>) -> Self {
let now = Instant::now();
Self { dev_id, tx, connected_at: now, last_seen: now, authenticated: false }
}
/// 标记活跃时间。
pub fn touch(&mut self) {
self.last_seen = Instant::now();
}
}
/// 线程安全的设备连接池。
#[derive(Clone)]
pub struct ConnectionPool {
inner: Arc<RwLock<HashMap<String, DeviceConnection>>>,
}
impl ConnectionPool {
pub fn new() -> Self {
Self { inner: Arc::new(RwLock::new(HashMap::new())) }
}
/// 注册设备连接。如果已存在则替换旧连接。
pub async fn register(&self, dev_id: String, conn: DeviceConnection) {
let mut map = self.inner.write().await;
map.insert(dev_id, conn);
}
/// 移除设备连接(断连时调用)。
pub async fn remove(&self, dev_id: &str) {
let mut map = self.inner.write().await;
map.remove(dev_id);
}
/// 向指定设备发送 JSON 指令。
/// 返回 `Ok(())` 表示已投递到通道;设备可能因缓冲区满而断开。
pub async fn send_command(&self, dev_id: &str, json: &str) -> Result<(), &'static str> {
let map = self.inner.read().await;
match map.get(dev_id) {
Some(conn) => conn.tx.send(json.to_string()).map_err(|_| "通道已关闭"),
None => 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)
}
/// 更新设备活跃时间。
pub async fn touch(&self, dev_id: &str) {
let mut map = self.inner.write().await;
if let Some(conn) = map.get_mut(dev_id) {
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;
}
}
/// 清理超时未活跃的连接(心跳检测)。
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()
}
}

View File

@ -0,0 +1,55 @@
//! 设备消息处理逻辑(轻量化)。
//!
//! 设备服务只处理通信和指令解析,不处理业务逻辑:
//! - 所有设备上报消息统一转发到 Redis `device:reports` 队列
//! - 不写 DB、不验证签名、不处理业务规则
//! - auth_str/login 等协议级消息也由 API 服务处理后通过 `device:commands` 回复
use super::connection::ConnectionPool;
use super::protocol::DeviceMessage;
/// 分发消息:统一转发到 Redis 上报队列。
///
/// 设备服务不做任何业务处理,仅负责解析 JSON 并转发。
pub async fn handle_message(
msg: DeviceMessage,
_pool: &ConnectionPool,
redis: &redis::aio::ConnectionManager,
) -> Option<super::protocol::ServerResponse> {
// 所有消息统一转发到 Redis由 API 服务的 report_worker 处理
forward_to_redis(redis, &msg).await;
// 不直接回复设备,等待 API 服务通过 device:commands 队列回复
None
}
/// 将设备消息转发到 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);
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)
.query_async(&mut conn)
.await;
match result {
Ok(_) => {
tracing::debug!(
"[forward] 已推送 dev_id={} act={}",
msg.dev_id.as_deref().unwrap_or("?"),
msg.act
);
}
Err(e) => tracing::error!("[forward] Redis LPUSH 失败: {}", e),
}
}

View File

@ -0,0 +1,9 @@
//! TCP 设备通讯服务。
//!
//! 处理充电柜设备的长连接登录、签名验证和消息转发。
//! 接收的消息通过 Redis 转发给 API 服务处理。
pub mod connection;
pub mod handler;
pub mod protocol;
pub mod server;

View File

@ -0,0 +1,84 @@
#![allow(dead_code)]
//! 设备通讯协议消息类型定义与序列化。
//!
//! 设备与平台间采用 JSON 格式通讯LF (`\n`) 作为消息边界。
//! 所有 JSON 键名使用蛇形命名snake_case
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// 设备发往平台的消息。
///
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
#[derive(Debug, Deserialize, Serialize)]
pub struct DeviceMessage {
/// 动作类型login / auth_str / status_post / pow_fail / 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>,
}
/// 平台回复设备的消息。
#[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>,
}
impl ServerResponse {
/// 创建成功响应。
pub fn success(msg_id: Option<u32>) -> Self {
Self { suc: 1, msg_id, err: None, auth_str: None, data: 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 }
}
/// 序列化为 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 序列化不应失败")
}
}

View File

@ -0,0 +1,144 @@
//! 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);
/// 启动 TCP 监听循环。
pub async fn run_tcp_server(
addr: String,
pool: ConnectionPool,
redis: redis::aio::ConnectionManager,
) {
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);
let pool = pool.clone();
let redis = redis.clone();
tokio::spawn(handle_connection(stream, peer, pool, redis));
}
Err(e) => {
tracing::error!("accept 失败: {}", e);
}
}
}
}
/// 处理单条 TCP 连接。
async fn handle_connection(
stream: TcpStream,
peer: std::net::SocketAddr,
pool: ConnectionPool,
redis: redis::aio::ConnectionManager,
) {
let (reader, writer) = tokio::io::split(stream);
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(4096));
// 写通道HTTP 命令下发通过此通道写入 TCP
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let mut current_dev_id: Option<String> = None;
let mut writer = writer;
loop {
tokio::select! {
// 读取设备消息
line = lines.next() => {
match line {
Some(Ok(text)) => {
if text.trim().is_empty() {
continue;
}
let msg: DeviceMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
tracing::warn!("[{}] JSON 解析失败: {} raw={}", peer, e, text);
continue;
}
};
// 如果是 login/auth_str需要 dev_id 来关联连接
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());
pool.register(dev_id.clone(), conn).await;
current_dev_id = Some(dev_id.clone());
}
// 更新活跃时间
if let Some(ref dev_id) = current_dev_id {
pool.touch(dev_id).await;
}
// 处理消息
let resp = handle_message(msg, &pool, &redis).await;
if let Some(resp) = resp {
let json = resp.to_json();
if let Err(e) = tx.send(json) {
tracing::error!("[{}] 响应发送失败: {}", peer, e);
break;
}
}
}
Some(Err(e)) => {
tracing::warn!("[{}] 读取错误: {}", peer, e);
break;
}
None => {
tracing::info!("[{}] 连接关闭", peer);
break;
}
}
}
// 向设备写指令
Some(json) = rx.recv() => {
use tokio::io::AsyncWriteExt;
let framed = format!("{}\n", json);
if let Err(e) = writer.write_all(framed.as_bytes()).await {
tracing::error!("[{}] 写入失败: {}", peer, e);
break;
}
}
}
}
// 连接断开,清理
if let Some(ref dev_id) = current_dev_id {
pool.remove(dev_id).await;
tracing::info!("[断连] dev_id={}", dev_id);
}
}

2695
software/server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
[package]
name = "pms-server"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"
dotenvy = "0.15"
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6", features = ["cors"] }
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
tokio-util = { version = "0.7", features = ["codec"] }
bytes = "1"
futures = "0.3"
rand = "0.8"
hex = "0.4"
rust_xlsxwriter = "0.80"
jsonwebtoken = "9"
argon2 = "0.5"

View File

@ -0,0 +1,52 @@
//! 应用配置管理
/// 默认 JWT 密钥(开发环境用,生产环境必须通过 JWT_SECRET 环境变量覆盖)
pub const JWT_SECRET_DEFAULT: &str = "pms-dev-secret-change-me-in-production";
/// 用户默认密码(创建用户或重置密码时使用)
pub const DEFAULT_PASSWORD: &str = "123456";
/// 应用配置
pub struct Config {
pub database_url: String,
pub redis_url: String,
pub host: String,
pub port: u16,
/// TCP 设备通讯端口
pub tcp_port: u16,
/// JWT 签名密钥
pub jwt_secret: String,
}
impl Config {
/// 从环境变量加载配置,优先读取 `.env` 文件
pub fn from_env() -> Self {
// 忽略 dotenvy 加载失败(生产环境可能通过系统 env 注入)
let _ = dotenvy::dotenv();
Self {
database_url: std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set"),
redis_url: std::env::var("REDIS_URL")
.expect("REDIS_URL must be set"),
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
port: std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be a valid number"),
tcp_port: std::env::var("TCP_PORT")
.unwrap_or_else(|_| "3002".to_string())
.parse()
.expect("TCP_PORT must be a valid number"),
jwt_secret: std::env::var("JWT_SECRET")
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()),
}
}
/// 校验配置安全性,打印警告
pub fn validate(&self) {
if self.jwt_secret == JWT_SECRET_DEFAULT {
tracing::warn!("使用默认JWT密钥生产环境请设置JWT_SECRET环境变量");
}
}
}

View File

@ -0,0 +1,406 @@
//! 数据库迁移 — 自动建表
use sqlx::MySqlPool;
/// 执行数据库迁移,创建所有必要的数据表
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let sql = r#"
CREATE TABLE IF NOT EXISTS organizations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '',
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 projects (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
organization_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabinets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT,
abstract_id VARCHAR(20) NOT NULL UNIQUE COMMENT 'ID10-00000000',
imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
auth_str VARCHAR(8) COMMENT '',
name VARCHAR(100) COMMENT '',
status TINYINT DEFAULT 0 COMMENT '0线 1线',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabin_boards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
board_id TINYINT NOT NULL COMMENT 'ID 1-6',
status TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS compartments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabin_board_id BIGINT NOT NULL,
channel_id TINYINT NOT NULL COMMENT 'ID 1-6',
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,
FOREIGN KEY (cabin_board_id) REFERENCES cabin_boards(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '',
name VARCHAR(50) COMMENT '',
password VARCHAR(255) NOT NULL,
role TINYINT DEFAULT 0 COMMENT '0 1 2',
organization_id BIGINT,
status TINYINT DEFAULT 1 COMMENT '0 1',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS charge_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
compartment_id BIGINT NOT NULL,
cabinet_id BIGINT NOT NULL,
start_time TIMESTAMP,
end_time TIMESTAMP,
energy DECIMAL(10,2) COMMENT 'kWh',
max_temperature DECIMAL(5,1) COMMENT '',
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS device_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
log_type TINYINT NOT NULL COMMENT '1 2 3 4',
content TEXT COMMENT 'JSON',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS energy_stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
total_energy DECIMAL(10,2) COMMENT 'kWh',
charge_count INT DEFAULT 0 COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
UNIQUE KEY uk_cabinet_date (cabinet_id, stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS operation_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
action VARCHAR(100) NOT NULL COMMENT '',
target_type VARCHAR(50) COMMENT '',
target_id BIGINT COMMENT 'ID',
detail TEXT COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE COMMENT '',
description VARCHAR(200) COMMENT '',
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 permissions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE COMMENT 'device:view',
name VARCHAR(100) NOT NULL COMMENT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS role_permissions (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_scopes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
scope_type TINYINT NOT NULL COMMENT '1 2 3',
scope_id BIGINT NOT NULL COMMENT '//ID',
permission_type TINYINT NOT NULL COMMENT '1 2',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS download_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
task_type VARCHAR(50) NOT NULL COMMENT 'charge_records/energy_stats/device_logs',
status TINYINT DEFAULT 0 COMMENT '0 1 2 3',
file_path VARCHAR(500) COMMENT '',
file_name VARCHAR(200) COMMENT '',
progress INT DEFAULT 0 COMMENT '',
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"#;
// 逐条执行,避免多语句执行问题
for statement in sql.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
sqlx::query(statement).execute(pool).await?;
}
// 创建关键索引IF NOT EXISTS 避免重复)
create_indexes(pool).await?;
// 初始化默认权限码INSERT IGNORE 避免重复插入)
seed_permissions(pool).await?;
// 初始化默认角色
seed_roles(pool).await?;
tracing::info!("数据库迁移完成");
Ok(())
}
/// 创建关键查询索引(幂等,已存在则跳过)
async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> {
/// 辅助:检查索引是否存在,不存在则创建
async fn ensure_index(
pool: &MySqlPool,
table: &str,
index: &str,
columns: &str,
) -> Result<(), sqlx::Error> {
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM information_schema.statistics \
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
)
.bind(table)
.bind(index)
.fetch_one(pool)
.await?;
if exists.0 == 0 {
let ddl = format!("CREATE INDEX {} ON {}({})", index, table, columns);
sqlx::query(&ddl).execute(pool).await?;
}
Ok(())
}
ensure_index(pool, "cabinets", "idx_cabinets_project", "project_id").await?;
ensure_index(pool, "cabin_boards", "idx_cabin_boards_cabinet", "cabinet_id").await?;
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?;
tracing::info!("数据库索引创建完成");
Ok(())
}
/// 插入默认权限码
async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let perms: Vec<(&str, &str)> = vec![
("device:view", "查看设备"),
("device:operate", "操作设备"),
("device:create", "添加设备"),
("device:edit", "编辑设备"),
("device:delete", "删除设备"),
("charge:start", "开始充电"),
("charge:stop", "停止充电"),
("charge:open_door", "开门"),
("charge_record:view", "查看充电记录"),
("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", "删除组织"),
// 项目管理
("project:view", "查看项目"),
("project:create", "创建项目"),
("project:edit", "编辑项目"),
("project:delete", "删除项目"),
];
for (code, name) in perms {
sqlx::query(
"INSERT IGNORE INTO permissions (code, name) VALUES (?, ?)"
)
.bind(code)
.bind(name)
.execute(pool)
.await?;
}
tracing::info!("默认权限码初始化完成");
Ok(())
}
/// 插入默认角色并分配权限
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 创建总管理员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("super_admin")
.bind("总管理员 — 全部权限")
.execute(pool)
.await?;
// 创建企业管理员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("org_admin")
.bind("企业管理员 — 管理本组织下项目/设备/人员")
.execute(pool)
.await?;
// 创建普通用户角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("normal_user")
.bind("普通用户 — 查看/操作指定范围设备")
.execute(pool)
.await?;
// 为总管理员分配所有权限
let super_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'super_admin'"
)
.fetch_optional(pool)
.await?;
if let Some((role_id,)) = super_admin_id {
// 查询所有权限ID
let perm_ids: Vec<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions"
)
.fetch_all(pool)
.await?;
for (perm_id,) in perm_ids {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
// 为企业管理员分配非系统管理权限
let org_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'org_admin'"
)
.fetch_optional(pool)
.await?;
let org_perm_codes = [
"device:view", "device:operate", "device:create", "device:edit",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "charge_record:export",
"device_log:view", "energy:view", "energy:export",
"user:view", "user:edit",
"operation_log:view",
// 组织管理
"org:view", "org:create", "org:edit", "org:delete",
// 项目管理
"project:view", "project:create", "project:edit", "project:delete",
];
if let Some((role_id,)) = org_admin_id {
for code in &org_perm_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
}
// 为普通用户分配查看和操作权限
let normal_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'normal_user'"
)
.fetch_optional(pool)
.await?;
let normal_perm_codes = [
"device:view", "device:operate",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "device_log:view", "energy:view",
];
if let Some((role_id,)) = normal_id {
for code in &normal_perm_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
}
tracing::info!("默认角色及权限分配完成");
Ok(())
}

View File

@ -0,0 +1,17 @@
//! MySQL 数据库连接池
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
pub mod migrate;
/// 初始化 MySQL 连接池
pub async fn init_pool(database_url: &str) -> Result<MySqlPool, sqlx::Error> {
let pool = MySqlPoolOptions::new()
.max_connections(20)
.connect(database_url)
.await?;
tracing::info!("MySQL 连接池已建立");
Ok(pool)
}

View File

@ -0,0 +1,66 @@
//! 统一错误处理
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// 应用统一错误类型
#[derive(thiserror::Error, Debug)]
#[allow(dead_code)]
pub enum AppError {
#[error("数据库错误: {0}")]
Database(#[from] sqlx::Error),
#[error("Redis错误: {0}")]
Redis(#[from] redis::RedisError),
#[error("未找到: {0}")]
NotFound(String),
#[error("参数错误: {0}")]
BadRequest(String),
#[error("未授权: {0}")]
Unauthorized(String),
#[error("无权限: {0}")]
Forbidden(String),
#[error("请求过于频繁: {0}")]
TooManyRequests(String),
#[error("内部错误: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::Database(e) => {
tracing::error!("数据库错误: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "DATABASE_ERROR", "服务器内部错误")
}
AppError::Redis(e) => {
tracing::error!("Redis错误: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "REDIS_ERROR", "缓存服务错误")
}
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.as_str()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.as_str()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.as_str()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.as_str()),
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS", msg.as_str()),
AppError::Internal(msg) => {
tracing::error!("内部错误: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误")
}
};
let body = json!({
"code": code,
"message": message,
});
(status, Json(body)).into_response()
}
}

View File

@ -0,0 +1,82 @@
//! 充电柜矩阵系统 — 后端入口
mod config;
mod db;
mod error;
mod middleware;
mod redis;
mod routes;
mod tcp;
use std::net::SocketAddr;
use tracing_subscriber::EnvFilter;
use tcp::commands::AppState;
use tcp::connection::ConnectionPool;
#[tokio::main]
async fn main() {
// 初始化日志
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
// 加载配置
let cfg = config::Config::from_env();
cfg.validate();
tracing::info!("配置加载完成");
// 初始化 MySQL 连接池
let db_pool = db::init_pool(&cfg.database_url)
.await
.expect("MySQL 连接失败");
tracing::info!("数据库连接成功");
// 执行自动迁移
db::migrate::run(&db_pool)
.await
.expect("数据库迁移失败");
tracing::info!("数据库迁移完成");
// 初始化 Redis 连接ConnectionManager 内部管理重连,可直接 Clone 共享)
let redis_conn = redis::init_connection(&cfg.redis_url)
.await
.expect("Redis 连接失败");
tracing::info!("Redis 连接成功");
// 初始化设备连接池
let device_pool = ConnectionPool::new();
// 构建共享状态
let state = AppState {
device_pool: device_pool.clone(),
redis: redis_conn.clone(),
mysql: db_pool.clone(),
};
// 启动 TCP 设备服务(后台)
let tcp_addr = format!("{}:{}", cfg.host, cfg.tcp_port);
tracing::info!("TCP 设备服务启动于 {}", tcp_addr);
let tcp_pool = device_pool.clone();
let tcp_redis = redis_conn.clone();
let tcp_mysql = db_pool.clone();
tokio::spawn(async move {
tcp::run_tcp_server(tcp_addr, tcp_pool, tcp_redis, tcp_mysql).await;
});
// 构建 HTTP 路由(包含设备指令下发端点和认证中间件)
let app = routes::build(state);
// 启动 HTTP 服务
let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
.parse()
.expect("无效的监听地址");
tracing::info!("HTTP 服务器启动于 http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("端口绑定失败");
axum::serve(listener, app)
.await
.expect("服务器运行失败");
}

View File

@ -0,0 +1,286 @@
//! 认证与权限校验中间件
//!
//! 提供 JWT 令牌解析和 RBAC 权限校验功能:
//! - `auth_middleware`:从请求头提取 JWT解析用户信息并注入请求扩展
//! - `check_permission`:校验用户是否拥有指定权限码
//! - `CurrentUser`从请求扩展中提取当前登录用户信息Axum Extractor
//! - `generate_token`:签发 JWT token
//! - `hash_password` / `verify_password`:密码哈希与验证
use std::collections::HashSet;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::body::Body;
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::http::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sqlx::MySqlPool;
use crate::config::JWT_SECRET_DEFAULT;
use crate::error::AppError;
use crate::tcp::commands::AppState;
/// JWT 签名密钥环境变量名
const JWT_SECRET_ENV: &str = "JWT_SECRET";
/// JWT Claims 结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
/// 用户IDsubject
pub sub: i64,
/// 手机号
pub phone: String,
/// 过期时间Unix 时间戳,秒)
pub exp: u64,
/// 签发时间Unix 时间戳,秒)
pub iat: u64,
}
/// 当前登录用户信息(从请求扩展中提取)
#[derive(Debug, Clone)]
pub struct CurrentUser {
/// 用户ID
pub user_id: i64,
/// 手机号
#[allow(dead_code)]
pub phone: String,
/// 用户拥有的权限码集合
pub permissions: HashSet<String>,
/// 角色等级0普通 1企业管理员 2总管理员
pub role_level: i32,
/// 所属组织ID总管理员为 None
pub organization_id: Option<i64>,
}
/// 认证中间件 — 从请求头提取 JWT解析用户信息并注入请求扩展
///
/// 配合 `axum::middleware::from_fn_with_state(state, auth_middleware)` 使用
pub async fn auth_middleware(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
let pool = &state.mysql;
// 从 Authorization 头提取 Bearer token
let auth_header = req.headers().get("Authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let token = match auth_header.strip_prefix("Bearer ") {
Some(t) => t.trim().to_string(),
None => {
return AppError::Unauthorized("缺少认证令牌".into()).into_response();
}
};
// 获取 JWT 密钥
let secret = std::env::var(JWT_SECRET_ENV)
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
// 解码并验证 JWT
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
let token_data = match decode::<Claims>(
&token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
) {
Ok(data) => data,
Err(_) => {
return AppError::Unauthorized("令牌无效或已过期".into()).into_response();
}
};
let claims = token_data.claims;
// 查询用户权限码集合
let permissions = match load_user_permissions(pool, claims.sub).await {
Ok(perms) => perms,
Err(e) => {
tracing::error!("查询用户权限失败: {}", e);
return AppError::Internal("权限查询失败".into()).into_response();
}
};
// 查询用户角色等级和组织ID
let (role_level, organization_id) = match load_user_role_and_org(pool, claims.sub).await {
Ok(data) => data,
Err(e) => {
tracing::error!("查询用户角色失败: {}", e);
return AppError::Internal("角色查询失败".into()).into_response();
}
};
// 构建当前用户信息并注入请求扩展
let current_user = CurrentUser {
user_id: claims.sub,
phone: claims.phone,
permissions,
role_level,
organization_id,
};
// 拆分请求,插入扩展,重新组合
let (mut parts, body) = req.into_parts();
parts.extensions.insert(current_user);
let req = Request::from_parts(parts, body);
next.run(req).await
}
/// 从请求中提取当前用户Axum Extractor 实现)
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
parts.extensions.get::<CurrentUser>().cloned().ok_or_else(|| {
AppError::Unauthorized("未认证".into()).into_response()
})
}
}
/// 权限校验 — 检查用户是否拥有指定权限码
///
/// 总管理员role_level >= 2自动拥有全部权限
pub fn check_permission(user: &CurrentUser, code: &str) -> Result<(), AppError> {
if user.role_level >= 2 {
return Ok(());
}
if user.permissions.contains(code) {
Ok(())
} else {
Err(AppError::Forbidden(format!("缺少权限: {}", code)))
}
}
/// 加载用户权限码集合(公开接口,供路由处理器调用)
pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result<HashSet<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT DISTINCT p.code
FROM permissions p
INNER JOIN role_permissions rp ON rp.permission_id = p.id
INNER JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = ?
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(code,)| code).collect())
}
/// 加载用户最高角色等级和组织ID
async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32, Option<i64>), sqlx::Error> {
let result: Option<(i32, Option<i64>)> = sqlx::query_as(
"SELECT COALESCE(MAX(role), 0), organization_id FROM users WHERE id = ?",
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(result.unwrap_or((0, None)))
}
/// 生成 JWT token7天有效期
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());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| AppError::Internal(format!("时间错误: {}", e)))?
.as_secs();
let claims = Claims {
sub: user_id,
phone: phone.to_string(),
exp: now + 86400 * 7,
iat: now,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| AppError::Internal(format!("Token生成失败: {}", e)))
}
// ---------------------------------------------------------------------------
// 密码哈希
// ---------------------------------------------------------------------------
/// 使用 argon2 对密码进行哈希
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(format!("密码哈希失败: {}", e)))?;
Ok(hash.to_string())
}
/// 验证密码与 argon2 哈希是否匹配
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| AppError::Internal(format!("密码哈希解析失败: {}", e)))?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
// ---------------------------------------------------------------------------
// 组织数据过滤
// ---------------------------------------------------------------------------
/// 返回角色等级 >= 2 时「总管理员」看全部
/// 否则按 organization_id 过滤(子查询方式,通过 cabinet → project → org 链路)
pub fn org_condition(user: &CurrentUser, cabinet_table: &str) -> (String, Vec<String>) {
if user.role_level >= 2 {
return (String::new(), vec![]);
}
if let Some(org_id) = user.organization_id {
(
format!(
" AND {}.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)",
cabinet_table
),
vec![org_id.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
}
}
/// 操作日志的组织过滤(通过 users.organization_id 链路)
pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, Vec<String>) {
if user.role_level >= 2 {
return (String::new(), vec![]);
}
if let Some(org_id) = user.organization_id {
(
format!(
" AND {}.user_id IN (SELECT id FROM users WHERE organization_id = ?)",
log_table
),
vec![org_id.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
}
}

View File

@ -0,0 +1,3 @@
//! 中间件模块
pub mod auth;

View File

@ -0,0 +1,12 @@
//! Redis 连接管理
use redis::aio::ConnectionManager;
use redis::Client;
/// 建立 Redis 连接管理器
pub async fn init_connection(redis_url: &str) -> Result<ConnectionManager, redis::RedisError> {
let client = Client::open(redis_url)?;
let conn = ConnectionManager::new(client).await?;
tracing::info!("Redis 连接已建立");
Ok(conn)
}

View File

@ -0,0 +1,118 @@
//! 认证路由 — 登录/登出/当前用户信息
//!
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
/// 登录请求体
#[derive(Debug, Deserialize)]
pub(crate) struct LoginInput {
phone: String,
password: String,
}
/// POST /api/auth/login — 用户登录
pub async fn login(
State(state): State<AppState>,
Json(input): Json<LoginInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
if input.phone.is_empty() || input.password.is_empty() {
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
}
// 查询用户(含密码比对)
let user_row: Option<(i64, Option<String>, String, String, i32, i32)> = sqlx::query_as(
"SELECT id, name, phone, password, role, status FROM users WHERE phone = ?"
)
.bind(&input.phone)
.fetch_optional(pool)
.await?;
let (id, name, phone, stored_password, role, status) =
user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
// 检查账户状态
if status == 0 {
return Err(AppError::Forbidden("账户已被禁用".into()));
}
// 密码校验argon2 哈希比对)
let valid = auth::verify_password(&input.password, &stored_password)
.map_err(|_| AppError::Internal("密码验证失败".into()))?;
if !valid {
return Err(AppError::Unauthorized("手机号或密码错误".into()));
}
// 生成 JWT token
let token = auth::generate_token(id, &phone)?;
// 查询用户权限码
let permissions = auth::load_user_permissions(pool, 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": role,
"permissions": perm_list,
}
},
"message": "登录成功"
})))
}
/// POST /api/auth/logout — 用户登出
pub async fn logout() -> Json<Value> {
// JWT 无状态,客户端清除 token 即可
Json(json!({ "code": 0, "data": null, "message": "登出成功" }))
}
/// GET /api/auth/me — 获取当前用户信息及权限
#[allow(clippy::type_complexity)]
pub async fn me(
user: CurrentUser,
State(state): State<AppState>,
) -> 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 = ?"
)
.bind(user.user_id)
.fetch_optional(pool)
.await?;
let (id, name, phone, role, org_id) =
row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
let perm_list: Vec<String> = user.permissions.into_iter().collect();
Ok(Json(json!({
"code": 0,
"data": {
"id": id,
"name": name,
"phone": phone,
"role": role,
"organization_id": org_id,
"permissions": perm_list,
},
"message": "ok"
})))
}

View File

@ -0,0 +1,113 @@
//! 组织树 API
//!
//! 返回组织->项目->设备完整树状结构。
//! 根据用户角色进行数据隔离:总管理员看全部,企业管理员只看本组织。
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
/// GET /api/organization-tree — 返回组织->项目->设备完整树
///
/// 需要 `device:view` 权限。
/// 数据隔离:总管理员看所有组织,企业管理员只看本组织下的数据。
pub async fn organization_tree(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
// 根据角色过滤组织:总管理员看所有,企业管理员只看本组织
let orgs: Vec<OrganizationRow> = if user.role_level >= 2 {
sqlx::query_as(
r#"SELECT id, name FROM organizations ORDER BY id"#,
)
.fetch_all(db)
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as(
r#"SELECT id, name FROM organizations WHERE id = ? ORDER BY id"#,
)
.bind(org_id)
.fetch_all(db)
.await?
} else {
// 无组织关联的用户看不到任何数据
return Ok(Json(json!([])));
};
let projects: Vec<ProjectRow> = sqlx::query_as(
r#"SELECT id, organization_id, name FROM projects ORDER BY id"#,
)
.fetch_all(db)
.await?;
let cabinets: Vec<CabinetRow> = sqlx::query_as(
r#"SELECT id, project_id, abstract_id, imei, name, status
FROM cabinets ORDER BY id"#,
)
.fetch_all(db)
.await?;
// 构建树
let tree: Vec<TreeNode> = orgs
.into_iter()
.map(|org| {
let org_projects: Vec<ProjectRow> = projects
.iter()
.filter(|p| p.organization_id == Some(org.id))
.cloned()
.collect();
let children: Vec<TreeNode> = org_projects
.into_iter()
.map(|proj| {
let proj_cabinets: Vec<CabinetRow> = cabinets
.iter()
.filter(|c| c.project_id == Some(proj.id))
.cloned()
.collect();
let cab_nodes: Vec<TreeNode> = proj_cabinets
.into_iter()
.map(|c| TreeNode {
id: c.id,
name: c.name.unwrap_or_else(|| c.abstract_id.clone()),
children: None,
abstract_id: Some(c.abstract_id),
imei: Some(c.imei),
status: Some(c.status),
})
.collect();
TreeNode {
id: proj.id,
name: proj.name,
children: Some(cab_nodes),
abstract_id: None,
imei: None,
status: None,
}
})
.collect();
TreeNode {
id: org.id,
name: org.name,
children: Some(children),
abstract_id: None,
imei: None,
status: None,
}
})
.collect();
Ok(Json(json!(tree)))
}

View File

@ -0,0 +1,425 @@
//! 设备管理 API — CRUD + 详情 + 级联下拉
//!
//! 提供柜子的增删改查、详情查询、安全码重新生成,
//! 以及柜子/仓板/仓体的级联下拉选项。
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::MySqlPool;
use super::organizations::{
generate_abstract_id, generate_auth_str,
CabinetDetail, CabinetRow, CabinBoardRow, CabinBoardWithCompartments,
CompartmentRow, CreateCabinetBody, UpdateCabinetBody,
};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
// ---------------------------------------------------------------------------
// 项目访问权限验证
// ---------------------------------------------------------------------------
/// 验证用户是否有权访问指定项目
///
/// 总管理员role_level >= 2可访问所有项目
/// 企业管理员只能访问本组织下的项目。
async fn check_project_access(
user: &CurrentUser,
pool: &MySqlPool,
project_id: i64,
) -> Result<(), AppError> {
// 总管理员可访问所有项目
if user.role_level >= 2 {
return Ok(());
}
if let Some(org_id) = user.organization_id {
// 验证项目是否属于用户的组织
let project: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM projects WHERE id = ? AND organization_id = ?",
)
.bind(project_id)
.bind(org_id)
.fetch_optional(pool)
.await?;
if project.is_none() {
return Err(AppError::Forbidden("无权访问该项目".into()));
}
} else {
return Err(AppError::Forbidden("无组织关联".into()));
}
Ok(())
}
// ---------------------------------------------------------------------------
// 设备 CRUD
// ---------------------------------------------------------------------------
/// GET /api/projects/:project_id/cabinets — 按项目查询柜子列表
pub async fn list_cabinets(
user: CurrentUser,
State(state): State<super::AppState>,
Path(project_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
check_project_access(&user, &state.mysql, project_id).await?;
let db = &state.mysql;
let rows = sqlx::query_as::<_, CabinetRow>(
r#"SELECT id, project_id, abstract_id, imei, name, status
FROM cabinets WHERE project_id = ? ORDER BY id"#,
)
.bind(project_id)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
}
/// POST /api/cabinets — 批量添加柜子
///
/// 每个 IMEI 创建一个柜子,同时生成默认仓控板和 6 个仓体。
pub async fn create_cabinet(
user: CurrentUser,
State(state): State<super::AppState>,
Json(body): Json<CreateCabinetBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:create")?;
let db = &state.mysql;
if body.imeis.is_empty() {
return Err(AppError::BadRequest("IMEI 列表不能为空".into()));
}
let mut unique_imeis: Vec<String> = Vec::new();
for imei in &body.imeis {
let trimmed = imei.trim().to_string();
if trimmed.len() != 15 {
return Err(AppError::BadRequest(
format!("IMEI 必须为15位数字: {}", trimmed),
));
}
if !unique_imeis.contains(&trimmed) {
unique_imeis.push(trimmed);
}
}
let mut created_ids: Vec<i64> = Vec::new();
for imei in &unique_imeis {
let abstract_id = generate_abstract_id(imei);
let auth_str = generate_auth_str();
let mut tx = db.begin().await.map_err(AppError::Database)?;
let result = sqlx::query(
r#"INSERT INTO cabinets (project_id, abstract_id, imei, auth_str)
VALUES (?, ?, ?, ?)"#,
)
.bind(body.project_id)
.bind(&abstract_id)
.bind(imei)
.bind(&auth_str)
.execute(&mut *tx)
.await?;
let cabinet_id = result.last_insert_id() as i64;
let board_result = sqlx::query(
r#"INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (?, 1)"#,
)
.bind(cabinet_id)
.execute(&mut *tx)
.await?;
let board_pk = board_result.last_insert_id() as i64;
for ch in 1..=6 {
sqlx::query(
r#"INSERT INTO compartments (cabin_board_id, channel_id) VALUES (?, ?)"#,
)
.bind(board_pk)
.bind(ch)
.execute(&mut *tx)
.await?;
}
tx.commit().await.map_err(AppError::Database)?;
created_ids.push(cabinet_id);
tracing::info!("创建柜子 id={} abstract_id={} imei={}", cabinet_id, abstract_id, imei);
}
Ok(Json(json!({ "ids": created_ids })))
}
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
pub async fn update_cabinet(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<UpdateCabinetBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
let db = &state.mysql;
let rows = sqlx::query(
r#"UPDATE cabinets SET
project_id = COALESCE(?, project_id),
name = COALESCE(?, name)
WHERE id = ?"#,
)
.bind(body.project_id)
.bind(&body.name)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("更新柜子 id={}", id);
Ok(Json(json!({})))
}
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
pub async fn delete_cabinet(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:delete")?;
let db = &state.mysql;
let mut tx = db.begin().await.map_err(AppError::Database)?;
sqlx::query(
r#"DELETE compartments FROM compartments
INNER JOIN cabin_boards ON compartments.cabin_board_id = cabin_boards.id
WHERE cabin_boards.cabinet_id = ?"#,
)
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query(r#"DELETE FROM cabin_boards WHERE cabinet_id = ?"#)
.bind(id)
.execute(&mut *tx)
.await?;
let rows = sqlx::query(r#"DELETE FROM cabinets WHERE id = ?"#)
.bind(id)
.execute(&mut *tx)
.await?
.rows_affected();
tx.commit().await.map_err(AppError::Database)?;
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("删除柜子 id={}", id);
Ok(Json(json!({})))
}
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
pub async fn regenerate_auth(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:edit")?;
let db = &state.mysql;
let auth_str = generate_auth_str();
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ? WHERE id = ?"#)
.bind(&auth_str)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
}
tracing::info!("重新生成 auth_str id={}", id);
Ok(Json(json!({ "auth_str": auth_str })))
}
// ---------------------------------------------------------------------------
// 柜子详情(含仓控板 + 仓体)
// ---------------------------------------------------------------------------
/// GET /api/cabinets/:id — 柜子详情,包含仓控板和仓体
pub async fn get_cabinet_detail(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<CabinetDetail>, AppError> {
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
let cabinet = sqlx::query_as::<_, CabinetRow>(
r#"SELECT id, project_id, abstract_id, imei, name, status
FROM cabinets WHERE id = ?"#,
)
.bind(id)
.fetch_optional(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("柜子 {} 不存在", id)))?;
// 验证用户是否有权访问该柜子所属的项目
if let Some(pid) = cabinet.project_id {
check_project_access(&user, db, pid).await?;
}
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)
.fetch_all(db)
.await?;
let mut cabin_boards = Vec::new();
for board in boards {
let compartments: Vec<CompartmentRow> = sqlx::query_as(
r#"SELECT id, cabin_board_id, channel_id, status
FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"#,
)
.bind(board.id)
.fetch_all(db)
.await?;
cabin_boards.push(CabinBoardWithCompartments {
id: board.id,
board_id: board.board_id,
status: board.status,
compartments,
});
}
Ok(Json(CabinetDetail {
id: cabinet.id,
project_id: cabinet.project_id,
abstract_id: cabinet.abstract_id,
imei: cabinet.imei,
name: cabinet.name,
status: cabinet.status,
cabin_boards,
}))
}
// ---------------------------------------------------------------------------
// 级联下拉选项
// ---------------------------------------------------------------------------
/// 柜子下拉查询参数
#[derive(Debug, Deserialize)]
pub struct CabinetListQuery {
pub project_id: Option<i64>,
}
/// 仓板下拉查询参数
#[derive(Debug, Deserialize)]
pub struct CabinBoardListQuery {
pub cabinet_id: i64,
}
/// 仓体下拉查询参数
#[derive(Debug, Deserialize)]
pub struct CompartmentListQuery {
pub cabin_board_id: i64,
}
/// 柜子下拉选项
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CabinetOption {
pub id: i64,
pub name: Option<String>,
pub abstract_id: String,
}
/// 仓板下拉选项
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CabinBoardOption {
pub id: i64,
pub board_id: i8,
}
/// 仓体下拉选项
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CompartmentOption {
pub id: i64,
pub channel_id: i8,
}
/// GET /api/cabinets/options — 柜子下拉列表
pub async fn list_cabinet_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let rows = if let Some(pid) = params.project_id {
sqlx::query_as::<_, CabinetOption>(
"SELECT id, name, abstract_id FROM cabinets WHERE project_id = ? ORDER BY id"
)
.bind(pid)
.fetch_all(&state.mysql)
.await?
} else {
sqlx::query_as::<_, CabinetOption>(
"SELECT id, name, abstract_id FROM cabinets ORDER BY id"
)
.fetch_all(&state.mysql)
.await?
};
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}
/// GET /api/cabin-boards/options — 仓板下拉列表
pub async fn list_cabin_board_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CabinBoardListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let rows = sqlx::query_as::<_, CabinBoardOption>(
"SELECT id, board_id FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"
)
.bind(params.cabinet_id)
.fetch_all(&state.mysql)
.await?;
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}
/// GET /api/compartments/options — 仓体下拉列表
pub async fn list_compartment_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CompartmentListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let rows = sqlx::query_as::<_, CompartmentOption>(
"SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"
)
.bind(params.cabin_board_id)
.fetch_all(&state.mysql)
.await?;
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
}

View File

@ -0,0 +1,397 @@
//! 充电记录 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};
/// 充电记录列表查询参数
#[derive(Debug, Deserialize)]
pub struct ChargeRecordQuery {
pub cabinet_id: Option<i64>,
pub cabin_board_id: Option<i64>,
pub compartment_id: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_page_size")]
pub page_size: i64,
}
/// 充电记录导出查询参数
#[derive(Debug, Deserialize)]
pub struct ChargeRecordExportQuery {
pub cabinet_id: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
/// 充电记录列表项
#[derive(Debug, Serialize)]
pub struct ChargeRecordItem {
pub id: i64,
pub cabinet_id: i64,
pub cabinet_name: Option<String>,
pub cabin_board_id: i64,
pub board_id: i8,
pub compartment_id: i64,
pub channel_id: i8,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub energy: Option<f64>,
pub max_temperature: Option<f64>,
pub status: i8,
pub status_text: String,
}
fn default_page() -> i64 { 1 }
fn default_page_size() -> i64 { 20 }
/// 状态码转中文描述
fn status_text(status: i8) -> &'static str {
match status {
0 => "正常",
1 => "异常",
2 => "手动停止",
_ => "未知",
}
}
/// 数据库行映射 — DECIMAL 列通过 CAST 转为 f64
#[derive(Debug, sqlx::FromRow)]
struct ChargeRecordRow {
id: i64,
cabinet_id: i64,
cabinet_name: Option<String>,
compartment_id: i64,
cabin_board_id: Option<i64>,
board_id: Option<i8>,
channel_id: Option<i8>,
start_time: Option<NaiveDateTime>,
end_time: Option<NaiveDateTime>,
energy: Option<f64>,
max_temperature: Option<f64>,
status: i8,
}
/// 构建 WHERE 条件子句及绑定值
fn build_where_clause(
cabinet_id: Option<i64>,
cabin_board_id: Option<i64>,
compartment_id: Option<i64>,
start_time: Option<&str>,
end_time: Option<&str>,
) -> (String, Vec<String>) {
let mut conditions = Vec::new();
let mut bind_values = Vec::new();
if let Some(id) = cabinet_id {
conditions.push("cr.cabinet_id = ?");
bind_values.push(id.to_string());
}
if let Some(id) = cabin_board_id {
conditions.push("cb.id = ?");
bind_values.push(id.to_string());
}
if let Some(id) = compartment_id {
conditions.push("cr.compartment_id = ?");
bind_values.push(id.to_string());
}
if let Some(t) = start_time {
conditions.push("cr.start_time >= ?");
bind_values.push(t.to_string());
}
if let Some(t) = end_time {
conditions.push("cr.start_time <= ?");
bind_values.push(t.to_string());
}
let clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(clause, bind_values)
}
/// 查询充电记录行(复用 SQL 模板)
async fn fetch_charge_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<ChargeRecordRow>, sqlx::Error> {
let limit_clause = match (limit, offset) {
(Some(l), Some(o)) => format!("LIMIT {} OFFSET {}", l, o),
(Some(l), None) => format!("LIMIT {}", l),
_ => String::new(),
};
let sql = format!(
"SELECT cr.id, cr.cabinet_id, c.name AS cabinet_name, \
cr.compartment_id, cb.id AS cabin_board_id, cb.board_id, comp.channel_id, \
cr.start_time, cr.end_time, \
CAST(cr.energy AS DOUBLE) AS energy, \
CAST(cr.max_temperature AS DOUBLE) AS max_temperature, \
cr.status \
FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{} ORDER BY cr.start_time DESC {}",
where_clause, limit_clause
);
let mut query = sqlx::query_as::<_, ChargeRecordRow>(&sql);
for val in bind_values {
query = query.bind(val);
}
query.fetch_all(pool).await
}
/// GET /api/charge-records — 充电记录分页列表
pub async fn list_charge_records(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<ChargeRecordQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:view")?;
let offset = (params.page - 1) * params.page_size;
let (mut where_clause, mut bind_values) = build_where_clause(
params.cabinet_id,
params.cabin_board_id,
params.compartment_id,
params.start_time.as_deref(),
params.end_time.as_deref(),
);
// 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "cr");
if !org_cond.is_empty() {
if where_clause.is_empty() {
where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
} else {
where_clause.push_str(&org_cond);
}
bind_values.extend(org_binds);
}
// 查询总数(需要与数据查询保持一致的 JOIN 链路)
let count_sql = format!(
"SELECT COUNT(*) FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(&state.mysql).await?;
// 查询列表
let rows = fetch_charge_rows(
&state.mysql,
&where_clause,
&bind_values,
Some(params.page_size),
Some(offset),
)
.await?;
let list: Vec<ChargeRecordItem> = rows
.into_iter()
.map(|r| ChargeRecordItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
cabin_board_id: r.cabin_board_id.unwrap_or(0),
board_id: r.board_id.unwrap_or(0),
compartment_id: r.compartment_id,
channel_id: r.channel_id.unwrap_or(0),
start_time: r.start_time.map(|t| t.to_string()),
end_time: r.end_time.map(|t| t.to_string()),
energy: r.energy,
max_temperature: r.max_temperature,
status: r.status,
status_text: status_text(r.status).to_string(),
})
.collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": list,
"total": total,
"page": params.page,
"page_size": params.page_size,
},
"message": "ok"
})))
}
/// GET /api/charge-records/export — 创建异步导出任务
pub async fn export_charge_records(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<ChargeRecordExportQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
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,
}))
.unwrap_or_default();
// 插入下载任务error_message 临时传递查询参数)
let result = sqlx::query(
"INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
VALUES (?, ?, 0, ?, ?)"
)
.bind(user_id)
.bind("charge_records")
.bind(&file_name)
.bind(&query_json)
.execute(&state.mysql)
.await?;
let task_id = result.last_insert_id() as i64;
// 启动后台异步生成 Excel
let pool = state.mysql.clone();
tokio::spawn(async move {
if let Err(e) = generate_charge_records_excel(pool, task_id).await {
tracing::error!("导出充电记录失败 task_id={}: {}", task_id, e);
}
});
Ok(Json(json!({
"code": 0,
"data": { "task_id": task_id },
"message": "导出任务已创建"
})))
}
/// 生成导出文件名充电记录_柜名_时间范围.xlsx
fn generate_export_filename(params: &ChargeRecordExportQuery) -> String {
let start = params
.start_time
.as_ref()
.map(|s| s.replace(['-', ':', ' '], ""))
.unwrap_or_default();
let end = params
.end_time
.as_ref()
.map(|s| s.replace(['-', ':', ' '], ""))
.unwrap_or_default();
let cabinet_part = params
.cabinet_id
.map(|id| format!("{}", id))
.unwrap_or_default();
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
}
/// 后台异步生成 Excel 文件
async fn generate_charge_records_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(&pool)
.await?;
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_where_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
None,
None,
params.get("start_time").and_then(|v| v.as_str()),
params.get("end_time").and_then(|v| v.as_str()),
);
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("充电记录")?;
let headers = [
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
"充电量(kWh)", "最高温度(°C)", "状态",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
}
for (row_idx, record) in rows.iter().enumerate() {
let row = (row_idx + 1) as u32;
sheet.write_number(row, 0, record.id as f64)?;
sheet.write_string(row, 1, record.cabinet_name.as_deref().unwrap_or("-"))?;
sheet.write_number(row, 2, record.board_id.unwrap_or(0) as f64)?;
sheet.write_number(row, 3, record.channel_id.unwrap_or(0) as f64)?;
sheet.write_string(
row, 4,
record.start_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
)?;
sheet.write_string(
row, 5,
record.end_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
)?;
sheet.write_number(row, 6, record.energy.unwrap_or(0.0))?;
sheet.write_number(row, 7, record.max_temperature.unwrap_or(0.0))?;
sheet.write_string(row, 8, status_text(record.status))?;
}
// 保存文件
let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?;
let file_path = format!("{}/{}", file_dir, file_name);
workbook.save(&file_path)?;
// 更新任务状态为完成
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
Ok(())
}

View File

@ -0,0 +1,157 @@
//! 设备日志 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};
/// 设备日志查询参数
#[derive(Debug, Deserialize)]
pub struct DeviceLogQuery {
pub cabinet_id: Option<i64>,
pub log_type: Option<i8>,
pub start_time: Option<String>,
pub end_time: Option<String>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_page_size")]
pub page_size: i64,
}
/// 设备日志列表项
#[derive(Debug, Serialize)]
pub struct DeviceLogItem {
pub id: i64,
pub cabinet_id: i64,
pub cabinet_name: Option<String>,
pub log_type: i8,
pub log_type_text: String,
pub content: Option<String>,
pub created_at: Option<String>,
}
fn default_page() -> i64 { 1 }
fn default_page_size() -> i64 { 20 }
/// 日志类型码转中文
fn log_type_text(log_type: i8) -> &'static str {
match log_type {
1 => "状态上报",
2 => "故障",
3 => "告警",
4 => "停电",
_ => "未知",
}
}
/// 数据库行映射
#[derive(Debug, sqlx::FromRow)]
struct DeviceLogRow {
id: i64,
cabinet_id: i64,
cabinet_name: Option<String>,
log_type: i8,
content: Option<String>,
created_at: Option<NaiveDateTime>,
}
/// GET /api/device-logs — 设备日志分页列表
pub async fn list_device_logs(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DeviceLogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device_log:view")?;
let offset = (params.page - 1) * params.page_size;
// 构建 WHERE 条件
let mut conditions = Vec::new();
let mut bind_values: Vec<String> = Vec::new();
if let Some(cabinet_id) = params.cabinet_id {
conditions.push("dl.cabinet_id = ?");
bind_values.push(cabinet_id.to_string());
}
if let Some(log_type) = params.log_type {
conditions.push("dl.log_type = ?");
bind_values.push(log_type.to_string());
}
if let Some(ref start_time) = params.start_time {
conditions.push("dl.created_at >= ?");
bind_values.push(start_time.clone());
}
if let Some(ref end_time) = params.end_time {
conditions.push("dl.created_at <= ?");
bind_values.push(end_time.clone());
}
// 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "dl");
if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND "));
bind_values.extend(org_binds);
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// 查询总数
let count_sql = format!(
"SELECT COUNT(*) FROM device_logs dl {}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(&state.mysql).await?;
// 查询列表
let list_sql = format!(
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
dl.log_type, dl.content, dl.created_at \
FROM device_logs dl \
LEFT JOIN cabinets c ON dl.cabinet_id = c.id \
{} ORDER BY dl.created_at DESC LIMIT ? OFFSET ?",
where_clause
);
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
for val in &bind_values {
list_query = list_query.bind(val);
}
list_query = list_query.bind(params.page_size).bind(offset);
let rows = list_query.fetch_all(&state.mysql).await?;
let list: Vec<DeviceLogItem> = rows
.into_iter()
.map(|r| DeviceLogItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
log_type: r.log_type,
log_type_text: log_type_text(r.log_type).to_string(),
content: r.content,
created_at: r.created_at.map(|t| t.to_string()),
})
.collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": list,
"total": total,
"page": params.page,
"page_size": params.page_size,
},
"message": "ok"
})))
}

View File

@ -0,0 +1,280 @@
//! 下载中心 API — 任务列表、下载链接、文件下载、过期清理
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::Response;
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};
/// 下载任务列表查询参数
#[derive(Debug, Deserialize)]
pub struct DownloadListQuery {
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_page_size")]
pub page_size: i64,
}
/// 清理请求参数
#[derive(Debug, Deserialize)]
pub struct CleanupRequest {
/// 保留天数默认7天
#[serde(default = "default_retain_days")]
pub retain_days: i32,
}
/// 下载任务列表项
#[derive(Debug, Serialize)]
pub struct DownloadTaskItem {
pub id: i64,
pub task_type: String,
pub status: i8,
pub status_text: String,
pub file_name: Option<String>,
pub progress: i32,
pub error_message: Option<String>,
pub created_at: Option<String>,
pub completed_at: Option<String>,
}
fn default_page() -> i64 { 1 }
fn default_page_size() -> i64 { 20 }
fn default_retain_days() -> i32 { 7 }
/// 状态码转文本
fn download_status_text(status: i8) -> &'static str {
match status {
0 => "排队中",
1 => "处理中",
2 => "已完成",
3 => "失败",
_ => "未知",
}
}
/// 数据库行映射
#[derive(Debug, sqlx::FromRow)]
struct DownloadTaskRow {
id: i64,
task_type: String,
status: i8,
file_name: Option<String>,
file_path: Option<String>,
progress: i32,
error_message: Option<String>,
created_at: Option<NaiveDateTime>,
completed_at: Option<NaiveDateTime>,
}
/// 将数据库行转为响应对象
fn row_to_item(row: DownloadTaskRow) -> DownloadTaskItem {
DownloadTaskItem {
id: row.id,
task_type: row.task_type,
status: row.status,
status_text: download_status_text(row.status).to_string(),
file_name: row.file_name,
progress: row.progress,
error_message: if row.status == 3 { row.error_message } else { None },
created_at: row.created_at.map(|t| t.to_string()),
completed_at: row.completed_at.map(|t| t.to_string()),
}
}
/// GET /api/downloads — 下载任务列表
pub async fn list_downloads(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DownloadListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let user_id = user.user_id;
let offset = (params.page - 1) * params.page_size;
// 查询总数
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM download_tasks WHERE user_id = ?"
)
.bind(user_id)
.fetch_one(&state.mysql)
.await?;
// 查询列表
let rows = sqlx::query_as::<_, DownloadTaskRow>(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE user_id = ? \
ORDER BY created_at DESC LIMIT ? OFFSET ?"
)
.bind(user_id)
.bind(params.page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?;
let list: Vec<DownloadTaskItem> = rows.into_iter().map(row_to_item).collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": list,
"total": total,
"page": params.page,
"page_size": params.page_size,
},
"message": "ok"
})))
}
/// GET /api/downloads/:id — 获取下载链接
pub async fn get_download(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let row: Option<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE id = ?"
)
.bind(id)
.fetch_optional(&state.mysql)
.await?;
let row = row.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
if row.status != 2 {
return Err(AppError::BadRequest("任务尚未完成".into()));
}
let file_path = row.file_path.unwrap_or_default();
// 检查文件是否存在
if !std::path::Path::new(&file_path).exists() {
return Err(AppError::NotFound("文件不存在或已清理".into()));
}
// 返回文件下载路径后续可改为签名URL
let download_url = format!("/api/downloads/{}/file", id);
Ok(Json(json!({
"code": 0,
"data": {
"id": row.id,
"file_name": row.file_name,
"download_url": download_url,
},
"message": "ok"
})))
}
/// GET /api/downloads/:id/file — 下载文件(二进制流)
pub async fn download_file(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Response<Body>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let db = &state.mysql;
// 查询下载任务(限定本人)
let task: Option<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE id = ? AND user_id = ?",
)
.bind(id)
.bind(user.user_id)
.fetch_optional(db)
.await?;
let task = task.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
if task.status != 2 {
return Err(AppError::BadRequest("文件未生成完成".into()));
}
let file_path = task
.file_path
.ok_or_else(|| AppError::NotFound("文件路径不存在".into()))?;
// 读取文件内容
let file_content = tokio::fs::read(&file_path).await.map_err(|e| {
tracing::error!("读取下载文件失败 path={} err={}", file_path, e);
AppError::Internal(format!("读取文件失败: {}", e))
})?;
// 推断文件名
let file_name = task.file_name.unwrap_or_else(|| "download.xlsx".to_string());
// 构建文件下载响应
let response = Response::builder()
.status(200)
.header(
header::CONTENT_TYPE,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", file_name),
)
.body(Body::from(file_content))
.map_err(|e| AppError::Internal(format!("构建响应失败: {}", e)))?;
Ok(response)
}
/// POST /api/downloads/cleanup — 清理过期文件
pub async fn cleanup_downloads(
user: CurrentUser,
State(state): State<super::AppState>,
Json(req): Json<CleanupRequest>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
// 查询过期任务(状态为完成且超过保留天数)
let expired: Vec<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks \
WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
)
.bind(req.retain_days)
.fetch_all(&state.mysql)
.await?;
let mut cleaned = 0u32;
for task in &expired {
// 删除物理文件
if let Some(ref path) = task.file_path {
let _ = tokio::fs::remove_file(path).await;
}
// 更新数据库记录
let _ = sqlx::query(
"UPDATE download_tasks SET file_path = NULL, status = 3, \
error_message = '' WHERE id = ?"
)
.bind(task.id)
.execute(&state.mysql)
.await;
cleaned += 1;
}
tracing::info!("清理过期下载文件 {} 个", cleaned);
Ok(Json(json!({
"code": 0,
"data": { "cleaned": cleaned },
"message": format!("已清理 {} 个过期文件", cleaned)
})))
}

View File

@ -0,0 +1,553 @@
//! 能耗管理 API — 总览、排行、趋势、导出
//!
//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。
//! 电费估算按 ¥0.80/kWh 计算(可配置)。
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 电费单价(元/kWh
const COST_PER_KWH: f64 = 0.80;
/// 趋势查询参数
#[derive(Debug, Deserialize)]
pub struct TrendQuery {
/// day | week | month
#[serde(default = "default_granularity")]
pub granularity: String,
/// 天数范围默认30天
#[serde(default = "default_range")]
pub range: i64,
}
/// 导出查询参数
#[derive(Debug, Deserialize)]
pub struct ExportQuery {
pub cabinet_id: Option<i64>,
pub project_id: Option<i64>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
/// 柜子排行行
#[derive(Debug, sqlx::FromRow)]
struct CabinetRankRow {
id: i64,
abstract_id: String,
name: Option<String>,
today_energy: Option<f64>,
month_energy: Option<f64>,
}
/// 项目统计行
#[derive(Debug, sqlx::FromRow)]
struct ProjectStatRow {
id: i64,
name: String,
cabinet_count: i64,
today_energy: Option<f64>,
month_energy: Option<f64>,
}
/// 趋势数据行
#[derive(Debug, sqlx::FromRow)]
struct TrendRow {
period: String, // 日期或周/月标识
energy: Option<f64>,
charge_count: Option<i64>,
}
/// 能耗明细行(用于导出)
#[derive(Debug, sqlx::FromRow)]
struct EnergyDetailRow {
stat_date: chrono::NaiveDate,
cabinet_id: i64,
cabinet_name: Option<String>,
project_name: Option<String>,
total_energy: Option<f64>,
charge_count: Option<i64>,
}
fn default_granularity() -> String { "day".to_string() }
fn default_range() -> i64 { 30 }
/// GET /api/energy-stats/summary — 总览
///
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
pub async fn get_summary(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
// 组织过滤子查询
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(),
vec![oid.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
};
// 今日总能耗
let today_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es WHERE stat_date = CURDATE(){}",
org_filter
);
let mut today_q = sqlx::query_scalar::<_, f64>(&today_sql);
for v in &org_binds { today_q = today_q.bind(v); }
let today_energy: f64 = today_q.fetch_one(pool).await?;
// 本月总能耗
let month_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}",
org_filter
);
let mut month_q = sqlx::query_scalar::<_, f64>(&month_sql);
for v in &org_binds { month_q = month_q.bind(v); }
let month_energy: f64 = month_q.fetch_one(pool).await?;
// 柜子总数(含组织过滤)
let cabinet_count: i64 = if user.role_level >= 2 {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets")
.fetch_one(pool).await?
} else if let Some(oid) = user.organization_id {
sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"
)
.bind(oid)
.fetch_one(pool).await?
} else {
0
};
// 在线柜子数
let online_count: i64 = if user.role_level >= 2 {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
.fetch_one(pool).await?
} else if let Some(oid) = user.organization_id {
sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE c.status = 1 AND p.organization_id = ?"
)
.bind(oid)
.fetch_one(pool).await?
} else {
0
};
let today = today_energy;
let month = month_energy;
Ok(Json(json!({
"code": 0,
"data": {
"today_energy": (today * 100.0).round() / 100.0,
"month_energy": (month * 100.0).round() / 100.0,
"estimated_cost": ((month * COST_PER_KWH) * 100.0).round() / 100.0,
"cabinet_count": cabinet_count,
"online_count": online_count,
},
"message": "ok"
})))
}
/// GET /api/energy-stats/cabinets — 柜子能耗排行
///
/// 按今日能耗降序排列,返回柜子列表及今日/本月能耗。
pub async fn get_cabinet_ranking(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
let (org_join, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" JOIN projects p ON c.project_id = p.id AND p.organization_id = ?".to_string(),
vec![oid.to_string()],
)
} else {
(" JOIN projects p ON 1=0".to_string(), vec![])
};
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 \
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') \
{} \
ORDER BY today_energy DESC, c.id ASC",
org_join
);
let mut query = sqlx::query_as::<_, CabinetRankRow>(&sql);
for v in &org_binds { query = query.bind(v); }
let rows = query.fetch_all(pool).await?;
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"cabinet_id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// GET /api/energy-stats/projects — 项目能耗统计
///
/// 按今日能耗降序排列,返回项目级聚合数据。
pub async fn get_project_stats(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(" WHERE p.organization_id = ?".to_string(), vec![oid.to_string()])
} else {
(" WHERE 1=0".to_string(), vec![])
};
let sql = format!(
"SELECT p.id, p.name, \
COUNT(DISTINCT c.id) AS cabinet_count, \
COALESCE(SUM(es_today.total_energy), 0) AS today_energy, \
COALESCE(SUM(es_month.total_energy), 0) 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') \
{} \
GROUP BY p.id, p.name \
ORDER BY today_energy DESC, p.id ASC",
org_filter
);
let mut query = sqlx::query_as::<_, ProjectStatRow>(&sql);
for v in &org_binds { query = query.bind(v); }
let rows = query.fetch_all(pool).await?;
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"project_id": r.id,
"name": r.name,
"cabinet_count": r.cabinet_count,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// GET /api/energy-stats/trend — 趋势数据
///
/// granularity: day | week | month, 默认 day
/// range: 天数范围, 默认 30
pub async fn get_trend(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<TrendQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
// 组织过滤es.cabinet_id → cabinets.project_id → projects.organization_id
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(),
vec![oid.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
};
let rows = match params.granularity.as_str() {
"week" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
"month" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
_ => {
// day
let sql = format!(
"SELECT stat_date AS period, \
total_energy AS energy, charge_count AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
ORDER BY stat_date ASC",
org_filter
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
for v in &org_binds { q = q.bind(v); }
q.fetch_all(pool).await?
}
};
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"period": r.period,
"energy": (r.energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"charge_count": r.charge_count.unwrap_or(0),
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": {
"granularity": params.granularity,
"list": list,
},
"message": "ok"
})))
}
/// GET /api/energy-stats/export — 异步导出能耗报表
pub async fn export_energy_stats(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<ExportQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:export")?;
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,
}))
.unwrap_or_default();
// 插入下载任务
let result = sqlx::query(
"INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
VALUES (?, ?, 0, ?, ?)"
)
.bind(user_id)
.bind("energy_stats")
.bind(&file_name)
.bind(&query_json)
.execute(&state.mysql)
.await?;
let task_id = result.last_insert_id() as i64;
// 后台异步生成 Excel
let pool = state.mysql.clone();
tokio::spawn(async move {
if let Err(e) = generate_energy_excel(pool, task_id).await {
tracing::error!("导出能耗统计失败 task_id={}: {}", task_id, e);
}
});
Ok(Json(json!({
"code": 0,
"data": { "task_id": task_id },
"message": "导出任务已创建"
})))
}
/// 生成导出文件名
fn generate_export_filename(params: &ExportQuery) -> String {
let start = params
.start_date
.as_ref()
.map(|s| s.replace('-', ""))
.unwrap_or_default();
let end = params
.end_date
.as_ref()
.map(|s| s.replace('-', ""))
.unwrap_or_default();
format!("能耗统计_{}-{}.xlsx", start, end)
}
/// 构建导出条件
fn build_export_clause(
cabinet_id: Option<i64>,
project_id: Option<i64>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> (String, Vec<String>) {
let mut conditions = Vec::new();
let mut bind_values = Vec::new();
if let Some(id) = cabinet_id {
conditions.push("es.cabinet_id = ?");
bind_values.push(id.to_string());
}
if let Some(id) = project_id {
conditions.push("c.project_id = ?");
bind_values.push(id.to_string());
}
if let Some(d) = start_date {
conditions.push("es.stat_date >= ?");
bind_values.push(d.to_string());
}
if let Some(d) = end_date {
conditions.push("es.stat_date <= ?");
bind_values.push(d.to_string());
}
let clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(clause, bind_values)
}
/// 后台异步生成能耗 Excel 文件
async fn generate_energy_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(&pool)
.await?;
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(
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 sql = format!(
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
p.name AS project_name, \
CAST(es.total_energy AS DOUBLE) AS total_energy, \
es.charge_count \
FROM energy_stats es \
LEFT JOIN cabinets c ON es.cabinet_id = c.id \
LEFT JOIN projects p ON c.project_id = p.id \
{} ORDER BY es.stat_date DESC, es.cabinet_id ASC",
where_clause
);
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
for val in &bind_values {
query = query.bind(val);
}
let rows = query.fetch_all(&pool).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("能耗统计")?;
let headers = [
"日期", "柜子ID", "柜子名称", "所属项目", "能耗(kWh)", "充电次数",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
}
for (row_idx, record) in rows.iter().enumerate() {
let row = (row_idx + 1) as u32;
sheet.write_string(row, 0, record.stat_date.to_string())?;
sheet.write_number(row, 1, record.cabinet_id as f64)?;
sheet.write_string(row, 2, record.cabinet_name.as_deref().unwrap_or("-"))?;
sheet.write_string(row, 3, record.project_name.as_deref().unwrap_or("-"))?;
sheet.write_number(row, 4, record.total_energy.unwrap_or(0.0))?;
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
}
// 保存文件
let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?;
let file_path = format!("{}/{}", file_dir, file_name);
workbook.save(&file_path)?;
// 更新任务状态为完成
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
Ok(())
}

View File

@ -0,0 +1,989 @@
//! H5 用户端 API
//!
//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
//! - POST /api/h5/auth/login — H5 手机号登录
//! - GET /api/h5/dashboard — 首页仪表盘数据
//! - GET /api/h5/projects — 项目列表
//! - GET /api/h5/cabinets — 设备列表(按项目过滤)
//! - GET /api/h5/cabinets/:id — 设备详情(含仓板+仓体状态)
//! - GET /api/h5/cabinets/abstract/:aid — 通过抽象ID查设备扫码入口
//! - GET /api/h5/compartments/:id — 仓体/通道详情
//! - POST /api/h5/charge/start — 开始充电
//! - POST /api/h5/charge/stop — 停止充电
//! - POST /api/h5/door/open — 开门
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
use crate::tcp::protocol::DeviceCommand;
// ---------------------------------------------------------------------------
// 请求体定义
// ---------------------------------------------------------------------------
/// H5 登录请求体
#[derive(Debug, Deserialize)]
pub(crate) struct H5LoginInput {
phone: String,
password: String,
}
/// 充电控制请求体(开始/停止充电、开门共用)
#[derive(Debug, Deserialize)]
pub(crate) struct CompartmentActionInput {
compartment_id: i64,
}
/// 设备列表查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct CabinetListQuery {
project_id: Option<i64>,
}
// ---------------------------------------------------------------------------
// 行映射结构体
// ---------------------------------------------------------------------------
/// 项目简要信息行
#[derive(Debug, sqlx::FromRow)]
struct ProjectRow {
id: i64,
name: String,
}
/// 柜子列表行
#[derive(Debug, sqlx::FromRow)]
struct H5CabinetRow {
id: i64,
abstract_id: String,
name: Option<String>,
status: i8,
project_id: Option<i64>,
}
/// 仓控板行
#[derive(Debug, sqlx::FromRow)]
struct H5CabinBoardRow {
id: i64,
board_id: i8,
status: i8,
}
/// 仓体行
#[derive(Debug, sqlx::FromRow)]
struct H5CompartmentRow {
id: i64,
channel_id: i8,
status: i8,
}
/// 充电记录简要行
#[derive(Debug, sqlx::FromRow)]
struct ChargeRecordBriefRow {
id: i64,
start_time: Option<chrono::NaiveDateTime>,
end_time: Option<chrono::NaiveDateTime>,
energy: Option<f64>,
}
/// 柜子 IMEI 查询行(用于指令下发)
#[derive(Debug, sqlx::FromRow)]
struct CabinetImeiRow {
imei: String,
}
// ---------------------------------------------------------------------------
// H5 登录
// ---------------------------------------------------------------------------
/// POST /api/h5/auth/login — H5 用户登录(复用 users 表)
pub async fn h5_login(
State(state): State<AppState>,
Json(input): Json<H5LoginInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
if input.phone.is_empty() || input.password.is_empty() {
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
}
// 查询用户
let user_row: Option<(i64, Option<String>, String, String, i32)> = sqlx::query_as(
"SELECT id, name, phone, password, status FROM users WHERE phone = ?",
)
.bind(&input.phone)
.fetch_optional(pool)
.await?;
let (id, name, phone, stored_password, status) =
user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
if status == 0 {
return Err(AppError::Forbidden("账户已被禁用".into()));
}
let valid = auth::verify_password(&input.password, &stored_password)
.map_err(|_| AppError::Internal("密码验证失败".into()))?;
if !valid {
return Err(AppError::Unauthorized("手机号或密码错误".into()));
}
let token = auth::generate_token(id, &phone)?;
Ok(Json(json!({
"code": 0,
"data": {
"token": token,
"user": {
"id": id,
"phone": phone,
"name": name,
}
},
"message": "登录成功"
})))
}
// ---------------------------------------------------------------------------
// 仪表盘
// ---------------------------------------------------------------------------
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离)
pub async fn get_dashboard(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 根据角色确定组织过滤条件
let org_id = user.organization_id;
let is_super = user.role_level >= 2;
// 统计在线柜子数(带组织隔离)
let online_cabinets = count_online_cabinets(pool, is_super, org_id).await?;
// 统计充电中仓体数(带组织隔离)
let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?;
// 统计空闲仓体数(带组织隔离)
let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?;
// 统计故障仓体数(带组织隔离)
let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?;
// 今日充电次数(带组织隔离)
let today_charge_count = count_today_charges(pool, is_super, org_id).await?;
// 今日充电量kWh带组织隔离
let today_energy = sum_today_energy(pool, is_super, org_id).await?;
// 今日充电时长(小时,带组织隔离)
let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?;
// 故障告警取最近10条故障仓体返回项目名+柜名)
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
// 项目摘要
let projects = fetch_project_summaries(pool, &user).await?;
Ok(Json(json!({
"code": 0,
"data": {
"online_cabinets": online_cabinets,
"charging_count": charging_count,
"idle_channels": idle_channels,
"fault_channels": fault_channels,
"today_charge_count": today_charge_count,
"today_energy_kwh": today_energy,
"today_charge_hours": today_charge_hours,
"alerts": alerts,
"projects": projects,
},
"message": "ok"
})))
}
/// 统计在线柜子数(带组织隔离)
async fn count_online_cabinets(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.status = 1 AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 按状态统计仓体数(带组织隔离,通过 cabin_boards → cabinets → projects 链路)
async fn count_compartments_by_status(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
status: i8,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar("SELECT COUNT(*) FROM compartments WHERE status = ?")
.bind(status)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
INNER JOIN cabinets cab ON cb.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE comp.status = ? AND p.organization_id = ?"#,
)
.bind(status)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电次数(带组织隔离)
async fn count_today_charges(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<i64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0);
}
let count: i64 = if is_super {
sqlx::query_scalar(
"SELECT COUNT(*) FROM charge_records WHERE DATE(start_time) = CURDATE()",
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电量kWh带组织隔离
async fn sum_today_energy(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<f64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0.0);
}
let energy: Option<f64> = if is_super {
sqlx::query_scalar(
"SELECT CAST(SUM(energy) AS DOUBLE) FROM charge_records WHERE DATE(start_time) = CURDATE()",
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT CAST(SUM(cr.energy) AS DOUBLE) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(energy.unwrap_or(0.0))
}
/// 今日充电时长(小时,带组织隔离)
async fn sum_today_charge_hours(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<f64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0.0);
}
// 计算总秒数(未结束的记录用 NOW() 代替 end_time
let total_seconds: f64 = if is_super {
sqlx::query_scalar(
r#"SELECT COALESCE(SUM(
TIMESTAMPDIFF(SECOND, start_time, COALESCE(end_time, NOW()))
), 0) FROM charge_records
WHERE DATE(start_time) = CURDATE()"#,
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COALESCE(SUM(
TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW()))
), 0) FROM charge_records cr
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
// 秒转小时保留1位小数
Ok((total_seconds / 3600.0 * 10.0).round() / 10.0)
}
/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离)
async fn fetch_fault_alerts(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<Vec<Value>, AppError> {
if !is_super && org_id.is_none() {
return Ok(vec![]);
}
let base_sql = if is_super {
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
comp.channel_id
FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
LEFT JOIN projects p ON cab.project_id = p.id
WHERE comp.status = 2
ORDER BY comp.updated_at DESC
LIMIT 10"#
} else {
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
comp.channel_id
FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
INNER JOIN projects p ON cab.project_id = p.id
WHERE comp.status = 2 AND p.organization_id = ?
ORDER BY comp.updated_at DESC
LIMIT 10"#
};
let rows: Vec<(String, String, i8)> = if is_super {
sqlx::query_as(base_sql).fetch_all(pool).await?
} else {
sqlx::query_as(base_sql)
.bind(org_id.unwrap())
.fetch_all(pool)
.await?
};
Ok(rows
.into_iter()
.map(|(project, cabinet, channel)| {
json!({
"project": project,
"cabinet": cabinet,
"channel": channel,
"alert_type": "fault",
})
})
.collect())
}
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
async fn fetch_project_summaries(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
) -> Result<Vec<Value>, AppError> {
// 获取用户可见的项目
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
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",
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let mut result = Vec::with_capacity(projects.len());
for p in &projects {
let cabinet_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets WHERE project_id = ?",
)
.bind(p.id)
.fetch_one(pool)
.await?;
let charging: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 1"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
let idle: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 0"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
let fault: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 2"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
result.push(json!({
"id": p.id,
"name": p.name,
"cabinet_count": cabinet_count,
"charging": charging,
"idle": idle,
"fault": fault,
}));
}
Ok(result)
}
// ---------------------------------------------------------------------------
// 项目列表
// ---------------------------------------------------------------------------
/// GET /api/h5/projects — H5 项目列表(简要)
pub async fn get_projects(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
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",
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let list: Vec<Value> = projects
.into_iter()
.map(|p| json!({ "id": p.id, "name": p.name }))
.collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备列表
// ---------------------------------------------------------------------------
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
pub async fn get_cabinets(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
let rows: Vec<H5CabinetRow> = if let Some(pid) = query.project_id {
// 按项目过滤(同时验证组织权限)
if user.role_level >= 2 {
sqlx::query_as::<_, H5CabinetRow>(
"SELECT id, abstract_id, name, status, project_id FROM cabinets WHERE project_id = ?",
)
.bind(pid)
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT id, abstract_id, name, status, project_id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.project_id = ? AND p.organization_id = ?"#,
)
.bind(pid)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
}
} else if user.role_level >= 2 {
// 总管理员看全部
sqlx::query_as::<_, H5CabinetRow>(
"SELECT id, abstract_id, name, status, project_id FROM cabinets",
)
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT id, abstract_id, name, status, project_id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE p.organization_id = ?"#,
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"status": r.status,
"project_id": r.project_id,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备详情
// ---------------------------------------------------------------------------
/// GET /api/h5/cabinets/:id — H5 设备详情(含仓控板+仓体,带权限校验)
pub async fn get_cabinet_detail(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
// 组织隔离:验证用户是否有权访问该设备
verify_cabinet_access(&user, id, pool).await?;
// 查询柜子基本信息
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
let (cab_id, abstract_id, imei, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 查询仓控板
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",
)
.bind(cab_id)
.fetch_all(pool)
.await?;
// 查询每个仓控板下的仓体
let mut cabin_boards = Vec::with_capacity(boards.len());
for board in &boards {
let comps: Vec<H5CompartmentRow> = sqlx::query_as::<_, H5CompartmentRow>(
"SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id",
)
.bind(board.id)
.fetch_all(pool)
.await?;
let compartments: Vec<Value> = comps
.into_iter()
.map(|c| {
json!({
"id": c.id,
"channel_id": c.channel_id,
"status": c.status,
})
})
.collect();
cabin_boards.push(json!({
"id": board.id,
"board_id": board.board_id,
"status": board.status,
"compartments": compartments,
}));
}
Ok(Json(json!({
"code": 0,
"data": {
"id": cab_id,
"abstract_id": abstract_id,
"imei": imei,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
/// GET /api/h5/cabinets/abstract/:abstract_id — 通过抽象ID查设备扫码入口带权限校验
pub async fn get_cabinet_by_abstract_id(
user: CurrentUser,
State(state): State<AppState>,
Path(abstract_id): Path<String>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
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, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 组织隔离:验证用户是否有权访问该设备
verify_cabinet_access(&user, cab_id, pool).await?;
// 查询仓控板+仓体
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",
)
.bind(cab_id)
.fetch_all(pool)
.await?;
let mut cabin_boards = Vec::with_capacity(boards.len());
for board in &boards {
let comps: Vec<H5CompartmentRow> = sqlx::query_as::<_, H5CompartmentRow>(
"SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id",
)
.bind(board.id)
.fetch_all(pool)
.await?;
let compartments: Vec<Value> = comps
.into_iter()
.map(|c| json!({ "id": c.id, "channel_id": c.channel_id, "status": c.status }))
.collect();
cabin_boards.push(json!({
"id": board.id,
"board_id": board.board_id,
"status": board.status,
"compartments": compartments,
}));
}
Ok(Json(json!({
"code": 0,
"data": {
"id": cab_id,
"abstract_id": abs_id,
"imei": imei,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 仓体/通道详情
// ---------------------------------------------------------------------------
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
pub async fn get_compartment_detail(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
// 查询仓体基本信息
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
let (comp_id, channel_id, status, cabin_board_id) =
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
let cabinet_id: Option<(i64,)> = sqlx::query_as(
"SELECT cabinet_id FROM cabin_boards WHERE id = ?",
)
.bind(cabin_board_id)
.fetch_optional(pool)
.await?;
if let Some((cab_id,)) = cabinet_id {
verify_cabinet_access(&user, cab_id, pool).await?;
}
// 从 Redis 获取实时数据
let redis_key = format!("compartment:status:{}", comp_id);
let mut conn = state.redis.clone();
let realtime_data: Option<String> = redis::cmd("GET")
.arg(&redis_key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let realtime: Value = realtime_data
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(json!({}));
// 查询最近充电记录
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
r#"SELECT id, start_time, end_time, CAST(energy AS DOUBLE) AS energy
FROM charge_records
WHERE compartment_id = ?
ORDER BY start_time DESC LIMIT 10"#,
)
.bind(comp_id)
.fetch_all(pool)
.await?;
let charge_records: Vec<Value> = records
.into_iter()
.map(|r| {
json!({
"id": r.id,
"start_time": r.start_time.map(|t| t.to_string()),
"end_time": r.end_time.map(|t| t.to_string()),
"energy_kwh": r.energy.unwrap_or(0.0),
"cost": 0,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": {
"id": comp_id,
"channel_id": channel_id,
"status": status,
"cabin_board_id": cabin_board_id,
"voltage": realtime.get("voltage").and_then(|v| v.as_f64()).unwrap_or(0.0),
"current": realtime.get("current").and_then(|v| v.as_f64()).unwrap_or(0.0),
"power": realtime.get("power").and_then(|v| v.as_f64()).unwrap_or(0.0),
"energy": realtime.get("energy").and_then(|v| v.as_f64()).unwrap_or(0.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!([])),
"charge_records": charge_records,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 充电控制
// ---------------------------------------------------------------------------
/// POST /api/h5/charge/start — 开始充电
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
}
/// POST /api/h5/charge/stop — 停止充电
pub async fn stop_charge(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:stop")?;
send_compartment_command(&state, body.compartment_id, "off").await
}
/// POST /api/h5/door/open — 开门
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
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
/// 根据仓体ID查找所属柜子的IMEI并下发指令
async fn send_compartment_command(
state: &AppState,
compartment_id: i64,
action: &str,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 通过 compartment → cabin_board → cabinet 链路查找 IMEI 和 sub_device_id
let row: Option<CabinetImeiRow> = sqlx::query_as(
r#"SELECT cab.imei 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(compartment_id)
.fetch_optional(pool)
.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 = ?"#,
)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
let sub_device_id = sub_row.map(|(bid,)| bid as u8);
// 检查设备在线
if !state.device_pool.is_online(imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 构建指令
let cmd = DeviceCommand {
act: action.to_string(),
msg_id: 0,
dev_id: Some(imei.clone()),
sub_device_id,
params: Some(json!({})),
};
let json_str = cmd.to_json();
state
.device_pool
.send_command(imei, &json_str)
.await
.map_err(|e| AppError::Internal(format!("指令下发失败: {}", e)))?;
tracing::info!(
"[h5] 指令下发 imei={} action={} compartment_id={}",
imei, action, compartment_id
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "指令已下发"
})))
}
/// 验证用户是否有权访问指定柜子(组织数据隔离)
///
/// 总管理员role_level >= 2可访问所有柜子
/// 企业管理员只能访问本组织下项目关联的柜子;
/// 无组织关联的用户无权访问任何柜子。
async fn verify_cabinet_access(
user: &CurrentUser,
cabinet_id: i64,
pool: &sqlx::MySqlPool,
) -> Result<(), AppError> {
// 总管理员无需校验
if user.role_level >= 2 {
return Ok(());
}
let org_id = user
.organization_id
.ok_or_else(|| AppError::Forbidden("无组织关联,无权访问设备".into()))?;
// 通过 cabinet → project → organization 链路验证归属
let access: Option<(i64,)> = sqlx::query_as(
r#"SELECT cab.id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.id = ? AND p.organization_id = ?"#,
)
.bind(cabinet_id)
.bind(org_id)
.fetch_optional(pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
Ok(())
}

View File

@ -0,0 +1,11 @@
//! 健康检查接口
use axum::Json;
use serde_json::{json, Value};
/// GET /api/health — 服务健康检查
pub async fn health_check() -> Json<Value> {
Json(json!({
"status": "ok"
}))
}

View File

@ -0,0 +1,107 @@
//! 路由注册 — 聚合所有子路由
//!
//! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。
//! 权限校验在各路由处理器内部通过 `check_permission` 完成。
use axum::middleware;
use axum::routing::{get, post, put};
use axum::Router;
use crate::middleware::auth::auth_middleware;
use crate::tcp::commands::{self, AppState};
mod auth;
mod cabinets;
mod cabinet_tree;
mod charge_records;
mod device_logs;
mod downloads;
mod energy_stats;
mod h5;
mod health;
mod operation_logs;
mod organizations;
mod projects;
mod roles;
mod users;
mod users_perm;
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
pub fn build(state: AppState) -> Router {
let protected = Router::<AppState>::new()
// 认证相关
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
// 设备指令下发
.route("/api/devices/:dev_id/command", post(commands::send_command))
.route("/api/devices/stats", get(commands::device_stats))
// 角色管理
.route("/api/roles", get(roles::list_roles).post(roles::create_role))
.route("/api/roles/:id", put(roles::update_role).delete(roles::delete_role))
.route("/api/roles/:id/permissions", put(roles::set_role_permissions))
// 权限码列表
.route("/api/permissions", get(roles::list_permissions))
// 用户权限管理
.route("/api/users/:id/permissions", get(users_perm::get_user_permissions))
.route("/api/users/:id/roles", put(users_perm::set_user_roles))
.route("/api/users/:id/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes))
// 组织管理
.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/:org_id/projects", get(projects::list_projects).post(projects::create_project))
.route("/api/projects/:id", put(projects::update_project).delete(projects::delete_project))
// 设备管理
.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/regenerate-auth", post(cabinets::regenerate_auth))
// 组织树
.route("/api/organization-tree", get(cabinet_tree::organization_tree))
// 充电记录
.route("/api/charge-records", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_charge_records))
// 设备日志
.route("/api/device-logs", get(device_logs::list_device_logs))
// 下载中心
.route("/api/downloads", get(downloads::list_downloads))
.route("/api/downloads/:id", get(downloads::get_download))
.route("/api/downloads/:id/file", get(downloads::download_file))
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
// 设备级联下拉
.route("/api/cabinets/options", get(cabinets::list_cabinet_options))
.route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options))
.route("/api/compartments/options", get(cabinets::list_compartment_options))
// 能耗管理
.route("/api/energy-stats/summary", get(energy_stats::get_summary))
.route("/api/energy-stats/cabinets", get(energy_stats::get_cabinet_ranking))
.route("/api/energy-stats/projects", get(energy_stats::get_project_stats))
.route("/api/energy-stats/trend", get(energy_stats::get_trend))
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
// 用户管理
.route("/api/users", get(users::list_users).post(users::create_user))
.route("/api/users/:id", put(users::update_user).delete(users::delete_user))
.route("/api/users/:id/reset-password", put(users::reset_password))
.route("/api/users/:id/status", put(users::set_user_status))
// 操作日志
.route("/api/operation-logs", get(operation_logs::list_operation_logs))
// H5 用户端接口(需认证)
.route("/api/h5/dashboard", get(h5::get_dashboard))
.route("/api/h5/projects", get(h5::get_projects))
.route("/api/h5/cabinets", get(h5::get_cabinets))
.route("/api/h5/cabinets/abstract/:abstract_id", get(h5::get_cabinet_by_abstract_id))
.route("/api/h5/cabinets/:id", get(h5::get_cabinet_detail))
.route("/api/h5/compartments/:id", get(h5::get_compartment_detail))
.route("/api/h5/charge/start", post(h5::start_charge))
.route("/api/h5/charge/stop", post(h5::stop_charge))
.route("/api/h5/door/open", post(h5::open_door))
// 认证中间件层
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
Router::<AppState>::new()
.route("/api/health", get(health::health_check))
.route("/api/auth/login", post(auth::login))
.route("/api/h5/auth/login", post(h5::h5_login))
.merge(protected)
.with_state(state)
}

View File

@ -0,0 +1,144 @@
//! 操作日志 API
//!
//! 提供操作日志查询接口,支持按用户、操作类型、时间范围过滤和分页:
//! - GET /api/operation-logs — 操作日志列表
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
/// 操作日志查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct LogQuery {
#[allow(dead_code)]
user_id: Option<i64>,
action: Option<String>,
start_time: Option<String>,
end_time: Option<String>,
page: Option<u32>,
page_size: Option<u32>,
}
/// GET /api/operation-logs — 操作日志列表(分页)
///
/// 支持 user_id / action / start_time / end_time 过滤。
pub async fn list_operation_logs(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<LogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
let mut conditions: Vec<String> = Vec::new();
let mut bind_values: Vec<String> = Vec::new();
if let Some(uid) = query.user_id {
conditions.push("o.user_id = ?".to_string());
bind_values.push(uid.to_string());
}
if let Some(ref act) = query.action
&& !act.is_empty()
{
conditions.push("o.action = ?".to_string());
bind_values.push(act.clone());
}
if let Some(ref t) = query.start_time
&& !t.is_empty()
{
conditions.push("o.created_at >= ?".to_string());
bind_values.push(t.clone());
}
if let Some(ref t) = query.end_time
&& !t.is_empty()
{
conditions.push("o.created_at <= ?".to_string());
bind_values.push(t.clone());
}
// 追加组织过滤
let (org_cond, org_binds) = auth::org_condition_for_logs(&user, "o");
if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND ").to_string());
bind_values.extend(org_binds);
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// 查询总数
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(pool).await?;
// 查询列表(关联用户姓名)
let list_sql = format!(
r#"SELECT o.id, o.user_id, u.name AS user_name, o.action, o.target_type, o.target_id, o.detail, o.created_at
FROM operation_logs o
LEFT JOIN users u ON u.id = o.user_id
{}
ORDER BY o.id DESC
LIMIT ? OFFSET ?"#,
where_clause
);
#[derive(sqlx::FromRow)]
struct LogRow {
id: i64,
user_id: i64,
user_name: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<i64>,
detail: Option<String>,
created_at: String,
}
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
for val in &bind_values {
list_query = list_query.bind(val);
}
let rows = list_query
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?;
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"user_id": r.user_id,
"user_name": r.user_name.unwrap_or_else(|| format!("#{}", r.user_id)),
"action": r.action,
"target_type": r.target_type,
"target_id": r.target_id,
"detail": r.detail,
"created_at": r.created_at,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": { "list": list, "total": total },
"message": "ok"
})))
}

View File

@ -0,0 +1,255 @@
//! 组织 CRUD API + 共享类型
//!
//! 提供组织的增删改查,以及跨模块共享的请求体/行结构体。
//! 项目、设备、树状结构分别拆分到 projects / cabinets / cabinet_tree 模块。
use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
// ---------------------------------------------------------------------------
// 共享请求体
// ---------------------------------------------------------------------------
/// 通用名称请求体(组织/项目共用)
#[derive(Debug, Deserialize)]
pub struct NameBody {
pub name: String,
}
/// 批量创建柜子请求体
#[derive(Debug, Deserialize)]
pub struct CreateCabinetBody {
pub imeis: Vec<String>,
pub project_id: i64,
}
/// 更新柜子请求体
#[derive(Debug, Deserialize)]
pub struct UpdateCabinetBody {
pub project_id: Option<i64>,
pub name: Option<String>,
}
// ---------------------------------------------------------------------------
// 共享行结构体
// ---------------------------------------------------------------------------
/// 组织表行
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct OrganizationRow {
pub id: i64,
pub name: String,
}
/// 项目表行
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct ProjectRow {
pub id: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization_id: Option<i64>,
pub name: String,
}
/// 柜子表行
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct CabinetRow {
pub id: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<i64>,
pub abstract_id: String,
pub imei: String,
pub name: Option<String>,
pub status: i8,
}
/// 树节点
#[derive(Debug, Serialize)]
pub struct TreeNode {
pub id: i64,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<TreeNode>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub abstract_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub imei: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<i8>,
}
/// 仓控板行
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CabinBoardRow {
pub id: i64,
pub cabinet_id: i64,
pub board_id: i8,
pub status: i8,
}
/// 仓体行
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CompartmentRow {
pub id: i64,
pub cabin_board_id: i64,
pub channel_id: i8,
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>,
pub abstract_id: String,
pub imei: String,
pub name: Option<String>,
pub status: i8,
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;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
(0..8).map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char).collect()
}
// ---------------------------------------------------------------------------
// 组织 CRUD
// ---------------------------------------------------------------------------
/// GET /api/organizations — 组织列表
pub async fn list_organizations(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:view")?;
let db = &state.mysql;
let rows = sqlx::query_as::<_, OrganizationRow>(
r#"SELECT id, name FROM organizations ORDER BY id"#,
)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
}
/// POST /api/organizations — 创建组织
pub async fn create_organization(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:create")?;
let db = &state.mysql;
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("组织名称不能为空".into()));
}
let result = sqlx::query(r#"INSERT INTO organizations (name) VALUES (?)"#)
.bind(name)
.execute(db)
.await?;
let id = result.last_insert_id() as i64;
tracing::info!("创建组织 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
}
/// PUT /api/organizations/:id — 更新组织
pub async fn update_organization(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:edit")?;
let db = &state.mysql;
let name = body.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("组织名称不能为空".into()));
}
let rows = sqlx::query(r#"UPDATE organizations SET name = ? WHERE id = ?"#)
.bind(&name)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("组织 {} 不存在", id)));
}
tracing::info!("更新组织 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
}
/// DELETE /api/organizations/:id — 删除组织
pub async fn delete_organization(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "org:delete")?;
let db = &state.mysql;
let count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM projects WHERE organization_id = ?"#,
)
.bind(id)
.fetch_one(db)
.await?;
if count.0 > 0 {
return Err(AppError::BadRequest(
format!("组织 {} 下仍有项目,无法删除", id),
));
}
let rows = sqlx::query(r#"DELETE FROM organizations WHERE id = ?"#)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("组织 {} 不存在", id)));
}
tracing::info!("删除组织 id={}", id);
Ok(Json(json!({})))
}

View File

@ -0,0 +1,135 @@
//! 项目 CRUD API
//!
//! 提供项目的增删改查,从 organizations 模块拆分而来。
use axum::extract::{Path, State};
use axum::Json;
use serde_json::{json, Value};
use super::organizations::{NameBody, ProjectRow};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
/// GET /api/organizations/:org_id/projects — 按组织查询项目列表
pub async fn list_projects(
user: CurrentUser,
State(state): State<AppState>,
Path(org_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:view")?;
let db = &state.mysql;
let rows = sqlx::query_as::<_, ProjectRow>(
r#"SELECT id, organization_id, name FROM projects WHERE organization_id = ? ORDER BY id"#,
)
.bind(org_id)
.fetch_all(db)
.await?;
Ok(Json(json!(rows)))
}
/// POST /api/organizations/:org_id/projects — 创建项目
pub async fn create_project(
user: CurrentUser,
State(state): State<AppState>,
Path(org_id): Path<i64>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:create")?;
let db = &state.mysql;
let name = body.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("项目名称不能为空".into()));
}
let org_exists: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM organizations WHERE id = ?"#,
)
.bind(org_id)
.fetch_one(db)
.await?;
if org_exists.0 == 0 {
return Err(AppError::NotFound(format!("组织 {} 不存在", org_id)));
}
let result = sqlx::query(r#"INSERT INTO projects (organization_id, name) VALUES (?, ?)"#)
.bind(org_id)
.bind(&name)
.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 })))
}
/// PUT /api/projects/:id — 更新项目
pub async fn update_project(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<NameBody>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:edit")?;
let db = &state.mysql;
let name = body.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("项目名称不能为空".into()));
}
let rows = sqlx::query(r#"UPDATE projects SET name = ? WHERE id = ?"#)
.bind(&name)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
}
tracing::info!("更新项目 id={} name={}", id, name);
Ok(Json(json!({ "id": id, "name": name })))
}
/// DELETE /api/projects/:id — 删除项目
pub async fn delete_project(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "project:delete")?;
let db = &state.mysql;
let count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM cabinets WHERE project_id = ?"#,
)
.bind(id)
.fetch_one(db)
.await?;
if count.0 > 0 {
return Err(AppError::BadRequest(
format!("项目 {} 下仍有设备,无法删除", id),
));
}
let rows = sqlx::query(r#"DELETE FROM projects WHERE id = ?"#)
.bind(id)
.execute(db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
}
tracing::info!("删除项目 id={}", id);
Ok(Json(json!({})))
}

View File

@ -0,0 +1,248 @@
//! 角色管理 API
//!
//! 提供角色的 CRUD 操作及角色权限分配接口:
//! - GET /api/roles — 角色列表
//! - POST /api/roles — 创建角色
//! - PUT /api/roles/:id — 更新角色
//! - DELETE /api/roles/:id — 删除角色
//! - PUT /api/roles/:id/permissions — 设置角色权限
//! - GET /api/permissions — 权限码列表
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::CurrentUser;
use crate::tcp::commands::AppState;
/// 创建/更新角色请求体
#[derive(Debug, Deserialize)]
pub(crate) struct RoleInput {
name: String,
description: Option<String>,
}
/// 设置角色权限请求体
#[derive(Debug, Deserialize)]
pub(crate) struct SetPermissionsInput {
/// 权限码列表
permission_codes: Vec<String>,
}
/// GET /api/roles — 获取所有角色及其权限码
pub async fn list_roles(
_user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 查询所有角色
let roles: Vec<(i64, String, Option<String>)> = sqlx::query_as(
"SELECT id, name, description FROM roles ORDER BY id"
)
.fetch_all(pool)
.await?;
let mut result = Vec::new();
for (id, name, description) in roles {
// 查询该角色的权限码
let perm_codes: Vec<(String,)> = sqlx::query_as(
r#"
SELECT p.code FROM permissions p
INNER JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = ?
ORDER BY p.code
"#
)
.bind(id)
.fetch_all(pool)
.await?;
result.push(json!({
"id": id,
"name": name,
"description": description,
"permission_codes": perm_codes.into_iter().map(|(c,)| c).collect::<Vec<_>>(),
}));
}
Ok(Json(json!({ "code": 0, "data": result, "message": "ok" })))
}
/// POST /api/roles — 创建角色
pub async fn create_role(
user: CurrentUser,
State(state): State<AppState>,
Json(input): Json<RoleInput>,
) -> Result<(StatusCode, Json<Value>), AppError> {
let pool = &state.mysql;
// 校验权限
crate::middleware::auth::check_permission(&user, "role:create")?;
if input.name.is_empty() || input.name.len() > 50 {
return Err(AppError::BadRequest("角色名长度需在1-50字符之间".into()));
}
let result = sqlx::query(
"INSERT INTO roles (name, description) VALUES (?, ?)"
)
.bind(&input.name)
.bind(&input.description)
.execute(pool)
.await;
match result {
Ok(r) => {
let id = r.last_insert_id() as i64;
Ok((StatusCode::CREATED, Json(json!({
"code": 0,
"data": { "id": id, "name": input.name, "description": input.description },
"message": "创建成功"
}))))
}
Err(e) => {
if e.to_string().contains("Duplicate") {
Err(AppError::BadRequest(format!("角色名 '{}' 已存在", input.name)))
} else {
Err(AppError::Database(e))
}
}
}
}
/// PUT /api/roles/:id — 更新角色
pub async fn update_role(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<RoleInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
crate::middleware::auth::check_permission(&user, "role:edit")?;
if input.name.is_empty() || input.name.len() > 50 {
return Err(AppError::BadRequest("角色名长度需在1-50字符之间".into()));
}
let result = sqlx::query(
"UPDATE roles SET name = ?, description = ? WHERE id = ?"
)
.bind(&input.name)
.bind(&input.description)
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("角色不存在".into()));
}
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
}
/// DELETE /api/roles/:id — 删除角色
pub async fn delete_role(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
crate::middleware::auth::check_permission(&user, "role:delete")?;
// 检查是否有用户正在使用该角色
let count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM user_roles WHERE role_id = ?"
)
.bind(id)
.fetch_one(pool)
.await?;
if count.0 > 0 {
return Err(AppError::BadRequest(
format!("该角色下还有 {} 个用户,请先移除", count.0)
));
}
let result = sqlx::query("DELETE FROM roles WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("角色不存在".into()));
}
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
}
/// PUT /api/roles/:id/permissions — 设置角色权限(全量替换)
pub async fn set_role_permissions(
user: CurrentUser,
State(state): State<AppState>,
Path(role_id): Path<i64>,
Json(input): Json<SetPermissionsInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
crate::middleware::auth::check_permission(&user, "role:edit")?;
// 检查角色是否存在
let role_exists: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE id = ?"
)
.bind(role_id)
.fetch_optional(pool)
.await?;
if role_exists.is_none() {
return Err(AppError::NotFound("角色不存在".into()));
}
// 删除旧权限
sqlx::query("DELETE FROM role_permissions WHERE role_id = ?")
.bind(role_id)
.execute(pool)
.await?;
// 批量插入新权限
for code in &input.permission_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
Ok(Json(json!({ "code": 0, "data": null, "message": "权限设置成功" })))
}
/// GET /api/permissions — 获取所有权限码列表
pub async fn list_permissions(
_user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
let perms: Vec<(i64, String, String)> = sqlx::query_as(
"SELECT id, code, name FROM permissions ORDER BY code"
)
.fetch_all(pool)
.await?;
let data: Vec<Value> = perms.into_iter().map(|(id, code, name)| {
json!({ "id": id, "code": code, "name": name })
}).collect();
Ok(Json(json!({ "code": 0, "data": data, "message": "ok" })))
}

View File

@ -0,0 +1,564 @@
//! 用户管理 API
//!
//! 提供用户的 CRUD 操作、密码重置和状态管理:
//! - GET /api/users — 用户列表(分页,支持关键字搜索)
//! - POST /api/users — 创建用户(手机号+姓名+角色)
//! - PUT /api/users/:id — 编辑用户
//! - PUT /api/users/:id/reset-password — 重置密码
//! - PUT /api/users/:id/status — 启用/禁用
//! - DELETE /api/users/:id — 删除用户
use axum::extract::{Path, Query, State};
use axum::Json;
use chrono::NaiveDateTime;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
use crate::config::DEFAULT_PASSWORD;
/// 用户列表查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct UserListQuery {
page: Option<u32>,
page_size: Option<u32>,
keyword: Option<String>,
}
/// 用户列表数据库行映射替代7元素元组sqlx 不支持超过6元素的元组
#[derive(Debug, sqlx::FromRow)]
struct UserRow {
id: i64,
phone: String,
name: Option<String>,
role: i32,
organization_id: Option<i64>,
status: i32,
created_at: NaiveDateTime,
}
/// 创建用户请求体
#[derive(Debug, Deserialize)]
pub(crate) struct CreateUserInput {
phone: String,
name: Option<String>,
role: Option<i32>,
organization_id: Option<i64>,
password: Option<String>,
}
/// 编辑用户请求体
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateUserInput {
name: Option<String>,
role: Option<i32>,
organization_id: Option<i64>,
}
/// 重置密码请求体
#[derive(Debug, Deserialize)]
pub(crate) struct ResetPasswordInput {
password: Option<String>,
}
/// 状态变更请求体
#[derive(Debug, Deserialize)]
pub(crate) struct StatusInput {
status: i32,
}
// ---------------------------------------------------------------------------
// 辅助函数: 记录操作日志
// ---------------------------------------------------------------------------
/// 记录一条操作日志到 operation_logs 表
async fn log_operation(
pool: &sqlx::MySqlPool,
user_id: i64,
action: &str,
target_type: &str,
target_id: Option<i64>,
detail: &str,
) {
let result = sqlx::query(
"INSERT INTO operation_logs (user_id, action, target_type, target_id, detail) VALUES (?, ?, ?, ?, ?)"
)
.bind(user_id)
.bind(action)
.bind(target_type)
.bind(target_id)
.bind(detail)
.execute(pool)
.await;
if let Err(e) = result {
tracing::error!("记录操作日志失败: {}", e);
}
}
// ---------------------------------------------------------------------------
// 用户列表
// ---------------------------------------------------------------------------
/// GET /api/users — 用户列表(分页)
///
/// 支持 keyword 搜索(匹配手机号/姓名page 从 1 开始。
pub async fn list_users(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<UserListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "user:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
let keyword = query.keyword.as_deref().unwrap_or("");
// 构建查询条件
let has_keyword = !keyword.is_empty();
let like_pattern = format!("%{}%", keyword);
// 组织隔离:总管理员看所有用户,企业管理员只看本组织用户
let org_filter = if user.role_level >= 2 {
None // 总管理员不过滤
} else {
user.organization_id // 企业管理员按组织过滤,无组织则返回空
};
// 无组织关联的非总管理员用户看不到任何人
if user.role_level < 2 && org_filter.is_none() {
return Ok(Json(json!({
"code": 0,
"data": { "list": [], "total": 0 },
"message": "ok"
})));
}
// 查询总数
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);
}
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>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at
FROM users u
WHERE u.phone LIKE ? OR u.name LIKE ?
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
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);
}
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
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
FROM users u
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
};
// 收集组织ID并批量查询组织名称
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
let org_names = if org_ids.is_empty() {
vec![]
} else {
// 使用 IN 查询, 构造占位符
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT id, name FROM organizations WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
for id in &org_ids {
q = q.bind(id);
}
q.fetch_all(pool).await.unwrap_or_default()
};
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
// 构建角色名称映射
let role_names: [&str; 3] = ["普通用户", "企业管理员", "总管理员"];
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
json!({
"id": r.id,
"phone": r.phone,
"name": r.name,
"role": r.role,
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
"organization_id": r.organization_id,
"organization_name": org_name,
"status": r.status,
"created_at": r.created_at.to_string(),
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": { "list": list, "total": total.0 },
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 创建用户
// ---------------------------------------------------------------------------
/// POST /api/users — 创建用户
pub async fn create_user(
current_user: CurrentUser,
State(state): State<AppState>,
Json(input): Json<CreateUserInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:create")?;
let pool = &state.mysql;
// 校验手机号
let phone = input.phone.trim();
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
}
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()));
}
// 企业管理员必须指定所属组织
if role == 1 && input.organization_id.is_none() {
return Err(AppError::BadRequest("企业管理员必须指定所属组织".into()));
}
let org_id = input.organization_id;
// 校验组织存在
if let Some(oid) = org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
}
let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
let password = auth::hash_password(&plain_password)?;
// 检查手机号是否已存在
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM users WHERE phone = ?"
)
.bind(phone)
.fetch_one(pool)
.await?;
if exists.0 > 0 {
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
}
let result = sqlx::query(
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
)
.bind(phone)
.bind(&name)
.bind(&password)
.bind(role)
.bind(org_id)
.execute(pool)
.await?;
let user_id = result.last_insert_id() as i64;
// 记录操作日志
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);
Ok(Json(json!({
"code": 0,
"data": { "id": user_id },
"message": "创建成功"
})))
}
// ---------------------------------------------------------------------------
// 编辑用户
// ---------------------------------------------------------------------------
/// PUT /api/users/:id — 编辑用户
pub async fn update_user(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<UpdateUserInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:edit")?;
let pool = &state.mysql;
// 检查用户存在
let old: Option<(String, Option<String>, i32, Option<i64>)> = sqlx::query_as(
"SELECT phone, name, role, organization_id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let (phone, old_name, old_role, old_org_id) = old
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
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()));
}
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()));
}
// 校验组织存在
if let Some(oid) = new_org_id
&& Some(oid) != old_org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
}
let rows = sqlx::query(
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
)
.bind(&new_name)
.bind(new_role)
.bind(new_org_id)
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
// 记录操作日志
let detail = format!(
"编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}",
phone,
old_name.as_deref().unwrap_or("-"),
new_name.as_deref().unwrap_or("-"),
old_role, new_role
);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("编辑用户 id={}", user_id);
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
}
// ---------------------------------------------------------------------------
// 重置密码
// ---------------------------------------------------------------------------
/// PUT /api/users/:id/reset-password — 重置密码
pub async fn reset_password(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<ResetPasswordInput>,
) -> Result<Json<Value>, AppError> {
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());
let new_password = auth::hash_password(&plain_password)?;
let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?")
.bind(&new_password)
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
// 记录操作日志
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);
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
}
// ---------------------------------------------------------------------------
// 启用/禁用
// ---------------------------------------------------------------------------
/// PUT /api/users/:id/status — 启用/禁用用户
pub async fn set_user_status(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<StatusInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:edit")?;
let pool = &state.mysql;
// 不能禁用自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能修改自己的状态".into()));
}
if ![0, 1].contains(&input.status) {
return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into()));
}
let rows = sqlx::query("UPDATE users SET status = ? WHERE id = ?")
.bind(input.status)
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
let status_text = if input.status == 1 { "启用" } else { "禁用" };
// 记录操作日志
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);
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
}
// ---------------------------------------------------------------------------
// 删除用户
// ---------------------------------------------------------------------------
/// DELETE /api/users/:id — 删除用户
pub async fn delete_user(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&current_user, "user:delete")?;
let pool = &state.mysql;
// 不能删除自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能删除自己的账户".into()));
}
// 查询用户信息用于日志
let user_info: Option<(String,)> = sqlx::query_as(
"SELECT phone FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let phone = user_info.map(|(p,)| p).unwrap_or_default();
let rows = sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("用户不存在".into()));
}
// 记录操作日志
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);
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
}

View File

@ -0,0 +1,245 @@
//! 用户权限管理 API
//!
//! 提供用户角色分配和权限范围配置:
//! - GET /api/users/:id/permissions — 获取用户权限(含角色和权限码)
//! - PUT /api/users/:id/roles — 设置用户角色
//! - PUT /api/users/:id/scopes — 设置用户权限范围(组织/项目/设备级)
//! - GET /api/users/:id/scopes — 获取用户权限范围
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::tcp::commands::AppState;
/// 设置用户角色请求体
#[derive(Debug, Deserialize)]
pub(crate) struct SetRolesInput {
/// 角色ID列表
role_ids: Vec<i64>,
}
/// 权限范围条目
#[derive(Debug, Deserialize)]
pub(crate) struct ScopeEntry {
/// 范围类型1组织 2项目 3设备
scope_type: i8,
/// 对应的组织/项目/设备ID
scope_id: i64,
/// 权限类型1查看 2操作
permission_type: i8,
}
/// 设置用户权限范围请求体
#[derive(Debug, Deserialize)]
pub(crate) struct SetScopesInput {
scopes: Vec<ScopeEntry>,
}
/// GET /api/users/:id/permissions — 获取用户权限详情
#[allow(clippy::type_complexity)]
pub async fn get_user_permissions(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:view")?;
// 查询用户基本信息
let user_row: Option<(i64, String, Option<String>, i32, Option<i64>)> = sqlx::query_as(
"SELECT id, name, phone, role, organization_id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let user_row = user_row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
// 查询用户角色
let roles: Vec<(i64, String, Option<String>)> = sqlx::query_as(
r#"
SELECT r.id, r.name, r.description
FROM roles r
INNER JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = ?
ORDER BY r.id
"#
)
.bind(user_id)
.fetch_all(pool)
.await?;
// 查询用户权限码
let perm_codes = auth::load_user_permissions(pool, user_id).await?;
// 查询用户权限范围
let scopes: Vec<(i64, i8, i64, i8)> = sqlx::query_as(
"SELECT id, scope_type, scope_id, permission_type FROM user_scopes WHERE user_id = ?"
)
.bind(user_id)
.fetch_all(pool)
.await?;
let roles_data: Vec<Value> = roles.into_iter().map(|(id, name, desc)| {
json!({ "id": id, "name": name, "description": desc })
}).collect();
let scopes_data: Vec<Value> = scopes.into_iter().map(|(id, st, sid, pt)| {
json!({
"id": id,
"scope_type": st,
"scope_id": sid,
"permission_type": pt,
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": {
"user_id": user_row.0,
"name": user_row.1,
"phone": user_row.2,
"role_level": user_row.3,
"organization_id": user_row.4,
"roles": roles_data,
"permission_codes": perm_codes,
"scopes": scopes_data,
},
"message": "ok"
})))
}
/// PUT /api/users/:id/roles — 设置用户角色(全量替换)
pub async fn set_user_roles(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<SetRolesInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:edit")?;
// 不允许修改自己的角色
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能修改自己的角色".into()));
}
// 检查用户是否存在
let user_exists: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
if user_exists.is_none() {
return Err(AppError::NotFound("用户不存在".into()));
}
// 删除旧角色
sqlx::query("DELETE FROM user_roles WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await?;
// 插入新角色
for role_id in &input.role_ids {
// 验证角色存在
let role_exists: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE id = ?"
)
.bind(role_id)
.fetch_optional(pool)
.await?;
if role_exists.is_some() {
sqlx::query(
"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)"
)
.bind(user_id)
.bind(role_id)
.execute(pool)
.await?;
}
}
Ok(Json(json!({ "code": 0, "data": null, "message": "角色设置成功" })))
}
/// PUT /api/users/:id/scopes — 设置用户权限范围(全量替换)
pub async fn set_user_scopes(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
Json(input): Json<SetScopesInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:edit")?;
// 校验 scope_type 和 permission_type 范围
for scope in &input.scopes {
if ![1, 2, 3].contains(&scope.scope_type) {
return Err(AppError::BadRequest(
"scope_type 必须为 1(组织)/2(项目)/3(设备)".into()
));
}
if ![1, 2].contains(&scope.permission_type) {
return Err(AppError::BadRequest(
"permission_type 必须为 1(查看)/2(操作)".into()
));
}
}
// 删除旧范围
sqlx::query("DELETE FROM user_scopes WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await?;
// 插入新范围
for scope in &input.scopes {
sqlx::query(
"INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (?, ?, ?, ?)"
)
.bind(user_id)
.bind(scope.scope_type)
.bind(scope.scope_id)
.bind(scope.permission_type)
.execute(pool)
.await?;
}
Ok(Json(json!({ "code": 0, "data": null, "message": "权限范围设置成功" })))
}
/// GET /api/users/:id/scopes — 获取用户权限范围
pub async fn get_user_scopes(
current_user: CurrentUser,
State(state): State<AppState>,
Path(user_id): Path<i64>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
auth::check_permission(&current_user, "user:view")?;
let scopes: Vec<(i64, i8, i64, i8)> = sqlx::query_as(
"SELECT id, scope_type, scope_id, permission_type FROM user_scopes WHERE user_id = ?"
)
.bind(user_id)
.fetch_all(pool)
.await?;
let data: Vec<Value> = scopes.into_iter().map(|(id, st, sid, pt)| {
json!({
"id": id,
"scope_type": st,
"scope_id": sid,
"permission_type": pt,
})
}).collect();
Ok(Json(json!({ "code": 0, "data": data, "message": "ok" })))
}

View File

@ -0,0 +1,78 @@
//! 设备指令下发 HTTP API。
//!
//! 提供 HTTP 端点供后台系统调用,通过设备连接池向在线设备发送指令。
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use super::connection::ConnectionPool;
use super::protocol::DeviceCommand;
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// HTTP 请求体:包含要下发的指令内容。
#[derive(Debug, Deserialize)]
pub struct CommandRequest {
pub act: String,
#[serde(default)]
pub msg_id: u32,
pub sub_device_id: Option<u8>,
#[serde(flatten)]
pub params: Option<Value>,
}
/// 应用共享状态(与 main.rs 联动)。
#[allow(dead_code)]
#[derive(Clone)]
pub struct AppState {
pub device_pool: ConnectionPool,
pub redis: redis::aio::ConnectionManager,
pub mysql: sqlx::MySqlPool,
}
/// POST /api/devices/:dev_id/command
///
/// 向指定设备下发实时指令。设备不在线时返回错误。
/// 需要 `device:operate` 权限。
pub async fn send_command(
user: CurrentUser,
State(state): State<AppState>,
Path(dev_id): Path<String>,
Json(req): Json<CommandRequest>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
if !state.device_pool.is_online(&dev_id).await {
return Ok(Json(json!({ "suc": 0, "err": "设备不在线" })));
}
let cmd = DeviceCommand {
act: req.act,
msg_id: req.msg_id,
dev_id: Some(dev_id.clone()),
sub_device_id: req.sub_device_id,
params: req.params,
};
let json = cmd.to_json();
match state.device_pool.send_command(&dev_id, &json).await {
Ok(()) => {
tracing::info!("[command] 已下发 dev_id={} act={}", dev_id, cmd.act);
Ok(Json(json!({ "suc": 1 })))
}
Err(e) => Ok(Json(json!({ "suc": 0, "err": e }))),
}
}
/// GET /api/devices/stats — 在线设备统计。
/// 需要 `device:view` 权限。
pub async fn device_stats(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let count = state.device_pool.online_count().await;
Ok(Json(json!({ "online_count": count })))
}

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