init: PMS充电柜管理系统源代码
This commit is contained in:
commit
12395ee9e2
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
258
AGENTS.md
Normal file
@ -0,0 +1,258 @@
|
||||
# AGENTS.md - 充电柜项目工作手册
|
||||
|
||||
## 项目简介
|
||||
|
||||
充电柜矩阵系统(Power Matrix System / PMS)
|
||||
产品名:安知充(anzhizhichong),已有充电站平台在运行,充电柜是新增产品线。
|
||||
仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
|
||||
|
||||
## 设备标识
|
||||
|
||||
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
|
||||
- **设备ID**:4G SIM卡15位IMEI(10进制)
|
||||
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
|
||||
- **扫码链接**:`https://app.anzhizhichong.com/pms/{抽象ID}`
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Rust + Axum
|
||||
- 前端:React + Arco Design Pro(React版)
|
||||
- 数据库: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 9(2核/3.5G/49G)
|
||||
- **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`,IP `10.8.0.252`
|
||||
- **本地电脑ZeroTier IP**: `10.8.0.248`
|
||||
- **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat(开机自启)
|
||||
- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、Gitea(3080)、acme.sh
|
||||
- **不装**: Docker/Node/Rust(本地编译传二进制)
|
||||
- **SSH**: plink连接,首次需 `echo y` 接受host key
|
||||
|
||||
### 本地开发环境
|
||||
|
||||
**数据库连接(通过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 check,TS→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`:IMEI(10进制),`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
348
README.md
Normal 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.206(CentOS Stream 9,2核/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`:IMEI(10进制),`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 check,TS→tsc --noEmit),零报错零警告
|
||||
384
docs/1TN协议内容_完整.txt
Normal file
384
docs/1TN协议内容_完整.txt
Normal 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 代表0x01,10 代表0x02,11 代表0x03。
|
||||
对应电源双拨码开关,001 代表0x01,010 代表0x02,011 代表0x03,100 代表0x04,
|
||||
101 代表0x05,110 代表0x06,111 代表0x07。
|
||||
1.2.2
|
||||
通道码(CHx)
|
||||
对应各个通道识别码。
|
||||
通道
|
||||
Bit1-7
|
||||
Bit0
|
||||
CHx Byte 解析
|
||||
通道
|
||||
Bit1-7
|
||||
Bit0
|
||||
CHx Byte 解析
|
||||
CH1
|
||||
00
|
||||
0/1
|
||||
00:CH1 主通道
|
||||
01:CH1 副通道
|
||||
CH4
|
||||
03
|
||||
0/1
|
||||
06:CH4 主通道
|
||||
07:CH4 副通道
|
||||
|
||||
--- Page 4 ---
|
||||
-4-
|
||||
-
|
||||
CH2
|
||||
01
|
||||
0/1
|
||||
02:CH2 主通道
|
||||
03:CH2 副通道
|
||||
CH5
|
||||
04
|
||||
0/1
|
||||
08:CH5 主通道
|
||||
09:CH5 副通道
|
||||
CH3
|
||||
02
|
||||
0/1
|
||||
04:CH3 主通道
|
||||
05:CH3 副通道
|
||||
CH6
|
||||
05
|
||||
0/1
|
||||
0A:CH6 主通道
|
||||
0B:CH6 副通道
|
||||
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
|
||||
DATA:0x0258代表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.0V,0064代表设置电流10.0A
|
||||
电源回复给主控
|
||||
ADDR
|
||||
CHx
|
||||
CMD
|
||||
LEN
|
||||
DATA
|
||||
SUM
|
||||
0x01
|
||||
0x00
|
||||
0x04
|
||||
0x07
|
||||
0x02 58 00 64 01 01 1A
|
||||
0xE6
|
||||
回复DATA: 0258代表当前输出电压60.0V,0064代表当前输出电流10.0A,01 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.00V,03E8输入电流10.00A,0898输入功率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.
|
||||
|
||||
210
docs/1TN协议标准_YG20240118-1_V1.0.md
Normal file
210
docs/1TN协议标准_YG20240118-1_V1.0.md
Normal 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 |
|
||||
|
||||
DATA:0x0258 = 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 |
|
||||
|
||||
DATA:0x01 = 开机, 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 | 序列号 |
|
||||
BIN
docs/1TN协议标准_苏州翌工电源_V1.0.pdf
Normal file
BIN
docs/1TN协议标准_苏州翌工电源_V1.0.pdf
Normal file
Binary file not shown.
272
docs/build_contract.js
Normal file
272
docs/build_contract.js
Normal 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
22
docs/convert.js
Normal 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
548
docs/package-lock.json
generated
Normal 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
7
docs/package.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"docx": "^9.7.1",
|
||||
"marked": "^18.0.5",
|
||||
"puppeteer": "^25.1.0"
|
||||
}
|
||||
}
|
||||
26
docs/toPdf.js
Normal file
26
docs/toPdf.js
Normal 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');
|
||||
})();
|
||||
BIN
docs/开发合同-充电柜-KF-06062401.docx
Normal file
BIN
docs/开发合同-充电柜-KF-06062401.docx
Normal file
Binary file not shown.
161
docs/开发合同.html
Normal file
161
docs/开发合同.html
Normal 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
127
docs/开发合同.md
Normal 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
BIN
docs/开发合同.pdf
Normal file
Binary file not shown.
935
docs/开发文档.md
Normal file
935
docs/开发文档.md
Normal file
@ -0,0 +1,935 @@
|
||||
# 36仓体直流电瓶车充电柜 - 开发文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 项目目标
|
||||
|
||||
开发一款36仓体直流电瓶车充电柜,配置6个电源(每个电源对应6个仓体的独立直流输出口),支持每个仓体输出口独立设置电流、电压参数,适配星恒BMS Modbus协议与电源1TN RS485协议,实现安全、高效的独立充电功能。每个仓体配套状态指示灯,通过不同灯光状态反馈仓体工作情况,同时支持平台远程禁用仓体。
|
||||
|
||||
核心新增定制仓控主板及柜控主板功能:
|
||||
- 每个仓控主板单独与所属电源通讯
|
||||
- 仓控板采用拨码开关设置ID(ID范围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路隔离485:1路接柜控板(ID128)、1路接BMS(6仓体共用总线)、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
347
docs/开发计划.md
Normal 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读取电表数据,上报平台显示正确 |
|
||||
534
docs/附件-开发需求规格说明书.html
Normal file
534
docs/附件-开发需求规格说明书.html
Normal 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从站ID(1-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>加热风扇自动温控(<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>响应柜控板指令,按拨码ID(1-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/功能码/长度/数据/CRC16),14个功能码</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>
|
||||
BIN
docs/附件-开发需求规格说明书.pdf
Normal file
BIN
docs/附件-开发需求规格说明书.pdf
Normal file
Binary file not shown.
BIN
docs/附件一-硬件规格.docx
Normal file
BIN
docs/附件一-硬件规格.docx
Normal file
Binary file not shown.
108
docs/附件一-硬件规格.md
Normal file
108
docs/附件一-硬件规格.md
Normal 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从站ID(1-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从站通讯 | 响应柜控板指令,按拨码ID(1-6)寻址 |
|
||||
| BMS多协议通讯 | 按配置协议轮询6仓体电池数据:星恒Modbus-RTU/天能自定义/无协议跳过 |
|
||||
| 1TN电源协议 | 与所属充电电源独立485通讯,电压/电流/开关控制(电源自带通断) |
|
||||
| 充电控制逻辑 | 平台/柜控双模式,含电池握手/充满判断/无协议充电/低电流自动停止 |
|
||||
| 状态采集与指示 | 6路红绿LED指示灯控制 + 6路温控采集(可选) |
|
||||
| OTA升级与ID管理 | 接收固件包校验写入,拨码开关读取识别 |
|
||||
BIN
docs/附件三-交付与验收标准.docx
Normal file
BIN
docs/附件三-交付与验收标准.docx
Normal file
Binary file not shown.
62
docs/附件三-交付与验收标准.md
Normal file
62
docs/附件三-交付与验收标准.md
Normal 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/功能码/长度/数据/CRC16),14个功能码 |
|
||||
| 柜控-平台TCP协议 | 自定义TCP长连接,含心跳/数据上报/指令下发/OTA |
|
||||
|
||||
### 3.2 仓控-柜控485功能码表
|
||||
|
||||
| 码 | 指令 | 方向 |
|
||||
|----|------|------|
|
||||
| 0x01 | 上传BMS数据 | 仓控→柜控 |
|
||||
| 0x02 | 上传温控数据 | 仓控→柜控 |
|
||||
| 0x03 | 上传电源状态 | 仓控→柜控 |
|
||||
| 0x06 | 上传OTA状态 | 仓控→柜控 |
|
||||
| 0x82 | 电源参数设置 | 柜控→仓控 |
|
||||
| 0x83 | 指示灯控制 | 柜控→仓控 |
|
||||
| 0x84 | 查询指令 | 柜控→仓控 |
|
||||
| 0x85 | 平台禁用 | 柜控→仓控 |
|
||||
| 0x86 | 模式切换 | 平台→柜控 |
|
||||
| 0x87 | 接触器控制 | 平台→柜控 |
|
||||
| 0x89 | OTA升级 | 柜控→仓控 |
|
||||
| 0x8A | BMS类型配置 | 柜控→仓控 |
|
||||
| 0x8B | 报警阈值配置 | 柜控→仓控 |
|
||||
BIN
docs/附件二-软件规格.docx
Normal file
BIN
docs/附件二-软件规格.docx
Normal file
Binary file not shown.
53
docs/附件二-软件规格.md
Normal file
53
docs/附件二-软件规格.md
Normal 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
3118
software/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
software/api-server/.env.production
Normal file
15
software/api-server/.env.production
Normal 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
|
||||
15
software/api-server/.env.test
Normal file
15
software/api-server/.env.test
Normal 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
2695
software/api-server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
software/api-server/Cargo.toml
Normal file
28
software/api-server/Cargo.toml
Normal 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"
|
||||
301
software/api-server/src/commands.rs
Normal file
301
software/api-server/src/commands.rs
Normal 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(),
|
||||
})))
|
||||
}
|
||||
59
software/api-server/src/config.rs
Normal file
59
software/api-server/src/config.rs
Normal 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 环境变量");
|
||||
}
|
||||
}
|
||||
}
|
||||
406
software/api-server/src/db/migrate.rs
Normal file
406
software/api-server/src/db/migrate.rs
Normal 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 '平台抽象ID,如10-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(())
|
||||
}
|
||||
17
software/api-server/src/db/mod.rs
Normal file
17
software/api-server/src/db/mod.rs
Normal 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)
|
||||
}
|
||||
66
software/api-server/src/error.rs
Normal file
66
software/api-server/src/error.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
97
software/api-server/src/main.rs
Normal file
97
software/api-server/src/main.rs
Normal 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("服务器运行失败");
|
||||
}
|
||||
286
software/api-server/src/middleware/auth.rs
Normal file
286
software/api-server/src/middleware/auth.rs
Normal 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 {
|
||||
/// 用户ID(subject)
|
||||
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 token(7天有效期)
|
||||
pub fn generate_token(user_id: i64, phone: &str) -> Result<String, AppError> {
|
||||
let secret = std::env::var(JWT_SECRET_ENV)
|
||||
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
|
||||
|
||||
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![])
|
||||
}
|
||||
}
|
||||
3
software/api-server/src/middleware/mod.rs
Normal file
3
software/api-server/src/middleware/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
//! 中间件模块
|
||||
|
||||
pub mod auth;
|
||||
70
software/api-server/src/nodes.rs
Normal file
70
software/api-server/src/nodes.rs
Normal 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()})))
|
||||
}
|
||||
61
software/api-server/src/protocol.rs
Normal file
61
software/api-server/src/protocol.rs
Normal 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 序列化不应失败")
|
||||
}
|
||||
}
|
||||
12
software/api-server/src/redis.rs
Normal file
12
software/api-server/src/redis.rs
Normal 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)
|
||||
}
|
||||
118
software/api-server/src/routes/auth.rs
Normal file
118
software/api-server/src/routes/auth.rs
Normal 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"
|
||||
})))
|
||||
}
|
||||
113
software/api-server/src/routes/cabinet_tree.rs
Normal file
113
software/api-server/src/routes/cabinet_tree.rs
Normal 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)))
|
||||
}
|
||||
425
software/api-server/src/routes/cabinets.rs
Normal file
425
software/api-server/src/routes/cabinets.rs
Normal 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" })))
|
||||
}
|
||||
397
software/api-server/src/routes/charge_records.rs
Normal file
397
software/api-server/src/routes/charge_records.rs
Normal 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(¶ms);
|
||||
|
||||
// 序列化查询参数
|
||||
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(())
|
||||
}
|
||||
157
software/api-server/src/routes/device_logs.rs
Normal file
157
software/api-server/src/routes/device_logs.rs
Normal 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"
|
||||
})))
|
||||
}
|
||||
280
software/api-server/src/routes/downloads.rs
Normal file
280
software/api-server/src/routes/downloads.rs
Normal 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)
|
||||
})))
|
||||
}
|
||||
553
software/api-server/src/routes/energy_stats.rs
Normal file
553
software/api-server/src/routes/energy_stats.rs
Normal 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(¶ms);
|
||||
|
||||
// 序列化查询参数
|
||||
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(())
|
||||
}
|
||||
990
software/api-server/src/routes/h5.rs
Normal file
990
software/api-server/src/routes/h5.rs
Normal 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(())
|
||||
}
|
||||
11
software/api-server/src/routes/health.rs
Normal file
11
software/api-server/src/routes/health.rs
Normal 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"
|
||||
}))
|
||||
}
|
||||
112
software/api-server/src/routes/mod.rs
Normal file
112
software/api-server/src/routes/mod.rs
Normal 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)
|
||||
}
|
||||
144
software/api-server/src/routes/operation_logs.rs
Normal file
144
software/api-server/src/routes/operation_logs.rs
Normal 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"
|
||||
})))
|
||||
}
|
||||
255
software/api-server/src/routes/organizations.rs
Normal file
255
software/api-server/src/routes/organizations.rs
Normal 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!({})))
|
||||
}
|
||||
135
software/api-server/src/routes/projects.rs
Normal file
135
software/api-server/src/routes/projects.rs
Normal 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!({})))
|
||||
}
|
||||
248
software/api-server/src/routes/roles.rs
Normal file
248
software/api-server/src/routes/roles.rs
Normal 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" })))
|
||||
}
|
||||
564
software/api-server/src/routes/users.rs
Normal file
564
software/api-server/src/routes/users.rs
Normal 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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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": "删除成功" })))
|
||||
}
|
||||
245
software/api-server/src/routes/users_perm.rs
Normal file
245
software/api-server/src/routes/users_perm.rs
Normal 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(¤t_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(¤t_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(¤t_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(¤t_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" })))
|
||||
}
|
||||
5
software/api-server/src/workers/mod.rs
Normal file
5
software/api-server/src/workers/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
//! 后台协程 — 处理 Redis 队列中的设备消息
|
||||
|
||||
pub mod node_checker;
|
||||
pub mod report_worker;
|
||||
pub mod reply_worker;
|
||||
49
software/api-server/src/workers/node_checker.rs
Normal file
49
software/api-server/src/workers/node_checker.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
software/api-server/src/workers/reply_worker.rs
Normal file
47
software/api-server/src/workers/reply_worker.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
397
software/api-server/src/workers/report_worker.rs
Normal file
397
software/api-server/src/workers/report_worker.rs
Normal 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(())
|
||||
}
|
||||
17
software/device-server/.env.production
Normal file
17
software/device-server/.env.production
Normal 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
|
||||
17
software/device-server/.env.test
Normal file
17
software/device-server/.env.test
Normal 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
1229
software/device-server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
software/device-server/Cargo.toml
Normal file
18
software/device-server/Cargo.toml
Normal 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"] }
|
||||
41
software/device-server/src/config.rs
Normal file
41
software/device-server/src/config.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
177
software/device-server/src/main.rs
Normal file
177
software/device-server/src/main.rs
Normal 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 后单独设置 TTL(redis-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
software/device-server/src/redis_conn.rs
Normal file
12
software/device-server/src/redis_conn.rs
Normal 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)
|
||||
}
|
||||
113
software/device-server/src/tcp/connection.rs
Normal file
113
software/device-server/src/tcp/connection.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
55
software/device-server/src/tcp/handler.rs
Normal file
55
software/device-server/src/tcp/handler.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
9
software/device-server/src/tcp/mod.rs
Normal file
9
software/device-server/src/tcp/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
//! TCP 设备通讯服务。
|
||||
//!
|
||||
//! 处理充电柜设备的长连接登录、签名验证和消息转发。
|
||||
//! 接收的消息通过 Redis 转发给 API 服务处理。
|
||||
|
||||
pub mod connection;
|
||||
pub mod handler;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
84
software/device-server/src/tcp/protocol.rs
Normal file
84
software/device-server/src/tcp/protocol.rs
Normal 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 序列化不应失败")
|
||||
}
|
||||
}
|
||||
144
software/device-server/src/tcp/server.rs
Normal file
144
software/device-server/src/tcp/server.rs
Normal 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
2695
software/server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
software/server/Cargo.toml
Normal file
28
software/server/Cargo.toml
Normal 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"
|
||||
52
software/server/src/config.rs
Normal file
52
software/server/src/config.rs
Normal 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环境变量");
|
||||
}
|
||||
}
|
||||
}
|
||||
406
software/server/src/db/migrate.rs
Normal file
406
software/server/src/db/migrate.rs
Normal 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 '平台抽象ID,如10-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(())
|
||||
}
|
||||
17
software/server/src/db/mod.rs
Normal file
17
software/server/src/db/mod.rs
Normal 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)
|
||||
}
|
||||
66
software/server/src/error.rs
Normal file
66
software/server/src/error.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
82
software/server/src/main.rs
Normal file
82
software/server/src/main.rs
Normal 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("服务器运行失败");
|
||||
}
|
||||
286
software/server/src/middleware/auth.rs
Normal file
286
software/server/src/middleware/auth.rs
Normal 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 {
|
||||
/// 用户ID(subject)
|
||||
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 token(7天有效期)
|
||||
pub fn generate_token(user_id: i64, phone: &str) -> Result<String, AppError> {
|
||||
let secret = std::env::var(JWT_SECRET_ENV)
|
||||
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
|
||||
|
||||
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![])
|
||||
}
|
||||
}
|
||||
3
software/server/src/middleware/mod.rs
Normal file
3
software/server/src/middleware/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
//! 中间件模块
|
||||
|
||||
pub mod auth;
|
||||
12
software/server/src/redis.rs
Normal file
12
software/server/src/redis.rs
Normal 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)
|
||||
}
|
||||
118
software/server/src/routes/auth.rs
Normal file
118
software/server/src/routes/auth.rs
Normal 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"
|
||||
})))
|
||||
}
|
||||
113
software/server/src/routes/cabinet_tree.rs
Normal file
113
software/server/src/routes/cabinet_tree.rs
Normal 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)))
|
||||
}
|
||||
425
software/server/src/routes/cabinets.rs
Normal file
425
software/server/src/routes/cabinets.rs
Normal 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" })))
|
||||
}
|
||||
397
software/server/src/routes/charge_records.rs
Normal file
397
software/server/src/routes/charge_records.rs
Normal 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(¶ms);
|
||||
|
||||
// 序列化查询参数
|
||||
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(())
|
||||
}
|
||||
157
software/server/src/routes/device_logs.rs
Normal file
157
software/server/src/routes/device_logs.rs
Normal 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"
|
||||
})))
|
||||
}
|
||||
280
software/server/src/routes/downloads.rs
Normal file
280
software/server/src/routes/downloads.rs
Normal 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)
|
||||
})))
|
||||
}
|
||||
553
software/server/src/routes/energy_stats.rs
Normal file
553
software/server/src/routes/energy_stats.rs
Normal 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(¶ms);
|
||||
|
||||
// 序列化查询参数
|
||||
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(())
|
||||
}
|
||||
989
software/server/src/routes/h5.rs
Normal file
989
software/server/src/routes/h5.rs
Normal 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(())
|
||||
}
|
||||
11
software/server/src/routes/health.rs
Normal file
11
software/server/src/routes/health.rs
Normal 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"
|
||||
}))
|
||||
}
|
||||
107
software/server/src/routes/mod.rs
Normal file
107
software/server/src/routes/mod.rs
Normal 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)
|
||||
}
|
||||
144
software/server/src/routes/operation_logs.rs
Normal file
144
software/server/src/routes/operation_logs.rs
Normal 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"
|
||||
})))
|
||||
}
|
||||
255
software/server/src/routes/organizations.rs
Normal file
255
software/server/src/routes/organizations.rs
Normal 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!({})))
|
||||
}
|
||||
135
software/server/src/routes/projects.rs
Normal file
135
software/server/src/routes/projects.rs
Normal 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!({})))
|
||||
}
|
||||
248
software/server/src/routes/roles.rs
Normal file
248
software/server/src/routes/roles.rs
Normal 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" })))
|
||||
}
|
||||
564
software/server/src/routes/users.rs
Normal file
564
software/server/src/routes/users.rs
Normal 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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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": "删除成功" })))
|
||||
}
|
||||
245
software/server/src/routes/users_perm.rs
Normal file
245
software/server/src/routes/users_perm.rs
Normal 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(¤t_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(¤t_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(¤t_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(¤t_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" })))
|
||||
}
|
||||
78
software/server/src/tcp/commands.rs
Normal file
78
software/server/src/tcp/commands.rs
Normal 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
Loading…
x
Reference in New Issue
Block a user