Compare commits
No commits in common. "8bc78127339599712f212631c0fb02a949033a93" and "721df313ee6d08f6ee2c3cebffe0feaf05728c40" have entirely different histories.
8bc7812733
...
721df313ee
19
.gitignore
vendored
@ -14,10 +14,9 @@ package-lock.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Environment / 凭据
|
# Environment
|
||||||
# ⚠️ 用 **/.env* 覆盖所有层级的 .env / .env.production / .env.test 等变体;仅放行示例模板
|
.env
|
||||||
**/.env*
|
.env.local
|
||||||
!**/.env.example
|
|
||||||
|
|
||||||
# Temporary
|
# Temporary
|
||||||
*.tmp
|
*.tmp
|
||||||
@ -39,18 +38,6 @@ dist/
|
|||||||
# SWC compiler platform plugins (auto-downloaded by Taro)
|
# SWC compiler platform plugins (auto-downloaded by Taro)
|
||||||
.swc/
|
.swc/
|
||||||
|
|
||||||
# Uploads
|
|
||||||
uploads/
|
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
换电柜用锂电池充电器1TN标准通信协议*.pdf
|
换电柜用锂电池充电器1TN标准通信协议*.pdf
|
||||||
生产环境.txt
|
生产环境.txt
|
||||||
.code-graph/
|
|
||||||
|
|
||||||
# 本地 cargo check 的独立 target-dir(避免与主 target 抢锁,体积大,不入库)
|
|
||||||
.cargo-check-*/
|
|
||||||
|
|
||||||
# 本地临时/工具产物
|
|
||||||
tmp/
|
|
||||||
.codebuddy/
|
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
# 2026-07-07 Session Notes
|
|
||||||
|
|
||||||
## 完成工作:安知充H5应用设计稿 — 修复全部7屏填充色问题
|
|
||||||
|
|
||||||
### 问题诊断
|
|
||||||
- 用户反馈"位置都是乱的",截图验证发现所有7屏几乎全白/空白
|
|
||||||
- 根因:上一轮重建时所有frame节点的 `fills: []` 为空(容器无背景色),只有text节点有fills所以能看到零星文字
|
|
||||||
- batch_read 确认:用户卡片、菜单卡片、设备卡片、订单卡片、通道卡片等所有容器的 fills 均为空数组
|
|
||||||
|
|
||||||
### 修复操作(共6个batch_edit批次,约120+个U()操作)
|
|
||||||
1. **Batch 1 (23 ops)**: 7屏背景色(登录渐变+其余#f5f5f5) + 登录表单元素(输入框白底边框+渐变按钮) + 首页卡片(组织切换/筛选标签/统计行/设备卡×2) + TabBar×2
|
|
||||||
2. **Batch 2 (10 ops)**: 设备列表卡×3 + 仓体详情卡片×3(状态/参数/记录) + 操作按钮×2(停止橙色/开门线框) + 订单卡×3
|
|
||||||
3. **Batch 3 (8 ops)**: 设备详情-信息卡白色 + 开门按钮蓝色 + 板头×6(灰蓝色调)
|
|
||||||
4. **Batch 4 (22 ops)**: 通道卡1-12(板1-2): 白底圆角6px + 彩色状态条(绿/蓝/红/紫)
|
|
||||||
5. **Batch 5 (22 ops)**: 通道卡13-24(板3离线灰色 + 板4混合状态)
|
|
||||||
6. **Batch 6 (22 ops)**: 通道卡25-36(板5-6混合状态)
|
|
||||||
7. **我的页补完**: 用户卡片白底 + 头像蓝底#dbeafe + 菜单卡片白底 + 退出登录按钮(红字红边框) + 底部TabBar(SVG图标)
|
|
||||||
|
|
||||||
### 关键技术发现
|
|
||||||
- ardot batch_edit 中 Update 已有节点:`fills` 必须用 `[{type:"SOLID", color:{r,g,b}}]` 数组格式
|
|
||||||
- `strokes` 必须用数组格式 `[{type:"SOLID", color:{r,g,b}, width:N}]`
|
|
||||||
- Insert 新节点时 `fill:"#FFF"` 简写可用,但 Update 时不可靠
|
|
||||||
|
|
||||||
### 颜色修正(用户反馈"颜色不对"后修复)
|
|
||||||
- 对照全部7个源文件的CSS,逐一修正了所有颜色
|
|
||||||
- **登录页**: 输入框背景 #f8fafc(更正白色), 边框 #e2e8f0(更正#e5e7eb)
|
|
||||||
- **首页**: 组织切换器背景 #f3f4f6(更正白色), 筛选标签激活态 #2563eb蓝(更正#dbeafe浅蓝)
|
|
||||||
- **设备列表**: 状态徽章添加背景圆角#f0fdf4/#f9fafb
|
|
||||||
- **设备详情**: 在线状态徽章/DC/AC标签添加圆角背景#f0fdf4, 导航栏添加底部边框
|
|
||||||
- **仓体详情**: 状态徽章添加圆角背景#eff6ff, 记录项添加底部边框
|
|
||||||
- **订单**: 状态徽章添加圆角背景(正常#f0fdf4/异常#fef2f2/停止#fffbeb), 订单体添加顶部边框
|
|
||||||
- 均用 `strokes: [{type:"SOLID", color:{r,g,b}, width:N}]` 数组格式设置边框
|
|
||||||
@ -4,14 +4,3 @@
|
|||||||
- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局
|
- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局
|
||||||
- **生图能力**:项目有 ImageGen 文生图工具可用,可用于生成产品渲染图等
|
- **生图能力**:项目有 ImageGen 文生图工具可用,可用于生成产品渲染图等
|
||||||
- **操作规则**:开发/修改程序前必须确认需求,不可自说自话操作;生产环境服务器文件和数据必须用户授权后才可动
|
- **操作规则**:开发/修改程序前必须确认需求,不可自说自话操作;生产环境服务器文件和数据必须用户授权后才可动
|
||||||
|
|
||||||
## Ardot 设计稿经验(充电柜H5应用)
|
|
||||||
- **设计稿文件ID**: `701384244279693`("安知充H5应用设计稿")
|
|
||||||
- **7屏完整列表**: 登录(2:1004) 首页(2:1022) 设备详情(2:1094,高1260) 设备列表(2:1334) 仓体详情(2:1359) 订单(2:1411) 我的(2:1468)
|
|
||||||
- **关键教训 — frame fills 必须用数组格式**: `fills` 属性必须用 `[{type:"SOLID", color:{r,g,b}}]` RGB对象数组。`fill:"#FFF"` 十六进制简写在 batch_edit 的 Update 操作中对已有frame节点可能不生效(Insert 新节点时可用简写,Update 已有节点必须用数组格式)
|
|
||||||
- **strokes 必须用数组格式**: `strokes: [{type:"SOLID", color:{r,g,b}, width:N}]`,不是对象形式
|
|
||||||
- **cornerRadius 必须是数字**: 不能传数组
|
|
||||||
- **屏幕背景色**: tab页面 #f5f5f5,子页面(设备详情/列表/仓体详情) 也是 #f5f5f5;登录页用渐变 linear-gradient(135deg,#f0f4ff,#e0e7ff,#f0f9ff)
|
|
||||||
- **卡片统一白色**: 所有卡片/容器 #FFFFFF + 圆角
|
|
||||||
- **颜色系统**: 主蓝#2563eb, 在线绿#16a34a(bg:#f0fdf4), 离线灰#9ca3af(bg:#f9fafb), 故障红#dc2626(bg:#fef2f2), 停止橙#f59e0b(bg:#fffbeb), 充电中蓝#2563eb(bg:#eff6ff), 满电紫#7c3aed
|
|
||||||
- **设备详情36仓**: 6板×6通道=36, 通道卡片白底+顶部3px彩色状态条(色条用RECTANGLE子节点fill表示状态色)
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 264 KiB |
806
AGENTS.md
@ -3,7 +3,7 @@
|
|||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
充电柜矩阵系统(Power Matrix System / PMS)
|
充电柜矩阵系统(Power Matrix System / PMS)
|
||||||
产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。
|
产品名:安知充(anzhizhichong),已有充电站平台在运行,充电柜是新增产品线。
|
||||||
仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
|
仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
|
||||||
|
|
||||||
## 设备标识
|
## 设备标识
|
||||||
@ -11,13 +11,13 @@
|
|||||||
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
|
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
|
||||||
- **设备ID**:4G SIM卡15位IMEI(10进制)
|
- **设备ID**:4G SIM卡15位IMEI(10进制)
|
||||||
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
|
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
|
||||||
- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`)
|
- **扫码链接**:`https://app.anzhizhichong.com/h5/{抽象ID}`
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 后端:Rust + Axum
|
- 后端:Rust + Axum
|
||||||
- 前端:React + Arco Design Pro(React版)
|
- 前端:React + Arco Design Pro(React版)
|
||||||
- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
|
- H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序)
|
||||||
- 数据库:MySQL(阿里云RDS),开发/生产共用
|
- 数据库:MySQL(阿里云RDS),开发/生产共用
|
||||||
- 缓存:Redis(阿里云Redis),开发/生产共用
|
- 缓存:Redis(阿里云Redis),开发/生产共用
|
||||||
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
||||||
@ -38,14 +38,12 @@ D:\charging-cabinet\
|
|||||||
├── software/
|
├── software/
|
||||||
│ ├── api-server/ # Rust API服务
|
│ ├── api-server/ # Rust API服务
|
||||||
│ ├── device-server/ # Rust TCP设备服务
|
│ ├── device-server/ # Rust TCP设备服务
|
||||||
│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
|
│ ├── h5-uniapp/ # H5用户端(uni-app + Vue 3)
|
||||||
│ └── web/ # React前端(WEB管理端)
|
│ └── web/ # React前端(WEB管理端)
|
||||||
├── hardware/ # 硬件资料(原理图/PCB等)
|
├── hardware/ # 硬件资料(原理图/PCB等)
|
||||||
└── test/ # 测试用例/报告
|
└── test/ # 测试用例/报告
|
||||||
```
|
```
|
||||||
|
|
||||||
> **技能库不入库** — 仓库内不存放任何 skills(`.opencode/skills`、`software/h5-uniapp/.agents/skills`、`software/h5-uniapp/agent/skills` 已于提交 `9829e9d` 移除)。开发期技能统一从全局技能目录加载:`~/.dsh/skills`(如 wechatide-skill)、`~/.agents/skills`(如 anysearch)。请勿在仓库内重新 vendor 技能副本。
|
|
||||||
|
|
||||||
## 部署架构
|
## 部署架构
|
||||||
|
|
||||||
### 环境规划
|
### 环境规划
|
||||||
@ -55,27 +53,14 @@ D:\charging-cabinet\
|
|||||||
| 正式环境 | app.anzhizhichong.com | :9000 | :9001 | db5 | /data/pms | /pms/ |
|
| 正式环境 | app.anzhizhichong.com | :9000 | :9001 | db5 | /data/pms | /pms/ |
|
||||||
| 测试环境 | test.anzhizhichong.com | :3000 | :3001 | db6 | /data/pms_dev | /pms/ |
|
| 测试环境 | test.anzhizhichong.com | :3000 | :3001 | db6 | /data/pms_dev | /pms/ |
|
||||||
|
|
||||||
**服务端口分配(规划):**
|
**服务端口分配:**
|
||||||
- 正式环境:API 9000,TCP 9001
|
|
||||||
- 测试环境:API 3000,TCP 3001
|
- 测试环境:API 3000,TCP 3001
|
||||||
|
- 正式环境:API 9000,TCP 9001
|
||||||
> ✅ **实际部署现状(2026-09 已按规划完成迁移)**:
|
|
||||||
> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx
|
|
||||||
> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env`
|
|
||||||
> (**新二进制强制要求环境变量,缺它起不来**)。
|
|
||||||
> - **测试环境按本表规划独立部署在同一台服务器上**(测试就是测试,与生产完全隔离):
|
|
||||||
> `pms-api-test :3000` + `pms-device-test :3001`,路径 `/data/pms_dev`,连 `pms_dev` 库 + Redis **db6**;
|
|
||||||
> `test.anzhizhichong.com` 的 nginx `/api/pms/` 指向 `:3000`。
|
|
||||||
> - ⚠️ **绝对不要把测试域名的 API 指到生产端口 `:9000`** —— 历史事故:测试 nginx 曾指向
|
|
||||||
> `:3000`,而当时 `:3000` 正是生产 API,等于**测试后台在操作生产数据**。
|
|
||||||
> 判断方法:`test` 域名 `/api/pms/` 返回 **401**(有测试后端)是对的;若返回 **502** 说明测试后端没起。
|
|
||||||
> - 两个环境必须同时满足:生产 `:9000/:9001`(db5/pms)、测试 `:3000/:3001`(db6/pms_dev),
|
|
||||||
> 端口或库号任一串了就是事故。
|
|
||||||
|
|
||||||
**部署路径:**
|
**部署路径:**
|
||||||
- 产品介绍页:/data/landing
|
- 产品介绍页:/data/landing
|
||||||
- 正式环境:/data/pms(api-server、device-server、web)
|
- 正式环境:/data/pms(api-server、device-server、web、h5)
|
||||||
- 测试环境:/data/pms_dev(api-server、device-server、web)
|
- 测试环境:/data/pms_dev(api-server、device-server、web、h5)
|
||||||
|
|
||||||
**Nginx配置:**
|
**Nginx配置:**
|
||||||
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
|
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
|
||||||
@ -155,19 +140,23 @@ python tools/db.py import data.sql
|
|||||||
|
|
||||||
### 禁止操作
|
### 禁止操作
|
||||||
|
|
||||||
- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
|
|
||||||
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
|
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
|
||||||
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
|
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
|
||||||
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
|
|
||||||
|
### 编码规范
|
||||||
|
|
||||||
|
- 任务需求写到 `tasks/` 目录的md文件
|
||||||
|
- 通过Agent子代理包裹opencode执行,异步通知
|
||||||
|
- 任务要拆的尽量细
|
||||||
|
- 质量约束模板固定追加到每个任务文件
|
||||||
|
- opencode超时必须报告用户,不能fallback手动执行
|
||||||
|
- 审查用不同模型(不用开发同款)
|
||||||
|
|
||||||
### 编码任务下发
|
### 编码任务下发
|
||||||
|
|
||||||
- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md)
|
- 任务需求写到 `tasks/` 目录的md文件
|
||||||
- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json)
|
- 通过Agent子代理包裹opencode执行,异步通知
|
||||||
- 完成后由 PM(本 agent)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件
|
- 质量约束模板固定追加到每个任务文件
|
||||||
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
|
|
||||||
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
|
|
||||||
- 已完成的任务文件及时清理
|
|
||||||
|
|
||||||
### 质量约束模板
|
### 质量约束模板
|
||||||
|
|
||||||
@ -183,294 +172,9 @@ python tools/db.py import data.sql
|
|||||||
|
|
||||||
### 验收流程
|
### 验收流程
|
||||||
|
|
||||||
1. `cargo check` 两个服务(api-server + device-server)零错误
|
1. 静态校验命令通过
|
||||||
2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
|
2. 跑起来看效果
|
||||||
3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
|
3. 抽查关键逻辑(异常处理、权限校验、协议解析)
|
||||||
4. 审查通过 → 清理任务文件,更新文档
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rust 编码规范
|
|
||||||
|
|
||||||
### 错误处理
|
|
||||||
|
|
||||||
- 使用 `thiserror` 定义业务错误枚举,`AppError` 统一包装
|
|
||||||
- 所有 `sqlx` / `redis` 调用必须 `.map_err(AppError::Database)?`
|
|
||||||
- 网络请求/外部IO 必须 `match` 或 `if let` 处理,禁止裸 `.unwrap()`
|
|
||||||
- 关键业务路径用 `tracing::error!` 记录错误上下文
|
|
||||||
|
|
||||||
### Async 模式
|
|
||||||
|
|
||||||
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
|
|
||||||
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
|
|
||||||
- Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** ——
|
|
||||||
本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`,
|
|
||||||
这种组合下 `BRPOP` 语义不可靠(实测 2026-09):
|
|
||||||
- `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**;
|
|
||||||
- `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。
|
|
||||||
**规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致),
|
|
||||||
延迟稳定在 200ms 内;取到消息后 `tokio::spawn` 并发处理。
|
|
||||||
|
|
||||||
### 模块划分
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── routes/ # HTTP 路由处理器(按业务拆分)
|
|
||||||
├── middleware/ # 中间件(认证、日志、CORS、scope_guard 组织归属校验)
|
|
||||||
├── db/ # 数据库连接池、迁移
|
|
||||||
├── commands/ # 设备指令下发逻辑(含 AppState 定义)
|
|
||||||
├── nodes/ # 设备服务节点管理
|
|
||||||
├── workers/ # 后台 Worker(report/reply)
|
|
||||||
└── error.rs # 统一错误定义
|
|
||||||
```
|
|
||||||
|
|
||||||
### 命名约定
|
|
||||||
|
|
||||||
- 路由处理器:`动词 + 名词`(如 `get_cabinets`、`create_organization`)
|
|
||||||
- 数据库查询结构体:`XxxRow`(如 `CabinetBasicRow`)
|
|
||||||
- 请求体:`XxxBody` 或 `XxxQuery`(如 `CreateCabinetBody`)
|
|
||||||
- 常量:`UPPER_SNAKE_CASE`(如 `JWT_SECRET_ENV`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## React 编码规范
|
|
||||||
|
|
||||||
### 组件规范
|
|
||||||
|
|
||||||
- 纯函数组件 + Hooks,禁止 Class 组件
|
|
||||||
- 组件文件与路由页面一一对应,放 `src/pages/` 下
|
|
||||||
- 复用组件放 `src/components/`,按功能命名
|
|
||||||
- Props 接口定义在组件文件顶部,用 `interface` 声明
|
|
||||||
|
|
||||||
### 状态管理
|
|
||||||
|
|
||||||
- 页面级状态用 `useState` / `useEffect`
|
|
||||||
- 全局状态用 `zustand`(如用户信息、权限)
|
|
||||||
|
|
||||||
### 样式规范
|
|
||||||
|
|
||||||
- 使用 Arco Design 组件库,优先使用组件自带的样式
|
|
||||||
- 内联 style 仅用于动态计算值,静态样式用 CSS class
|
|
||||||
- 表格列统一 `min-width` + `align="center"`,无 `fixed="right"`
|
|
||||||
- 分页:上下双分页,`page-sizes="[10,20,30,50]"`
|
|
||||||
|
|
||||||
### 图标映射
|
|
||||||
|
|
||||||
| 操作 | 图标 |
|
|
||||||
|------|------|
|
|
||||||
| 编辑 | Edit |
|
|
||||||
| 删除 | Delete |
|
|
||||||
| 查看 | ViewIcon |
|
|
||||||
| 搜索 | Search |
|
|
||||||
| 重置 | Refresh |
|
|
||||||
| 新增 | Plus(保留文字) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 设计规范
|
|
||||||
|
|
||||||
### 响应格式
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0, // 0=成功,非0=失败
|
|
||||||
"data": { ... }, // 业务数据
|
|
||||||
"message": "ok" // 提示信息
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 错误码
|
|
||||||
|
|
||||||
| code | 含义 |
|
|
||||||
|------|------|
|
|
||||||
| 0 | 成功 |
|
|
||||||
| 400 | 请求参数错误 |
|
|
||||||
| 401 | 未认证(token缺失/无效) |
|
|
||||||
| 403 | 无权限 |
|
|
||||||
| 404 | 资源不存在 |
|
|
||||||
| 500 | 服务器内部错误 |
|
|
||||||
|
|
||||||
### 分页约定
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": [...],
|
|
||||||
"total": 100,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### RESTful 约定
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | /api/xxx | 列表查询 |
|
|
||||||
| GET | /api/xxx/:id | 详情 |
|
|
||||||
| POST | /api/xxx | 创建 |
|
|
||||||
| PUT | /api/xxx/:id | 更新 |
|
|
||||||
| DELETE | /api/xxx/:id | 删除 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git 工作流
|
|
||||||
|
|
||||||
### 分支策略
|
|
||||||
|
|
||||||
- `main` — 正式环境,只接受 PR 合并
|
|
||||||
- `develop` — 开发分支,日常开发在此分支
|
|
||||||
- `feature/*` — 功能分支,从 develop 拉出,完成后合并回 develop
|
|
||||||
- `fix/*` — 修复分支,从 main 或 develop 拉出
|
|
||||||
|
|
||||||
### Commit 规范
|
|
||||||
|
|
||||||
格式:`<type>(<scope>): <subject>`
|
|
||||||
|
|
||||||
| type | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| feat | 新功能 |
|
|
||||||
| fix | 修复 bug |
|
|
||||||
| docs | 文档变更 |
|
|
||||||
| style | 代码格式(不影响逻辑) |
|
|
||||||
| refactor | 重构(不新增功能/修复bug) |
|
|
||||||
| test | 测试相关 |
|
|
||||||
| chore | 构建/工具变更 |
|
|
||||||
|
|
||||||
示例:
|
|
||||||
- `feat(cabinets): 添加设备RSSI信号显示`
|
|
||||||
- `fix(mp): 修复登录后token未持久化问题`
|
|
||||||
- `docs(api): 更新充电记录接口文档`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试标准
|
|
||||||
|
|
||||||
### 测试类型
|
|
||||||
|
|
||||||
| 类型 | 工具 | 要求 |
|
|
||||||
|------|------|------|
|
|
||||||
| 单元测试 | Rust `#[test]` / Vitest | 核心业务逻辑必须覆盖 |
|
|
||||||
| 集成测试 | API 请求验证 | 关键接口(登录/充电/开门)必须验证 |
|
|
||||||
| 静态校验 | `cargo check` / `tsc --noEmit` | 零报错零警告 |
|
|
||||||
|
|
||||||
### 测试覆盖率
|
|
||||||
|
|
||||||
- 核心业务逻辑(充电控制、指令下发、权限校验):≥80%
|
|
||||||
- 数据库查询函数:≥60%
|
|
||||||
- 工具函数:≥90%
|
|
||||||
|
|
||||||
### 测试命名
|
|
||||||
|
|
||||||
```
|
|
||||||
test_<被测函数>_<场景>_<期望结果>
|
|
||||||
|
|
||||||
test_verify_password_correct_returns_true
|
|
||||||
test_verify_password_wrong_returns_false
|
|
||||||
test_check_permission_admin_returns_ok
|
|
||||||
test_check_permission_user_returns_forbidden
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安全规范
|
|
||||||
|
|
||||||
### 认证授权
|
|
||||||
|
|
||||||
- JWT Token 有效期:24小时,过期需重新登录
|
|
||||||
- JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret
|
|
||||||
- 密码存储:argon2 哈希,禁止明文或 MD5/SHA1
|
|
||||||
- RBAC 权限码:精确到按钮级别(如 `device:door:unlock`)
|
|
||||||
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
|
|
||||||
- 敏感操作(删除/禁用/开门)需二次确认
|
|
||||||
- TCP 连接认证:设备 login 成功(响应含 `spec` + `suc=1`)前,敏感指令(open/pow_on/pow_off/reset/auth_str)不投递
|
|
||||||
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
|
|
||||||
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
|
|
||||||
- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定
|
|
||||||
- **未登录连接不予保持**:建连后 30 秒内未 `login` 成功即主动断开
|
|
||||||
|
|
||||||
### 输入校验
|
|
||||||
|
|
||||||
- 所有 API 入参必须校验(长度、格式、范围)
|
|
||||||
- SQL 查询使用参数化查询,禁止字符串拼接
|
|
||||||
- IMEI 校验:必须15位数字
|
|
||||||
- 手机号校验:11位数字,正则 `^1[3-9]\d{9}$`
|
|
||||||
|
|
||||||
### 数据安全
|
|
||||||
|
|
||||||
- 生产数据库密码不入代码,使用环境变量
|
|
||||||
- Redis 密码同上
|
|
||||||
- 日志禁止输出密码/Token/密钥
|
|
||||||
- 敏感字段(密码、auth_str)在序列化时跳过
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 日志规范
|
|
||||||
|
|
||||||
### 日志级别
|
|
||||||
|
|
||||||
| 级别 | 用途 | 示例 |
|
|
||||||
|------|------|------|
|
|
||||||
| `ERROR` | 系统异常、业务错误 | 数据库连接失败、指令下发超时 |
|
|
||||||
| `WARN` | 潜在问题、可恢复异常 | 设备离线、重试成功 |
|
|
||||||
| `INFO` | 关键业务事件 | 设备登录、充电开始/结束、用户操作 |
|
|
||||||
| `DEBUG` | 调试信息 | API 请求参数、SQL 执行时间 |
|
|
||||||
|
|
||||||
### 日志格式
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 使用 tracing crate
|
|
||||||
tracing::info!(imei = %imei, board_id = board_id, "设备登录成功");
|
|
||||||
tracing::error!(err = %e, "数据库查询失败");
|
|
||||||
tracing::warn!(compartment_id = id, "仓体状态异常");
|
|
||||||
```
|
|
||||||
|
|
||||||
### 关键操作日志
|
|
||||||
|
|
||||||
以下操作必须记录:
|
|
||||||
|
|
||||||
| 操作 | 日志内容 |
|
|
||||||
|------|----------|
|
|
||||||
| 用户登录/登出 | 用户ID、手机号、IP |
|
|
||||||
| 设备指令下发 | IMEI、指令类型、仓体ID |
|
|
||||||
| 充电开始/结束 | 仓体ID、时长、电量 |
|
|
||||||
| 组织/项目变更 | 操作类型、ID、名称 |
|
|
||||||
| 权限变更 | 用户ID、权限码、操作人 |
|
|
||||||
| 数据删除 | 表名、ID、操作人 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 性能规范
|
|
||||||
|
|
||||||
### 数据库
|
|
||||||
|
|
||||||
- 查询必须走索引,禁止全表扫描(`EXPLAIN` 验证)
|
|
||||||
- 分页查询:避免深分页,用游标分页(`WHERE id > ?`)
|
|
||||||
- 批量操作:用 `INSERT ... VALUES (...), (...)` 而非逐条插入
|
|
||||||
- 连接池:MySQL 最大连接数 ≤ 50,Redis 连接数 ≤ 20
|
|
||||||
|
|
||||||
### Redis
|
|
||||||
|
|
||||||
- 缓存 key 规范:`{业务}:{标识}`(如 `device:{imei}`)
|
|
||||||
- TTL 设置:在线状态 300秒,缓存数据按业务设定
|
|
||||||
- 避免大 Key:单个 Hash ≤ 500 字段,List ≤ 1000 元素
|
|
||||||
- 批量操作:用 Pipeline 减少往返次数
|
|
||||||
|
|
||||||
### API 响应
|
|
||||||
|
|
||||||
- 列表接口响应时间 ≤ 500ms
|
|
||||||
- 详情接口响应时间 ≤ 200ms
|
|
||||||
- 批量操作接口响应时间 ≤ 1s
|
|
||||||
- 响应体大小:列表 ≤ 1MB,详情 ≤ 100KB
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
|
|
||||||
- 首屏加载 ≤ 3s(3G网络)
|
|
||||||
- 交互响应 ≤ 100ms
|
|
||||||
- 列表滚动:虚拟滚动(≥100条数据)
|
|
||||||
- 图片:WebP 格式,懒加载
|
|
||||||
|
|
||||||
## 部署与维护
|
## 部署与维护
|
||||||
|
|
||||||
@ -486,48 +190,19 @@ echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@"
|
|||||||
|
|
||||||
### 数据库连接
|
### 数据库连接
|
||||||
|
|
||||||
所有数据库操作通过 ZeroTier 直接连接 `10.8.0.252`,无需 SSH 隧道到服务器。
|
|
||||||
|
|
||||||
**MySQL(通过 ZeroTier):**
|
|
||||||
```bash
|
```bash
|
||||||
# 命令行
|
# MySQL(通过ZeroTier代理)
|
||||||
mysql -h 10.8.0.252 -u root -p
|
mysql -h 10.8.0.252 -u root -p
|
||||||
# 密码:Hbhyg731024@
|
# 密码:Hbhyg731024@
|
||||||
# 测试环境库:pms_dev
|
# 测试环境库:pms_dev
|
||||||
# 正式环境库:pms
|
# 正式环境库:pms
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
# Redis(通过ZeroTier代理)
|
||||||
# Python(Agent 常用方式)
|
|
||||||
import pymysql
|
|
||||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 正式环境
|
|
||||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms_dev') # 测试环境
|
|
||||||
```
|
|
||||||
|
|
||||||
**Redis(通过 ZeroTier):**
|
|
||||||
```bash
|
|
||||||
# 命令行
|
|
||||||
redis-cli -h 10.8.0.252 -a "Hbhyg731024@"
|
redis-cli -h 10.8.0.252 -a "Hbhyg731024@"
|
||||||
# 测试环境:select 6
|
# 测试环境:db6
|
||||||
# 正式环境:select 5
|
# 正式环境:db5
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
|
||||||
# Python(Agent 常用方式)
|
|
||||||
import redis
|
|
||||||
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5) # 正式环境
|
|
||||||
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6) # 测试环境
|
|
||||||
```
|
|
||||||
|
|
||||||
**数据库工具 `tools/db.py`(默认连接测试库 pms_dev):**
|
|
||||||
```bash
|
|
||||||
python tools/db.py tables # 列出所有表
|
|
||||||
python tools/db.py count # 统计各表数据量
|
|
||||||
python tools/db.py query "SELECT * FROM users" # 查询
|
|
||||||
python tools/db.py exec "UPDATE users SET ..." # 写入
|
|
||||||
```
|
|
||||||
> 注意:`tools/db.py` 默认连的是 `pms_dev`(测试库),要操作正式库请用上面的 Python 直连方式。
|
|
||||||
|
|
||||||
### Nginx配置
|
### Nginx配置
|
||||||
|
|
||||||
**正式环境** (`/etc/nginx/conf.d/app.anzhizhichong.com.conf`):
|
**正式环境** (`/etc/nginx/conf.d/app.anzhizhichong.com.conf`):
|
||||||
@ -560,28 +235,34 @@ server {
|
|||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
}
|
}
|
||||||
|
|
||||||
# PMS API 入口(微服务统一前缀 /api/{服务})
|
location /pms/api/ {
|
||||||
location /api/pms/ {
|
proxy_pass http://127.0.0.1:9000/;
|
||||||
proxy_pass http://127.0.0.1:9000/api/;
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# PMS管理前端 - 不带斜杠时重定向
|
||||||
|
location = /pms {
|
||||||
|
return 301 /pms/;
|
||||||
|
}
|
||||||
|
|
||||||
# PMS管理前端
|
# PMS管理前端
|
||||||
location /pms/ {
|
location /pms/ {
|
||||||
alias /data/pms/web/;
|
alias /data/pms/web/;
|
||||||
try_files $uri $uri/ /pms/index.html;
|
try_files $uri $uri/ /pms/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 微信回调(免认证)
|
# H5用户端 - 不带斜杠时重定向
|
||||||
location /api/wechat/ {
|
location = /h5 {
|
||||||
proxy_pass http://127.0.0.1:9000;
|
return 301 /h5/;
|
||||||
proxy_set_header Host $host;
|
}
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# H5用户端
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
location /h5/ {
|
||||||
|
alias /data/pms/h5/;
|
||||||
|
try_files $uri $uri/ /h5/index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -606,9 +287,8 @@ server {
|
|||||||
root /data/pms_dev;
|
root /data/pms_dev;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# PMS API 入口(微服务统一前缀 /api/{服务})
|
location /pms/api/ {
|
||||||
location /api/pms/ {
|
proxy_pass http://127.0.0.1:3000/;
|
||||||
proxy_pass http://127.0.0.1:3000/api/;
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@ -619,13 +299,10 @@ server {
|
|||||||
try_files $uri $uri/ /pms/index.html;
|
try_files $uri $uri/ /pms/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 微信回调(免认证)
|
# H5用户端
|
||||||
location /api/wechat/ {
|
location /h5/ {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
alias /data/pms_dev/h5/;
|
||||||
proxy_set_header Host $host;
|
try_files $uri $uri/ /h5/index.html;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -634,80 +311,55 @@ server {
|
|||||||
|
|
||||||
### 部署步骤
|
### 部署步骤
|
||||||
|
|
||||||
> ✅ **推荐用一键脚本(在本地开发机执行)**:
|
|
||||||
> ```bash
|
|
||||||
> pwsh deploy/deploy-prod.ps1 # 完整部署:构建 → 校验产物 → 服务器备份 → 传 .new → 原子替换 → 重启 → 验收
|
|
||||||
> pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,只重发现有产物
|
|
||||||
> pwsh deploy/deploy-prod.ps1 -DryRun # 演练,不做任何改动
|
|
||||||
> pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端
|
|
||||||
> ```
|
|
||||||
> 脚本会自动做:产物路径校验、**上传后 `chmod +x`**、**传 `.new` 再原子 `mv`**(避开 ETXTBSY)、
|
|
||||||
> `nginx -t` 后才 reload、验收(服务/端口/域名/ERROR 数)、以及**用不存在的 IMEI 探测上报消费链路**
|
|
||||||
> (不碰真实设备数据),最后打印回滚方法。
|
|
||||||
>
|
|
||||||
> ⛔ **`deploy/deploy-prod.sh` 与 `deploy/deploy-test.sh` 已废弃**(执行即报错退出):
|
|
||||||
> 它们在 `target/release/` 找产物、会安装重复的 `pms-api-prod` 单元、并会用本地 `.env`
|
|
||||||
> 覆盖线上 `.env`(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。
|
|
||||||
|
|
||||||
**1. 本地编译**
|
**1. 本地编译**
|
||||||
```bash
|
```bash
|
||||||
# 后端(Rust)— Windows 交叉编译 Linux 目标
|
# 后端(Rust)
|
||||||
cd software/api-server
|
cd software/api-server
|
||||||
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
cargo build --release
|
||||||
|
|
||||||
cd software/device-server
|
cd software/device-server
|
||||||
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
cargo build --release
|
||||||
|
|
||||||
# 前端(React 管理端)
|
# 前端(React)
|
||||||
cd software/web
|
cd software/web
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# 微信小程序用户端(uni-app)
|
|
||||||
cd software/h5-uniapp
|
|
||||||
npm run build:mp-weixin
|
|
||||||
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. 传输到服务器**
|
**2. 传输到服务器**
|
||||||
```bash
|
```bash
|
||||||
# 传输后端二进制(测试环境)
|
# 传输后端二进制(测试环境)
|
||||||
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
|
scp software/api-server/target/release/api-server root@118.31.119.206:/data/pms_dev/api-server/
|
||||||
scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms_dev/device-server/
|
scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms_dev/device-server/
|
||||||
|
|
||||||
# 传输后端二进制(正式环境)
|
# 传输后端二进制(正式环境)
|
||||||
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/
|
scp software/api-server/target/release/api-server root@118.31.119.206:/data/pms/api-server/
|
||||||
scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms/device-server/
|
scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms/device-server/
|
||||||
|
|
||||||
# 传输前端静态文件
|
# 传输前端静态文件
|
||||||
scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境
|
scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境
|
||||||
scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境
|
scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境
|
||||||
|
|
||||||
|
# 传输H5用户端(tar.gz打包方式,保留UTF-8编码)
|
||||||
|
cd software/h5-uniapp/dist/build/h5
|
||||||
|
tar -czf /tmp/h5-build.tar.gz .
|
||||||
|
pscp -pw "Hbhyg731024@" /tmp/h5-build.tar.gz root@118.31.119.206:/tmp/
|
||||||
|
plink -ssh -pw "Hbhyg731024@" root@118.31.119.206 "rm -rf /data/pms/h5/* && cd /data/pms/h5 && tar -xzf /tmp/h5-build.tar.gz"
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ 后端二进制必须用 `cargo zigbuild --release --target x86_64-unknown-linux-gnu` 交叉编译,服务器 2核/3.5G 会 OOM,禁止在服务器上编译 Rust。
|
> ⚠️ H5部署必须用tar.gz打包方式,不要用plink/pscp管道传单文件(会丢失UTF-8编码导致中文变问号)。
|
||||||
>
|
|
||||||
> ⚠️ **`pscp`/`scp` 上传后必须 `chmod +x`**(否则 systemd 报 `203/EXEC`);
|
|
||||||
> 且**运行中的二进制不可直接覆盖**(Linux 报 `ETXTBSY`)—— 正确姿势:
|
|
||||||
> 传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。
|
|
||||||
>
|
|
||||||
> ⚠️ **`.env` 不支持行内注释**:`PORT=3000 # 注释` 会让配置解析失败、服务起不来,注释必须独占一行。
|
|
||||||
|
|
||||||
**2.1 微信小程序上传**
|
|
||||||
|
|
||||||
小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传:
|
|
||||||
|
|
||||||
1. 打开微信开发者工具
|
|
||||||
2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin`
|
|
||||||
3. 填入 AppID(已配置在 `manifest.json`)
|
|
||||||
4. 预览/真机调试 → 上传体验版/正式版
|
|
||||||
|
|
||||||
**3. 服务器重启服务**
|
**3. 服务器重启服务**
|
||||||
```bash
|
```bash
|
||||||
# SSH到服务器
|
# SSH到服务器
|
||||||
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
|
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
|
||||||
|
|
||||||
# 正式环境
|
# 重启测试环境
|
||||||
systemctl restart pms-api
|
systemctl restart pms-api-test
|
||||||
systemctl restart pms-device
|
systemctl restart pms-device-test
|
||||||
|
|
||||||
|
# 重启正式环境
|
||||||
|
systemctl restart pms-api-prod
|
||||||
|
systemctl restart pms-device-prod
|
||||||
|
|
||||||
# 重启Nginx
|
# 重启Nginx
|
||||||
systemctl restart nginx
|
systemctl restart nginx
|
||||||
@ -717,13 +369,13 @@ systemctl restart nginx
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看服务状态
|
# 查看服务状态
|
||||||
systemctl status pms-api # 正式环境API
|
systemctl status pms-api-test # 测试环境API
|
||||||
systemctl status pms-device # 正式环境TCP
|
systemctl status pms-api-prod # 正式环境API
|
||||||
systemctl status nginx
|
systemctl status nginx
|
||||||
|
|
||||||
# 查看日志
|
# 查看日志
|
||||||
journalctl -u pms-api -f
|
journalctl -u pms-api-test -f
|
||||||
journalctl -u pms-device -f
|
journalctl -u pms-api-prod -f
|
||||||
tail -f /var/log/nginx/error.log
|
tail -f /var/log/nginx/error.log
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -740,11 +392,12 @@ tail -f /var/log/nginx/error.log
|
|||||||
### 做
|
### 做
|
||||||
|
|
||||||
- 后台管理系统(Web端)
|
- 后台管理系统(Web端)
|
||||||
- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传)
|
- 用户端H5页面(`/pms/{id}`,微信扫码可访问)
|
||||||
- 设备TCP通讯服务(协议文档已就绪)
|
- 设备TCP通讯服务(协议文档已就绪)
|
||||||
|
|
||||||
### 不做
|
### 不做
|
||||||
|
|
||||||
|
- 微信小程序(名额暂不可用,先用H5,uni-app可后续转换)
|
||||||
- 支付/计费(本系统不涉及)
|
- 支付/计费(本系统不涉及)
|
||||||
|
|
||||||
## 组织架构
|
## 组织架构
|
||||||
@ -833,128 +486,259 @@ device:{imei} # 设备级 Hash
|
|||||||
|
|
||||||
## TCP通讯协议
|
## TCP通讯协议
|
||||||
|
|
||||||
详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
|
- 长连接,无需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`)
|
||||||
|
|
||||||
## 数据单位规范
|
### 签名机制
|
||||||
|
|
||||||
详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
|
- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次)
|
||||||
|
- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)`
|
||||||
|
- 时间戳秒级,平台校验±60秒
|
||||||
|
|
||||||
> ✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 `write_number` API 参数为 f64(`i64 as f64` 转换,非业务浮点)。
|
### 指令清单
|
||||||
|
|
||||||
## 微信小程序用户端
|
| 动作 | 方向 | id格式 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) |
|
||||||
|
| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) |
|
||||||
|
| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) |
|
||||||
|
| `pow_fail` | 设备→平台 | 无 | 12V电源掉电上报 |
|
||||||
|
| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入(含BMS数据,字段待定) |
|
||||||
|
| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
|
||||||
|
| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) |
|
||||||
|
| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) |
|
||||||
|
| `open` | 平台→设备 | `IMEI` | 开门 |
|
||||||
|
| `pow_on` | 平台→设备 | `IMEI` | 接触器合闸(恢复380V供电) |
|
||||||
|
| `pow_off` | 平台→设备 | `IMEI` | 接触器分闸(切断380V供电) |
|
||||||
|
| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 |
|
||||||
|
|
||||||
### 小程序配置
|
### 指令详情
|
||||||
|
|
||||||
| 参数 | 值 |
|
#### login(设备→平台)
|
||||||
|------|-----|
|
|
||||||
| AppID | `wx226061c550c52e85` |
|
|
||||||
| AppSecret | `f11dfee9dc73265b75bf54277ebb1047` |
|
|
||||||
| 账号 | `13951516272@163.com` |
|
|
||||||
| 消息加解密方式 | 明文模式 |
|
|
||||||
| 数据格式 | XML |
|
|
||||||
| 服务器地址(URL) | `https://app.anzhizhichong.com/api/wechat/callback` |
|
|
||||||
| Token(令牌) | `7GW16XdnFyak0mE32rc8ZPij` |
|
|
||||||
| EncodingAESKey | `kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu` |
|
|
||||||
|
|
||||||
> ⚠️ 以上密钥仅记录在内网 Git 仓库 `生产环境.txt`,不入代码仓库。
|
设备登录,验证签名。
|
||||||
|
|
||||||
### 构建命令
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"h_ver":"1.0.0","s_ver":"1.0.0"}
|
||||||
|
|
||||||
```bash
|
// 响应
|
||||||
cd software/h5-uniapp
|
{"suc":1,"msg_id":1,"cell":6}
|
||||||
npm run build:mp-weixin
|
|
||||||
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### API 域名配置
|
**字段说明:**
|
||||||
|
- `sign` — 签名(SHA256(dev_id+auth_str+timestamp)后8位)
|
||||||
|
- `timestamp` — 秒级时间戳(平台校验±60秒)
|
||||||
|
- `h_ver` — 硬件版本
|
||||||
|
- `s_ver` — 软件版本
|
||||||
|
- `cell` — 仓控板数量(响应)
|
||||||
|
|
||||||
小程序请求后端 API 必须配置合法域名(HTTPS):
|
---
|
||||||
|
|
||||||
- 后台「开发管理 → 开发设置 → 服务器域名」
|
#### auth_str(设备→平台)
|
||||||
- request 合法域名:`https://app.anzhizhichong.com`
|
|
||||||
- 开发阶段可在 `manifest.json` 设 `urlCheck: false` 跳过校验
|
|
||||||
|
|
||||||
### API base URL
|
获取8位安全码,仅首次连接调用。
|
||||||
|
|
||||||
小程序用全 URL(`api/index.ts` 条件编译):
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345","ccid":"89860000000000000000"}
|
||||||
|
|
||||||
```typescript
|
// 响应
|
||||||
// #ifdef MP-WEIXIN
|
{"suc":1,"msg_id":2,"data":{"auth_str":"abcd1234"}}
|
||||||
const BASE_URL = 'https://app.anzhizhichong.com/api/pms'
|
|
||||||
// #endif
|
|
||||||
// #ifndef MP-WEIXIN
|
|
||||||
const BASE_URL = '/api/pms'
|
|
||||||
// #endif
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 信号强度显示(RSSI)
|
**字段说明:**
|
||||||
|
- `ccid` — SIM卡ICCID号
|
||||||
|
- `auth_str` — 8位随机安全码(响应,仅返回一次)
|
||||||
|
|
||||||
设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和小程序共用同一套逻辑。
|
---
|
||||||
|
|
||||||
**RSSI 映射:**
|
#### status_post(设备→平台)
|
||||||
|
|
||||||
| RSSI 范围 | 图标 (Lucide) | 信号格 | 颜色 |
|
定时上报,空闲60秒/充电15秒。
|
||||||
|-----------|--------------|--------|------|
|
|
||||||
| ≥ 70 | signal | 5 格(含 M22) | #16a34a 绿色 |
|
|
||||||
| ≥ 55 | signal-high | 4 格(含 M17) | #2563eb 蓝色 |
|
|
||||||
| ≥ 40 | signal-medium | 3 格(含 M12) | #f59e0b 黄色 |
|
|
||||||
| ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 |
|
|
||||||
| < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) |
|
|
||||||
|
|
||||||
**实现方式(小程序):**
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,"status":{"123456789012345-01":"000100020003000400050006"}}
|
||||||
|
|
||||||
```vue
|
// 响应
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" ...>
|
{"suc":1,"msg_id":10}
|
||||||
<path v-if="(rssi ?? -1) >= 30" d="M7 20v-4"/>
|
|
||||||
<path v-if="(rssi ?? -1) >= 40" d="M12 20v-8"/>
|
|
||||||
<path v-if="(rssi ?? -1) >= 55" d="M17 20V8"/>
|
|
||||||
<path v-if="(rssi ?? -1) >= 70" d="M22 4v16"/>
|
|
||||||
<path d="M2 20h.01"/>
|
|
||||||
</svg>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `v-if` 按 RSSI 阈值动态显示信号格
|
**status字段说明:**
|
||||||
- `<text>` 不支持 `display: flex`,统一用 `<view>` 包裹
|
- key: `IMEI-仓控板ID`
|
||||||
|
- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
|
||||||
|
|
||||||
**实现方式(PC 管理端):**
|
---
|
||||||
|
|
||||||
React 组件 `SignalIcon`,同上逻辑用条件渲染 `<path>`。
|
#### pow_fail(设备→平台)
|
||||||
|
|
||||||
**后端 RSSI 获取:**
|
电源掉电上报。
|
||||||
|
|
||||||
设备列表接口通过 Redis pipeline 批量 HGET 查询 `device:{imei}` 的 `rssi` 字段:
|
```json
|
||||||
```
|
// 请求
|
||||||
HSET device:{imei} online 1 rssi 85
|
{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":15}
|
||||||
```
|
```
|
||||||
|
|
||||||
**相关文件:**
|
**字段说明:**
|
||||||
- PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件
|
- `info` — 停电类型:`0`=直流停电(12V掉电),`1`=交流停电(380V掉电)
|
||||||
- 小程序: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板
|
|
||||||
- 小程序 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline
|
|
||||||
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
|
|
||||||
|
|
||||||
### 近期 UI 改动
|
---
|
||||||
|
|
||||||
1. **设备卡片头部**:去掉"在线/离线"文字 badge,改为信号图标 + RSSI 数值
|
#### pow_on(平台→设备)
|
||||||
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
|
|
||||||
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
|
|
||||||
|
|
||||||
### 开发约束(uni-app 特定)
|
接触器合闸,恢复380V充电电源供电。
|
||||||
|
|
||||||
1. **`<text>` 不支持 `display: flex`** — uni-app 的 `<text>` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `<view>`
|
```json
|
||||||
2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠
|
// 请求
|
||||||
3. **内联 SVG + v-if**:Vue 3 支持在 `<path>` 上用 `v-if` 条件渲染,适用于动态图标
|
{"act":"pow_on","msg_id":16,"dev_id":"123456789012345"}
|
||||||
4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `<svg>` 上写死
|
|
||||||
|
|
||||||
## 代码审查豁免
|
// 响应
|
||||||
|
{"suc":1,"msg_id":16}
|
||||||
|
```
|
||||||
|
|
||||||
以下模式已确认无需修改:
|
---
|
||||||
|
|
||||||
| 审查项 | 原因 |
|
#### pow_off(平台→设备)
|
||||||
|--------|------|
|
|
||||||
| TCP keepalive 配置 | 我们是 TCP server,设备才是 client。设备每 60s 发 status_post 就是应用层保活,不需要在 server 侧设 keepalive。 |
|
接触器分闸,切断380V充电电源供电。
|
||||||
| SHA256 签名只取 8 位 | MCU 硬件协议固定,不可改 |
|
|
||||||
| AGENTS.md 明文密码 | 内网 Git 仓库,agent 需要直连 |
|
```json
|
||||||
| 小程序 token 存 Storage | 小程序无 HttpOnly cookie,纯前端场景 |
|
// 请求
|
||||||
| 8-char sign 长度 | 硬件约束,设备一次只能获取一次 auth_str |
|
{"act":"pow_off","msg_id":16,"dev_id":"123456789012345"}
|
||||||
| ServerResponse / DeviceCommand 含额外内部字段(`_dev_id`、`_close`) | `_dev_id` = 目标设备 IMEI,**供 device-server 路由指令**(协议 `id` 已不含 IMEI,只表示动作目标);`_close` 指示发送后断开连接(`login` 失败时)。两者**下发前均由 device-server 摘除**(`split_internal_flags`),设备侧不可见 |
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":16}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### bat_in(设备→平台)
|
||||||
|
|
||||||
|
电池插入事件,立即上报。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-6","bms":{"soc":45,"voltage":52.3}}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":20}
|
||||||
|
```
|
||||||
|
|
||||||
|
**平台处理:**
|
||||||
|
1. 记录电池信息
|
||||||
|
2. 验证电池合规性
|
||||||
|
3. 自动下发 `on` 指令
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### bat_out(设备→平台)
|
||||||
|
|
||||||
|
电池拔出事件,立即上报。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"bat_out","msg_id":21,"id":"123456789012345-01-6"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":21}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### on(双向)
|
||||||
|
|
||||||
|
**平台→设备:** 开始充电
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":30}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设备→平台:** 充电开始确认(自动充电模式)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 上报
|
||||||
|
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### off(双向)
|
||||||
|
|
||||||
|
**平台→设备:** 手动停止充电
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":31}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设备→平台:** 充满自动停止
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 上报
|
||||||
|
{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### open(平台→设备)
|
||||||
|
|
||||||
|
开门,只需IMEI。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"open","msg_id":40,"id":"123456789012345"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":40}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码63 = 门锁故障。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### reset(平台→设备)
|
||||||
|
|
||||||
|
重启整柜或仓控板。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 重启整柜
|
||||||
|
{"act":"reset","msg_id":60,"id":"123456789012345"}
|
||||||
|
|
||||||
|
// 重启仓控板
|
||||||
|
{"act":"reset","msg_id":61,"id":"123456789012345-01"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":60}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 充电结束原因(平台判断)
|
||||||
|
|
||||||
|
| 平台收到 | 判断原因 |
|
||||||
|
|---------|---------|
|
||||||
|
| `off` (设备→平台) | `normal` — 正常充满 |
|
||||||
|
| `off` (平台→设备) | `user_stop` — 用户手动停止 |
|
||||||
|
| `bat_out` | `normal` — 电池拔出 |
|
||||||
|
| `bat_in` (通道已有电池) | `forced` — 新电池插入,旧电池强制结束 |
|
||||||
|
| 超时未响应 | `timeout` — 通信超时 |
|
||||||
|
|
||||||
|
## H5用户端
|
||||||
|
|
||||||
|
- 路径:`/pms/{抽象ID}`
|
||||||
|
- 功能:显示柜子每个通道状态,可开始/停止充电
|
||||||
|
- 无支付/计费功能
|
||||||
|
|||||||
294
README.md
@ -1,6 +1,6 @@
|
|||||||
# 充电柜矩阵系统 (Power Matrix System / PMS)
|
# 充电柜矩阵系统 (Power Matrix System / PMS)
|
||||||
|
|
||||||
产品名:安知充(anzhizhichong),品牌名:艾动智能
|
产品名:安知充(anzhizhichong)
|
||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- **后端**:Rust + Axum
|
- **后端**:Rust + Axum
|
||||||
- **前端**:React + Arco Design Pro(管理端)
|
- **前端**:React + Arco Design Pro(管理端)
|
||||||
- **微信小程序用户端**:uni-app + Vue 3(构建目标 mp-weixin)
|
- **H5用户端**:uni-app + Vue 3(跨平台,支持 H5 / 微信小程序)
|
||||||
- **数据库**:MySQL(阿里云RDS)
|
- **数据库**:MySQL(阿里云RDS)
|
||||||
- **缓存**:Redis(阿里云Redis)
|
- **缓存**:Redis(阿里云Redis)
|
||||||
- **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
- **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
||||||
@ -25,14 +25,12 @@ charging-cabinet/
|
|||||||
│ ├── api-server/ # Rust API服务
|
│ ├── api-server/ # Rust API服务
|
||||||
│ ├── device-server/ # Rust TCP设备服务
|
│ ├── device-server/ # Rust TCP设备服务
|
||||||
│ ├── web/ # React管理端前端
|
│ ├── web/ # React管理端前端
|
||||||
│ └── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
|
│ └── h5-uniapp/ # H5用户端(uni-app + Vue 3)
|
||||||
├── hardware/ # 硬件资料(原理图/PCB等)
|
├── hardware/ # 硬件资料(原理图/PCB等)
|
||||||
├── deploy/ # 部署配置
|
├── deploy/ # 部署配置
|
||||||
└── test/ # 测试用例/报告
|
└── test/ # 测试用例/报告
|
||||||
```
|
```
|
||||||
|
|
||||||
> 本仓库不存放技能库(skills)。开发期技能从全局技能目录加载(`~/.dsh/skills`、`~/.agents/skills`),请勿在仓库内重新 vendor。
|
|
||||||
|
|
||||||
## 部署架构
|
## 部署架构
|
||||||
|
|
||||||
| 环境 | 域名 | 说明 |
|
| 环境 | 域名 | 说明 |
|
||||||
@ -51,7 +49,7 @@ charging-cabinet/
|
|||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ API服务 (api-server) — 主服务 │
|
│ API服务 (api-server) — 主服务 │
|
||||||
│ - HTTP API(后台管理 + 小程序)端口: 3000 │
|
│ - HTTP API(后台管理 + H5)端口: 3000 │
|
||||||
│ - 前端轮询刷新(每5秒) │
|
│ - 前端轮询刷新(每5秒) │
|
||||||
│ - 业务逻辑处理(验证/存DB/告警) │
|
│ - 业务逻辑处理(验证/存DB/告警) │
|
||||||
│ - 节点管理(设备服务注册/心跳) │
|
│ - 节点管理(设备服务注册/心跳) │
|
||||||
@ -87,20 +85,284 @@ charging-cabinet/
|
|||||||
- 每60秒心跳,180秒无心跳视为离线
|
- 每60秒心跳,180秒无心跳视为离线
|
||||||
- 支持多节点扩展(当前单节点)
|
- 支持多节点扩展(当前单节点)
|
||||||
|
|
||||||
## TCP 设备通讯协议
|
## TCP通讯协议
|
||||||
|
|
||||||
> 完整协议文档见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,唯一权威 TCP 文档)
|
### 通信规则
|
||||||
|
|
||||||
- TCP 长连接,空闲 60 秒/充电 15 秒上报
|
- TCP长连接,无需PING包
|
||||||
- 所有设备上报消息平台都会回复 `{"act":"<同请求>","msg_id":N,"suc":1}` 确认
|
- 空闲1分钟上报,充电15秒上报
|
||||||
- 响应扁平化,不加 `data` 包裹
|
- 超时5000ms
|
||||||
- 签名机制:`SHA256(dev_id + auth_str + timestamp)`,取 `hex[56..64]`,**8 位**小写十六进制
|
- JSON去空格回车省流量,LF处理粘包
|
||||||
- `id` 字段:仓体级 `IMEI-板-通道`,柜级 `IMEI`
|
- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID
|
||||||
- 全局整数单位:voltage mV, current mA, temp K×100, soc ‰
|
- `msg_id`:自增会话ID
|
||||||
|
- 全局通道号:`dev_id-sub_device_id-通道号`(如`123456789012345-01-6`)
|
||||||
|
|
||||||
**11 条指令:** login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off / open / pow_on / pow_off / reset
|
### 签名机制
|
||||||
|
|
||||||
完整请求/响应示例、字段表、签名自测向量、超时与自愈流程 → [docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)
|
- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次)
|
||||||
|
- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)`
|
||||||
|
- 时间戳秒级,平台校验±60秒
|
||||||
|
|
||||||
|
### 指令清单
|
||||||
|
|
||||||
|
| 动作 | 方向 | id格式 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) |
|
||||||
|
| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) |
|
||||||
|
| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) |
|
||||||
|
| `pow_fail` | 设备→平台 | 无 | 电源掉电上报(含info区分交直流) |
|
||||||
|
| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入(含BMS数据,字段待定) |
|
||||||
|
| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
|
||||||
|
| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) |
|
||||||
|
| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) |
|
||||||
|
| `open` | 平台→设备 | `IMEI` | 开门 |
|
||||||
|
| `pow_on` | 平台→设备 | `IMEI` | 接触器合闸(恢复380V供电) |
|
||||||
|
| `pow_off` | 平台→设备 | `IMEI` | 接触器分闸(切断380V供电) |
|
||||||
|
| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 |
|
||||||
|
|
||||||
|
### status_post格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"act": "status_post",
|
||||||
|
"msg_id": 42,
|
||||||
|
"dev_id": "123456789012345",
|
||||||
|
"rssi": 85,
|
||||||
|
"status": {
|
||||||
|
"123456789012345-01": "000100020003000400050006",
|
||||||
|
"123456789012345-02": "000100020003000400050006"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- key: `IMEI-仓控板ID`
|
||||||
|
- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
|
||||||
|
- 平台从key解析仓控板数量
|
||||||
|
|
||||||
|
### 指令详情
|
||||||
|
|
||||||
|
#### login(设备→平台)
|
||||||
|
|
||||||
|
设备登录,验证签名。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"h_ver":"1.0.0","s_ver":"1.0.0"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":1,"cell":6}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
- `sign` — 签名(SHA256(dev_id+auth_str+timestamp)后8位)
|
||||||
|
- `timestamp` — 秒级时间戳(平台校验±60秒)
|
||||||
|
- `h_ver` — 硬件版本
|
||||||
|
- `s_ver` — 软件版本
|
||||||
|
- `cell` — 仓控板数量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### auth_str(设备→平台)
|
||||||
|
|
||||||
|
获取8位安全码,仅首次连接调用。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345","ccid":"89860000000000000000"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":2,"data":{"auth_str":"abcd1234"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
- `ccid` — SIM卡ICCID号
|
||||||
|
- `auth_str` — 8位随机安全码(仅返回一次)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### status_post(设备→平台)
|
||||||
|
|
||||||
|
定时上报,空闲60秒/充电15秒。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,"status":{"123456789012345-01":"000100020003000400050006","123456789012345-02":"000100020003000400050006"}}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":10}
|
||||||
|
```
|
||||||
|
|
||||||
|
**status字段说明:**
|
||||||
|
- key: `IMEI-仓控板ID`
|
||||||
|
- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
|
||||||
|
- 平台从key解析仓控板数量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### pow_fail(设备→平台)
|
||||||
|
|
||||||
|
电源掉电上报。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":15}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
- `info` — 停电类型:`0`=直流停电(12V掉电),`1`=交流停电(380V掉电)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### pow_on(平台→设备)
|
||||||
|
|
||||||
|
接触器合闸,恢复380V充电电源供电。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"pow_on","msg_id":16,"dev_id":"123456789012345"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":16}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### pow_off(平台→设备)
|
||||||
|
|
||||||
|
接触器分闸,切断380V充电电源供电。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"pow_off","msg_id":16,"dev_id":"123456789012345"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":16}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### bat_in(设备→平台)
|
||||||
|
|
||||||
|
电池插入事件,立即上报。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-6","bms":{"soc":45,"voltage":52.3}}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":20}
|
||||||
|
```
|
||||||
|
|
||||||
|
**平台处理:**
|
||||||
|
1. 记录电池信息
|
||||||
|
2. 验证电池合规性
|
||||||
|
3. 自动下发 `on` 指令
|
||||||
|
|
||||||
|
**BMS数据字段待定。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### bat_out(设备→平台)
|
||||||
|
|
||||||
|
电池拔出事件,立即上报。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"bat_out","msg_id":21,"id":"123456789012345-01-6"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":21}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### on(双向)
|
||||||
|
|
||||||
|
**平台→设备:** 开始充电
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":30}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设备→平台:** 充电开始确认(自动充电模式)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 上报
|
||||||
|
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### off(双向)
|
||||||
|
|
||||||
|
**平台→设备:** 手动停止充电
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":31}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设备→平台:** 充满自动停止
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 上报
|
||||||
|
{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### open(平台→设备)
|
||||||
|
|
||||||
|
开门,只需IMEI。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{"act":"open","msg_id":40,"id":"123456789012345"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":40}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码63 = 门锁故障。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### reset(平台→设备)
|
||||||
|
|
||||||
|
重启整柜或仓控板。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 重启整柜
|
||||||
|
{"act":"reset","msg_id":60,"id":"123456789012345"}
|
||||||
|
|
||||||
|
// 重启仓控板
|
||||||
|
{"act":"reset","msg_id":61,"id":"123456789012345-01"}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{"suc":1,"msg_id":60}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 充电结束原因(平台判断)
|
||||||
|
|
||||||
|
| 平台收到 | 判断原因 |
|
||||||
|
|---------|---------|
|
||||||
|
| `off` (设备→平台) | `normal` — 正常充满 |
|
||||||
|
| `off` (平台→设备) | `user_stop` — 用户手动停止 |
|
||||||
|
| `bat_out` | `normal` — 电池拔出 |
|
||||||
|
| `bat_in` (通道已有电池) | `forced` — 新电池插入,旧电池强制结束 |
|
||||||
|
| 超时未响应 | `timeout` — 通信超时 |
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
- 任务需求写到 `tasks/` 目录的md文件
|
- 任务需求写到 `tasks/` 目录的md文件
|
||||||
- 通过Agent子代理包裹opencode执行,异步通知
|
- 通过Agent子代理包裹opencode执行,异步通知
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 MiB |
@ -1,156 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# PMS 正式环境部署脚本(在**本地开发机**执行,不要拿到服务器上跑)
|
|
||||||
#
|
|
||||||
# 为什么是 PowerShell 而不是原 deploy-prod.sh:
|
|
||||||
# 原脚本有 3 个致命问题(2026-09 修复)——
|
|
||||||
# 1. 它在 `target/release/` 找二进制,而实际构建产物在
|
|
||||||
# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件
|
|
||||||
# 2. 它安装并重启 `pms-api-prod` / `pms-device-prod`,而线上真实单元名是
|
|
||||||
# `pms-api` / `pms-device` → 会**多出一套重复单元**且重启失败
|
|
||||||
# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错,
|
|
||||||
# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂)
|
|
||||||
# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收"
|
|
||||||
# 的顺序执行,并在收尾打印回滚方法。
|
|
||||||
#
|
|
||||||
# 用法:
|
|
||||||
# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收)
|
|
||||||
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署
|
|
||||||
# pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动
|
|
||||||
# pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端
|
|
||||||
# =============================================================================
|
|
||||||
param(
|
|
||||||
[switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build
|
|
||||||
[switch]$SkipWeb, # 跳过前端
|
|
||||||
[switch]$DryRun # 只演练,不执行
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# ---- 环境参数(正式环境)---------------------------------------------------
|
|
||||||
$Host_ = '118.31.119.206'
|
|
||||||
$User = 'root'
|
|
||||||
$Pwd_ = 'Hbhyg731024@' # 内网仓库明文密码(见 AGENTS.md「代码审查豁免」)
|
|
||||||
$Dst = '/data/pms'
|
|
||||||
$ApiPort = 9000
|
|
||||||
$TcpPort = 9001
|
|
||||||
$Domain = 'app.anzhizhichong.com'
|
|
||||||
|
|
||||||
# ---- 路径 -------------------------------------------------------------------
|
|
||||||
$Repo = Split-Path -Parent $PSScriptRoot
|
|
||||||
$ApiBin = Join-Path $Repo 'software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api'
|
|
||||||
$DevBin = Join-Path $Repo 'software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server'
|
|
||||||
$WebDist = Join-Path $Repo 'software/web/dist'
|
|
||||||
|
|
||||||
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
|
|
||||||
function Say($msg) { Write-Host " $msg" }
|
|
||||||
|
|
||||||
function Invoke-Remote([string]$cmd) {
|
|
||||||
if ($DryRun) { Say "[DryRun] ssh: $cmd"; return }
|
|
||||||
& plink -batch -pw $Pwd_ "$User@$Host_" $cmd
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "远端命令失败(exit $LASTEXITCODE):$cmd" }
|
|
||||||
}
|
|
||||||
function Send-File([string]$local, [string]$remote) {
|
|
||||||
if ($DryRun) { Say "[DryRun] scp: $local -> $remote"; return }
|
|
||||||
& pscp -batch -pw $Pwd_ $local "$User@$Host_`:$remote"
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "上传失败(exit $LASTEXITCODE):$local" }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- 1. 构建 ---------------------------------------------------------------
|
|
||||||
if (-not $SkipBuild) {
|
|
||||||
Step '1/7 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
|
|
||||||
Say 'api-server ...'
|
|
||||||
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/api-server/Cargo.toml') }
|
|
||||||
Say 'device-server ...'
|
|
||||||
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/device-server/Cargo.toml') }
|
|
||||||
if (-not $SkipWeb) {
|
|
||||||
Say 'web ...'
|
|
||||||
if (-not $DryRun) { Push-Location (Join-Path $Repo 'software/web'); npm run build; Pop-Location }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- 2. 校验产物 + 静态检查 ------------------------------------------------
|
|
||||||
Step '2/7 校验产物与静态检查'
|
|
||||||
foreach ($f in @($ApiBin, $DevBin)) {
|
|
||||||
if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" }
|
|
||||||
Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB))
|
|
||||||
}
|
|
||||||
if (-not $SkipWeb) {
|
|
||||||
if (-not (Test-Path (Join-Path $WebDist 'index.html'))) { throw "找不到前端产物:$WebDist" }
|
|
||||||
Say ("✅ web/dist ({0} 个文件)" -f (Get-ChildItem $WebDist -Recurse -File).Count)
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- 3. 服务器备份(可回滚)------------------------------------------------
|
|
||||||
Step '3/7 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
|
|
||||||
Invoke-Remote @"
|
|
||||||
set -e
|
|
||||||
TS=`$(date +%Y%m%d-%H%M%S)
|
|
||||||
mkdir -p $Dst/backup-`$TS
|
|
||||||
cp -a $Dst/api-server/pms-api $Dst/backup-`$TS/ 2>/dev/null || true
|
|
||||||
cp -a $Dst/device-server/pms-device-server $Dst/backup-`$TS/ 2>/dev/null || true
|
|
||||||
cp -a $Dst/api-server/.env $Dst/device-server/.env $Dst/backup-`$TS/ 2>/dev/null || true
|
|
||||||
mkdir -p $Dst/backup-`$TS/web && cp -a $Dst/web/. $Dst/backup-`$TS/web/ 2>/dev/null || true
|
|
||||||
echo `$TS > /tmp/pms_last_backup
|
|
||||||
echo " 备份目录: $Dst/backup-`$TS"
|
|
||||||
"@
|
|
||||||
|
|
||||||
# ---- 4. 上传(临时名,避免 ETXTBSY)----------------------------------------
|
|
||||||
Step '4/7 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
|
|
||||||
Send-File $ApiBin "$Dst/api-server/pms-api.new"
|
|
||||||
Send-File $DevBin "$Dst/device-server/pms-device-server.new"
|
|
||||||
if (-not $SkipWeb) {
|
|
||||||
$tgz = Join-Path $env:TEMP 'pms-dist.tgz'
|
|
||||||
if (-not $DryRun) { tar -czf $tgz -C $WebDist . }
|
|
||||||
Send-File $tgz '/tmp/pms-dist.tgz'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- 5. 切换 + 重启 --------------------------------------------------------
|
|
||||||
Step '5/7 原子替换并重启服务'
|
|
||||||
Invoke-Remote @"
|
|
||||||
set -e
|
|
||||||
chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new
|
|
||||||
systemctl stop pms-api pms-device
|
|
||||||
mv -f $Dst/api-server/pms-api.new $Dst/api-server/pms-api
|
|
||||||
mv -f $Dst/device-server/pms-device-server.new $Dst/device-server/pms-device-server
|
|
||||||
$(if (-not $SkipWeb) { "rm -rf $Dst/web/* && tar -xzf /tmp/pms-dist.tgz -C $Dst/web/ && rm -f /tmp/pms-dist.tgz" } else { 'true' })
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl start pms-device pms-api
|
|
||||||
sleep 8
|
|
||||||
nginx -t && systemctl reload nginx
|
|
||||||
"@
|
|
||||||
|
|
||||||
# ---- 6. 验收 ---------------------------------------------------------------
|
|
||||||
Step '6/7 验收(服务/端口/域名/日志)'
|
|
||||||
Invoke-Remote @"
|
|
||||||
echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
|
|
||||||
echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}'
|
|
||||||
echo '--- 域名 ---'
|
|
||||||
curl -sk -m 8 -o /dev/null -w ' /api/pms/cabinets -> %{http_code}\n' https://$Domain/api/pms/cabinets
|
|
||||||
curl -sk -m 8 -o /dev/null -w ' /pms/ -> %{http_code}\n' https://$Domain/pms/
|
|
||||||
echo '--- 启动日志尾部 ---'; journalctl -u pms-api --no-pager -n 4 | tail -4
|
|
||||||
echo '--- 新进程 ERROR 数(应为 0)---'
|
|
||||||
MAIN=`$(systemctl show -p MainPID --value pms-api)
|
|
||||||
journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^/ ERROR 行数: /'
|
|
||||||
"@
|
|
||||||
|
|
||||||
# ---- 7. 消费链路探测(无副作用)-------------------------------------------
|
|
||||||
Step '7/7 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
|
|
||||||
if (-not $DryRun) {
|
|
||||||
$probe = @'
|
|
||||||
import redis, json, time
|
|
||||||
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5)
|
|
||||||
r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1}))
|
|
||||||
time.sleep(3)
|
|
||||||
left = r.llen('device:reports')
|
|
||||||
print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}")
|
|
||||||
'@
|
|
||||||
$probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py"
|
|
||||||
python "$env:TEMP\pms_probe.py"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
|
|
||||||
Say "正式环境:https://$Domain/pms/"
|
|
||||||
Say "API :$ApiPort TCP :$TcpPort"
|
|
||||||
Say ''
|
|
||||||
Say '回滚方法(如需):'
|
|
||||||
Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`""
|
|
||||||
Say ' 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份)'
|
|
||||||
@ -1,21 +1,50 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# =============================================================================
|
# PMS 部署脚本 - 正式环境
|
||||||
# ⛔ 本脚本已废弃(2026-09),请勿执行!改用:deploy/deploy-prod.ps1
|
|
||||||
#
|
set -e
|
||||||
# 废弃原因(原脚本会直接搞坏生产):
|
|
||||||
# 1. 在 `target/release/` 找二进制 —— 实际产物在
|
echo "=== PMS 正式环境部署 ==="
|
||||||
# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件
|
|
||||||
# 2. 安装并重启 `pms-api-prod` / `pms-device-prod` —— 线上真实单元名是
|
# 1. 创建目录
|
||||||
# `pms-api` / `pms-device`;照原样执行会**多出一套重复单元**且重启失败
|
echo "1. 创建部署目录..."
|
||||||
# 3. 用本地 `.env.production` **覆盖线上 `.env`** —— 历史上该文件带行内注释
|
mkdir -p /data/pms/api-server
|
||||||
# (`PORT=9000 # 注释` 会让解析失败),照原样执行会让**生产直接起不来**
|
mkdir -p /data/pms/device-server
|
||||||
# 4. 它假定"服务器上有仓库源码",而实际流程是**本地交叉编译后传二进制**
|
mkdir -p /data/pms/web
|
||||||
#
|
|
||||||
# 正确做法(在本地开发机执行):
|
# 2. 复制文件
|
||||||
# pwsh deploy/deploy-prod.ps1 # 完整部署
|
echo "2. 复制服务文件..."
|
||||||
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 只重发产物
|
cp software/api-server/target/release/api-server /data/pms/api-server/
|
||||||
# pwsh deploy/deploy-prod.ps1 -DryRun # 演练
|
cp software/api-server/.env.production /data/pms/api-server/.env
|
||||||
# =============================================================================
|
|
||||||
echo "⛔ deploy-prod.sh 已废弃,请使用:pwsh deploy/deploy-prod.ps1" >&2
|
cp software/device-server/target/release/device-server /data/pms/device-server/
|
||||||
echo " (原因见本文件头部注释:原脚本会装重复 systemd 单元、覆盖线上 .env 导致生产起不来)" >&2
|
cp software/device-server/.env.production /data/pms/device-server/.env
|
||||||
exit 1
|
|
||||||
|
# 3. 复制前端静态文件
|
||||||
|
echo "3. 复制前端文件..."
|
||||||
|
cp -r software/web/dist/* /data/pms/web/
|
||||||
|
|
||||||
|
# 3. 复制systemd服务
|
||||||
|
echo "3. 安装systemd服务..."
|
||||||
|
cp deploy/pms-api-prod.service /etc/systemd/system/
|
||||||
|
cp deploy/pms-device-prod.service /etc/systemd/system/
|
||||||
|
|
||||||
|
# 4. 复制nginx配置
|
||||||
|
echo "4. 安装nginx配置..."
|
||||||
|
cp deploy/nginx/app.anzhizhichong.com.conf /etc/nginx/conf.d/
|
||||||
|
|
||||||
|
# 5. 重启服务
|
||||||
|
echo "5. 重启服务..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart pms-api-prod
|
||||||
|
systemctl restart pms-device-prod
|
||||||
|
systemctl restart nginx
|
||||||
|
|
||||||
|
# 6. 设置开机启动
|
||||||
|
echo "6. 设置开机启动..."
|
||||||
|
systemctl enable pms-api-prod
|
||||||
|
systemctl enable pms-device-prod
|
||||||
|
|
||||||
|
echo "=== 部署完成 ==="
|
||||||
|
echo "正式环境: https://app.anzhizhichong.com"
|
||||||
|
echo "API端口: 9000"
|
||||||
|
echo "TCP端口: 9001"
|
||||||
|
|||||||
@ -1,22 +1,50 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# =============================================================================
|
# PMS 部署脚本 - 测试环境
|
||||||
# ⛔ 本脚本已废弃(2026-09),请勿在 118.31.119.206 上执行!
|
|
||||||
#
|
set -e
|
||||||
# 两条重要约束:
|
|
||||||
# 1. **118.31.119.206 就是生产服务器**(用户已确认),上面**不部署测试环境**。
|
echo "=== PMS 测试环境部署 ==="
|
||||||
# 原先的 `pms-api-test` / `pms-device-test` 单元已卸载,:3000 / :3001 已释放。
|
|
||||||
# 2. 即使要在别处搭测试环境,也不要用本脚本 —— 它与 deploy-prod.sh 有同样的缺陷
|
# 1. 创建目录
|
||||||
# (错误产物路径、单元名不匹配、用本地 .env 覆盖线上 .env)。
|
echo "1. 创建部署目录..."
|
||||||
#
|
mkdir -p /data/pms_dev/api-server
|
||||||
# 如需临时联调环境(另一台机器,或本机):
|
mkdir -p /data/pms_dev/device-server
|
||||||
# - 参考 `deploy/deploy-prod.ps1` 的流程改写,端口用 3000/3001
|
mkdir -p /data/pms_dev/web
|
||||||
# - 环境文件用 `software/*/.env.test`(连 pms_dev 库 + Redis db6)
|
|
||||||
# - 注意 `/data/pms_dev` 下的旧文件与 `pms_dev` 库、Redis db6 都还保留着
|
# 2. 复制文件
|
||||||
#
|
echo "2. 复制服务文件..."
|
||||||
# ⚠️ 另请注意:`test.anzhizhichong.com` 的 nginx `/api/pms/` 现指向 :3000,
|
cp software/api-server/target/release/api-server /data/pms_dev/api-server/
|
||||||
# 而该端口**没有后端**,所以返回 502 —— 这是**刻意的**(历史上它指向 :3000,
|
cp software/api-server/.env.test /data/pms_dev/api-server/.env
|
||||||
# 而那时 :3000 正是生产 API,等于测试后台在操作生产数据)。
|
|
||||||
# **绝不要把测试域名的 API 指回生产端口(9000)。**
|
cp software/device-server/target/release/device-server /data/pms_dev/device-server/
|
||||||
# =============================================================================
|
cp software/device-server/.env.test /data/pms_dev/device-server/.env
|
||||||
echo "⛔ deploy-test.sh 已废弃,且本服务器为生产环境,不部署测试环境。" >&2
|
|
||||||
exit 1
|
# 3. 复制前端静态文件
|
||||||
|
echo "3. 复制前端文件..."
|
||||||
|
cp -r software/web/dist/* /data/pms_dev/web/
|
||||||
|
|
||||||
|
# 3. 复制systemd服务
|
||||||
|
echo "3. 安装systemd服务..."
|
||||||
|
cp deploy/pms-api-test.service /etc/systemd/system/
|
||||||
|
cp deploy/pms-device-test.service /etc/systemd/system/
|
||||||
|
|
||||||
|
# 4. 复制nginx配置
|
||||||
|
echo "4. 安装nginx配置..."
|
||||||
|
cp deploy/nginx/test.anzhizhichong.com.conf /etc/nginx/conf.d/
|
||||||
|
|
||||||
|
# 5. 重启服务
|
||||||
|
echo "5. 重启服务..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart pms-api-test
|
||||||
|
systemctl restart pms-device-test
|
||||||
|
systemctl restart nginx
|
||||||
|
|
||||||
|
# 6. 设置开机启动
|
||||||
|
echo "6. 设置开机启动..."
|
||||||
|
systemctl enable pms-api-test
|
||||||
|
systemctl enable pms-device-test
|
||||||
|
|
||||||
|
echo "=== 部署完成 ==="
|
||||||
|
echo "测试环境: https://test.anzhizhichong.com"
|
||||||
|
echo "API端口: 3000"
|
||||||
|
echo "TCP端口: 3001"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>艾动智能 — 智能充电柜矩阵系统</title>
|
<title>安知充 — 智能充电柜矩阵系统</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
@ -161,8 +161,8 @@ h1 .accent { color: #2563EB; }
|
|||||||
<!-- 上:顶部导航 -->
|
<!-- 上:顶部导航 -->
|
||||||
<div class="top">
|
<div class="top">
|
||||||
<div class="top-brand">
|
<div class="top-brand">
|
||||||
<img class="logo" src="logo-1.png" alt="艾动智能">
|
<img class="logo" src="logo-1.png" alt="安知充">
|
||||||
<span class="top-name">艾动智能</span>
|
<span class="top-name">安知充</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-links">
|
<div class="top-links">
|
||||||
<a href="/doc/" class="top-link">开发文档</a>
|
<a href="/doc/" class="top-link">开发文档</a>
|
||||||
@ -195,7 +195,7 @@ h1 .accent { color: #2563EB; }
|
|||||||
|
|
||||||
<!-- 下:底部 ICP -->
|
<!-- 下:底部 ICP -->
|
||||||
<div class="bot">
|
<div class="bot">
|
||||||
© 2026 艾动智能 · <a href="https://beian.miit.gov.cn/" target="_blank">苏ICP备2025198376号-1</a>
|
© 2026 安知充 · <a href="https://beian.miit.gov.cn/" target="_blank">苏ICP备2025198376号-1</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,23 +26,12 @@ server {
|
|||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/pms/ {
|
location /pms/api/ {
|
||||||
proxy_pass http://127.0.0.1:9000/api/;
|
proxy_pass http://127.0.0.1:3000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
client_max_body_size 10m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 头像上传文件
|
|
||||||
location /uploads/ {
|
|
||||||
proxy_pass http://127.0.0.1:9000/uploads/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
expires 30d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# PMS管理前端 - 不带斜杠时重定向
|
# PMS管理前端 - 不带斜杠时重定向
|
||||||
@ -55,4 +44,15 @@ server {
|
|||||||
alias /data/pms/web/;
|
alias /data/pms/web/;
|
||||||
try_files $uri $uri/ /pms/index.html;
|
try_files $uri $uri/ /pms/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# H5用户端 - 不带斜杠时重定向
|
||||||
|
location = /h5 {
|
||||||
|
return 301 /h5/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# H5用户端
|
||||||
|
location /h5/ {
|
||||||
|
alias /data/pms/h5/;
|
||||||
|
try_files $uri $uri/ /h5/index.html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ After=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/data/pms/api-server
|
WorkingDirectory=/data/pms/api-server
|
||||||
ExecStart=/data/pms/api-server/pms-api
|
ExecStart=/data/pms/api-server/api-server
|
||||||
EnvironmentFile=/data/pms/api-server/.env
|
EnvironmentFile=/data/pms/api-server/.env
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@ -6,7 +6,7 @@ After=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/data/pms_dev/api-server
|
WorkingDirectory=/data/pms_dev/api-server
|
||||||
ExecStart=/data/pms_dev/api-server/pms-api
|
ExecStart=/data/pms_dev/api-server/api-server
|
||||||
EnvironmentFile=/data/pms_dev/api-server/.env
|
EnvironmentFile=/data/pms_dev/api-server/.env
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@ -6,7 +6,7 @@ After=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/data/pms/device-server
|
WorkingDirectory=/data/pms/device-server
|
||||||
ExecStart=/data/pms/device-server/pms-device-server
|
ExecStart=/data/pms/device-server/device-server
|
||||||
EnvironmentFile=/data/pms/device-server/.env
|
EnvironmentFile=/data/pms/device-server/.env
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
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.
|
||||||
|
|
||||||
780
docs/API.md
@ -1,780 +0,0 @@
|
|||||||
# API 接口文档
|
|
||||||
|
|
||||||
> 充电柜矩阵系统(PMS)接口规范。PC 管理端与微信小程序用户端共用同一套 API。
|
|
||||||
|
|
||||||
## 基础信息
|
|
||||||
|
|
||||||
- **测试环境**:`https://test.anzhizhichong.com/pms/api/`
|
|
||||||
- **正式环境**:`https://app.anzhizhichong.com/pms/api/`
|
|
||||||
- **认证方式**:Bearer Token(JWT)
|
|
||||||
- **响应格式**:`{ "code": 0, "data": ..., "message": "ok" }`
|
|
||||||
|
|
||||||
## 统一端点清单
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 权限 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/api/auth/login` | 登录 | 免认证 |
|
|
||||||
| GET | `/api/auth/logout` | 登出 | 需认证 |
|
|
||||||
| GET | `/api/auth/me` | 当前用户信息 | 需认证 |
|
|
||||||
| GET | `/api/dashboard` | 仪表盘数据 | 需认证 |
|
|
||||||
| GET | `/api/cabinets` | 设备列表(含 RSSI) | `device:view` |
|
|
||||||
| POST | `/api/cabinets` | 批量创建柜子 | `device:create` |
|
|
||||||
| GET | `/api/cabinets/:id` | 设备详情(含仓板+仓体) | `device:view` |
|
|
||||||
| PUT | `/api/cabinets/:id` | 更新柜子 | `device:edit` |
|
|
||||||
| DELETE | `/api/cabinets/:id` | 删除柜子 | `device:delete` |
|
|
||||||
| POST | `/api/cabinets/:id/regenerate-auth` | 重置安全码(仅总管理员) | `device:auth_str:reset` |
|
|
||||||
| POST | `/api/cabinets/:id/bind` | 绑定设备到项目 | `device:edit` |
|
|
||||||
| POST | `/api/cabinets/:id/replace-imei` | 更换 IMEI | `device:edit` |
|
|
||||||
| GET | `/api/cabinets/abstract/:abstract_id` | 扫码查设备(免认证) | 免认证 |
|
|
||||||
| GET | `/api/cabinets/scan/:abstract_id` | 扫码验证(需认证) | 需认证 |
|
|
||||||
| GET | `/api/cabinets/filter` | 设备列表(管理端) | 需认证 |
|
|
||||||
| GET | `/api/cabinets/options` | 设备下拉选项 | 需认证 |
|
|
||||||
| GET | `/api/cabin-boards/options` | 仓控板下拉选项 | 需认证 |
|
|
||||||
| GET | `/api/compartments/options` | 仓体下拉选项 | 需认证 |
|
|
||||||
| GET | `/api/cabinets/realtime-status` | 设备实时状态 | 需认证 |
|
|
||||||
| GET | `/api/compartments/:id` | 仓体/通道详情 | 需认证 |
|
|
||||||
| POST | `/api/charge/start` | 开始充电 | `charge:start` |
|
|
||||||
| POST | `/api/charge/stop` | 停止充电 | `charge:stop` |
|
|
||||||
| POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` |
|
|
||||||
| POST | `/api/cabinets/{id}/open` | 解锁柜门(柜级,同上;管理端设备详情页用) | `charge:open_door` |
|
|
||||||
| POST | `/api/cabinets/{id}/pow-on` | 接触器合闸(柜级) | `device:operate` |
|
|
||||||
| POST | `/api/cabinets/{id}/pow-off` | 接触器分闸(柜级) | `device:operate` |
|
|
||||||
| POST | `/api/cabinets/{id}/reset` | 重启整柜(柜级) | `device:operate` |
|
|
||||||
| POST | `/api/cabin-boards/{id}/reset` | 重启单块仓控板 | `device:operate` |
|
|
||||||
| GET | `/api/organizations` | 组织列表 | `org:view` |
|
|
||||||
| POST | `/api/organizations` | 创建组织 | `org:create` |
|
|
||||||
| PUT | `/api/organizations/:id` | 更新组织 | `org:edit` |
|
|
||||||
| DELETE | `/api/organizations/:id` | 删除组织 | `org:delete` |
|
|
||||||
| GET | `/api/organizations/:org_id/projects` | 项目列表 | 需认证 |
|
|
||||||
| POST | `/api/organizations/:org_id/projects` | 创建项目 | `project:create` |
|
|
||||||
| PUT | `/api/projects/:id` | 更新项目 | `project:edit` |
|
|
||||||
| DELETE | `/api/projects/:id` | 删除项目 | `project:delete` |
|
|
||||||
| GET | `/api/organization-tree` | 组织树(含项目/设备) | 需认证 |
|
|
||||||
| GET | `/api/charge-records` | 充电记录 | `charge_record:view` |
|
|
||||||
| GET | `/api/charge-records/export` | 导出充电记录 | `charge_record:export` |
|
|
||||||
| GET | `/api/charge-orders` | 充电订单列表 | `charge_record:view` |
|
|
||||||
| GET | `/api/batteries` | 电池台账列表 | `device:view` |
|
|
||||||
| GET | `/api/energy-stats/summary` | 能耗概览 | `energy:view` |
|
|
||||||
| GET | `/api/energy-stats/cabinets` | 柜子能耗排行 | `energy:view` |
|
|
||||||
| GET | `/api/energy-stats/projects` | 项目能耗统计 | `energy:view` |
|
|
||||||
| GET | `/api/energy-stats/trend` | 能耗趋势 | `energy:view` |
|
|
||||||
| GET | `/api/energy-stats/export` | 导出能耗数据 | `energy:view` |
|
|
||||||
| GET | `/api/device-logs` | 设备日志 | `device:view` |
|
|
||||||
| GET | `/api/operation-logs` | 操作日志 | `system:log` |
|
|
||||||
| GET | `/api/users` | 用户列表 | `user:view` |
|
|
||||||
| POST | `/api/users` | 创建用户 | `user:create` |
|
|
||||||
| PUT | `/api/users/:id` | 更新用户 | `user:edit` |
|
|
||||||
| DELETE | `/api/users/:id` | 删除用户 | `user:delete` |
|
|
||||||
| PUT | `/api/users/:id/reset-password` | 重置密码 | `user:edit` |
|
|
||||||
| PUT | `/api/users/:id/status` | 设置用户状态 | `user:edit` |
|
|
||||||
| GET | `/api/users/:id/permissions` | 用户权限 | `user:view` |
|
|
||||||
| PUT | `/api/users/:id/roles` | 分配角色 | `user:edit` |
|
|
||||||
| GET | `/api/users/:id/scopes` | 用户数据范围 | `user:view` |
|
|
||||||
| PUT | `/api/users/:id/scopes` | 设置数据范围 | `user:edit` |
|
|
||||||
| GET | `/api/roles` | 角色列表 | `role:view` |
|
|
||||||
| POST | `/api/roles` | 创建角色 | `role:create` |
|
|
||||||
| PUT | `/api/roles/:id` | 更新角色 | `role:edit` |
|
|
||||||
| DELETE | `/api/roles/:id` | 删除角色 | `role:delete` |
|
|
||||||
| PUT | `/api/roles/:id/permissions` | 角色赋权 | `role:edit` |
|
|
||||||
| GET | `/api/permissions` | 权限码列表 | `role:view` |
|
|
||||||
| GET | `/api/downloads` | 下载中心 | 需认证 |
|
|
||||||
| GET | `/api/downloads/:id` | 下载详情 | 需认证 |
|
|
||||||
| GET | `/api/downloads/:id/file` | 下载文件 | 需认证 |
|
|
||||||
| POST | `/api/downloads/cleanup` | 清理过期下载 | 需认证 |
|
|
||||||
| POST | `/api/devices/:dev_id/command` | 下发设备指令 | `device:operate` |
|
|
||||||
| GET | `/api/devices/stats` | 设备服务统计 | 需认证 |
|
|
||||||
| GET | `/api/health` | 健康检查 | 免认证 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 认证接口
|
|
||||||
|
|
||||||
### POST /api/auth/login
|
|
||||||
|
|
||||||
**请求:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"phone": "13800138000",
|
|
||||||
"password": "admin123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"token": "eyJ0eXAi...",
|
|
||||||
"user": {
|
|
||||||
"id": 1,
|
|
||||||
"name": "管理员",
|
|
||||||
"phone": "13800138000",
|
|
||||||
"role_level": 2,
|
|
||||||
"organization_id": null,
|
|
||||||
"permissions": ["device:view", "device:create", "..."]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"message": "登录成功"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**角色等级:** 0=操作员,1=企业管理员,2=总管理员
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 小程序专用接口
|
|
||||||
|
|
||||||
### GET /api/dashboard
|
|
||||||
|
|
||||||
小程序首页仪表盘数据。支持 `org_id` 参数按组织过滤。
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"online_cabinets": 68,
|
|
||||||
"charging_count": 214,
|
|
||||||
"idle_channels": 722,
|
|
||||||
"fault_channels": 0,
|
|
||||||
"full_count": 0,
|
|
||||||
"today_charge_count": 0,
|
|
||||||
"today_energy_kwh": 0,
|
|
||||||
"today_charge_hours": 0,
|
|
||||||
"alerts": [],
|
|
||||||
"projects": [
|
|
||||||
{ "id": 1, "name": "无锡太湖新城换电站", "cabinet_count": 2, "charging": 16, "idle": 56, "fault": 0 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/cabinets
|
|
||||||
|
|
||||||
设备列表,支持 `project_id` 和 `org_id` 过滤。响应包含实时 RSSI。
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"abstract_id": "10-00000001",
|
|
||||||
"name": "太湖新城换电柜A",
|
|
||||||
"status": 1,
|
|
||||||
"project_id": 1,
|
|
||||||
"rssi": 85,
|
|
||||||
"channel_count": 36,
|
|
||||||
"idle_count": 30,
|
|
||||||
"charging_count": 6,
|
|
||||||
"full_count": 0,
|
|
||||||
"fault_count": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/cabinets/abstract/:abstract_id
|
|
||||||
|
|
||||||
扫码入口,免认证。通过二维码中的抽象ID查找设备。
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"id": 1,
|
|
||||||
"abstract_id": "10-00000001",
|
|
||||||
"imei": "864720562557021",
|
|
||||||
"iccid": "8986012520983901841",
|
|
||||||
"name": "太湖新城换电柜A",
|
|
||||||
"status": 1,
|
|
||||||
"cabin_boards": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"board_id": 1,
|
|
||||||
"status": 0,
|
|
||||||
"compartments": [
|
|
||||||
{ "id": 1, "channel_id": 1, "status": 0 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/compartments/:id
|
|
||||||
|
|
||||||
仓体/通道详情,含实时 BMS 数据和充电记录。
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
- `voltage` — mV(整数)
|
|
||||||
- `current` — mA(整数)
|
|
||||||
- `energy` — Wh(整数)
|
|
||||||
- `soc` — ‰(0-10000,前端已自动转为 %)
|
|
||||||
- `cell_voltages` — mV 数组
|
|
||||||
|
|
||||||
### POST /api/charge/start
|
|
||||||
|
|
||||||
**请求:** `{ "compartment_id": 123 }`
|
|
||||||
|
|
||||||
> 权限:`charge:start` + 组织隔离(用户只能操作所属组织的设备)
|
|
||||||
|
|
||||||
### POST /api/charge/stop
|
|
||||||
|
|
||||||
**请求:** `{ "compartment_id": 123 }`
|
|
||||||
|
|
||||||
> 权限:`charge:stop` + 组织隔离
|
|
||||||
|
|
||||||
### POST /api/door/open
|
|
||||||
|
|
||||||
**请求:** `{ "compartment_id": 123 }`
|
|
||||||
|
|
||||||
> 权限:`charge:open_door` + 组织隔离
|
|
||||||
|
|
||||||
### GET /api/cabinets/scan/:abstract_id
|
|
||||||
|
|
||||||
扫码验证,需认证。验证当前用户是否有权访问该设备。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 管理端接口
|
|
||||||
|
|
||||||
### GET /api/charge-records
|
|
||||||
|
|
||||||
充电记录列表,支持按柜子/时间过滤,响应含组织数据隔离。
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
| 参数 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `page` | int | 页码(默认 1) |
|
|
||||||
| `page_size` | int | 每页条数(默认 20,最大 100) |
|
|
||||||
| `cabinet_id` | int | 按柜子过滤 |
|
|
||||||
| `cabin_board_id` | int | 按仓控板过滤 |
|
|
||||||
| `compartment_id` | int | 按仓体过滤 |
|
|
||||||
| `start_time` | string | 开始时间(含),格式 `YYYY-MM-DD HH:MM:SS` |
|
|
||||||
| `end_time` | string | 结束时间(含),格式同上 |
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"cabinet_id": 1,
|
|
||||||
"cabinet_name": "太湖新城换电柜A",
|
|
||||||
"cabin_board_id": 1,
|
|
||||||
"board_id": 1,
|
|
||||||
"compartment_id": 1,
|
|
||||||
"channel_id": 1,
|
|
||||||
"start_time": "2026-07-05 10:00:00",
|
|
||||||
"end_time": "2026-07-05 12:00:00",
|
|
||||||
"energy": 1200,
|
|
||||||
"max_temperature": 30435,
|
|
||||||
"status": 1,
|
|
||||||
"status_text": "正常结束"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 50,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**状态码:** 0=充电中,1=正常结束,2=异常中断,3=手动停止
|
|
||||||
|
|
||||||
### GET /api/charge-records/export
|
|
||||||
|
|
||||||
异步导出充电记录 Excel。创建导出任务后自行去下载中心取文件。
|
|
||||||
|
|
||||||
**查询参数:** 同 `GET /api/charge-records`
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "code": 0, "data": { "task_id": 1 }, "message": "导出任务已创建" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/charge-orders
|
|
||||||
|
|
||||||
充电订单列表。订单记录一次充电事件,含电池ID、SOC变化、结束原因。
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
| 参数 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `page` | int | 页码(默认 1) |
|
|
||||||
| `page_size` | int | 每页条数(默认 20,最大 100) |
|
|
||||||
| `cabinet_id` | int | 按柜子过滤 |
|
|
||||||
| `compartment_id` | int | 按仓体过滤 |
|
|
||||||
| `battery_id` | string | 电池ID(模糊匹配) |
|
|
||||||
| `status` | int | 状态过滤 |
|
|
||||||
| `start_time` | string | 开始时间(含) |
|
|
||||||
| `end_time` | string | 结束时间(含) |
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"cabinet_id": 1,
|
|
||||||
"cabinet_name": "太湖新城换电柜A",
|
|
||||||
"compartment_id": 1,
|
|
||||||
"battery_id": "BAT001",
|
|
||||||
"start_time": "2026-07-05 10:00:00",
|
|
||||||
"end_time": "2026-07-05 12:00:00",
|
|
||||||
"start_soc": 20,
|
|
||||||
"end_soc": 95,
|
|
||||||
"energy": 1200,
|
|
||||||
"reason": null,
|
|
||||||
"reason_text": "-",
|
|
||||||
"status": 1,
|
|
||||||
"status_text": "正常结束",
|
|
||||||
"created_at": "2026-07-05 10:00:05"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 50,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**订单状态:** 0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束
|
|
||||||
|
|
||||||
### GET /api/batteries
|
|
||||||
|
|
||||||
电池台账列表。记录每块电池的插入/拔出事件和当前状态。
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
| 参数 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `page` | int | 页码(默认 1) |
|
|
||||||
| `page_size` | int | 每页条数(默认 20,最大 100) |
|
|
||||||
| `status` | int | 状态过滤 |
|
|
||||||
| `battery_id` | string | 电池ID(模糊匹配) |
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"battery_id": "BAT001",
|
|
||||||
"brand": "星恒",
|
|
||||||
"model": "XH-48V",
|
|
||||||
"voltage": 52000,
|
|
||||||
"soc": 45,
|
|
||||||
"status": 1,
|
|
||||||
"cabinet_id": 1,
|
|
||||||
"compartment_id": 1,
|
|
||||||
"last_seen": "2026-07-05 12:00:00",
|
|
||||||
"created_at": "2026-07-01 08:00:00"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 50,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/energy-stats/summary
|
|
||||||
|
|
||||||
能耗概览,按组织数据隔离返回今日/本月能耗数据。
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"today_energy": 45000,
|
|
||||||
"month_energy": 1200000,
|
|
||||||
"estimated_cost": 840.00,
|
|
||||||
"cabinet_count": 36,
|
|
||||||
"online_count": 34
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `today_energy` / `month_energy` — Wh(整数)
|
|
||||||
- `estimated_cost` — 预估费用(float,元),按 `COST_PER_WH` 单价计算
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据单位规范
|
|
||||||
|
|
||||||
**核心原则:所有数值字段使用整数存储,禁止浮点类型 (f32/f64/DECIMAL/FLOAT)**
|
|
||||||
|
|
||||||
**单位转换仅在前端:** API 始终返回原始单位值,前端负责转换为用户友好单位显示。后端禁止做任何单位换算。
|
|
||||||
|
|
||||||
### 标量字段
|
|
||||||
|
|
||||||
| 字段 | 类型 | 单位 | 前端显示 | 转换公式 |
|
|
||||||
|------|------|------|----------|---------|
|
|
||||||
| `energy` / `total_energy` / `today_energy` / `month_energy` / `energy_kwh`¹ | i64 | **Wh** | kWh | `/ 1000` |
|
|
||||||
| `voltage` / `cell_voltages[]` | i64 | **mV** | V | `/ 1000` |
|
|
||||||
| `current` | i64 | **mA** | A | `/ 1000` |
|
|
||||||
| `soc` / `start_soc` / `end_soc` | i64 | **‰** | % | `/ 10` |
|
|
||||||
| `temp` / `cell_temps[]` / `max_temperature` | i64 | **K×100** | °C | `/ 100 - 273.15` |
|
|
||||||
| `rssi` | i64 | **0–99 信号等级** | 直接显示 | 不转换(不是 dBm) |
|
|
||||||
| `cost` / `estimated_cost` | i64/f64 | **元** | ¥ | 不转换(后端业务计算) |
|
|
||||||
| `today_charge_count` / `today_charge_hours` | i64 | 计数值 | 直接显示 | 不转换 |
|
|
||||||
|
|
||||||
¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。
|
|
||||||
|
|
||||||
### 示例
|
|
||||||
|
|
||||||
| 原始值 | 含义 | 前端显示 |
|
|
||||||
|--------|------|----------|
|
|
||||||
| `52000` | 电压(mV) | `52.0 V` |
|
|
||||||
| `10000` | 电流(mA) | `10.0 A` |
|
|
||||||
| `120000` | 功率(mW) | `120.0 W` |
|
|
||||||
| `1000` | 充电量(Wh) | `1.000 kWh` |
|
|
||||||
| `850` | SOC(‰) | `85.0%` |
|
|
||||||
| `29865` | 温度(K×100) | `25.5°C` |
|
|
||||||
| `85` | RSSI 信号等级(0–99) | `85` |
|
|
||||||
| `[32456,32500,32300]` | 单体电压(mV) | `32.456V, 32.500V, 32.300V` |
|
|
||||||
| `[29865,29900]` | 单体温度(K×100) | `25.5°C, 25.9°C` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TCP 设备通讯协议
|
|
||||||
|
|
||||||
> ⛔ **本节(自本行起至下一个 `##` 标题之前)全部内容已废弃,禁止作为实现依据。**
|
|
||||||
> **唯一权威文档:[`柜控板固件对接文档.md`](./柜控板固件对接文档.md)(v1.1)** —— 所有 TCP 报文格式、字段名、
|
|
||||||
> `act` 清单、签名算法、保活/超时规则、时序与异常处理,**一律以该文档为准**。
|
|
||||||
>
|
|
||||||
> 本节保留仅为历史参考。已知与定稿规范的**重大冲突**(仅列关键几处,避免误读):
|
|
||||||
> - ~~`dev_id` 上行全部报文必带~~ → **只有 `auth_str` / `login` 带**,其余报文不带(设备身份由 TCP 连接确定)
|
|
||||||
> - ~~`id` = `IMEI` / `IMEI-仓控板-通道`~~ → **`id` 不含 IMEI**:柜级**省略**、单板一段 `"01"`、仓体级两段 `"01-3"`
|
|
||||||
> - ~~空闲 1 分钟 / 充电 15 秒上报~~ → **`status_post` 固定每 60 秒一次、每次全量**
|
|
||||||
> - ~~`login` 应答返回 `cell`~~ → 返回 **`spec`**(柜子规格,如 `"0606"`)与 **`work_mode`**
|
|
||||||
> - ~~`pow_fail` 带 `info`~~ → 改为 **`pow_fail_dc` / `pow_fail_ac` 两条独立指令**,无附加字段
|
|
||||||
> - ~~`status_post` 的 `status` HEX 字典 / `bat_in` 的 `bms` 块~~ → 改为 **`cabinet` + `boards[].channels[]`(每通道 18 字符 hex 透传)** 与 **`battery` 块**
|
|
||||||
> - ~~`bat_in` 建单~~ → **`bat_in` 与订单无关,订单只由 `on` 建立**
|
|
||||||
> - 内部字段已由 `id` 改为 **`_dev_id`**(供 device-server 路由,下发前摘除)
|
|
||||||
|
|
||||||
### 设计原则
|
|
||||||
|
|
||||||
> MCU(Air780E)资源有限,TCP 协议遵循以下原则:
|
|
||||||
> 1. **响应扁平化**:字段直接放在顶层,不包 `data`/`result` 等嵌套层
|
|
||||||
> 2. **字段最小化**:需要几个字段传几个,不传空值、默认值
|
|
||||||
> 3. **JSON 紧凑**:去空格回车,不传多余引号和逗号(可用 `serde_json::to_string` 紧凑模式)
|
|
||||||
> 4. **响应必达**:所有**上行**报文平台都会回复;但**设备回执(带 `suc`)平台不再回包**(防误配对)
|
|
||||||
|
|
||||||
### 通信规则(⚠️ 下列旧描述已废弃,见节首说明)
|
|
||||||
|
|
||||||
- TCP 长连接,无需 PING 包 ✅(保活由 `status_post` 承担)
|
|
||||||
- ~~空闲 1 分钟上报,充电 15 秒上报~~ ❌ → **固定每 60 秒全量**
|
|
||||||
- ~~超时 5000ms~~ ❌ → **每个交互 60 秒超时,超时作废、迟到应答丢弃、不重试**
|
|
||||||
- JSON 去空格回车省流量,LF 处理粘包 ✅
|
|
||||||
- ~~`dev_id`:设备上报时使用~~ ❌ → **仅 `auth_str` / `login` 携带**
|
|
||||||
- ~~`id`:`IMEI-仓控板-通道` / `IMEI`~~ ❌ → **不含 IMEI**:柜级省略 / 单板 `"01"` / 仓体级 `"01-3"`
|
|
||||||
- `msg_id`:两个方向**各自独立计数,都每连接从 `1` 起自增**,重连归零;判"应答 vs 指令"看有无 `suc`
|
|
||||||
|
|
||||||
### 签名机制
|
|
||||||
|
|
||||||
- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次)
|
|
||||||
- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)`
|
|
||||||
- 时间戳秒级,平台校验±60秒
|
|
||||||
|
|
||||||
### 指令清单
|
|
||||||
|
|
||||||
> ⛔ **以下整段(含本表与其后的报文示例、`cell` / `pow_fail` / `bms` 字段说明)均为废弃的历史内容,请勿据此实现。**
|
|
||||||
> 现行清单与报文以 [`柜控板固件对接文档.md`](./柜控板固件对接文档.md) §4 / §5 为准。**`act` 共 13 个**:
|
|
||||||
> `auth_str` / `login` / `status_post` / `pow_fail_dc` / `pow_fail_ac` / `bat_in` / `bat_out` / `on` / `off` / `open` / `pow_on` / `pow_off` / `reset`
|
|
||||||
> (**已无 `pow_fail`**,拆成 `pow_fail_dc` / `pow_fail_ac`)
|
|
||||||
|
|
||||||
| 动作 | 方向 | 说明(⚠️ 历史,见上方说明) |
|
|
||||||
|------|------|------|
|
|
||||||
| `login` | 设备→平台 | 登录(含 `sign`+`timestamp`+`iccid`);成功应答返回 **`spec`** + **`work_mode`**(不再是 `cell`) |
|
|
||||||
| `auth_str` | 设备→平台 | 获取安全码(首次 / 重置后;失败统一回 `invalid signature` 之外的两条见权威文档) |
|
|
||||||
| `status_post` | 设备→平台 | **每 60 秒全量**;结构为 `cabinet` + `boards[].channels[]`(每通道 18 字符 hex),**不再是 `status` 字典** |
|
|
||||||
| ~~`pow_fail`~~ | 设备→平台 | **已废弃** → 改为 `pow_fail_dc` / `pow_fail_ac`(无附加字段) |
|
|
||||||
| `pow_fail_dc` / `pow_fail_ac` | 设备→平台 | 直流停电 / 交流停电(事件上报) |
|
|
||||||
| `bat_in` | 设备→平台 | 电池插入(`battery` 块:属性 + `info` 18hex + `cells` hex);**不建单** |
|
|
||||||
| `bat_out` | 设备→平台 | 电池拔出(结束订单 `user_stop`) |
|
|
||||||
| `on` | **双向** | 开始充电(上行两形态:带 `suc`=回执 / 不带=上报;**建单唯一入口**) |
|
|
||||||
| `off` | **双向** | 充电停止(结束订单 `normal`) |
|
|
||||||
| `open` | 平台→设备 | **柜门解锁**(柜级:整柜共用一扇门,需人拉开;无"关门"指令) |
|
|
||||||
| `pow_on` / `pow_off` | 平台→设备 | 接触器合闸 / 分闸(`pow_off` 后仓控板随之下电 → 上报 `pow_fail_dc`) |
|
|
||||||
| `reset` | 平台→设备 | 重启整柜(`id` 省略)或单块仓控板(`id="01"`);**先回执再重启** |
|
|
||||||
|
|
||||||
> ⚠️ 所有设备上报消息平台都会回复 `{"suc":1,"msg_id":N}` 确认。响应字段扁平化,不加 `data` 包裹。
|
|
||||||
|
|
||||||
### 全局单位规范
|
|
||||||
|
|
||||||
所有数值字段使用整数存储,禁止浮点类型:
|
|
||||||
|
|
||||||
| 字段 | 单位 | 精度 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `voltage` | mV | 1 | 52000 = 52.000V |
|
|
||||||
| `current` | mA | 1 | 10000 = 10.000A |
|
|
||||||
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
|
|
||||||
| `soc` | ‰ | 0.1% | 4500 = 45.00% |
|
|
||||||
|
|
||||||
### 服务架构
|
|
||||||
|
|
||||||
```
|
|
||||||
设备 ─TCP─→ device-server ─LPUSH→ Redis ─BRPOP→ api-server
|
|
||||||
设备 ←TCP─ device-server ←BRPOP─ Redis ←LPUSH─ api-server
|
|
||||||
```
|
|
||||||
|
|
||||||
**职责分层:**
|
|
||||||
|
|
||||||
| 层 | 职责 |
|
|
||||||
|---|------|
|
|
||||||
| **device-server** | TCP连接管理、协议解析(HEX→结构化)、消息转发 |
|
|
||||||
| **api-server** | 业务逻辑:签名验证、状态持久化、充电事件、告警、指令下发 |
|
|
||||||
|
|
||||||
**Redis 队列:**
|
|
||||||
|
|
||||||
| 队列 | 方向 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `device:reports` | 设备→平台 | device-server 转发设备上报 |
|
|
||||||
| `device:commands` | 平台→设备 | api-server 下发指令/响应 |
|
|
||||||
|
|
||||||
### 全局单位规范
|
|
||||||
|
|
||||||
#### login(设备→平台)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"iccid":"89860000000000000000"}
|
|
||||||
// 响应:{"suc":1,"msg_id":1,"cell":6}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `iccid` — SIM卡ICCID(变更时写 SIM 变更日志)
|
|
||||||
- `cell` — 仓控板数量(柜子配置属性)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### auth_str(设备→平台)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345"}
|
|
||||||
// 响应:{"suc":1,"msg_id":2,"auth_str":"abcd1234"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `auth_str` — 8位随机安全码(仅返回一次,标记后不可重复获取)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### status_post(设备→平台)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,
|
|
||||||
"status":{"00":"0001","01":"000100020003000400050006","02":"000100020003000400050006"}}
|
|
||||||
// 响应:{"suc":1,"msg_id":10}
|
|
||||||
```
|
|
||||||
|
|
||||||
**status 字段:**
|
|
||||||
- key: `"00"`=柜子级, `"01"`~`"N"`=仓控板级(N = cell-1)
|
|
||||||
- value: 6通道状态拼接(每通道4个HEX字符=2字节)
|
|
||||||
- device-server 解析后附加 `parsed_status` 字段,api-server 直接写入 Redis
|
|
||||||
|
|
||||||
**HEX 解析(device-server 负责,格式待硬件团队最终确认):**
|
|
||||||
- 字节1 bit1=充电中, bit2=充满, bit3=故障
|
|
||||||
- 当前实现:每通道4字符,取字节1解析
|
|
||||||
|
|
||||||
> ⚠️ HEX 编码格式待硬件团队最终确认,当前为预留实现。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### pow_fail(设备→平台)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"}
|
|
||||||
// 响应:{"suc":1,"msg_id":15}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `info` — 停电类型:`0`=直流停电(12V掉电),`1`=交流停电(380V掉电)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### bat_in / bat_out(设备→平台)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-01","bms":{
|
|
||||||
"soc":4500,"voltage":52000,"current":10000,"temp":29865,
|
|
||||||
"battery_id":"BAT001","brand":"星恒","model":"XH-48V","sn":"SN123456"
|
|
||||||
}}
|
|
||||||
// 响应:{"suc":1,"msg_id":20}
|
|
||||||
|
|
||||||
{"act":"bat_out","msg_id":21,"id":"123456789012345-01-01","bms":{
|
|
||||||
"battery_id":"BAT001","soc":4500
|
|
||||||
}}
|
|
||||||
// 响应:{"suc":1,"msg_id":21}
|
|
||||||
```
|
|
||||||
|
|
||||||
**BMS 字段(全局整数单位):**
|
|
||||||
|
|
||||||
| 字段 | 单位 | 精度 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `soc` | ‰ | 0.1% | 4500 = 45.00% |
|
|
||||||
| `voltage` | mV | 1 | 52000 = 52.000V |
|
|
||||||
| `current` | mA | 1 | 10000 = 10.000A |
|
|
||||||
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
|
|
||||||
| `battery_id` | — | — | 电池唯一标识 |
|
|
||||||
| `brand` | — | — | BMS品牌 |
|
|
||||||
| `model` | — | — | BMS型号 |
|
|
||||||
| `sn` | — | — | BMS序列号 |
|
|
||||||
| `cell_voltages` | mV | 1 | 单体电芯电压数组 |
|
|
||||||
|
|
||||||
> `bat_out` 只需上传 `battery_id`、`soc`(其他数据不变)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### on / off(双向)
|
|
||||||
|
|
||||||
```json
|
|
||||||
// 平台→设备:下发充电
|
|
||||||
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
|
|
||||||
// 设备响应:{"suc":1,"msg_id":30}
|
|
||||||
|
|
||||||
// 设备→平台:充电开始确认
|
|
||||||
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
|
|
||||||
// 平台响应:{"suc":1,"msg_id":30}
|
|
||||||
|
|
||||||
// 平台→设备:下发停止
|
|
||||||
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
|
|
||||||
// 设备响应:{"suc":1,"msg_id":31}
|
|
||||||
|
|
||||||
// 设备→平台:充满自动停止
|
|
||||||
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
|
|
||||||
// 平台响应:{"suc":1,"msg_id":31}
|
|
||||||
```
|
|
||||||
|
|
||||||
> `on` 必须由平台下发,设备不可主动充电。MCU 保证 `off` → `bat_out` 顺序。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### open(平台→设备)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"act":"open","msg_id":40,"id":"123456789012345"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:** `{"suc":1,"msg_id":40}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### pow_on / pow_off(平台→设备)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"act":"pow_on","msg_id":50,"id":"123456789012345"}
|
|
||||||
{"act":"pow_off","msg_id":51,"id":"123456789012345"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:** `{"suc":1,"msg_id":50}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### reset(平台→设备)
|
|
||||||
|
|
||||||
```json
|
|
||||||
// 重启整柜
|
|
||||||
{"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` — 通信超时 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Redis 实时状态存储
|
|
||||||
|
|
||||||
### 层级结构
|
|
||||||
|
|
||||||
```
|
|
||||||
device:{imei} # 设备级 Hash
|
|
||||||
├─ online = "1" # 在线标记(TTL 300秒)
|
|
||||||
├─ rssi = "85" # 信号强度
|
|
||||||
├─ pow_fail_dc = "0/1" # 直流停电
|
|
||||||
├─ pow_fail_ac = "0/1" # 交流停电
|
|
||||||
└─ device:{imei}:board:{board_idx} # 板级 Hash
|
|
||||||
├─ status_hex = "00010002..." # 原始hex状态
|
|
||||||
└─ device:{imei}:board:{board_idx}:ch:{ch} # 通道级 Hash(1~6)
|
|
||||||
├─ on = "0/1" # 充电中
|
|
||||||
├─ full = "0/1" # 充满
|
|
||||||
├─ fault = "0/1" # 故障
|
|
||||||
├─ hex = "0001" # 原始hex(4字符)
|
|
||||||
├─ battery_id = "BAT001" # 电池ID(bat_in 写入,bat_out 清除)
|
|
||||||
├─ soc = "4500" # 电量 ‰
|
|
||||||
├─ voltage = "52000" # 电压 mV
|
|
||||||
├─ current = "10000" # 电流 mA
|
|
||||||
├─ temp = "29865" # 温度 K×100
|
|
||||||
├─ brand = "星恒" # BMS品牌
|
|
||||||
├─ model = "XH-48V" # BMS型号
|
|
||||||
└─ sn = "SN123456" # BMS序列号
|
|
||||||
```
|
|
||||||
|
|
||||||
> HEX 解析由 device-server 负责,api-server 只接收结构化数据写入 Redis。
|
|
||||||
|
|
||||||
### BMS 字段(全局整数单位)
|
|
||||||
|
|
||||||
| 字段 | 单位 | 精度 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `soc` | ‰ | 0.1% | 4500 = 45.00% |
|
|
||||||
| `voltage` | mV | 1 | 52000 = 52.000V |
|
|
||||||
| `current` | mA | 1 | 10000 = 10.000A |
|
|
||||||
| `energy` | Wh | 1 | 累计充电量 |
|
|
||||||
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
|
|
||||||
| `battery_id` | — | — | 电池唯一标识 |
|
|
||||||
| `brand` | — | — | BMS品牌 |
|
|
||||||
| `model` | — | — | BMS型号 |
|
|
||||||
| `sn` | — | — | BMS序列号 |
|
|
||||||
| `cell_voltages` | mV | 1 | 单体电芯电压数组 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 权限码列表
|
|
||||||
|
|
||||||
> 以 `software/api-server/src/db/migrate.rs::seed_permissions` 的**种子表为准**(下表与之对齐)。
|
|
||||||
> ⚠️ 历史上代码里曾误用一个**不存在**的权限码 `device:door:unlock`(种子里从来没有它)→ 导致开门对所有角色 403,**已修正为 `charge:open_door`**。
|
|
||||||
|
|
||||||
| 权限码 | 说明 |
|
|
||||||
|--------|------|
|
|
||||||
| `device:view` | 查看设备 |
|
|
||||||
| `device:operate` | 操作设备(开合闸 / 重启 / 开门等柜级控制) |
|
|
||||||
| `device:create` | 添加设备 |
|
|
||||||
| `device:edit` | 编辑设备(含修改充电工作模式) |
|
|
||||||
| `device:delete` | 删除设备 |
|
|
||||||
| `device:auth_str:reset` | 重置安全码(**仅总管理员**) |
|
|
||||||
| `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` | 组织管理 |
|
|
||||||
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)");
|
||||||
|
});
|
||||||
@ -1,47 +0,0 @@
|
|||||||
# SQL 注入:format! 拼 SQL 的连锁风险
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
`src/routes/charge_orders.rs` 的 `build_count_query` 和 `build_list_query` 两个辅助函数存在 SQL 注入漏洞。`battery_id` 参数通过 `format!` 直接拼入 SQL 字符串,攻击者可注入任意 SQL。
|
|
||||||
|
|
||||||
## 根因分析
|
|
||||||
|
|
||||||
**核心问题**:代码演进过程中出现了"双重 SQL 构建"模式。
|
|
||||||
|
|
||||||
1. `list_charge_orders` 主函数已经正确使用 `?` 占位符拼接了 `query` 和 `count_query`
|
|
||||||
2. 但 `build_count_query` / `build_list_query` **完全忽略**了主函数预拼接的 SQL 字符串
|
|
||||||
3. 它们自行用 `format!` 重建查询,导致所有 `format!` 拼接的参数(`cabinet_id`、`compartment_id`、`battery_id`、`status`、`LIMIT/OFFSET`)都存在注入风险
|
|
||||||
4. 两个函数还包含大量重复的分支逻辑(~100 行),不同分支的绑定顺序不一致,难以维护
|
|
||||||
|
|
||||||
**根本原因**:重构时只改了 `list_charge_orders` 主函数,没同步改造辅助函数,导致新旧模式并存。
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
1. **删除重复逻辑**:让 `build_count_query` / `build_list_query` 直接使用主函数预拼接的 SQL 字符串
|
|
||||||
2. **统一绑定顺序**:按固定顺序(`cabinet_id → compartment_id → battery_id → status → start_time → end_time → LIMIT/OFFSET`)bind 参数,与主函数追加 `?` 占位符的顺序一致
|
|
||||||
3. **LIKE 值处理**:百分号在 bind 值中拼接(`format!("%{}%", bid)`),不进 SQL 文本
|
|
||||||
4. **消除未使用变量**:`if let Some(x)` 改为 `.is_some()`,避免未使用变量告警
|
|
||||||
|
|
||||||
## 模式/反模式
|
|
||||||
|
|
||||||
### 反模式 ❌:format! + SQL 字符串拼接
|
|
||||||
```rust
|
|
||||||
// 注入风险
|
|
||||||
query.push_str(&format!(" AND battery_id LIKE '%{}%'", bid));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 模式 ✅:主函数拼占位符,辅助函数按序 bind
|
|
||||||
```rust
|
|
||||||
// 主函数:统一追加 ? 占位符
|
|
||||||
if params.battery_id.is_some() {
|
|
||||||
query.push_str(" AND battery_id LIKE ?");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数:按固定顺序 bind 值
|
|
||||||
if let Some(ref bid) = params.battery_id {
|
|
||||||
q = q.bind(format!("%{}%", bid));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 模式 ✅:SQL 构建统一入口
|
|
||||||
所有的 SQL 构建逻辑放在一个地方(主函数),辅助函数只负责 bind 参数,不做 WHERE 条件判断。这样改了主函数就改了所有查询路径,不会出现新旧模式不一致。
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
# Compound Note 模板
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
## 根因分析
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
## 模式/反模式
|
|
||||||
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
@ -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
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"docx": "^9.7.1",
|
||||||
|
"marked": "^18.0.5",
|
||||||
|
"puppeteer": "^25.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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');
|
||||||
|
})();
|
||||||
@ -1,167 +0,0 @@
|
|||||||
# 数据单位审查报告 — 充电柜项目
|
|
||||||
|
|
||||||
**日期:** 2026-07-06
|
|
||||||
**审查范围:** 硬件固件(协议层)、后端 API(api-server)、前端展示(web + h5-uniapp)、API 文档(docs/)
|
|
||||||
**协议基准:** [docs/tcp-protocol.md](./tcp-protocol.md) 全局单位规范(第23-35行)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 协议基准(tcp-protocol.md)
|
|
||||||
|
|
||||||
| 字段 | 单位 | 精度 | 示例 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `voltage` | mV | 1 | 52000 = 52.000V |
|
|
||||||
| `current` | mA | 1 | 10000 = 10.000A |
|
|
||||||
| `power` | mW | 1 | 500000 = 500.000W |
|
|
||||||
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
|
|
||||||
| `soc` | **‰** | 0.1% | 4500 = 45.00% |
|
|
||||||
| `soh` | **‰** | 0.1% | 9500 = 95.00% |
|
|
||||||
| `energy` | Wh | 1 | 累计充电量 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 审查结果总览
|
|
||||||
|
|
||||||
### ✅ 正确的转换
|
|
||||||
|
|
||||||
| 字段 | 协议单位 | 前端转换 | 文件位置 | 状态 |
|
|
||||||
|------|---------|---------|---------|------|
|
|
||||||
| voltage | mV | `/1000 → V` | h5 `compartment-detail/index.vue:144` | ✅ |
|
|
||||||
| current | mA | `/1000 → A` | h5 `compartment-detail/index.vue:145` | ✅ |
|
|
||||||
| power | mW | `/1000 → W` | h5 `compartment-detail/index.vue:146` | ✅ |
|
|
||||||
| temp | K×100 | `/100 - 273.15 → °C` | h5 `device-detail/index.vue:85`、web `cabinet.tsx` | ✅ |
|
|
||||||
| soc | ‰ | `/10 → %` | h5 `compartment-detail/index.vue:142`、`device-detail/index.vue:86` | ✅ |
|
|
||||||
| energy | Wh | `/1000 → kWh` | h5 `compartment-detail/index.vue:147` `fmtEnergyKwh` | ✅ |
|
|
||||||
| energy(累计) | Wh | `/1000 → kWh` | h5 `compartment-detail/index.vue:143` `fmtEnergy` | ✅ |
|
|
||||||
| cost计算 | Wh × 0.0008 → 元 | — | api-server `energy_stats.rs:15` COST_PER_WH | ✅ |
|
|
||||||
|
|
||||||
### 🔴 不一致:SOC/SOH 单位标注错误
|
|
||||||
|
|
||||||
**问题:** `docs/API.md` 第206-207行标注错误。
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- soc — %(0-100)
|
|
||||||
- soh — %(0-100)
|
|
||||||
+ soc — ‰(0-10000,前端已自动转为 %)
|
|
||||||
+ soh — ‰(0-10000,前端已自动转为 %)
|
|
||||||
```
|
|
||||||
|
|
||||||
**现状:** 协议层(tcp-protocol.md)明确定义 soc/soh 为 ‰(0-10000),但 API.md 标注为 %(0-100)。前端代码实际上做了正确的 ‰→% 转换(`/10`)。
|
|
||||||
|
|
||||||
**风险等级:** 🔴 高风险 — API 消费者(第三方、新的前端开发者)依据 API.md 会误认为值是 0-100,导致显示/逻辑错误。
|
|
||||||
**建议:** 修正 API.md 第206-207行,标注为 `‰(0-10000)`,并注明前端已做转换。
|
|
||||||
|
|
||||||
### 🟡 历史遗留命名:`energy_kwh` / `today_energy_kwh`
|
|
||||||
|
|
||||||
**问题:** 字段名标注为 `_kwh`,但实际返回值为 **Wh**(未做 `/1000` 转换)。
|
|
||||||
|
|
||||||
**涉及位置:**
|
|
||||||
|
|
||||||
| 位置 | 字段 | 实际值 | 问题 |
|
|
||||||
|------|------|-------|------|
|
|
||||||
| `api-server/src/routes/h5.rs:309` | `today_energy_kwh` | SUM(energy) → Wh | 注释写"kWh"但返回 Wh |
|
|
||||||
| `api-server/src/routes/h5.rs:776` | `energy_kwh` | `r.energy` → Wh | 字段名标 kWh,实际 Wh |
|
|
||||||
| `docs/API.md:137` | `today_energy_kwh` | 0 | 文档无明确单位说明 |
|
|
||||||
| `docs/API.md:421` | 多个能量字段 | — | **已有注释**:`¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。` |
|
|
||||||
|
|
||||||
**现状:** API.md 第421行已有明确注释说明这是历史遗留命名。前端 H5 通过 `fmtEnergy` 和 `fmtEnergyKwh` 正确执行 `/1000` 转换。
|
|
||||||
|
|
||||||
**风险等级:** 🟡 中风险 — API.md 已有注释说明,但字段名具有误导性。新接手开发者容易按字段名直接使用而不做 `/1000`。
|
|
||||||
|
|
||||||
**建议:** 保留现状(已有注释),但可在 API 文档对应字段示例中加 `// 实际单位: Wh, 需 /1000 转 kWh`。
|
|
||||||
|
|
||||||
### 🟡 `fmtEnergy` 缺少单位标签
|
|
||||||
|
|
||||||
**位置:** `h5-uniapp/src/pages/compartment-detail/index.vue:143`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const fmtEnergy = (v: number | null | undefined) => v != null ? (v / 1000).toFixed(2) : '-'
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题:** 转换后未标注单位(kWh),与 `fmtEnergyKwh`(L147,有 `kWh` 标签)不一致。
|
|
||||||
|
|
||||||
**风险等级:** 🟡 低风险 — 数值本身正确,只是缺少显示标签。
|
|
||||||
|
|
||||||
**建议:** 加 `' kWh'` 后缀,与 `fmtEnergyKwh` 对齐。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. DB 层验证
|
|
||||||
|
|
||||||
**migrate.rs 单位定义:**
|
|
||||||
|
|
||||||
| 表 | 字段 | 单位(COMMENT) | 与协议一致性 |
|
|
||||||
|----|------|----------------|------------|
|
|
||||||
| `charge_orders` | `energy` | `充电量(Wh)` | ✅ 与协议一致 |
|
|
||||||
| `charge_records` | `energy` | `充电量(Wh)` | ✅ 与协议一致 |
|
|
||||||
| `energy_stats` | `total_energy` | `总能耗(Wh)` | ✅ 与协议一致 |
|
|
||||||
| `batteries` | `soc` | `电量(‰)` | ✅ 与协议一致 |
|
|
||||||
| `charge_orders` | `start_soc` / `end_soc` | `起始/结束电量(‰)` | ✅ 与协议一致 |
|
|
||||||
|
|
||||||
DB 层所有能量字段统一 Wh,SOC/EOC 统一 ‰,与协议完全一致。✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 审查结论
|
|
||||||
|
|
||||||
### 正确率:91%
|
|
||||||
|
|
||||||
| 维度 | 正确 | 问题 |
|
|
||||||
|------|------|------|
|
|
||||||
| 协议 → DB 单位 | ✅ 100% | 无 |
|
|
||||||
| DB → 后端 API | ⚠️ 2/3 正确 | 2个字段名误导(energy_kwh / today_energy_kwh) |
|
|
||||||
| API → 前端展示 | ✅ 100% | 前端所有转换正确 |
|
|
||||||
| API 文档 | ⚠️ 83% | SOC/EOC 单位标注错误 + energy_kwh 命名误导 |
|
|
||||||
|
|
||||||
### 需修复
|
|
||||||
|
|
||||||
| 优先级 | 问题 | 文件 | 修改 |
|
|
||||||
|--------|------|------|------|
|
|
||||||
| 🔴 | SOC/EOC 单位标注 | `docs/API.md:206-207` | `%(0-100)` → `‰(0-10000)` |
|
|
||||||
| 🟡 | `fmtEnergy` 缺单位标签 | `h5-uniapp/compartment-detail/index.vue:143` | 加 `' kWh'` 后缀 |
|
|
||||||
|
|
||||||
### 已有保障(无需修复)
|
|
||||||
|
|
||||||
- `docs/API.md:421` 已对 `energy_kwh` / `today_energy_kwh` 的命名陷阱做注释
|
|
||||||
- 前端所有 7 个单位转换函数逻辑正确
|
|
||||||
- DB 层所有能量字段统一 Wh,SOC 统一 ‰,与协议一致
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 审查路径图
|
|
||||||
|
|
||||||
```
|
|
||||||
设备协议 (tcp-protocol.md)
|
|
||||||
├─ voltage mV ──────────────────────┐
|
|
||||||
├─ current mA ──────────────────────┤
|
|
||||||
├─ power mW ───────────────────────┤
|
|
||||||
├─ temp K×100 ─────────────────────┤
|
|
||||||
├─ soc ‰ ──────────────────────────┤
|
|
||||||
├─ soh ‰ ──────────────────────────┤
|
|
||||||
└─ energy Wh ──────────────────────┤
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
DB (migrate.rs)
|
|
||||||
energy BIGINT (Wh) ✅
|
|
||||||
soc INT (‰) ✅
|
|
||||||
temp 由 BMS 直接传入
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
api-server (h5.rs / cabinets.rs)
|
|
||||||
原样返回 raw 值 ✅
|
|
||||||
字段名 _kwh 误导 🟡
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
H5 前端 (compartment-detail / device-detail)
|
|
||||||
全部 /1000、/10、/100 转换 ✅
|
|
||||||
fmtEnergy 缺单位标签 🟡
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
API 文档 (API.md)
|
|
||||||
SOC 标 % 实际 ‰ 🔴
|
|
||||||
energy_kwh 注释齐全 ✅ (已有说明)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*审查基于代码静态分析,未运行实际设备验证。建议结合真实设备数据对 SOC 和 energy 展示做端到端验证。*
|
|
||||||
86
docs/开发文档.md
@ -765,52 +765,13 @@ TCP断连处理:
|
|||||||
|
|
||||||
### 7.1 后台管理系统
|
### 7.1 后台管理系统
|
||||||
|
|
||||||
#### 7.1.1 业务模型与权限体系
|
#### 7.1.1 技术栈建议
|
||||||
|
|
||||||
**组织架构:**
|
|
||||||
```
|
|
||||||
厂家(总管理员 role=2)
|
|
||||||
└─ 将设备分配给 B 端企业(organization)
|
|
||||||
└─ 企业管理员(role=1)
|
|
||||||
├─ 创建下级操作员(role=0,按权限码分配功能)
|
|
||||||
├─ 创建项目(project,设备放置地点)
|
|
||||||
└─ 管理本企业下的设备
|
|
||||||
```
|
|
||||||
|
|
||||||
**角色定义:**
|
|
||||||
|
|
||||||
| 角色 | role | 权限范围 |
|
|
||||||
|------|------|----------|
|
|
||||||
| 总管理员 | 2 | 全平台数据,分配柜子给企业,创建企业管理员 |
|
|
||||||
| 企业管理员 | 1 | 本企业下的设备/项目/用户管理 |
|
|
||||||
| 操作员 | 0 | 按权限码授予的具体功能(开门、查看等) |
|
|
||||||
|
|
||||||
- 所有用户均为管理员角色,**无 C 端普通用户**
|
|
||||||
- 权限:RBAC,支持精确到按钮级别的权限码(如 `device:door:unlock`)
|
|
||||||
- 组织隔离:role_level >= 2(总管理员)跳过组织过滤,其他用户只能操作所属组织的资源
|
|
||||||
|
|
||||||
**认证方式:**
|
|
||||||
- 全部使用手机号 + 密码登录(argon2 哈希)
|
|
||||||
- 不接入微信认证(无 C 端用户)
|
|
||||||
- 创建/重置用户时必须指定密码,无默认密码
|
|
||||||
|
|
||||||
**业务流转:**
|
|
||||||
```
|
|
||||||
厂家(总后台)→ 创建企业账号 → 分配充电柜给企业
|
|
||||||
→ 企业管理员登录 → 创建项目(放置地点)→ 将设备划拨到项目
|
|
||||||
→ 创建下级操作员 → 赋予具体权限码 → 日常运维
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7.1.2 技术栈(已确定)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
后端框架:Rust + Axum
|
后端框架:Spring Boot / Node.js (Egg.js/Nest.js) / Python (FastAPI)
|
||||||
前端框架:React + Arco Design Pro(管理端)
|
数据库:MySQL + Redis + InfluxDB(时序数据)
|
||||||
微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
|
通信协议:自定义TCP协议长连接(设备通讯)+ HTTP/WebSocket(前端)
|
||||||
数据库:MySQL(阿里云RDS)
|
前端框架:React / Vue3 + Element Plus / Ant Design
|
||||||
缓存:Redis(阿里云Redis)
|
|
||||||
通信协议:自定义TCP协议长连接(设备通讯)+ HTTP(API)
|
|
||||||
权限:RBAC,精确到按钮级别
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 7.1.2 核心模块
|
#### 7.1.2 核心模块
|
||||||
@ -822,7 +783,7 @@ TCP断连处理:
|
|||||||
| BMS协议配置 | 设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
|
| BMS协议配置 | 设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
|
||||||
| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
|
| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
|
||||||
| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
|
| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
|
||||||
| 用户管理 | 创建/管理管理员账号,按角色分级(总管理员/企业管理员/操作员) |
|
| 用户管理 | 注册/登录、权限分配(管理员/普通/骑手) |
|
||||||
| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计 |
|
| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计 |
|
||||||
| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
|
| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
|
||||||
| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置、设备告警管理 |
|
| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置、设备告警管理 |
|
||||||
@ -872,25 +833,22 @@ TCP断连处理:
|
|||||||
- 故障等级(预警/保护/严重)
|
- 故障等级(预警/保护/严重)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 移动管理端(微信小程序)
|
### 7.2 微信小程序
|
||||||
|
|
||||||
> 面向企业管理员/操作员的手持管理入口,使用微信小程序(uni-app 构建 mp-weixin)。
|
|
||||||
> 本系统无 C 端用户,所有用户均为 B 端管理员角色。
|
|
||||||
|
|
||||||
#### 7.2.1 功能页面
|
#### 7.2.1 功能页面
|
||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 首页 | 企业所属设备列表,空闲舱位显示,RSSI信号强度 |
|
| 首页 | 附近充电柜列表/地图,空闲舱位显示 |
|
||||||
| 设备详情 | 仓控板+仓体状态,ICCID信息 |
|
| 扫码充电 | 扫描仓体二维码,启动充电 |
|
||||||
| 扫码操作 | 扫描仓体二维码,开门/充电/查看状态 |
|
| 预约充电 | 选择仓体,预约时间段 |
|
||||||
| 充电进度 | 实时显示SOC/电压/温度/充电时间 |
|
| 充电进度 | 实时显示SOC/电压/温度/充电时间 |
|
||||||
| 消息通知 | 充电完成、故障告警、系统通知 |
|
| 消息通知 | 充电完成、故障告警、系统通知 |
|
||||||
| 个人中心 | 充电记录、账户信息、退出登录 |
|
| 个人中心 | 充电记录、用户信息、反馈 |
|
||||||
|
|
||||||
#### 7.2.2 仓体状态展示
|
#### 7.2.2 仓体状态展示
|
||||||
|
|
||||||
移动端同步显示仓体指示灯状态及门锁状态:
|
小程序同步显示仓体指示灯状态及门锁状态:
|
||||||
- 绿灯闪烁 → "充电中"
|
- 绿灯闪烁 → "充电中"
|
||||||
- 绿灯常亮 + 门锁图标开 → "空闲(门已开)"
|
- 绿灯常亮 + 门锁图标开 → "空闲(门已开)"
|
||||||
- 绿灯常亮 + 门锁图标关 → "空闲(门已关)"
|
- 绿灯常亮 + 门锁图标关 → "空闲(门已关)"
|
||||||
@ -898,7 +856,7 @@ TCP断连处理:
|
|||||||
|
|
||||||
扫码开门流程:
|
扫码开门流程:
|
||||||
1. 用户扫描仓体二维码
|
1. 用户扫描仓体二维码
|
||||||
2. 用户端显示仓体信息和操作按钮
|
2. 小程序显示仓体信息和操作按钮
|
||||||
3. 点击"开门"按钮 → 平台验证 → 下发开门指令
|
3. 点击"开门"按钮 → 平台验证 → 下发开门指令
|
||||||
4. 门锁打开,用户放入/取出电池
|
4. 门锁打开,用户放入/取出电池
|
||||||
5. 关门后系统自动检测门磁闭合
|
5. 关门后系统自动检测门磁闭合
|
||||||
@ -926,7 +884,7 @@ TCP断连处理:
|
|||||||
| 存储加密 | 用户信息、BMS数据加密存储 |
|
| 存储加密 | 用户信息、BMS数据加密存储 |
|
||||||
| 通讯校验 | 1TN和校验 + Modbus CRC16校验 |
|
| 通讯校验 | 1TN和校验 + Modbus CRC16校验 |
|
||||||
| 网络传输 | 自定义TCP协议加密传输 |
|
| 网络传输 | 自定义TCP协议加密传输 |
|
||||||
| 权限管控 | 总管理员/企业管理员/操作员三级权限,RBAC 精确到按钮 |
|
| 权限管控 | 管理员/普通用户分级权限 |
|
||||||
|
|
||||||
### 8.3 防护联动流程
|
### 8.3 防护联动流程
|
||||||
|
|
||||||
@ -975,19 +933,3 @@ TCP断连处理:
|
|||||||
| 指示灯寿命 | ≥ 50000小时 |
|
| 指示灯寿命 | ≥ 50000小时 |
|
||||||
| 工作温度 | -10℃ ~ +70℃ |
|
| 工作温度 | -10℃ ~ +70℃ |
|
||||||
| 工作湿度 | 10% ~ 90% |
|
| 工作湿度 | 10% ~ 90% |
|
||||||
|
|
||||||
## 10. 第三方服务配置
|
|
||||||
|
|
||||||
### 10.1 腾讯地图(JS API GL)
|
|
||||||
|
|
||||||
管理端项目地址选点使用腾讯地图 JS API GL(v1.exp),需加载 `service` 附加库。
|
|
||||||
|
|
||||||
| 参数 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| Key | `HWVBZ-CMACV-JEBP5-5RDRJ-OIHLK-ZNFE2` |
|
|
||||||
| Secret Key (SK) | `LYVx9XenBOKGnbAKkmzJxOYFhASp9FQD` |
|
|
||||||
| 签名验证 | 已开启,所有 Geocoder/Service 调用通过 `servicesk` 参数传 SK |
|
|
||||||
| 使用场景 | 管理端「设备管理 → 项目添加/编辑 → 地图选点」 |
|
|
||||||
| 组件 | `LocationPicker.tsx`(`@/components/LocationPicker`) |
|
|
||||||
|
|
||||||
> ⚠️ Key 内嵌在前端组件中,SK 仅用于 JS API 的 WebService 签名校验(客户端可见是腾讯地图 SDK 的设计要求)。如更换 Key,同步更新 `LocationPicker.tsx` 顶部的 `TMAP_KEY` 和 `TMAP_SK` 常量。
|
|
||||||
|
|||||||
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读取电表数据,上报平台显示正确 |
|
||||||
1445
docs/柜控板固件对接文档.md
@ -1,273 +0,0 @@
|
|||||||
# 充电柜矩阵系统(PMS)深度分析报告
|
|
||||||
|
|
||||||
**分析日期**:2026-07-06
|
|
||||||
**分析范围**:项目结构、文档、代码组织、技术栈实现、功能完整性
|
|
||||||
**分析方法**:静态代码分析、文档对比、架构验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、项目概览
|
|
||||||
|
|
||||||
**项目名称**:充电柜矩阵系统(Power Matrix System / PMS)
|
|
||||||
**产品名称**:安知充(anzhizhichong)
|
|
||||||
**技术栈**:Rust + Axum(后端)、React + Arco Design Pro(管理端)、uni-app + Vue 3(H5用户端)
|
|
||||||
**数据库**:MySQL(阿里云RDS)
|
|
||||||
**缓存**:Redis(阿里云Redis)
|
|
||||||
**设备通讯**:TCP长连接(Air780E Cat.1 4G模块)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、项目结构分析
|
|
||||||
|
|
||||||
### ✅ 符合规范的部分
|
|
||||||
|
|
||||||
1. **目录结构基本符合 AGENTS.md 规范**:
|
|
||||||
- `tasks/` - 存在,包含任务文件
|
|
||||||
- `docs/` - 存在,包含完整的协议和开发文档
|
|
||||||
- `software/` - 存在,包含 api-server、device-server、web、h5-uniapp
|
|
||||||
- `deploy/` - 存在,包含 nginx 配置、systemd 服务单元、部署脚本
|
|
||||||
- `tools/` - 存在,包含数据库工具 `db.py`
|
|
||||||
- `hardware/` - 存在
|
|
||||||
- `test/` - **缺失**
|
|
||||||
|
|
||||||
2. **模块化设计合理**:
|
|
||||||
- api-server 和 device-server 分离,符合"TCP服务轻量化"原则
|
|
||||||
- 通过 Redis LIST 进行服务间通信,避免直接 HTTP 调用
|
|
||||||
- 使用 tokio::spawn 处理后台协程任务
|
|
||||||
|
|
||||||
### ❌ 发现的问题
|
|
||||||
|
|
||||||
| 问题 | 严重程度 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| **敏感信息泄露** | 🔴 高 | AGENTS.md 中包含明文密码(数据库密码、SSH密码等),虽已 gitignore 但仍在文档中 |
|
|
||||||
| **目录名不一致** | 🟡 中 | README.md 提到 `h5-uniapp`,但实际可能使用 taro 框架(需确认) |
|
|
||||||
| **test/ 目录缺失** | 🟡 中 | AGENTS.md 规范中要求 `test/` 目录用于测试用例/报告,但实际不存在 |
|
|
||||||
| **温度单位不一致** | 🟡 中 | tcp-protocol.md 定义 `temp` 为 `K×100`,但 AGENTS.md 数据单位规范中描述为 `0.1°C` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、技术栈实现分析
|
|
||||||
|
|
||||||
### ✅ 符合文档描述的部分
|
|
||||||
|
|
||||||
1. **后端技术栈**:
|
|
||||||
- ✅ Rust + Axum 框架(Cargo.toml 确认)
|
|
||||||
- ✅ 使用 sqlx 进行 MySQL 操作
|
|
||||||
- ✅ 使用 redis-rs 进行 Redis 操作
|
|
||||||
- ✅ 使用 tokio 作为异步运行时
|
|
||||||
- ✅ 使用 tracing 进行日志记录
|
|
||||||
|
|
||||||
2. **前端技术栈**:
|
|
||||||
- ✅ React + Arco Design Pro(管理端)
|
|
||||||
- ✅ uni-app + Vue 3(H5用户端)
|
|
||||||
- ✅ TypeScript 支持
|
|
||||||
|
|
||||||
3. **服务架构**:
|
|
||||||
- ✅ api-server 负责 HTTP API 和业务逻辑
|
|
||||||
- ✅ device-server 负责 TCP 设备连接管理
|
|
||||||
- ✅ Redis 作为消息队列缓冲
|
|
||||||
|
|
||||||
### ❌ 实现与文档不一致的部分
|
|
||||||
|
|
||||||
| 问题 | 严重程度 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| **H5框架可能不一致** | 🟡 中 | AGENTS.md 说是 uni-app,但代码中可能出现 taro 相关依赖 |
|
|
||||||
| **OTA升级功能** | 🟡 中 | 合同和文档要求 OTA 固件升级,但代码中未找到完整实现 |
|
|
||||||
| **微信小程序** | 🟢 低 | 文档明确说明"不做"(名额暂不可用),但合同要求交付 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、代码组织分析
|
|
||||||
|
|
||||||
### ✅ 优点
|
|
||||||
|
|
||||||
1. **Rust 代码组织良好**:
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── routes/ # HTTP 路由处理器(按业务拆分)✅
|
|
||||||
├── middleware/ # 中间件(认证、日志、CORS)✅
|
|
||||||
├── db/ # 数据库连接池、迁移 ✅
|
|
||||||
├── commands/ # 设备指令下发逻辑 ✅
|
|
||||||
├── nodes/ # 设备服务节点管理 ✅
|
|
||||||
├── workers/ # 后台 Worker(report/reply/node_checker)✅
|
|
||||||
└── error.rs # 统一错误定义 ✅
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **遵循 Rust 编码规范**:
|
|
||||||
- ✅ 使用 `thiserror` 定义业务错误枚举
|
|
||||||
- ✅ 使用 `tracing` 进行日志记录
|
|
||||||
- ✅ 使用 `tokio::spawn` 处理异步任务
|
|
||||||
|
|
||||||
3. **API 设计规范**:
|
|
||||||
- ✅ 响应格式统一:`{ "code": 0, "data": {...}, "message": "ok" }`
|
|
||||||
- ✅ 使用 JWT Token 认证
|
|
||||||
- ✅ RBAC 权限控制
|
|
||||||
|
|
||||||
### ❌ 需要改进的部分
|
|
||||||
|
|
||||||
| 问题 | 严重程度 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| **浮点类型使用** | 🔴 高 | AGENTS.md 明确指出禁止使用浮点类型,但 `energy_stats.rs` 和 `charge_records.rs` 中仍使用 `f64` |
|
|
||||||
| **数据库迁移** | 🟡 中 | `db/migrate.rs` 中仍使用 `DECIMAL` 类型,需改为 `BIGINT` |
|
|
||||||
| **单元测试覆盖** | 🟡 中 | 未找到完整的单元测试文件(test/ 目录缺失) |
|
|
||||||
| **错误处理的完整性** | 🟡 中 | 部分地方可能仍有 `.unwrap()` 或未处理的错误 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、文档完整性分析
|
|
||||||
|
|
||||||
### ✅ 完整的部分
|
|
||||||
|
|
||||||
1. **协议文档完整**:
|
|
||||||
- ✅ `docs/tcp-protocol.md` - TCP 设备通讯协议(11条指令详细说明)
|
|
||||||
- ✅ `docs/API.md` - API 接口文档(包含请求/响应示例)
|
|
||||||
- ✅ `docs/1TN协议标准_YG20240118-1_V1.0.md` - 电源协议
|
|
||||||
|
|
||||||
2. **开发文档详细**:
|
|
||||||
- ✅ `docs/开发文档.md` - 硬件架构、软件架构、通讯协议设计
|
|
||||||
- ✅ `docs/附件一-硬件规格.md` - 柜控主板、仓控主板硬件规格
|
|
||||||
- ✅ `docs/附件二-软件规格.md` - 后台管理系统、微信小程序需求
|
|
||||||
- ✅ `docs/附件三-交付与验收标准.md` - 交付物清单、验收标准
|
|
||||||
|
|
||||||
3. **AGENTS.md 非常详细**:
|
|
||||||
- ✅ 项目简介、技术栈、项目结构
|
|
||||||
- ✅ 部署架构、服务器信息、数据库工具
|
|
||||||
- ✅ 开发约束、编码规范(Rust、React)
|
|
||||||
- ✅ API 设计规范、Git 工作流
|
|
||||||
- ✅ 测试标准、安全规范、日志规范、性能规范
|
|
||||||
|
|
||||||
### ❌ 文档问题
|
|
||||||
|
|
||||||
| 问题 | 严重程度 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| **README.md 与 AGENTS.md 重复** | 🟢 低 | 两份文档内容有大量重复,维护成本增加 |
|
|
||||||
| **合同模板未填写** | 🟢 低 | `docs/开发合同.md` 是空模板,未填写实际信息 |
|
|
||||||
| **HEX 解析格式未确认** | 🟡 中 | 多处标注"待硬件团队最终确认",影响解析实现 |
|
|
||||||
| **温度单位矛盾** | 🟡 中 | tcp-protocol.md 使用 `K×100`,AGENTS.md 描述为 `0.1°C` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、功能缺失分析
|
|
||||||
|
|
||||||
### 根据合同和文档要求,以下功能可能缺失或不完整:
|
|
||||||
|
|
||||||
| 功能 | 优先级 | 说明 |
|
|
||||||
|------|--------|------|
|
|
||||||
| **OTA 固件升级** | 🔴 高 | 合同要求交付,但代码中未找到完整实现 |
|
|
||||||
| **微信小程序** | 🟡 中 | 合同要求交付,但 AGENTS.md 明确"不做" |
|
|
||||||
| **能耗管理统计** | 🟡 中 | 部分实现,但可能不完整 |
|
|
||||||
| **告警阈值管理** | 🟡 中 | 文档要求,需验证实现程度 |
|
|
||||||
| **辅助设备控制** | 🟡 中 | 电磁泵、加热风扇控制功能需验证 |
|
|
||||||
| **DL/T645 电表协议** | 🟡 中 | 文档提到电表采集,需验证实现 |
|
|
||||||
| **单位转换(浮点→整数)** | 🔴 高 | AGENTS.md 明确要求,但代码中仍使用 f64 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、改进建议(优先级排序)
|
|
||||||
|
|
||||||
### 🔴 P0 - 立即处理
|
|
||||||
|
|
||||||
1. **移除 AGENTS.md 中的敏感信息**:
|
|
||||||
- 密码、SSH连接信息应移至 `.env.example` 或环境变量
|
|
||||||
- 当前文档包含明文密码:`Hbhyg731024`、`Hbhyg731024@`
|
|
||||||
|
|
||||||
2. **修复浮点类型使用**:
|
|
||||||
- 将 `energy_stats.rs` 和 `charge_records.rs` 中的 `f64` 改为 `i64`
|
|
||||||
- 将 `db/migrate.rs` 中的 `DECIMAL` 改为 `BIGINT`
|
|
||||||
- 涉及文件:
|
|
||||||
- `src/routes/charge_records.rs` (L46-47, L77-78)
|
|
||||||
- `src/routes/energy_stats.rs` (L43-72, L106, L116)
|
|
||||||
- `src/db/migrate.rs` (L76-77, L97)
|
|
||||||
|
|
||||||
3. **完善 OTA 升级功能**:
|
|
||||||
- 如果合同要求交付,必须实现完整的 OTA 功能
|
|
||||||
- 包括:固件上传、分片转发、校验、升级状态查询
|
|
||||||
|
|
||||||
### 🟡 P1 - 高优先级
|
|
||||||
|
|
||||||
4. **统一温度单位描述**:
|
|
||||||
- 确认 `temp` 字段实际使用的单位(K×100 还是 0.1°C)
|
|
||||||
- 更新所有相关文档和代码注释
|
|
||||||
|
|
||||||
5. **创建 test/ 目录并补充单元测试**:
|
|
||||||
- 按照 AGENTS.md 测试标准补充单元测试
|
|
||||||
- 核心业务逻辑(充电控制、指令下发、权限校验)覆盖率 ≥80%
|
|
||||||
|
|
||||||
6. **验证 H5 前端框架**:
|
|
||||||
- 确认是使用 uni-app 还是 taro
|
|
||||||
- 更新 README.md 和 AGENTS.md 保持一致性
|
|
||||||
|
|
||||||
7. **补充缺失的 API 实现**:
|
|
||||||
- 验证所有 `docs/API.md` 中列出的接口是否都已实现
|
|
||||||
- 特别关注:能耗统计、告警管理、辅助设备控制
|
|
||||||
|
|
||||||
### 🟢 P2 - 中优先级
|
|
||||||
|
|
||||||
8. **清理重复文档**:
|
|
||||||
- 合并 README.md 和 AGENTS.md 的重复内容
|
|
||||||
- 或者明确两份文档的分工(README 面向外部,AGENTS 面向开发者)
|
|
||||||
|
|
||||||
9. **填写合同模板**:
|
|
||||||
- 如果项目已进入开发阶段,应填写实际的合同信息
|
|
||||||
- 或者删除空模板,避免混淆
|
|
||||||
|
|
||||||
10. **增加输入验证**:
|
|
||||||
- 所有 API 入参必须校验(长度、格式、范围)
|
|
||||||
- IMEI 校验:必须15位数字
|
|
||||||
- 手机号校验:11位数字
|
|
||||||
|
|
||||||
11. **性能优化**:
|
|
||||||
- 数据库查询必须走索引(使用 `EXPLAIN` 验证)
|
|
||||||
- Redis 缓存 key 规范:`{业务}:{标识}`
|
|
||||||
- 避免大 Key:单个 Hash ≤ 500 字段,List ≤ 1000 元素
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、安全风险分析
|
|
||||||
|
|
||||||
| 风险 | 等级 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| **敏感信息泄露** | 🔴 高 | AGENTS.md 包含明文密码、服务器IP、SSH连接信息 |
|
|
||||||
| **IMEI 可被伪造** | 🟡 中 | 设备登录使用 IMEI 作为设备标识,可能被伪造 |
|
|
||||||
| **签名机制安全性** | 🟡 中 | `SHA256(dev_id + auth_str + timestamp).substring(56, 63)` 只取8位,安全性较弱 |
|
|
||||||
| **JWT Token 有效期** | 🟢 低 | 7天有效期较长,建议缩短或增加刷新机制 |
|
|
||||||
| **SQL 注入风险** | 🟢 低 | 使用 sqlx 参数化查询,风险较低 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、总结
|
|
||||||
|
|
||||||
### ✅ 项目优点
|
|
||||||
|
|
||||||
1. **架构设计合理**:api-server 和 device-server 分离,职责清晰
|
|
||||||
2. **文档非常详细**:AGENTS.md 涵盖了开发、部署、测试、安全等各个方面
|
|
||||||
3. **技术栈现代**:Rust + Axum 高性能后端,React + Arco Design Pro 现代化前端
|
|
||||||
4. **协议设计完整**:TCP 通讯协议、电源协议、BMS协议都有详细文档
|
|
||||||
|
|
||||||
### ❌ 主要问题
|
|
||||||
|
|
||||||
1. **敏感信息泄露**:AGENTS.md 包含明文密码和服务器信息
|
|
||||||
2. **浮点类型未清理**:违反 AGENTS.md 中的数据单位规范
|
|
||||||
3. **功能可能不完整**:OTA升级、微信小程序等功能可能缺失
|
|
||||||
4. **文档不一致**:温度单位、框架名称等存在矛盾
|
|
||||||
|
|
||||||
### 📋 优先级建议
|
|
||||||
|
|
||||||
- **P0**:移除敏感信息、修复浮点类型、完善 OTA 功能
|
|
||||||
- **P1**:统一单位描述、补充单元测试、验证功能完整性
|
|
||||||
- **P2**:清理文档、增加输入验证、性能优化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、后续行动建议
|
|
||||||
|
|
||||||
1. **立即创建 .env.example 文件**,将敏感信息移至环境变量
|
|
||||||
2. **创建技术债务清单**,跟踪浮点类型清理、功能补全工作
|
|
||||||
3. **与硬件团队确认 HEX 解析格式**,完成设备状态解析
|
|
||||||
4. **补充单元测试**,达到 AGENTS.md 中定义的覆盖率要求
|
|
||||||
5. **进行安全审查**,检查 JWT 签名、IMEI 验证、权限校验等安全环节
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**报告生成**:WorkBuddy AI Agent
|
|
||||||
**分析工具**:静态代码分析、文档对比、架构验证
|
|
||||||
|
Before Width: | Height: | Size: 164 KiB |
BIN
logo/圆切带文字.png
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 29 KiB |
BIN
logo/白色横向@2x.png
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 26 KiB |
BIN
logo/白色竖向@2x.png
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
logo/纯色横向@2x.png
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 11 KiB |
BIN
logo/纯色竖向@2x.png
|
Before Width: | Height: | Size: 86 KiB |
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"model": "sensenova/glm-5.2"
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# API 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值)
|
|
||||||
# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库
|
|
||||||
|
|
||||||
# MySQL 连接(阿里云 RDS)
|
|
||||||
DATABASE_URL=mysql://root:CHANGE_ME@HOST:3306/pms
|
|
||||||
|
|
||||||
# Redis 连接(阿里云 Redis)
|
|
||||||
REDIS_URL=redis://:CHANGE_ME@HOST:6379/5
|
|
||||||
|
|
||||||
# 监听
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=9000
|
|
||||||
|
|
||||||
# JWT 密钥(缺失则服务启动失败,禁止硬编码回退)
|
|
||||||
JWT_SECRET=CHANGE_ME
|
|
||||||
|
|
||||||
# 设备节点心跳超时(秒)
|
|
||||||
NODE_HEARTBEAT_TIMEOUT=60
|
|
||||||
15
software/api-server/.env.production
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# MySQL 连接(正式环境 pms 库)
|
||||||
|
DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms
|
||||||
|
|
||||||
|
# Redis 连接(生产 db5)
|
||||||
|
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
|
||||||
|
|
||||||
|
# HTTP 服务
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=9000 # 正式环境API
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=pms-dev-secret-change-me-in-production
|
||||||
|
|
||||||
|
# 节点心跳超时(秒)
|
||||||
|
NODE_HEARTBEAT_TIMEOUT=180
|
||||||
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 # 测试环境API
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=pms-test-secret
|
||||||
|
|
||||||
|
# 节点心跳超时(秒)
|
||||||
|
NODE_HEARTBEAT_TIMEOUT=180
|
||||||
472
software/api-server/Cargo.lock
generated
@ -32,6 +32,34 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "api-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"axum",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"dotenvy",
|
||||||
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"rand",
|
||||||
|
"redis",
|
||||||
|
"rust_xlsxwriter",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@ -58,7 +86,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"blake2",
|
"blake2",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -102,7 +130,6 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
"multer",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@ -190,24 +217,12 @@ version = "3.20.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytemuck"
|
|
||||||
version = "1.25.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder-lite"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@ -230,23 +245,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg_aliases"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures 0.3.0",
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@ -305,15 +303,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@ -429,15 +418,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "encoding_rs"
|
|
||||||
version = "0.8.35"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@ -482,15 +462,6 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fdeflate"
|
|
||||||
version = "0.3.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
|
||||||
dependencies = [
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@ -662,11 +633,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"rand_core 0.10.1",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -767,12 +735,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "http-range-header"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@ -803,23 +765,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
"want",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-rustls"
|
|
||||||
version = "0.27.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
|
||||||
dependencies = [
|
|
||||||
"http",
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"rustls",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tower-service",
|
|
||||||
"webpki-roots 1.0.8",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -828,21 +773,13 @@ version = "0.1.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
"ipnet",
|
|
||||||
"libc",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.4",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -972,19 +909,6 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "image"
|
|
||||||
version = "0.25.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
|
||||||
dependencies = [
|
|
||||||
"bytemuck",
|
|
||||||
"byteorder-lite",
|
|
||||||
"moxcms",
|
|
||||||
"num-traits",
|
|
||||||
"png",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@ -995,12 +919,6 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ipnet"
|
|
||||||
version = "2.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@ -1097,12 +1015,6 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru-slab"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -1128,12 +1040,6 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "md5"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.2"
|
version = "2.8.2"
|
||||||
@ -1146,16 +1052,6 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mime_guess"
|
|
||||||
version = "2.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
|
||||||
dependencies = [
|
|
||||||
"mime",
|
|
||||||
"unicase",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@ -1177,33 +1073,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "moxcms"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
"pxfm",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "multer"
|
|
||||||
version = "3.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"encoding_rs",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"memchr",
|
|
||||||
"mime",
|
|
||||||
"spin",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -1234,7 +1103,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.8.6",
|
"rand",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@ -1317,7 +1186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1385,53 +1254,6 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pms-api"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"argon2",
|
|
||||||
"axum",
|
|
||||||
"base64",
|
|
||||||
"bytes",
|
|
||||||
"chrono",
|
|
||||||
"dotenvy",
|
|
||||||
"futures",
|
|
||||||
"hex",
|
|
||||||
"image",
|
|
||||||
"jsonwebtoken",
|
|
||||||
"md5",
|
|
||||||
"rand 0.8.6",
|
|
||||||
"redis",
|
|
||||||
"reqwest",
|
|
||||||
"rust_xlsxwriter",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha1",
|
|
||||||
"sha2",
|
|
||||||
"sqlx",
|
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tower-http",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"urlencoding",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "png"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"crc32fast",
|
|
||||||
"fdeflate",
|
|
||||||
"flate2",
|
|
||||||
"miniz_oxide",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -1465,68 +1287,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pxfm"
|
|
||||||
version = "0.1.30"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn"
|
|
||||||
version = "0.11.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"cfg_aliases",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn-proto",
|
|
||||||
"quinn-udp",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls",
|
|
||||||
"socket2 0.6.4",
|
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-proto"
|
|
||||||
version = "0.11.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"getrandom 0.4.3",
|
|
||||||
"lru-slab",
|
|
||||||
"rand 0.10.2",
|
|
||||||
"rand_pcg",
|
|
||||||
"ring",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"slab",
|
|
||||||
"thiserror",
|
|
||||||
"tinyvec",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-udp"
|
|
||||||
version = "0.5.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
|
||||||
dependencies = [
|
|
||||||
"cfg_aliases",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"socket2 0.6.4",
|
|
||||||
"tracing",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.46"
|
version = "1.0.46"
|
||||||
@ -1550,18 +1310,7 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
|
||||||
dependencies = [
|
|
||||||
"chacha20",
|
|
||||||
"getrandom 0.4.3",
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1571,7 +1320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1583,21 +1332,6 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_pcg"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "0.28.2"
|
version = "0.28.2"
|
||||||
@ -1657,44 +1391,6 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "reqwest"
|
|
||||||
version = "0.12.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"bytes",
|
|
||||||
"futures-core",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-rustls",
|
|
||||||
"hyper-util",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tower",
|
|
||||||
"tower-http",
|
|
||||||
"tower-service",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
"webpki-roots 1.0.8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@ -1722,7 +1418,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@ -1738,12 +1434,6 @@ dependencies = [
|
|||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-hash"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.41"
|
version = "0.23.41"
|
||||||
@ -1764,7 +1454,6 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1870,7 +1559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1887,7 +1576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1923,7 +1612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2116,7 +1805,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.6",
|
"rand",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
@ -2155,7 +1844,7 @@ dependencies = [
|
|||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.8.6",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@ -2231,9 +1920,6 @@ name = "sync_wrapper"
|
|||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
@ -2358,16 +2044,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-rustls"
|
|
||||||
version = "0.26.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
|
||||||
dependencies = [
|
|
||||||
"rustls",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@ -2416,23 +2092,10 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"http-range-header",
|
|
||||||
"httpdate",
|
|
||||||
"mime",
|
|
||||||
"mime_guess",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tower",
|
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2509,24 +2172,12 @@ dependencies = [
|
|||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "try-lock"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicase"
|
|
||||||
version = "2.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@ -2572,12 +2223,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urlencoding"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@ -2613,15 +2258,6 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "want"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
|
||||||
dependencies = [
|
|
||||||
"try-lock",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@ -2647,16 +2283,6 @@ dependencies = [
|
|||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-futures"
|
|
||||||
version = "0.4.76"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.126"
|
version = "0.2.126"
|
||||||
@ -2689,26 +2315,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-sys"
|
|
||||||
version = "0.3.103"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-time"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "0.26.11"
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pms-api"
|
name = "api-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8", features = ["multipart"] }
|
axum = "0.8"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
|
||||||
redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] }
|
redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] }
|
||||||
@ -14,21 +14,15 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
sha1 = "0.10"
|
|
||||||
md5 = "0.7"
|
|
||||||
urlencoding = "2.1"
|
|
||||||
tokio-util = { version = "0.7", features = ["codec"] }
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
|
||||||
base64 = "0.22"
|
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
rust_xlsxwriter = "0.80"
|
rust_xlsxwriter = "0.80"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
|
||||||
|
|||||||
@ -1,290 +0,0 @@
|
|||||||
//! 滑块验证码生成与校验
|
|
||||||
//!
|
|
||||||
//! 参考 go-captcha 视觉风格:
|
|
||||||
//! - 渐变背景(双色渐变)
|
|
||||||
//! - 丰富纹理(线条+圆点)
|
|
||||||
//! - 拼图块带投影
|
|
||||||
//! - 缺口带白色描边
|
|
||||||
|
|
||||||
use base64::Engine;
|
|
||||||
use image::{Rgba, RgbaImage};
|
|
||||||
use rand::Rng;
|
|
||||||
use std::io::Cursor;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
const WIDTH: u32 = 300;
|
|
||||||
const HEIGHT: u32 = 150;
|
|
||||||
const PIECE_SIZE: i32 = 44;
|
|
||||||
const BUMP_RADIUS: i32 = 7;
|
|
||||||
const TOLERANCE: i32 = 5;
|
|
||||||
const PIECE_WIDTH: i32 = PIECE_SIZE + BUMP_RADIUS;
|
|
||||||
const CHALLENGE_TTL: u32 = 300;
|
|
||||||
const SHADOW_OFFSET: i32 = 3;
|
|
||||||
|
|
||||||
pub(crate) struct CaptchaData {
|
|
||||||
pub(crate) challenge_id: String,
|
|
||||||
pub(crate) bg_image: String,
|
|
||||||
pub(crate) piece_image: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn create_challenge(redis_url: &str) -> Result<CaptchaData, AppError> {
|
|
||||||
let challenge_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
let key = format!("challenge:{}", challenge_id);
|
|
||||||
|
|
||||||
let (bg_b64, piece_b64, correct_x) = tokio::task::spawn_blocking(|| {
|
|
||||||
let (bg, piece, correct_x) = generate_images();
|
|
||||||
let bg_b64 = encode_png(&bg);
|
|
||||||
let piece_b64 = encode_png(&piece);
|
|
||||||
(bg_b64, piece_b64, correct_x)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("[captcha] spawn_blocking 失败: {}", e);
|
|
||||||
AppError::Internal("服务临时不可用".into())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let client = redis::Client::open(redis_url).map_err(|e| {
|
|
||||||
tracing::error!("[captcha] Redis client 创建失败: {}", e);
|
|
||||||
AppError::Internal("服务临时不可用".into())
|
|
||||||
})?;
|
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
|
||||||
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
|
||||||
tracing::error!("[captcha] Redis 连接失败: {}", e);
|
|
||||||
AppError::Internal("服务临时不可用".into())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
redis::cmd("SET").arg(&key).arg(&correct_x.to_string()).arg("EX").arg(CHALLENGE_TTL)
|
|
||||||
.query_async::<()>(&mut conn).await.map_err(|e| {
|
|
||||||
tracing::error!("[captcha] Redis 写入失败: {}", e);
|
|
||||||
AppError::Internal("服务临时不可用".into())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::debug!("[captcha] 生成完成 cid={} x={}", challenge_id, correct_x);
|
|
||||||
Ok(CaptchaData { challenge_id, bg_image: bg_b64, piece_image: piece_b64 })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn verify_offset(redis_url: &str, challenge_id: &str, user_x: i32) -> Result<bool, AppError> {
|
|
||||||
let key = format!("challenge:{}", challenge_id);
|
|
||||||
let client = redis::Client::open(redis_url).map_err(|e| {
|
|
||||||
tracing::error!("[captcha] Redis client 创建失败: {}", e);
|
|
||||||
AppError::Internal("服务临时不可用".into())
|
|
||||||
})?;
|
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
|
||||||
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
|
||||||
tracing::error!("[captcha] Redis 连接失败: {}", e);
|
|
||||||
AppError::Internal("服务临时不可用".into())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let val: Option<String> = redis::cmd("GETDEL").arg(&key).query_async(&mut conn).await.map_err(|e| {
|
|
||||||
tracing::error!("[captcha] Redis 查询失败: {}", e);
|
|
||||||
AppError::Internal("服务临时不可用".into())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match val {
|
|
||||||
Some(s) => {
|
|
||||||
let correct_x: i32 = s.parse().unwrap_or(i32::MAX);
|
|
||||||
let ok = (user_x - correct_x).abs() <= TOLERANCE;
|
|
||||||
tracing::debug!("[captcha] 校验 cid={} user_x={} correct_x={} ok={}", challenge_id, user_x, correct_x, ok);
|
|
||||||
Ok(ok)
|
|
||||||
}
|
|
||||||
None => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成背景:渐变底 + 随机纹理线条 + 随机纹理圆点
|
|
||||||
///
|
|
||||||
/// 注意:按固定顺序消耗随机数(渐变 → 线条 → 圆点),
|
|
||||||
/// 调用方随后取拼图位置,保持与拆分前一致的随机序列。
|
|
||||||
fn generate_background(rng: &mut impl Rng) -> RgbaImage {
|
|
||||||
// 1. 渐变背景
|
|
||||||
let c1 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
|
||||||
let c2 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
|
||||||
let mut bg = RgbaImage::new(WIDTH, HEIGHT);
|
|
||||||
for y in 0..HEIGHT {
|
|
||||||
let t = y as f32 / HEIGHT as f32;
|
|
||||||
let r = c1[0] as f32 * (1.0 - t) + c2[0] as f32 * t;
|
|
||||||
let g = c1[1] as f32 * (1.0 - t) + c2[1] as f32 * t;
|
|
||||||
let b = c1[2] as f32 * (1.0 - t) + c2[2] as f32 * t;
|
|
||||||
let pix = Rgba([r as u8, g as u8, b as u8, 255]);
|
|
||||||
for x in 0..WIDTH { bg.put_pixel(x, y, pix); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 纹理线条(半透明随机线条)
|
|
||||||
for _ in 0..6 {
|
|
||||||
let x1 = rng.gen_range(0..WIDTH) as i32;
|
|
||||||
let y1 = rng.gen_range(0..HEIGHT) as i32;
|
|
||||||
let x2 = rng.gen_range(0..WIDTH) as i32;
|
|
||||||
let y2 = rng.gen_range(0..HEIGHT) as i32;
|
|
||||||
let color = Rgba([rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.gen_range(15..50u8)]);
|
|
||||||
draw_line(&mut bg, x1, y1, x2, y2, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 纹理圆点
|
|
||||||
for _ in 0..15 {
|
|
||||||
let cx = rng.gen_range(0..WIDTH);
|
|
||||||
let cy = rng.gen_range(0..HEIGHT);
|
|
||||||
let radius = rng.gen_range(3..15u32);
|
|
||||||
let color = Rgba([rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.gen_range(20..80u8)]);
|
|
||||||
fill_circle(&mut bg, cx, cy, radius, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
bg
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按拼图轮廓从背景中提取原始像素块
|
|
||||||
fn extract_piece_pixels(bg: &RgbaImage, piece_x: u32, piece_y: i32) -> RgbaImage {
|
|
||||||
// 提取拼图块像素
|
|
||||||
let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32);
|
|
||||||
for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
|
|
||||||
for py in 0..PIECE_SIZE {
|
|
||||||
for px in 0..PIECE_WIDTH {
|
|
||||||
if is_in_piece(px, py) {
|
|
||||||
raw.put_pixel(px as u32, py as u32, *bg.get_pixel((piece_x as i32 + px) as u32, (piece_y + py) as u32));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raw
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
|
|
||||||
fn build_piece_with_shadow(raw: &RgbaImage) -> RgbaImage {
|
|
||||||
let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32;
|
|
||||||
let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32;
|
|
||||||
let mut piece = RgbaImage::new(sw, sh);
|
|
||||||
for p in piece.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
|
|
||||||
// 先画阴影
|
|
||||||
for py in 0..PIECE_SIZE {
|
|
||||||
for px in 0..PIECE_WIDTH {
|
|
||||||
if is_in_piece(px, py) {
|
|
||||||
let sx = px + SHADOW_OFFSET;
|
|
||||||
let sy = py + SHADOW_OFFSET;
|
|
||||||
if sx < sw as i32 && sy < sh as i32 {
|
|
||||||
let d = piece.get_pixel(sx as u32, sy as u32);
|
|
||||||
piece.put_pixel(sx as u32, sy as u32, alpha_blend(*d, Rgba([0, 0, 0, 80])));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 再画拼图
|
|
||||||
for py in 0..PIECE_SIZE {
|
|
||||||
for px in 0..PIECE_WIDTH {
|
|
||||||
let p = raw.get_pixel(px as u32, py as u32);
|
|
||||||
if p[3] > 0 {
|
|
||||||
piece.put_pixel(px as u32, py as u32, *p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
piece
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_images() -> (RgbaImage, RgbaImage, i32) {
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
|
|
||||||
// 1-3. 渐变背景 + 纹理线条 + 纹理圆点
|
|
||||||
let mut bg = generate_background(&mut rng);
|
|
||||||
|
|
||||||
// 4. 拼图块位置
|
|
||||||
let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2;
|
|
||||||
let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32;
|
|
||||||
|
|
||||||
// 5. 提取拼图块像素
|
|
||||||
let raw = extract_piece_pixels(&bg, piece_x, piece_y);
|
|
||||||
|
|
||||||
// 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
|
|
||||||
let piece = build_piece_with_shadow(&raw);
|
|
||||||
|
|
||||||
// 7. 背景画缺口
|
|
||||||
draw_hole(&mut bg, piece_x as i32, piece_y);
|
|
||||||
|
|
||||||
(bg, piece, piece_x as i32)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_in_piece(px: i32, py: i32) -> bool {
|
|
||||||
if px >= 0 && px < PIECE_SIZE && py >= 0 && py < PIECE_SIZE { return true; }
|
|
||||||
let bcx = PIECE_SIZE;
|
|
||||||
let bcy = PIECE_SIZE / 2;
|
|
||||||
let dx = px - bcx;
|
|
||||||
let dy = py - bcy;
|
|
||||||
dx >= 0 && dx * dx + dy * dy <= BUMP_RADIUS * BUMP_RADIUS
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_hole(img: &mut RgbaImage, hx: i32, hy: i32) {
|
|
||||||
for y in 0..HEIGHT as i32 {
|
|
||||||
for x in 0..WIDTH as i32 {
|
|
||||||
let lx = x - hx;
|
|
||||||
let ly = y - hy;
|
|
||||||
if is_in_piece(lx, ly) {
|
|
||||||
let edge = (lx >= 0 && lx < 2) || (lx >= PIECE_WIDTH - 3 && lx < PIECE_WIDTH)
|
|
||||||
|| (ly >= 0 && ly < 2) || (ly >= PIECE_SIZE - 3 && ly < PIECE_SIZE)
|
|
||||||
|| {
|
|
||||||
let bcx = PIECE_SIZE;
|
|
||||||
let bcy = PIECE_SIZE / 2;
|
|
||||||
let dx = lx - bcx;
|
|
||||||
let dy = ly - bcy;
|
|
||||||
dx >= 0 && (dx * dx + dy * dy - BUMP_RADIUS * BUMP_RADIUS).abs() <= 20
|
|
||||||
};
|
|
||||||
if edge {
|
|
||||||
img.put_pixel(x as u32, y as u32, Rgba([255, 255, 255, 220]));
|
|
||||||
} else {
|
|
||||||
let o = img.get_pixel(x as u32, y as u32);
|
|
||||||
img.put_pixel(x as u32, y as u32, Rgba([
|
|
||||||
(o[0] as u16 * 2 / 3 + 85) as u8,
|
|
||||||
(o[1] as u16 * 2 / 3 + 85) as u8,
|
|
||||||
(o[2] as u16 * 2 / 3 + 85) as u8, 255,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fill_circle(img: &mut RgbaImage, cx: u32, cy: u32, r: u32, color: Rgba<u8>) {
|
|
||||||
let r = r as i32;
|
|
||||||
let cx = cx as i32;
|
|
||||||
let cy = cy as i32;
|
|
||||||
for y in (cy - r).max(0)..=(cy + r).min(HEIGHT as i32 - 1) {
|
|
||||||
for x in (cx - r).max(0)..=(cx + r).min(WIDTH as i32 - 1) {
|
|
||||||
if (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r * r {
|
|
||||||
img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_line(img: &mut RgbaImage, x1: i32, y1: i32, x2: i32, y2: i32, color: Rgba<u8>) {
|
|
||||||
let mut x = x1;
|
|
||||||
let mut y = y1;
|
|
||||||
let dx = (x2 - x1).abs();
|
|
||||||
let dy = -(y2 - y1).abs();
|
|
||||||
let sx = if x1 < x2 { 1 } else { -1 };
|
|
||||||
let sy = if y1 < y2 { 1 } else { -1 };
|
|
||||||
let mut err = dx + dy;
|
|
||||||
loop {
|
|
||||||
if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 {
|
|
||||||
img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color));
|
|
||||||
}
|
|
||||||
if x == x2 && y == y2 { break; }
|
|
||||||
let e2 = 2 * err;
|
|
||||||
if e2 >= dy { err += dy; x += sx; }
|
|
||||||
if e2 <= dx { err += dx; y += sy; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn alpha_blend(dst: Rgba<u8>, src: Rgba<u8>) -> Rgba<u8> {
|
|
||||||
let a = src[3] as u32;
|
|
||||||
let inv = 255 - a;
|
|
||||||
Rgba([
|
|
||||||
((src[0] as u32 * a + dst[0] as u32 * inv) / 255) as u8,
|
|
||||||
((src[1] as u32 * a + dst[1] as u32 * inv) / 255) as u8,
|
|
||||||
((src[2] as u32 * a + dst[2] as u32 * inv) / 255) as u8,
|
|
||||||
dst[3].max(src[3]),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_png(img: &RgbaImage) -> String {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png).expect("PNG 编码失败");
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(&buf)
|
|
||||||
}
|
|
||||||
@ -176,7 +176,7 @@ impl NodeRegistry {
|
|||||||
|
|
||||||
/// 检查设备是否在线(通过 Redis 中的在线标记)
|
/// 检查设备是否在线(通过 Redis 中的在线标记)
|
||||||
pub async fn is_device_online(&self, imei: &str) -> bool {
|
pub async fn is_device_online(&self, imei: &str) -> bool {
|
||||||
let key = format!("device:{}", imei);
|
let key = format!("device:online:{}", imei);
|
||||||
let mut conn = self.redis.clone();
|
let mut conn = self.redis.clone();
|
||||||
let exists: Result<bool, _> = redis::cmd("EXISTS")
|
let exists: Result<bool, _> = redis::cmd("EXISTS")
|
||||||
.arg(&key)
|
.arg(&key)
|
||||||
@ -195,8 +195,6 @@ pub struct AppState {
|
|||||||
pub pending_commands: PendingCommands,
|
pub pending_commands: PendingCommands,
|
||||||
pub nodes: NodeRegistry,
|
pub nodes: NodeRegistry,
|
||||||
pub redis_url: String,
|
pub redis_url: String,
|
||||||
/// JWT 签名密钥 — 启动阶段从环境变量读取,缺失则 panic(禁止硬编码回退)
|
|
||||||
pub jwt_secret: Arc<str>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 指令下发请求体
|
/// 指令下发请求体
|
||||||
@ -205,46 +203,39 @@ pub struct CommandRequest {
|
|||||||
pub act: String,
|
pub act: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub msg_id: u32,
|
pub msg_id: u32,
|
||||||
|
pub sub_device_id: Option<u8>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub params: Option<Value>,
|
pub params: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 内部路由字段名:推入 `device:commands` 时附加,值 = 目标设备 IMEI。
|
|
||||||
///
|
|
||||||
/// 用途:device-server 据此决定把指令发给哪条连接;**下发前摘除,设备侧不可见**。
|
|
||||||
/// 为什么需要它:协议定稿后 `id` 只表示**动作目标**(柜级省略 / 一段=单板 / 两段=仓体级),
|
|
||||||
/// **不再含 IMEI**,所以设备身份必须另用内部字段传递。
|
|
||||||
pub const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
|
|
||||||
|
|
||||||
/// 设备指令结构(序列化后推入 Redis)
|
/// 设备指令结构(序列化后推入 Redis)
|
||||||
///
|
|
||||||
/// 协议格式(v1.1):`id` 只表示**动作目标**,**不含 IMEI**(设备身份由 TCP 连接确定)
|
|
||||||
/// - **柜级**指令(open/pow_on/pow_off/reset 整柜):**不带 `id`**(字段省略)
|
|
||||||
/// - **单仓控板**指令(reset 单板):`仓控板`(如 `"01"`)
|
|
||||||
/// - **仓体级**指令(on/off):`仓控板-通道`(如 `"01-3"`)
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct DeviceCommand {
|
pub struct DeviceCommand {
|
||||||
pub act: String,
|
pub act: String,
|
||||||
pub msg_id: u32,
|
pub msg_id: u32,
|
||||||
/// 动作目标(柜级为 `None`),序列化为 "id"
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub dev_id: Option<String>,
|
||||||
/// 扩展参数(充电参数等)
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sub_device_id: Option<u8>,
|
||||||
|
/// 扩展参数(开门指令、充电参数等)
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub params: Option<Value>,
|
pub params: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DeviceCommand {
|
||||||
|
/// 序列化为 JSON 字符串(紧凑格式)。
|
||||||
|
pub fn to_json(&self) -> String {
|
||||||
|
serde_json::to_string(self).expect("DeviceCommand 序列化不应失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 向设备发送指令(通过 Redis 队列)
|
/// 向设备发送指令(通过 Redis 队列)
|
||||||
pub async fn send_device_command(
|
pub async fn send_device_command(
|
||||||
redis: &redis::aio::ConnectionManager,
|
redis: &redis::aio::ConnectionManager,
|
||||||
dev_id: &str,
|
_dev_id: &str,
|
||||||
cmd: &DeviceCommand,
|
cmd: &DeviceCommand,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut json_val =
|
let json_str = cmd.to_json();
|
||||||
serde_json::to_value(cmd).map_err(|e| format!("指令序列化失败: {}", e))?;
|
|
||||||
// 内部路由字段:告知 device-server 这条指令发给哪台设备(下发前摘除)
|
|
||||||
json_val[ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id);
|
|
||||||
let json_str = json_val.to_string();
|
|
||||||
let mut conn = redis.clone();
|
let mut conn = redis.clone();
|
||||||
redis::cmd("LPUSH")
|
redis::cmd("LPUSH")
|
||||||
.arg("device:commands")
|
.arg("device:commands")
|
||||||
@ -282,22 +273,11 @@ pub async fn send_command(
|
|||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:operate")?;
|
auth::check_permission(&user, "device:operate")?;
|
||||||
|
|
||||||
// 检查设备是否被禁用
|
|
||||||
let disabled: bool = sqlx::query_scalar(
|
|
||||||
r#"SELECT COUNT(*) > 0 FROM cabinets WHERE imei = ? AND status = 2"#,
|
|
||||||
)
|
|
||||||
.bind(&dev_id)
|
|
||||||
.fetch_one(&state.mysql)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
if disabled {
|
|
||||||
return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let cmd = DeviceCommand {
|
let cmd = DeviceCommand {
|
||||||
act: req.act,
|
act: req.act,
|
||||||
msg_id: req.msg_id,
|
msg_id: req.msg_id,
|
||||||
id: Some(dev_id.clone()),
|
dev_id: Some(dev_id.clone()),
|
||||||
|
sub_device_id: req.sub_device_id,
|
||||||
params: req.params,
|
params: req.params,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,11 @@
|
|||||||
//!
|
//!
|
||||||
//! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。
|
//! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。
|
||||||
|
|
||||||
/// JWT 密钥(必设,生产环境必须通过环境变量设置)
|
/// 默认 JWT 密钥(开发环境用,生产环境必须通过环境变量覆盖)
|
||||||
const JWT_SECRET_ENV: &str = "JWT_SECRET";
|
pub const JWT_SECRET_DEFAULT: &str = "pms-dev-secret-change-me-in-production";
|
||||||
|
|
||||||
|
/// 用户默认密码(创建用户或重置密码时使用)
|
||||||
|
pub const DEFAULT_PASSWORD: &str = "123456";
|
||||||
|
|
||||||
/// 应用配置
|
/// 应用配置
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@ -36,8 +39,8 @@ impl Config {
|
|||||||
.unwrap_or_else(|_| "3000".to_string())
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.expect("PORT must be a valid number"),
|
.expect("PORT must be a valid number"),
|
||||||
jwt_secret: std::env::var(JWT_SECRET_ENV)
|
jwt_secret: std::env::var("JWT_SECRET")
|
||||||
.expect("JWT_SECRET must be set"),
|
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()),
|
||||||
export_dir: std::env::var("EXPORT_DIR")
|
export_dir: std::env::var("EXPORT_DIR")
|
||||||
.unwrap_or_else(|_| "./exports".to_string()),
|
.unwrap_or_else(|_| "./exports".to_string()),
|
||||||
node_heartbeat_timeout: std::env::var("NODE_HEARTBEAT_TIMEOUT")
|
node_heartbeat_timeout: std::env::var("NODE_HEARTBEAT_TIMEOUT")
|
||||||
@ -46,4 +49,11 @@ impl Config {
|
|||||||
.expect("NODE_HEARTBEAT_TIMEOUT must be a valid number"),
|
.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 环境变量");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
/// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。
|
/// 执行数据库迁移,创建所有必要的数据表
|
||||||
/// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。
|
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||||
const SCHEMA_SQL: &str = r#"
|
let sql = r#"
|
||||||
CREATE TABLE IF NOT EXISTS organizations (
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(100) NOT NULL COMMENT '组织名称',
|
name VARCHAR(100) NOT NULL COMMENT '组织名称',
|
||||||
parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@ -27,11 +26,7 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
project_id BIGINT,
|
project_id BIGINT,
|
||||||
abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-00000001',
|
abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-00000001',
|
||||||
imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
|
imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
|
||||||
iccid VARCHAR(20) COMMENT 'SIM卡ICCID',
|
|
||||||
auth_str VARCHAR(8) COMMENT '签名安全码',
|
auth_str VARCHAR(8) COMMENT '签名安全码',
|
||||||
auth_str_issued BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发',
|
|
||||||
cell TINYINT DEFAULT 6 COMMENT '仓控板数量',
|
|
||||||
work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式',
|
|
||||||
name VARCHAR(100) COMMENT '柜子名称',
|
name VARCHAR(100) COMMENT '柜子名称',
|
||||||
status TINYINT DEFAULT 0 COMMENT '0离线 1在线',
|
status TINYINT DEFAULT 0 COMMENT '0离线 1在线',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
@ -39,53 +34,6 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sim_change_logs (
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
imei VARCHAR(15) NOT NULL COMMENT '设备IMEI',
|
|
||||||
old_iccid VARCHAR(20) COMMENT '旧ICCID',
|
|
||||||
new_iccid VARCHAR(20) NOT NULL COMMENT '新ICCID',
|
|
||||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间',
|
|
||||||
INDEX idx_imei (imei)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS batteries (
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
battery_id VARCHAR(50) NOT NULL UNIQUE COMMENT '电池唯一标识',
|
|
||||||
soc INT DEFAULT 0 COMMENT '电量(‰)',
|
|
||||||
brand VARCHAR(50) COMMENT '品牌(由 brand_code 映射出的名称,代码表未定前为空)',
|
|
||||||
model VARCHAR(50) COMMENT '型号(由 model_code 映射出的名称,代码表未定前为空)',
|
|
||||||
sn VARCHAR(100) COMMENT '序列号',
|
|
||||||
-- 以下为协议 §5.5「电池属性」原值(硬件代码,平台侧负责映射,见交付文档 §10 定稿第 7 条)
|
|
||||||
brand_code VARCHAR(20) COMMENT '厂家代码(原值透传)',
|
|
||||||
model_code VARCHAR(20) COMMENT '电池型号代码(原值透传)',
|
|
||||||
material_code VARCHAR(20) COMMENT '电芯材料代码(原值透传)',
|
|
||||||
cell_count INT COMMENT '电芯节数',
|
|
||||||
rated_voltage BIGINT COMMENT '额定电压(mV)',
|
|
||||||
rated_capacity BIGINT COMMENT '额定容量(mAh)',
|
|
||||||
status TINYINT DEFAULT 0 COMMENT '0空闲 1使用中 2故障 3报废',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS charge_orders (
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
compartment_id BIGINT NOT NULL COMMENT '仓体ID',
|
|
||||||
cabinet_id BIGINT NOT NULL COMMENT '柜子ID',
|
|
||||||
battery_id VARCHAR(50) NOT NULL COMMENT '电池ID',
|
|
||||||
start_time TIMESTAMP NULL COMMENT '充电开始时间',
|
|
||||||
end_time TIMESTAMP NULL COMMENT '充电结束时间',
|
|
||||||
start_soc INT COMMENT '起始电量(‰)',
|
|
||||||
end_soc INT COMMENT '结束电量(‰)',
|
|
||||||
energy INT DEFAULT 0 COMMENT '充电量(Wh)',
|
|
||||||
reason VARCHAR(20) COMMENT '结束原因: normal/user_stop/forced/timeout',
|
|
||||||
status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (compartment_id) REFERENCES compartments(id),
|
|
||||||
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
|
|
||||||
INDEX idx_battery (battery_id),
|
|
||||||
INDEX idx_compartment (compartment_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cabin_boards (
|
CREATE TABLE IF NOT EXISTS cabin_boards (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
cabinet_id BIGINT NOT NULL,
|
cabinet_id BIGINT NOT NULL,
|
||||||
@ -111,7 +59,7 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
|
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
|
||||||
name VARCHAR(50) COMMENT '姓名',
|
name VARCHAR(50) COMMENT '姓名',
|
||||||
password VARCHAR(255) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员',
|
role TINYINT DEFAULT 0 COMMENT '0普通 1企业管理员 2总管理员',
|
||||||
organization_id BIGINT,
|
organization_id BIGINT,
|
||||||
status TINYINT DEFAULT 1 COMMENT '0禁用 1启用',
|
status TINYINT DEFAULT 1 COMMENT '0禁用 1启用',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
@ -125,8 +73,8 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
cabinet_id BIGINT NOT NULL,
|
cabinet_id BIGINT NOT NULL,
|
||||||
start_time TIMESTAMP NULL COMMENT '充电开始时间',
|
start_time TIMESTAMP NULL COMMENT '充电开始时间',
|
||||||
end_time TIMESTAMP NULL COMMENT '充电结束时间',
|
end_time TIMESTAMP NULL COMMENT '充电结束时间',
|
||||||
energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)',
|
energy DECIMAL(10,2) COMMENT '充电量kWh',
|
||||||
max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)',
|
max_temperature DECIMAL(5,1) COMMENT '最高温度',
|
||||||
status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止',
|
status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (compartment_id) REFERENCES compartments(id),
|
FOREIGN KEY (compartment_id) REFERENCES compartments(id),
|
||||||
@ -146,7 +94,7 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
cabinet_id BIGINT NOT NULL,
|
cabinet_id BIGINT NOT NULL,
|
||||||
stat_date DATE NOT NULL,
|
stat_date DATE NOT NULL,
|
||||||
total_energy BIGINT NOT NULL DEFAULT 0 COMMENT '总能耗(Wh)',
|
total_energy DECIMAL(10,2) COMMENT '总能耗kWh',
|
||||||
charge_count INT DEFAULT 0 COMMENT '充电次数',
|
charge_count INT DEFAULT 0 COMMENT '充电次数',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
|
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
|
||||||
@ -226,22 +174,10 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE
|
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_feedback (
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id BIGINT NOT NULL COMMENT '提交用户ID',
|
|
||||||
content TEXT NOT NULL COMMENT '反馈内容',
|
|
||||||
contact VARCHAR(200) COMMENT '联系方式',
|
|
||||||
status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// 执行数据库迁移,创建所有必要的数据表
|
// 逐条执行,避免多语句执行问题
|
||||||
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
for statement in sql.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||||
// 逐条执行全量建表语句,避免多语句执行问题
|
|
||||||
for statement in SCHEMA_SQL.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
|
||||||
sqlx::query(statement).execute(pool).await?;
|
sqlx::query(statement).execute(pool).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,34 +185,9 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#)
|
let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#)
|
||||||
.execute(pool).await;
|
.execute(pool).await;
|
||||||
|
|
||||||
// 更新 charge_records.max_temperature 注释(0.1°C → K×100)
|
|
||||||
// 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY
|
|
||||||
ensure_column_comment(
|
|
||||||
pool,
|
|
||||||
"charge_records",
|
|
||||||
"max_temperature",
|
|
||||||
"K×100",
|
|
||||||
r#"ALTER TABLE charge_records MODIFY max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)'"#,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 更新 charge_orders.reason 注释:新增 timeout
|
|
||||||
// (订单超过 10 小时由 order_timeout 定时任务自动结束,落该枚举值)
|
|
||||||
ensure_column_comment(
|
|
||||||
pool,
|
|
||||||
"charge_orders",
|
|
||||||
"reason",
|
|
||||||
"timeout",
|
|
||||||
r#"ALTER TABLE charge_orders MODIFY COLUMN reason VARCHAR(20) NULL COMMENT '结束原因: normal/user_stop/forced/timeout'"#,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 创建关键索引(IF NOT EXISTS 避免重复)
|
// 创建关键索引(IF NOT EXISTS 避免重复)
|
||||||
create_indexes(pool).await?;
|
create_indexes(pool).await?;
|
||||||
|
|
||||||
// 兼容已有数据库:新增字段(幂等,列已存在则跳过)
|
|
||||||
add_columns_if_not_exists(pool).await?;
|
|
||||||
|
|
||||||
// 初始化默认权限码(INSERT IGNORE 避免重复插入)
|
// 初始化默认权限码(INSERT IGNORE 避免重复插入)
|
||||||
seed_permissions(pool).await?;
|
seed_permissions(pool).await?;
|
||||||
// 初始化默认角色
|
// 初始化默认角色
|
||||||
@ -316,147 +227,11 @@ async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
ensure_index(pool, "compartments", "idx_compartments_board", "cabin_board_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, "charge_records", "idx_charge_records_cabinet", "cabinet_id").await?;
|
||||||
ensure_index(pool, "device_logs", "idx_device_logs_cabinet", "cabinet_id").await?;
|
ensure_index(pool, "device_logs", "idx_device_logs_cabinet", "cabinet_id").await?;
|
||||||
// 订单超时兜底扫描(status = 0 AND start_time < ...)走该联合索引,避免全表扫描
|
|
||||||
ensure_index(pool, "charge_orders", "idx_charge_orders_status_start", "status, start_time").await?;
|
|
||||||
// device_logs 保留策略按 created_at 分批删除,必须有该列索引,否则每次清理全表扫描
|
|
||||||
ensure_index(pool, "device_logs", "idx_device_logs_created", "created_at").await?;
|
|
||||||
|
|
||||||
tracing::info!("数据库索引创建完成");
|
tracing::info!("数据库索引创建完成");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列级幂等迁移:列不存在时才 ALTER TABLE 补列
|
|
||||||
///
|
|
||||||
/// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」,
|
|
||||||
/// 对**已存在**的表永远不会补后加的列,导致线上 schema 漂移。
|
|
||||||
/// 安全性:表名/列名/DDL 全部来自本文件内的白名单常量,不含任何外部输入拼接。
|
|
||||||
async fn ensure_column(
|
|
||||||
pool: &MySqlPool,
|
|
||||||
table: &str,
|
|
||||||
column: &str,
|
|
||||||
ddl: &str,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
let exists: (i64,) = sqlx::query_as(
|
|
||||||
"SELECT COUNT(*) FROM information_schema.COLUMNS \
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
|
|
||||||
)
|
|
||||||
.bind(table)
|
|
||||||
.bind(column)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if exists.0 == 0 {
|
|
||||||
let alter = format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, ddl);
|
|
||||||
sqlx::query(&alter).execute(pool).await?;
|
|
||||||
tracing::info!("[migrate] 新增列 {}.{}", table, column);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列注释幂等更新:仅当现有注释不包含 `marker` 时才 ALTER
|
|
||||||
///
|
|
||||||
/// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新;
|
|
||||||
/// 而 MODIFY 会重建整表,因此必须先比对注释,避免每次启动都白跑一次 DDL。
|
|
||||||
async fn ensure_column_comment(
|
|
||||||
pool: &MySqlPool,
|
|
||||||
table: &str,
|
|
||||||
column: &str,
|
|
||||||
marker: &str,
|
|
||||||
ddl: &str,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
// ⚠️ `information_schema.COLUMNS.COLUMN_COMMENT` 在 MySQL 8 上会被 sqlx 报告为
|
|
||||||
// **VARBINARY**,直接按 `Option<String>` 解码会 panic(实测阿里云 RDS MySQL 8.0.36):
|
|
||||||
// `mismatched types; Rust type String (as SQL type VARCHAR) is not compatible with SQL type VARBINARY`
|
|
||||||
// 必须显式 `CAST(... AS CHAR)`。
|
|
||||||
let comment: Option<String> = sqlx::query_scalar(
|
|
||||||
"SELECT CAST(COLUMN_COMMENT AS CHAR) FROM information_schema.COLUMNS \
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
|
|
||||||
)
|
|
||||||
.bind(table)
|
|
||||||
.bind(column)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(current) = comment {
|
|
||||||
if !current.contains(marker) {
|
|
||||||
sqlx::query(ddl).execute(pool).await?;
|
|
||||||
tracing::info!("[migrate] 更新列注释 {}.{}", table, column);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 逐列补齐历史版本遗漏的列(幂等,兼容线上已存在的旧表)
|
|
||||||
///
|
|
||||||
/// 线上 pms 库曾因缺少 `cabinets.auth_str_issued` / `cabinets.cell`,
|
|
||||||
/// 导致设备认证链路直接报 `1054 Unknown column`,只能人工 ALTER 补救;
|
|
||||||
/// 本函数就是把这个人工步骤固化进启动迁移。
|
|
||||||
async fn add_columns_if_not_exists(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|
||||||
// users:头像 + 微信 openid
|
|
||||||
ensure_column(pool, "users", "avatar", "VARCHAR(500) NULL COMMENT '头像URL'").await?;
|
|
||||||
ensure_column(pool, "users", "openid", "VARCHAR(64) NULL UNIQUE COMMENT '微信openid'")
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// organizations:父组织(组织树层级)+ 禁用标记(甲方禁用客户组织,其下柜子全部失效)
|
|
||||||
ensure_column(pool, "organizations", "parent_id", "BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)'")
|
|
||||||
.await?;
|
|
||||||
ensure_column(
|
|
||||||
pool,
|
|
||||||
"organizations",
|
|
||||||
"disabled",
|
|
||||||
"TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1禁用'",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// projects:地址 + 经纬度
|
|
||||||
ensure_column(pool, "projects", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?;
|
|
||||||
ensure_column(pool, "projects", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?;
|
|
||||||
ensure_column(pool, "projects", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?;
|
|
||||||
|
|
||||||
// cabinets:地址 + 经纬度
|
|
||||||
ensure_column(pool, "cabinets", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?;
|
|
||||||
ensure_column(pool, "cabinets", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?;
|
|
||||||
ensure_column(pool, "cabinets", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?;
|
|
||||||
|
|
||||||
// cabinets:设备认证 / 拓扑 / 工作模式(线上曾缺失,必须补齐)
|
|
||||||
ensure_column(
|
|
||||||
pool,
|
|
||||||
"cabinets",
|
|
||||||
"auth_str_issued",
|
|
||||||
"BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发'",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
ensure_column(pool, "cabinets", "cell", "TINYINT DEFAULT 6 COMMENT '仓控板数量'").await?;
|
|
||||||
ensure_column(
|
|
||||||
pool,
|
|
||||||
"cabinets",
|
|
||||||
"work_mode",
|
|
||||||
"TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式'",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// batteries:协议 §5.5「电池属性」的原值字段(老库缺失,必须补齐,否则台账只能丢到 device_logs)
|
|
||||||
ensure_column(pool, "batteries", "brand_code", "VARCHAR(20) NULL COMMENT '厂家代码(原值透传)'").await?;
|
|
||||||
ensure_column(pool, "batteries", "model_code", "VARCHAR(20) NULL COMMENT '电池型号代码(原值透传)'").await?;
|
|
||||||
ensure_column(pool, "batteries", "material_code", "VARCHAR(20) NULL COMMENT '电芯材料代码(原值透传)'")
|
|
||||||
.await?;
|
|
||||||
ensure_column(pool, "batteries", "cell_count", "INT NULL COMMENT '电芯节数'").await?;
|
|
||||||
ensure_column(pool, "batteries", "rated_voltage", "BIGINT NULL COMMENT '额定电压(mV)'").await?;
|
|
||||||
ensure_column(pool, "batteries", "rated_capacity", "BIGINT NULL COMMENT '额定容量(mAh)'").await?;
|
|
||||||
|
|
||||||
// user_feedback:反馈图片(JSON 数组字符串)
|
|
||||||
ensure_column(
|
|
||||||
pool,
|
|
||||||
"user_feedback",
|
|
||||||
"images",
|
|
||||||
"TEXT NULL COMMENT '反馈图片URL列表(JSON数组)'",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::info!("数据库列级迁移完成");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 插入默认权限码
|
/// 插入默认权限码
|
||||||
async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||||
let perms: Vec<(&str, &str)> = vec![
|
let perms: Vec<(&str, &str)> = vec![
|
||||||
@ -465,8 +240,6 @@ async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
("device:create", "添加设备"),
|
("device:create", "添加设备"),
|
||||||
("device:edit", "编辑设备"),
|
("device:edit", "编辑设备"),
|
||||||
("device:delete", "删除设备"),
|
("device:delete", "删除设备"),
|
||||||
// 安全码重置(仅总管理员,不授予企业管理员/操作员)
|
|
||||||
("device:auth_str:reset", "重置安全码"),
|
|
||||||
("charge:start", "开始充电"),
|
("charge:start", "开始充电"),
|
||||||
("charge:stop", "停止充电"),
|
("charge:stop", "停止充电"),
|
||||||
("charge:open_door", "开门"),
|
("charge:open_door", "开门"),
|
||||||
@ -510,67 +283,8 @@ async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建默认角色并分配权限
|
/// 插入默认角色并分配权限
|
||||||
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||||
insert_default_roles(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 {
|
|
||||||
grant_all_permissions(pool, role_id).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:create", "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 {
|
|
||||||
grant_permissions_by_codes(pool, role_id, &org_perm_codes).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 {
|
|
||||||
grant_permissions_by_codes(pool, role_id, &normal_perm_codes).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("默认角色及权限分配完成");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建三个默认角色(INSERT IGNORE,已存在则跳过)
|
|
||||||
async fn insert_default_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|
||||||
// 创建总管理员角色
|
// 创建总管理员角色
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
|
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
|
||||||
@ -589,20 +303,23 @@ async fn insert_default_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 创建操作员角色
|
// 创建普通用户角色
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
|
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
|
||||||
)
|
)
|
||||||
.bind("normal_user")
|
.bind("normal_user")
|
||||||
.bind("操作员 — 查看/操作指定范围设备")
|
.bind("普通用户 — 查看/操作指定范围设备")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
// 为总管理员分配所有权限
|
||||||
}
|
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 {
|
||||||
async fn grant_all_permissions(pool: &MySqlPool, role_id: i64) -> Result<(), sqlx::Error> {
|
|
||||||
// 查询所有权限ID
|
// 查询所有权限ID
|
||||||
let perm_ids: Vec<(i64,)> = sqlx::query_as(
|
let perm_ids: Vec<(i64,)> = sqlx::query_as(
|
||||||
"SELECT id FROM permissions"
|
"SELECT id FROM permissions"
|
||||||
@ -619,17 +336,30 @@ async fn grant_all_permissions(pool: &MySqlPool, role_id: i64) -> Result<(), sql
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按权限码逐个为指定角色授权(权限码不存在则跳过)
|
// 为企业管理员分配非系统管理权限
|
||||||
async fn grant_permissions_by_codes(
|
let org_admin_id: Option<(i64,)> = sqlx::query_as(
|
||||||
pool: &MySqlPool,
|
"SELECT id FROM roles WHERE name = 'org_admin'"
|
||||||
role_id: i64,
|
)
|
||||||
codes: &[&str],
|
.fetch_optional(pool)
|
||||||
) -> Result<(), sqlx::Error> {
|
.await?;
|
||||||
for code in codes {
|
|
||||||
|
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(
|
let perm: Option<(i64,)> = sqlx::query_as(
|
||||||
"SELECT id FROM permissions WHERE code = ?"
|
"SELECT id FROM permissions WHERE code = ?"
|
||||||
)
|
)
|
||||||
@ -647,6 +377,42 @@ async fn grant_permissions_by_codes(
|
|||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
//! 提供 HTTP API(后台管理 + H5),处理后端业务逻辑。
|
//! 提供 HTTP API(后台管理 + H5),处理后端业务逻辑。
|
||||||
//! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。
|
//! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。
|
||||||
|
|
||||||
mod captcha;
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
@ -16,20 +15,23 @@ mod routes;
|
|||||||
mod workers;
|
mod workers;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use commands::{AppState, NodeRegistry, PendingCommands};
|
use commands::{AppState, NodeRegistry, PendingCommands};
|
||||||
|
|
||||||
/// 初始化日志订阅器(默认级别 info,可通过 RUST_LOG 覆盖)
|
#[tokio::main]
|
||||||
fn init_tracing() {
|
async fn main() {
|
||||||
|
// 初始化日志
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
.init();
|
.init();
|
||||||
}
|
|
||||||
|
|
||||||
/// 初始化 MySQL 连接池并执行自动迁移(失败即退出,启动阶段不降级)
|
// 加载配置
|
||||||
async fn init_database(cfg: &config::Config) -> sqlx::MySqlPool {
|
let cfg = config::Config::from_env();
|
||||||
|
cfg.validate();
|
||||||
|
tracing::info!("配置加载完成");
|
||||||
|
|
||||||
|
// 初始化 MySQL 连接池
|
||||||
let db_pool = db::init_pool(&cfg.database_url)
|
let db_pool = db::init_pool(&cfg.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("MySQL 连接失败");
|
.expect("MySQL 连接失败");
|
||||||
@ -41,64 +43,43 @@ async fn init_database(cfg: &config::Config) -> sqlx::MySqlPool {
|
|||||||
.expect("数据库迁移失败");
|
.expect("数据库迁移失败");
|
||||||
tracing::info!("数据库迁移完成");
|
tracing::info!("数据库迁移完成");
|
||||||
|
|
||||||
db_pool
|
// 初始化 Redis 连接
|
||||||
}
|
|
||||||
|
|
||||||
/// 初始化 Redis 连接管理器(失败即退出)
|
|
||||||
async fn init_redis(cfg: &config::Config) -> ::redis::aio::ConnectionManager {
|
|
||||||
let redis_conn = redis::init_connection(&cfg.redis_url)
|
let redis_conn = redis::init_connection(&cfg.redis_url)
|
||||||
.await
|
.await
|
||||||
.expect("Redis 连接失败");
|
.expect("Redis 连接失败");
|
||||||
tracing::info!("Redis 连接成功");
|
tracing::info!("Redis 连接成功");
|
||||||
redis_conn
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
|
// 初始化共享状态
|
||||||
fn load_jwt_secret() -> Arc<str> {
|
let pending_commands = PendingCommands::new();
|
||||||
std::env::var("JWT_SECRET")
|
let nodes = NodeRegistry::new(redis_conn.clone());
|
||||||
.expect("JWT_SECRET environment variable is required")
|
let state = AppState {
|
||||||
.into()
|
redis: redis_conn.clone(),
|
||||||
}
|
mysql: db_pool.clone(),
|
||||||
|
pending_commands: pending_commands.clone(),
|
||||||
|
nodes: nodes.clone(),
|
||||||
|
redis_url: cfg.redis_url.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
/// 启动全部后台协程:设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留
|
|
||||||
fn spawn_workers(cfg: &config::Config, state: &AppState) {
|
|
||||||
// 启动后台协程 — 处理设备上报
|
// 启动后台协程 — 处理设备上报
|
||||||
let report_redis = state.redis.clone();
|
let report_redis = redis_conn.clone();
|
||||||
let report_mysql = state.mysql.clone();
|
let report_mysql = db_pool.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::report_worker::start(report_redis, report_mysql).await;
|
workers::report_worker::start(report_redis, report_mysql).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动后台协程 — 处理指令响应
|
// 启动后台协程 — 处理指令响应
|
||||||
let reply_redis = state.redis.clone();
|
let reply_redis = redis_conn.clone();
|
||||||
let reply_pending = state.pending_commands.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::reply_worker::start(reply_redis, reply_pending).await;
|
workers::reply_worker::start(reply_redis, pending_commands).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动后台协程 — 节点心跳超时检测
|
// 启动后台协程 — 节点心跳超时检测
|
||||||
let checker_nodes = state.nodes.clone();
|
let checker_nodes = nodes.clone();
|
||||||
let heartbeat_timeout = cfg.node_heartbeat_timeout;
|
let heartbeat_timeout = cfg.node_heartbeat_timeout;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
|
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录)
|
|
||||||
let timeout_mysql = state.mysql.clone();
|
|
||||||
let timeout_redis = state.redis.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
workers::order_timeout::start(timeout_mysql, timeout_redis).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS)
|
|
||||||
let retention_mysql = state.mysql.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
workers::log_retention::start(retention_mysql).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建路由并启动 HTTP 服务(阻塞至进程退出)
|
|
||||||
async fn serve_http(cfg: &config::Config, state: AppState) {
|
|
||||||
// 构建 HTTP 路由
|
// 构建 HTTP 路由
|
||||||
let app = routes::build(state);
|
let app = routes::build(state);
|
||||||
|
|
||||||
@ -115,38 +96,3 @@ async fn serve_http(cfg: &config::Config, state: AppState) {
|
|||||||
.await
|
.await
|
||||||
.expect("服务器运行失败");
|
.expect("服务器运行失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
// 初始化日志
|
|
||||||
init_tracing();
|
|
||||||
|
|
||||||
// 加载配置
|
|
||||||
let cfg = config::Config::from_env();
|
|
||||||
tracing::info!("配置加载完成");
|
|
||||||
|
|
||||||
// 初始化 MySQL 连接池 + 自动迁移
|
|
||||||
let db_pool = init_database(&cfg).await;
|
|
||||||
|
|
||||||
// 初始化 Redis 连接
|
|
||||||
let redis_conn = init_redis(&cfg).await;
|
|
||||||
|
|
||||||
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
|
|
||||||
let jwt_secret = load_jwt_secret();
|
|
||||||
|
|
||||||
// 初始化共享状态
|
|
||||||
let state = AppState {
|
|
||||||
redis: redis_conn.clone(),
|
|
||||||
mysql: db_pool.clone(),
|
|
||||||
pending_commands: PendingCommands::new(),
|
|
||||||
nodes: NodeRegistry::new(redis_conn),
|
|
||||||
redis_url: cfg.redis_url.clone(),
|
|
||||||
jwt_secret,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 启动后台协程(设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留)
|
|
||||||
spawn_workers(&cfg, &state);
|
|
||||||
|
|
||||||
// 构建路由并启动 HTTP 服务
|
|
||||||
serve_http(&cfg, state).await;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -24,8 +24,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
use crate::commands::AppState;
|
use crate::commands::AppState;
|
||||||
|
use crate::config::JWT_SECRET_DEFAULT;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// JWT 签名密钥环境变量名
|
||||||
|
const JWT_SECRET_ENV: &str = "JWT_SECRET";
|
||||||
|
|
||||||
/// JWT Claims 结构
|
/// JWT Claims 结构
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
@ -49,7 +53,7 @@ pub struct CurrentUser {
|
|||||||
pub phone: String,
|
pub phone: String,
|
||||||
/// 用户拥有的权限码集合
|
/// 用户拥有的权限码集合
|
||||||
pub permissions: HashSet<String>,
|
pub permissions: HashSet<String>,
|
||||||
/// 角色等级:0操作员 1企业管理员 2总管理员
|
/// 角色等级:0普通 1企业管理员 2总管理员
|
||||||
pub role_level: i32,
|
pub role_level: i32,
|
||||||
/// 所属组织ID(总管理员为 None)
|
/// 所属组织ID(总管理员为 None)
|
||||||
pub organization_id: Option<i64>,
|
pub organization_id: Option<i64>,
|
||||||
@ -77,8 +81,9 @@ pub async fn auth_middleware(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从 AppState 获取 JWT 密钥(启动时已校验,无硬编码回退)
|
// 获取 JWT 密钥
|
||||||
let secret = state.jwt_secret.as_ref();
|
let secret = std::env::var(JWT_SECRET_ENV)
|
||||||
|
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
|
||||||
|
|
||||||
// 解码并验证 JWT
|
// 解码并验证 JWT
|
||||||
let mut validation = Validation::new(Algorithm::HS256);
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
@ -190,10 +195,11 @@ async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32,
|
|||||||
Ok(result.unwrap_or((0, None)))
|
Ok(result.unwrap_or((0, None)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成 JWT token(24小时有效期)
|
/// 生成 JWT token(7天有效期)
|
||||||
///
|
pub fn generate_token(user_id: i64, phone: &str) -> Result<String, AppError> {
|
||||||
/// `secret` 由调用方从 AppState.jwt_secret 传入,禁止硬编码回退。
|
let secret = std::env::var(JWT_SECRET_ENV)
|
||||||
pub fn generate_token(user_id: i64, phone: &str, secret: &str) -> Result<String, AppError> {
|
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map_err(|e| AppError::Internal(format!("时间错误: {}", e)))?
|
.map_err(|e| AppError::Internal(format!("时间错误: {}", e)))?
|
||||||
@ -202,7 +208,7 @@ pub fn generate_token(user_id: i64, phone: &str, secret: &str) -> Result<String,
|
|||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
phone: phone.to_string(),
|
phone: phone.to_string(),
|
||||||
exp: now + 86400, // 24小时
|
exp: now + 86400 * 7,
|
||||||
iat: now,
|
iat: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -278,80 +284,3 @@ pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, V
|
|||||||
(" AND 1=0".to_string(), vec![])
|
(" AND 1=0".to_string(), vec![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 单元测试
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// org_condition 总管理员不限制组织
|
|
||||||
#[test]
|
|
||||||
fn test_org_condition_super_admin() {
|
|
||||||
let user = CurrentUser {
|
|
||||||
user_id: 1,
|
|
||||||
phone: "13800138000".into(),
|
|
||||||
role_level: 2,
|
|
||||||
organization_id: None,
|
|
||||||
permissions: HashSet::new(),
|
|
||||||
};
|
|
||||||
let (cond, params) = org_condition(&user, "cr");
|
|
||||||
assert!(cond.is_empty());
|
|
||||||
assert!(params.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// org_condition 普通管理员按组织过滤
|
|
||||||
#[test]
|
|
||||||
fn test_org_condition_normal_admin() {
|
|
||||||
let user = CurrentUser {
|
|
||||||
user_id: 2,
|
|
||||||
phone: "13800138001".into(),
|
|
||||||
role_level: 1,
|
|
||||||
organization_id: Some(42),
|
|
||||||
permissions: HashSet::new(),
|
|
||||||
};
|
|
||||||
let (cond, params) = org_condition(&user, "cr");
|
|
||||||
assert!(cond.contains("organization_id = ?"), "应有组织过滤条件");
|
|
||||||
assert_eq!(params, vec!["42"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// org_condition 无组织用户应 1=0(看不到任何数据)
|
|
||||||
#[test]
|
|
||||||
fn test_org_condition_no_org() {
|
|
||||||
let user = CurrentUser {
|
|
||||||
user_id: 3,
|
|
||||||
phone: "13800138002".into(),
|
|
||||||
role_level: 0,
|
|
||||||
organization_id: None,
|
|
||||||
permissions: HashSet::new(),
|
|
||||||
};
|
|
||||||
let (cond, _params) = org_condition(&user, "cr");
|
|
||||||
assert!(cond.contains("1=0"), "无组织应返回 1=0");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// verify_password:正确密码应返回 true(round-trip:hash→verify)
|
|
||||||
#[test]
|
|
||||||
fn test_verify_password_correct() {
|
|
||||||
// 运行时生成 argon2 哈希再验证
|
|
||||||
let hash = hash_password("test123").unwrap();
|
|
||||||
let result = verify_password("test123", &hash).unwrap();
|
|
||||||
assert!(result, "正确密码应验证通过");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// verify_password:错误密码应返回 false(使用同一哈希比对)
|
|
||||||
#[test]
|
|
||||||
fn test_verify_password_wrong() {
|
|
||||||
let hash = hash_password("test123").unwrap();
|
|
||||||
let result = verify_password("wrongpass", &hash).unwrap();
|
|
||||||
assert!(!result, "错误密码应验证不通过");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// verify_password:无效哈希格式应返回 Err
|
|
||||||
#[test]
|
|
||||||
fn test_verify_password_invalid_hash() {
|
|
||||||
let result = verify_password("test123", "not-a-valid-hash");
|
|
||||||
assert!(result.is_err(), "无效哈希应返回错误");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,21 +6,34 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
// 说明:**上行报文的权威结构定义在 `device-server`**(`software/device-server/src/tcp/protocol.rs`)。
|
/// 设备发往平台的消息。
|
||||||
// api-server 收到的是 `device-server` 转发到 Redis 的 JSON,直接按 `serde_json::Value` 解析,
|
///
|
||||||
// 因此这里**不再保留** `DeviceMessage` 副本(旧副本曾带着已废弃的 `status` / `parsed_status` 字段,易误导)。
|
/// 字段均为 `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>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 平台回复设备的消息。
|
/// 平台回复设备的消息。
|
||||||
///
|
|
||||||
/// 协议要求(交付文档 §3.1 / §3.3 / §3.5):
|
|
||||||
/// - **`act` 必带**,且**与请求报文的 `act` 完全一致**
|
|
||||||
/// - **`msg_id` 必带**,且**与请求报文的 `msg_id` 完全一致**
|
|
||||||
/// - `suc` 必带;`suc=0` 时 `err` 必带
|
|
||||||
/// - `err` 一律为**英文小写短语**(§3.3 err 取值全集),例:`invalid signature` / `invalid id`
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ServerResponse {
|
pub struct ServerResponse {
|
||||||
/// 动作类型 —— **与请求的 `act` 一致**(协议 §3.1:`act` 双向全部报文必带)
|
|
||||||
pub act: String,
|
|
||||||
/// 1=成功,0=失败
|
/// 1=成功,0=失败
|
||||||
pub suc: u8,
|
pub suc: u8,
|
||||||
pub msg_id: Option<u32>,
|
pub msg_id: Option<u32>,
|
||||||
@ -28,52 +41,17 @@ pub struct ServerResponse {
|
|||||||
pub err: Option<String>,
|
pub err: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub auth_str: Option<String>,
|
pub auth_str: Option<String>,
|
||||||
/// login 成功时返回:柜子规格,固定 4 位(前 2 位仓控板数 + 后 2 位每板通道数)
|
|
||||||
/// 例 `"0606"` = 6 块仓控板 × 每板 6 仓(取代旧的 `cell`)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub spec: Option<String>,
|
|
||||||
/// login 成功时返回:充电工作模式 `"0"`=平台指令模式 / `"1"`=自动充电工作模式
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub work_mode: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerResponse {
|
impl ServerResponse {
|
||||||
/// 创建成功响应。`act` 必须与请求一致。
|
/// 创建成功响应。
|
||||||
pub fn success(act: impl Into<String>, msg_id: Option<u32>) -> Self {
|
pub fn success(msg_id: Option<u32>) -> Self {
|
||||||
Self {
|
Self { suc: 1, msg_id, err: None, auth_str: None }
|
||||||
act: act.into(),
|
|
||||||
suc: 1,
|
|
||||||
msg_id,
|
|
||||||
err: None,
|
|
||||||
auth_str: None,
|
|
||||||
spec: None,
|
|
||||||
work_mode: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建失败响应(`err` 必须传英文短语)。`act` 必须与请求一致。
|
/// 创建失败响应。
|
||||||
pub fn error(act: impl Into<String>, msg_id: Option<u32>, err: impl Into<String>) -> Self {
|
pub fn error(msg_id: Option<u32>, err: impl Into<String>) -> Self {
|
||||||
Self {
|
Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None }
|
||||||
act: act.into(),
|
|
||||||
suc: 0,
|
|
||||||
msg_id,
|
|
||||||
err: Some(err.into()),
|
|
||||||
auth_str: None,
|
|
||||||
spec: None,
|
|
||||||
work_mode: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从**设备请求报文**构造成功应答:自动取请求里的 `act` 与 `msg_id`。
|
|
||||||
///
|
|
||||||
/// 协议要求应答的 `act` / `msg_id` 必须与请求一致,直接从请求取值最不易出错。
|
|
||||||
pub fn success_for(req: &Value) -> Self {
|
|
||||||
Self::success(act_of(req), msg_id_of(req))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从**设备请求报文**构造失败应答(`err` 必须传英文短语)。
|
|
||||||
pub fn error_for(req: &Value, err: impl Into<String>) -> Self {
|
|
||||||
Self::error(act_of(req), msg_id_of(req), err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
|
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
|
||||||
@ -81,16 +59,3 @@ impl ServerResponse {
|
|||||||
serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
|
serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取请求报文里的 `act`(应答必须原样回带;未知 `act` 也照原值回带)。
|
|
||||||
fn act_of(req: &Value) -> String {
|
|
||||||
req.get("act")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 取请求报文里的 `msg_id`(应答必须原样回带)。
|
|
||||||
fn msg_id_of(req: &Value) -> Option<u32> {
|
|
||||||
req.get("msg_id").and_then(|v| v.as_u64()).map(|v| v as u32)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,58 +1,23 @@
|
|||||||
//! 认证路由 — 登录/登出/当前用户信息
|
//! 认证路由 — 登录/登出/当前用户信息
|
||||||
//!
|
//!
|
||||||
//! - GET /api/auth/challenge — 获取登录 challenge(防暴力破解)
|
|
||||||
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
|
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
|
||||||
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
|
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
|
||||||
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
|
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
|
||||||
//! - POST /api/auth/wx-login — 微信小程序手机号快速登录
|
|
||||||
//! - PUT /api/auth/avatar — 更新用户头像 URL
|
|
||||||
|
|
||||||
use axum::extract::{Multipart, State};
|
use axum::extract::State;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::io::Write;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const AVATAR_DIR: &str = "uploads/avatars";
|
|
||||||
use reqwest::Client;
|
|
||||||
|
|
||||||
use crate::captcha;
|
|
||||||
use crate::commands::AppState;
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
|
use crate::commands::AppState;
|
||||||
|
|
||||||
/// 登录请求体
|
/// 登录请求体
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct LoginInput {
|
pub(crate) struct LoginInput {
|
||||||
phone: String,
|
phone: String,
|
||||||
password: String,
|
password: String,
|
||||||
/// 登录 challenge token,从 GET /api/auth/challenge 获取
|
|
||||||
#[serde(default)]
|
|
||||||
challenge_id: String,
|
|
||||||
/// 滑块验证码偏移量(前端拖拽拼图后获得)
|
|
||||||
#[serde(default)]
|
|
||||||
captcha_x: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/auth/challenge — 获取登录滑块验证码
|
|
||||||
///
|
|
||||||
/// 返回背景图(带缺口)和拼图块图,前端拖动对齐后提交偏移量。
|
|
||||||
pub async fn get_challenge(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
let data = captcha::create_challenge(&state.redis_url).await?;
|
|
||||||
|
|
||||||
tracing::debug!("[challenge] 生成 cid={}", data.challenge_id);
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"challenge_id": data.challenge_id,
|
|
||||||
"bg_image": data.bg_image,
|
|
||||||
"piece_image": data.piece_image,
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/auth/login — 用户登录
|
/// POST /api/auth/login — 用户登录
|
||||||
@ -66,25 +31,15 @@ pub async fn login(
|
|||||||
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
|
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验滑块验证码(防暴力破解 + 人机验证)
|
// 查询用户(含密码比对)
|
||||||
if input.challenge_id.is_empty() {
|
let user_row: Option<(i64, Option<String>, String, String, i32, i32)> = sqlx::query_as(
|
||||||
return Err(AppError::BadRequest("缺少登录验证参数".into()));
|
"SELECT id, name, phone, password, role, status FROM users WHERE phone = ?"
|
||||||
}
|
|
||||||
let captcha_x = input.captcha_x.unwrap_or(i32::MAX);
|
|
||||||
let valid = captcha::verify_offset(&state.redis_url, &input.challenge_id, captcha_x).await?;
|
|
||||||
if !valid {
|
|
||||||
return Err(AppError::BadRequest("验证码错误,请刷新页面重试".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询用户(含角色和组织信息)
|
|
||||||
let user_row: Option<(i64, Option<String>, String, String, i32, i32, Option<i64>)> = sqlx::query_as(
|
|
||||||
"SELECT id, name, phone, password, status, role, organization_id FROM users WHERE phone = ?",
|
|
||||||
)
|
)
|
||||||
.bind(&input.phone)
|
.bind(&input.phone)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (id, name, phone, stored_password, status, role_level, organization_id) =
|
let (id, name, phone, stored_password, role, status) =
|
||||||
user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
|
user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
|
||||||
|
|
||||||
// 检查账户状态
|
// 检查账户状态
|
||||||
@ -100,7 +55,7 @@ pub async fn login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成 JWT token
|
// 生成 JWT token
|
||||||
let token = auth::generate_token(id, &phone, &state.jwt_secret)?;
|
let token = auth::generate_token(id, &phone)?;
|
||||||
|
|
||||||
// 查询用户权限码
|
// 查询用户权限码
|
||||||
let permissions = auth::load_user_permissions(pool, id).await?;
|
let permissions = auth::load_user_permissions(pool, id).await?;
|
||||||
@ -114,8 +69,7 @@ pub async fn login(
|
|||||||
"id": id,
|
"id": id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"phone": phone,
|
"phone": phone,
|
||||||
"role_level": role_level,
|
"role": role,
|
||||||
"organization_id": organization_id,
|
|
||||||
"permissions": perm_list,
|
"permissions": perm_list,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -137,14 +91,14 @@ pub async fn me(
|
|||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
// 查询用户详细信息
|
// 查询用户详细信息
|
||||||
let row: Option<(i64, Option<String>, String, i32, Option<i64>, Option<String>)> = sqlx::query_as(
|
let row: Option<(i64, Option<String>, String, i32, Option<i64>)> = sqlx::query_as(
|
||||||
"SELECT id, name, phone, role, organization_id, avatar FROM users WHERE id = ?"
|
"SELECT id, name, phone, role, organization_id FROM users WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(user.user_id)
|
.bind(user.user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (id, name, phone, role, org_id, avatar) =
|
let (id, name, phone, role, org_id) =
|
||||||
row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
|
row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
|
||||||
|
|
||||||
let perm_list: Vec<String> = user.permissions.into_iter().collect();
|
let perm_list: Vec<String> = user.permissions.into_iter().collect();
|
||||||
@ -157,323 +111,8 @@ pub async fn me(
|
|||||||
"phone": phone,
|
"phone": phone,
|
||||||
"role": role,
|
"role": role,
|
||||||
"organization_id": org_id,
|
"organization_id": org_id,
|
||||||
"avatar": avatar,
|
|
||||||
"permissions": perm_list,
|
"permissions": perm_list,
|
||||||
},
|
},
|
||||||
"message": "ok"
|
"message": "ok"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 微信扫码登录请求体
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct WxLoginInput {
|
|
||||||
/// wx.login() 获取的临时 code
|
|
||||||
pub code: String,
|
|
||||||
/// getPhoneNumber 获取的 phone code
|
|
||||||
pub phone_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 微信 jscode2session 响应
|
|
||||||
///
|
|
||||||
/// 字段与微信开放平台返回体一一对齐(当前仅读取 openid/errmsg,其余为预留能力,
|
|
||||||
/// 见各字段注释),故整体允许 dead_code,避免误删上游协议字段。
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct JsCode2SessionResp {
|
|
||||||
#[serde(default)]
|
|
||||||
openid: Option<String>,
|
|
||||||
/// 会话密钥:预留给敏感数据解密(如 encryptedData/水印校验),当前登录流程不需要
|
|
||||||
#[serde(default)]
|
|
||||||
session_key: Option<String>,
|
|
||||||
/// 微信错误码:预留用于后续按错误码细分排障(当前仅用 errmsg 文案)
|
|
||||||
#[serde(default)]
|
|
||||||
errcode: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
errmsg: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 微信 access_token 响应
|
|
||||||
///
|
|
||||||
/// 字段与微信开放平台返回体一一对齐(当前仅读取 access_token/errmsg,其余为预留能力),
|
|
||||||
/// 故整体允许 dead_code。
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct TokenResp {
|
|
||||||
#[serde(default)]
|
|
||||||
access_token: Option<String>,
|
|
||||||
/// 有效期秒数:预留用于 access_token 本地缓存(当前每次登录都重新获取)
|
|
||||||
#[serde(default)]
|
|
||||||
expires_in: Option<i64>,
|
|
||||||
/// 微信错误码:预留用于后续按错误码细分排障
|
|
||||||
#[serde(default)]
|
|
||||||
errcode: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
errmsg: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 微信 getuserphonenumber 响应
|
|
||||||
///
|
|
||||||
/// 字段与微信开放平台返回体一一对齐(当前仅读取 phone_info/errmsg),故整体允许 dead_code。
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct PhoneNumberResp {
|
|
||||||
/// 微信错误码:预留用于后续按错误码细分排障
|
|
||||||
#[serde(default)]
|
|
||||||
errcode: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
errmsg: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
phone_info: Option<PhoneInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 微信手机号信息
|
|
||||||
///
|
|
||||||
/// 当前业务用 pure_phone_number 匹配用户;带国码的 phone_number 与 country_code
|
|
||||||
/// 预留用于国际号段/归属地展示,故整体允许 dead_code。
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct PhoneInfo {
|
|
||||||
/// 带国码手机号(如 8613951516272),预留国际号段场景
|
|
||||||
#[serde(default)]
|
|
||||||
phone_number: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pure_phone_number: String,
|
|
||||||
/// 国家码(如 86),预留号段归属展示
|
|
||||||
#[serde(default)]
|
|
||||||
country_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取微信小程序凭证(环境变量缺失则报内部错误)
|
|
||||||
fn wechat_credentials() -> Result<(String, String), AppError> {
|
|
||||||
let appid = std::env::var("WECHAT_APPID")
|
|
||||||
.map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?;
|
|
||||||
let secret = std::env::var("WECHAT_APPSECRET")
|
|
||||||
.map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?;
|
|
||||||
Ok((appid, secret))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用 wx.login() 的 code 换取 openid
|
|
||||||
async fn fetch_openid(client: &Client, appid: &str, secret: &str, code: &str) -> Result<String, AppError> {
|
|
||||||
let jscode_url = format!(
|
|
||||||
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
|
|
||||||
appid, secret, code
|
|
||||||
);
|
|
||||||
let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))?
|
|
||||||
.json().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?;
|
|
||||||
|
|
||||||
js_resp.openid.ok_or_else(|| {
|
|
||||||
let err = js_resp.errmsg.unwrap_or_default();
|
|
||||||
AppError::Internal(format!("jscode2session 失败: {}", err))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取微信接口调用凭证 access_token
|
|
||||||
async fn fetch_access_token(client: &Client, appid: &str, secret: &str) -> Result<String, AppError> {
|
|
||||||
let token_url = format!(
|
|
||||||
"https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential",
|
|
||||||
appid, secret
|
|
||||||
);
|
|
||||||
let token_resp: TokenResp = client.get(&token_url).send().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("token 请求失败: {}", e)))?
|
|
||||||
.json().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?;
|
|
||||||
|
|
||||||
token_resp.access_token.ok_or_else(|| {
|
|
||||||
let err = token_resp.errmsg.unwrap_or_default();
|
|
||||||
AppError::Internal(format!("获取 access_token 失败: {}", err))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用 phone_code 换取微信绑定的手机号
|
|
||||||
async fn fetch_phone_number(
|
|
||||||
client: &Client,
|
|
||||||
access_token: &str,
|
|
||||||
phone_code: &str,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
let phone_url = format!(
|
|
||||||
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}",
|
|
||||||
access_token
|
|
||||||
);
|
|
||||||
let phone_resp: PhoneNumberResp = client.post(&phone_url)
|
|
||||||
.json(&json!({ "code": phone_code }))
|
|
||||||
.send().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))?
|
|
||||||
.json().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("getuserphonenumber 解析失败: {}", e)))?;
|
|
||||||
|
|
||||||
let phone = phone_resp.phone_info
|
|
||||||
.ok_or_else(|| {
|
|
||||||
let err = phone_resp.errmsg.unwrap_or_default();
|
|
||||||
AppError::Internal(format!("获取手机号失败: {}", err))
|
|
||||||
})?;
|
|
||||||
Ok(phone.pure_phone_number)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按手机号查找用户(手机号是唯一标识)
|
|
||||||
async fn find_user_by_phone(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
phone_number: &str,
|
|
||||||
) -> Result<(i64, Option<String>, String, i32, i32, Option<i64>), AppError> {
|
|
||||||
let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>(
|
|
||||||
"SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?",
|
|
||||||
)
|
|
||||||
.bind(phone_number)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
user_row.ok_or_else(|| {
|
|
||||||
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
|
|
||||||
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/auth/wx-login — 微信小程序手机号快速登录
|
|
||||||
///
|
|
||||||
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
|
|
||||||
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
|
|
||||||
pub async fn wx_login(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(input): Json<WxLoginInput>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
let (appid, secret) = wechat_credentials()?;
|
|
||||||
let client = Client::new();
|
|
||||||
|
|
||||||
// 1. 用 code 换取 openid
|
|
||||||
let openid = fetch_openid(&client, &appid, &secret, &input.code).await?;
|
|
||||||
|
|
||||||
// 2. 获取 access_token
|
|
||||||
let access_token = fetch_access_token(&client, &appid, &secret).await?;
|
|
||||||
|
|
||||||
// 3. 用 phone_code 换取手机号
|
|
||||||
let phone_number = fetch_phone_number(&client, &access_token, &input.phone_code).await?;
|
|
||||||
|
|
||||||
// 4. 按手机号查找用户(手机号是唯一标识)
|
|
||||||
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户");
|
|
||||||
let (id, name, _phone, status, role_level, organization_id) =
|
|
||||||
find_user_by_phone(&state.mysql, &phone_number).await?;
|
|
||||||
|
|
||||||
// 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息)
|
|
||||||
sqlx::query("UPDATE users SET openid = ? WHERE id = ?")
|
|
||||||
.bind(&openid)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&state.mysql)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if status == 0 {
|
|
||||||
return Err(AppError::Forbidden("账户已被禁用".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = auth::generate_token(id, &_phone, &state.jwt_secret)?;
|
|
||||||
let permissions = auth::load_user_permissions(&state.mysql, id).await?;
|
|
||||||
let perm_list: Vec<String> = permissions.into_iter().collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"token": token,
|
|
||||||
"user": {
|
|
||||||
"id": id,
|
|
||||||
"name": name,
|
|
||||||
"phone": _phone,
|
|
||||||
"role_level": role_level,
|
|
||||||
"organization_id": organization_id,
|
|
||||||
"permissions": perm_list,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"message": "登录成功"
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 微信头像更新请求体
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct AvatarInput {
|
|
||||||
pub avatar: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/auth/avatar — 更新当前用户头像 URL
|
|
||||||
pub async fn update_avatar(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(input): Json<AvatarInput>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
|
|
||||||
.bind(&input.avatar)
|
|
||||||
.bind(user.user_id)
|
|
||||||
.execute(&state.mysql)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": { "avatar": input.avatar }, "message": "ok" })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/auth/avatar/upload — 头像文件上传
|
|
||||||
pub async fn upload_avatar(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
mut multipart: Multipart,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
let mut file_data: Option<Vec<u8>> = None;
|
|
||||||
let mut ext = "".to_string();
|
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
|
|
||||||
let filename = field.file_name().unwrap_or("").to_string();
|
|
||||||
let data = field.bytes().await
|
|
||||||
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
|
|
||||||
|
|
||||||
// 提取扩展名
|
|
||||||
if let Some(pos) = filename.rfind('.') {
|
|
||||||
ext = filename[pos..].to_string();
|
|
||||||
}
|
|
||||||
file_data = Some(data.to_vec());
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
|
|
||||||
if data.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("上传内容为空".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 限制大小(2MB)
|
|
||||||
if data.len() > 2 * 1024 * 1024 {
|
|
||||||
return Err(AppError::BadRequest("头像文件不能超过 2MB".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 白名单扩展名
|
|
||||||
if ext.is_empty() {
|
|
||||||
ext = ".jpg".to_string();
|
|
||||||
}
|
|
||||||
let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
|
|
||||||
if !allowed.contains(&ext.as_str()) {
|
|
||||||
ext = ".jpg".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存文件
|
|
||||||
let dir = std::path::Path::new(AVATAR_DIR);
|
|
||||||
std::fs::create_dir_all(dir)
|
|
||||||
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
|
|
||||||
|
|
||||||
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
|
|
||||||
let filepath = dir.join(&filename);
|
|
||||||
let mut f = std::fs::File::create(&filepath)
|
|
||||||
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
|
|
||||||
f.write_all(&data)
|
|
||||||
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
|
|
||||||
|
|
||||||
// 更新用户头像
|
|
||||||
let avatar_url = format!("/uploads/avatars/{}", filename);
|
|
||||||
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
|
|
||||||
.bind(&avatar_url)
|
|
||||||
.bind(user.user_id)
|
|
||||||
.execute(&state.mysql)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::info!(user_id = user.user_id, path = %avatar_url, "头像上传成功");
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": { "avatar": avatar_url },
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,205 +0,0 @@
|
|||||||
//! 电池台账 API — 列表查询 + 详情
|
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use axum::Json;
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
|
||||||
|
|
||||||
fn status_text(status: i8) -> &'static str {
|
|
||||||
match status {
|
|
||||||
0 => "空闲",
|
|
||||||
1 => "使用中",
|
|
||||||
2 => "故障",
|
|
||||||
3 => "报废",
|
|
||||||
_ => "未知",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct BatteryQuery {
|
|
||||||
pub status: Option<i8>,
|
|
||||||
pub battery_id: Option<String>,
|
|
||||||
pub page: Option<i64>,
|
|
||||||
pub page_size: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct BatteryItem {
|
|
||||||
pub id: i64,
|
|
||||||
pub battery_id: String,
|
|
||||||
pub soc: i64,
|
|
||||||
pub brand: Option<String>,
|
|
||||||
pub model: Option<String>,
|
|
||||||
pub sn: Option<String>,
|
|
||||||
pub status: i8,
|
|
||||||
pub status_text: String,
|
|
||||||
pub created_at: Option<NaiveDateTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct BatteryRow {
|
|
||||||
id: i64,
|
|
||||||
battery_id: String,
|
|
||||||
soc: i64,
|
|
||||||
brand: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
sn: Option<String>,
|
|
||||||
status: i8,
|
|
||||||
created_at: Option<NaiveDateTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建列表 / 计数 SQL
|
|
||||||
///
|
|
||||||
/// 仅判断筛选条件是否存在以拼接 SQL,具体取值由调用方绑定。
|
|
||||||
fn build_battery_sql(params: &BatteryQuery) -> (String, String) {
|
|
||||||
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string();
|
|
||||||
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string();
|
|
||||||
|
|
||||||
if params.status.is_some() {
|
|
||||||
query.push_str(" AND status = ?");
|
|
||||||
count_query.push_str(" AND status = ?");
|
|
||||||
}
|
|
||||||
if params.battery_id.is_some() {
|
|
||||||
query.push_str(" AND battery_id LIKE ?");
|
|
||||||
count_query.push_str(" AND battery_id LIKE ?");
|
|
||||||
}
|
|
||||||
|
|
||||||
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
|
|
||||||
(query, count_query)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按筛选条件统计电池总数
|
|
||||||
async fn count_batteries(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
count_query: &str,
|
|
||||||
status: Option<i8>,
|
|
||||||
battery_id: Option<&str>,
|
|
||||||
) -> Result<i64, sqlx::Error> {
|
|
||||||
let total: i64 = if let Some(s) = status {
|
|
||||||
if let Some(bid) = battery_id {
|
|
||||||
sqlx::query_scalar(count_query)
|
|
||||||
.bind(s)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar(count_query)
|
|
||||||
.bind(s)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
} else if let Some(bid) = battery_id {
|
|
||||||
sqlx::query_scalar(count_query)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar(count_query)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
Ok(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按筛选条件查询当前页电池行
|
|
||||||
async fn fetch_battery_rows(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
query: &str,
|
|
||||||
status: Option<i8>,
|
|
||||||
battery_id: Option<&str>,
|
|
||||||
page_size: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<BatteryRow>, sqlx::Error> {
|
|
||||||
let rows: Vec<BatteryRow> = if let Some(s) = status {
|
|
||||||
if let Some(bid) = battery_id {
|
|
||||||
sqlx::query_as(query)
|
|
||||||
.bind(s)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(query)
|
|
||||||
.bind(s)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
} else if let Some(bid) = battery_id {
|
|
||||||
sqlx::query_as(query)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(query)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将数据库行映射为响应项列表
|
|
||||||
fn batteries_to_items(rows: Vec<BatteryRow>) -> Vec<BatteryItem> {
|
|
||||||
rows.into_iter().map(|r| BatteryItem {
|
|
||||||
id: r.id,
|
|
||||||
battery_id: r.battery_id,
|
|
||||||
soc: r.soc,
|
|
||||||
brand: r.brand,
|
|
||||||
model: r.model,
|
|
||||||
sn: r.sn,
|
|
||||||
status: r.status,
|
|
||||||
status_text: status_text(r.status).to_string(),
|
|
||||||
created_at: r.created_at,
|
|
||||||
}).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/batteries — 电池台账列表
|
|
||||||
pub async fn list_batteries(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<crate::commands::AppState>,
|
|
||||||
Query(params): Query<BatteryQuery>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
auth::check_permission(&user, "device:view")?;
|
|
||||||
|
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
|
||||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
|
||||||
let offset = (page - 1) * page_size;
|
|
||||||
|
|
||||||
// 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定
|
|
||||||
let (query, count_query) = build_battery_sql(¶ms);
|
|
||||||
let battery_id = params.battery_id.as_deref();
|
|
||||||
|
|
||||||
let total = count_batteries(&state.mysql, &count_query, params.status, battery_id).await?;
|
|
||||||
let rows = fetch_battery_rows(
|
|
||||||
&state.mysql,
|
|
||||||
&query,
|
|
||||||
params.status,
|
|
||||||
battery_id,
|
|
||||||
page_size,
|
|
||||||
offset,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let items = batteries_to_items(rows);
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": items,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ use axum::extract::{State, Query};
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
|
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -25,7 +24,6 @@ struct CabinetSqlRow {
|
|||||||
iccid: Option<String>,
|
iccid: Option<String>,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
address: Option<String>,
|
address: Option<String>,
|
||||||
project_address: Option<String>,
|
|
||||||
status: i8,
|
status: i8,
|
||||||
board_count: Option<i64>,
|
board_count: Option<i64>,
|
||||||
channel_count: Option<i64>,
|
channel_count: Option<i64>,
|
||||||
@ -45,7 +43,6 @@ impl From<CabinetSqlRow> for CabinetRow {
|
|||||||
iccid: row.iccid,
|
iccid: row.iccid,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
address: row.address,
|
address: row.address,
|
||||||
project_address: row.project_address,
|
|
||||||
status: row.status,
|
status: row.status,
|
||||||
board_count: row.board_count,
|
board_count: row.board_count,
|
||||||
channel_count: row.channel_count,
|
channel_count: row.channel_count,
|
||||||
@ -57,62 +54,10 @@ impl From<CabinetSqlRow> for CabinetRow {
|
|||||||
pow_fail_dc: None,
|
pow_fail_dc: None,
|
||||||
pow_fail_ac: None,
|
pow_fail_ac: None,
|
||||||
is_online: None,
|
is_online: None,
|
||||||
org_disabled: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SQL 常量
|
|
||||||
//
|
|
||||||
// 说明:以下 SQL 均由原函数体**原样搬移**,未改动任何 SELECT 字段列表、
|
|
||||||
// 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 全部组织(组织树用)
|
|
||||||
const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id";
|
|
||||||
|
|
||||||
/// 全部项目(经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题)
|
|
||||||
const PROJECTS_ALL_SQL: &str = "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id";
|
|
||||||
|
|
||||||
/// 各项目下的设备数量
|
|
||||||
const PROJECT_CABINET_COUNTS_SQL: &str =
|
|
||||||
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id";
|
|
||||||
|
|
||||||
/// 设备列表基础 SQL:联表查询获取通道状态统计(子查询 idle_count/charging_count/... 原样保留)
|
|
||||||
const CABINET_FILTER_BASE_SQL: &str = "
|
|
||||||
SELECT
|
|
||||||
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
|
|
||||||
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
|
|
||||||
c.status,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb2
|
|
||||||
JOIN compartments comp ON comp.cabin_board_id = cb2.id
|
|
||||||
WHERE cb2.cabinet_id = c.id) as channel_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb3
|
|
||||||
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
|
|
||||||
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb4
|
|
||||||
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
|
|
||||||
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb5
|
|
||||||
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
|
|
||||||
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb6
|
|
||||||
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
|
|
||||||
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
|
|
||||||
FROM cabinets c
|
|
||||||
";
|
|
||||||
|
|
||||||
/// 设备归属校验(非总管理员):设备必须属于当前用户组织
|
|
||||||
const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \
|
|
||||||
JOIN projects p ON c.project_id = p.id \
|
|
||||||
WHERE c.imei = ? AND p.organization_id = ?)";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 组织树
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 组织树接口 — 返回多级组织->项目树(含设备数量)
|
/// 组织树接口 — 返回多级组织->项目树(含设备数量)
|
||||||
pub async fn organization_tree(
|
pub async fn organization_tree(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -123,36 +68,36 @@ pub async fn organization_tree(
|
|||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
|
|
||||||
// 获取所有组织
|
// 获取所有组织
|
||||||
let orgs: Vec<OrganizationRow> = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?;
|
let orgs: Vec<OrganizationRow> = sqlx::query_as(
|
||||||
|
"SELECT id, name, parent_id FROM organizations ORDER BY id"
|
||||||
// 获取所有项目
|
)
|
||||||
let projects: Vec<ProjectRow> = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?;
|
|
||||||
|
|
||||||
// 获取每个项目的设备数量
|
|
||||||
let project_count_map = fetch_project_counts(db).await?;
|
|
||||||
|
|
||||||
// 递归构建树
|
|
||||||
let tree = build_tree(&orgs, &projects, &project_count_map, None);
|
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询「项目ID -> 设备数量」映射
|
|
||||||
async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result<HashMap<i64, i64>, AppError> {
|
|
||||||
let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL)
|
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(project_counts.into_iter().collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归构建「组织 -> 项目」树节点,项目名称携带设备数量
|
// 获取所有项目
|
||||||
|
let projects: Vec<ProjectRow> = sqlx::query_as(
|
||||||
|
"SELECT id, organization_id, name FROM projects ORDER BY id"
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 获取每个项目的设备数量
|
||||||
|
let project_counts: Vec<(i64, i64)> = sqlx::query_as(
|
||||||
|
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id"
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
let project_count_map: std::collections::HashMap<i64, i64> = project_counts.into_iter().collect();
|
||||||
|
|
||||||
|
// 递归构建树
|
||||||
fn build_tree(
|
fn build_tree(
|
||||||
orgs: &[OrganizationRow],
|
orgs: &[OrganizationRow],
|
||||||
projects: &[ProjectRow],
|
projects: &[ProjectRow],
|
||||||
project_count_map: &HashMap<i64, i64>,
|
project_count_map: &std::collections::HashMap<i64, i64>,
|
||||||
parent_id: Option<i64>,
|
parent_id: Option<i64>,
|
||||||
) -> Vec<TreeNode> {
|
) -> Vec<TreeNode> {
|
||||||
orgs.iter()
|
orgs
|
||||||
|
.iter()
|
||||||
.filter(|o| o.parent_id == parent_id)
|
.filter(|o| o.parent_id == parent_id)
|
||||||
.map(|org| {
|
.map(|org| {
|
||||||
// 递归获取子组织
|
// 递归获取子组织
|
||||||
@ -171,10 +116,6 @@ fn build_tree(
|
|||||||
abstract_id: None,
|
abstract_id: None,
|
||||||
imei: None,
|
imei: None,
|
||||||
status: None,
|
status: None,
|
||||||
disabled: None,
|
|
||||||
address: p.address.clone(),
|
|
||||||
latitude: p.latitude.clone(),
|
|
||||||
longitude: p.longitude.clone(),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -184,45 +125,33 @@ fn build_tree(
|
|||||||
all_children.extend(org_projects);
|
all_children.extend(org_projects);
|
||||||
|
|
||||||
// 计算本组织下的总设备数量
|
// 计算本组织下的总设备数量
|
||||||
let total_count: i64 = all_children
|
let total_count: i64 = all_children.iter()
|
||||||
.iter()
|
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
// 从项目名称中提取数量
|
// 从项目名称中提取数量
|
||||||
if let Some(start) = c.name.rfind('(') {
|
if let Some(start) = c.name.rfind('(') {
|
||||||
if let Some(end) = c.name.rfind(')') {
|
if let Some(end) = c.name.rfind(')') {
|
||||||
c.name[start+1..end].parse().unwrap_or(0)
|
c.name[start+1..end].parse().unwrap_or(0)
|
||||||
} else {
|
} else { 0 }
|
||||||
0
|
} else { 0 }
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
TreeNode {
|
TreeNode {
|
||||||
id: org.id,
|
id: org.id,
|
||||||
name: format!("{} ({})", org.name, total_count),
|
name: format!("{} ({})", org.name, total_count),
|
||||||
children: if all_children.is_empty() {
|
children: if all_children.is_empty() { None } else { Some(all_children) },
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(all_children)
|
|
||||||
},
|
|
||||||
abstract_id: None,
|
abstract_id: None,
|
||||||
imei: None,
|
imei: None,
|
||||||
status: None,
|
status: None,
|
||||||
disabled: org.disabled,
|
|
||||||
address: None,
|
|
||||||
latitude: None,
|
|
||||||
longitude: None,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
let tree = build_tree(&orgs, &projects, &project_count_map, None);
|
||||||
// 设备列表筛选
|
|
||||||
// ---------------------------------------------------------------------------
|
Ok(Json(json!(tree)))
|
||||||
|
}
|
||||||
|
|
||||||
/// 设备列表筛选参数
|
/// 设备列表筛选参数
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -243,42 +172,36 @@ pub async fn filter_cabinets(
|
|||||||
auth::check_permission(&user, "device:view")?;
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
|
let redis = &state.redis;
|
||||||
|
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
|
let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
|
||||||
let offset = (page - 1) * page_size;
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
// 组织/项目过滤 + 已绑定/未绑定过滤
|
// 基础SQL:联表查询获取通道状态统计
|
||||||
let (where_clause, binds) = build_filter_where(¶ms, &user);
|
let base_sql = "
|
||||||
|
SELECT
|
||||||
|
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, c.status,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb2
|
||||||
|
JOIN compartments comp ON comp.cabin_board_id = cb2.id
|
||||||
|
WHERE cb2.cabinet_id = c.id) as channel_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb3
|
||||||
|
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
|
||||||
|
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb4
|
||||||
|
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
|
||||||
|
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb5
|
||||||
|
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
|
||||||
|
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb6
|
||||||
|
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
|
||||||
|
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
|
||||||
|
FROM cabinets c
|
||||||
|
";
|
||||||
|
|
||||||
// 总数量
|
let (mut where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
|
||||||
let total = fetch_filter_total(db, &where_clause, &binds).await;
|
|
||||||
|
|
||||||
if total == 0 || offset >= total {
|
|
||||||
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页数据
|
|
||||||
let sql_rows = fetch_filter_page(db, &where_clause, &binds, page_size, offset).await?;
|
|
||||||
|
|
||||||
// 转换为CabinetRow
|
|
||||||
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
|
|
||||||
|
|
||||||
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
|
|
||||||
attach_realtime_status(&state, &mut rows).await?;
|
|
||||||
|
|
||||||
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
|
|
||||||
attach_org_disabled(db, &mut rows).await;
|
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致)
|
|
||||||
fn build_filter_where(
|
|
||||||
params: &CabinetFilterParams,
|
|
||||||
user: &CurrentUser,
|
|
||||||
) -> (String, Vec<String>) {
|
|
||||||
let (where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
|
|
||||||
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
|
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
|
||||||
} else if let Some(org_id) = params.organization_id {
|
} else if let Some(org_id) = params.organization_id {
|
||||||
("JOIN projects p ON c.project_id = p.id
|
("JOIN projects p ON c.project_id = p.id
|
||||||
@ -294,66 +217,65 @@ fn build_filter_where(
|
|||||||
("WHERE 1=0".into(), vec![])
|
("WHERE 1=0".into(), vec![])
|
||||||
};
|
};
|
||||||
|
|
||||||
// 已绑定/未绑定过滤(绑定 = 分配到项目)
|
// 已绑定/未绑定过滤
|
||||||
let where_clause = match params.bound.as_deref() {
|
if let Some(bound) = params.bound {
|
||||||
Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"),
|
match bound.as_str() {
|
||||||
Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"),
|
"1" => {
|
||||||
_ => where_clause,
|
|
||||||
};
|
|
||||||
|
|
||||||
(where_clause, binds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接
|
|
||||||
fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String {
|
|
||||||
if where_clause.is_empty() {
|
if where_clause.is_empty() {
|
||||||
where_new.to_string()
|
where_clause = "WHERE c.abstract_id IS NOT NULL".into();
|
||||||
} else {
|
} else {
|
||||||
let mut clause = where_clause;
|
where_clause.push_str(" AND c.abstract_id IS NOT NULL");
|
||||||
clause.push_str(and_tail);
|
}
|
||||||
clause
|
}
|
||||||
|
"0" => {
|
||||||
|
if where_clause.is_empty() {
|
||||||
|
where_clause = "WHERE c.abstract_id IS NULL".into();
|
||||||
|
} else {
|
||||||
|
where_clause.push_str(" AND c.abstract_id IS NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致)
|
// 总数量
|
||||||
async fn fetch_filter_total(db: &sqlx::MySqlPool, where_clause: &str, binds: &[String]) -> i64 {
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause);
|
let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause);
|
||||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
for b in binds {
|
for b in &binds {
|
||||||
count_query = count_query.bind(b);
|
count_query = count_query.bind(b);
|
||||||
}
|
}
|
||||||
count_query.fetch_one(db).await.unwrap_or(0)
|
let total: i64 = count_query.fetch_one(db).await.unwrap_or(0);
|
||||||
|
|
||||||
|
if total == 0 || offset >= total {
|
||||||
|
return Ok(Json(json!({"total": 0, "data": []})));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定)
|
// 分页数据
|
||||||
async fn fetch_filter_page(
|
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", base_sql, where_clause);
|
||||||
db: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
binds: &[String],
|
|
||||||
page_size: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<CabinetSqlRow>, AppError> {
|
|
||||||
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", CABINET_FILTER_BASE_SQL, where_clause);
|
|
||||||
let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql);
|
let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql);
|
||||||
for b in binds {
|
for b in &binds {
|
||||||
data_query = data_query.bind(b);
|
data_query = data_query.bind(b);
|
||||||
}
|
}
|
||||||
let rows: Vec<CabinetSqlRow> = data_query
|
let sql_rows: Vec<CabinetSqlRow> = data_query
|
||||||
.bind(page_size)
|
.bind(page_size)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(db)
|
.fetch_all(db).await?;
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返)
|
// 转换为CabinetRow
|
||||||
async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Result<(), AppError> {
|
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
|
||||||
let mut conn = open_redis_conn(state).await?;
|
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
|
||||||
|
{
|
||||||
|
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
||||||
|
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
||||||
|
})?;
|
||||||
|
let mut conn = client.get_async_connection().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Redis连接失败: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
|
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
|
||||||
let mut pipe = redis::pipe();
|
let mut pipe = redis::pipe();
|
||||||
for row in rows.iter() {
|
for row in &rows {
|
||||||
let key = format!("device:{}", row.imei);
|
let key = format!("device:{}", row.imei);
|
||||||
pipe.cmd("HGET").arg(&key).arg("online");
|
pipe.cmd("HGET").arg(&key).arg("online");
|
||||||
pipe.cmd("HGET").arg(&key).arg("rssi");
|
pipe.cmd("HGET").arg(&key).arg("rssi");
|
||||||
@ -368,53 +290,14 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re
|
|||||||
for (i, row) in rows.iter_mut().enumerate() {
|
for (i, row) in rows.iter_mut().enumerate() {
|
||||||
let base = i * 4;
|
let base = i * 4;
|
||||||
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
|
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
|
||||||
row.rssi = results
|
row.rssi = results.get(base + 1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok());
|
||||||
.get(base + 1)
|
row.pow_fail_dc = results.get(base + 2).and_then(|v| v.as_ref()).map(|v| v == "1");
|
||||||
.and_then(|v| v.as_ref())
|
row.pow_fail_ac = results.get(base + 3).and_then(|v| v.as_ref()).map(|v| v == "1");
|
||||||
.and_then(|v| v.parse::<i32>().ok());
|
|
||||||
row.pow_fail_dc = results
|
|
||||||
.get(base + 2)
|
|
||||||
.and_then(|v| v.as_ref())
|
|
||||||
.map(|v| v == "1");
|
|
||||||
row.pow_fail_ac = results
|
|
||||||
.get(base + 3)
|
|
||||||
.and_then(|v| v.as_ref())
|
|
||||||
.map(|v| v == "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标)
|
|
||||||
async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) {
|
|
||||||
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
|
||||||
if project_ids.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
|
||||||
let sql = format!(
|
|
||||||
r#"SELECT p.id, o.disabled FROM projects p
|
|
||||||
LEFT JOIN organizations o ON p.organization_id = o.id
|
|
||||||
WHERE p.id IN ({})"#,
|
|
||||||
placeholders.join(",")
|
|
||||||
);
|
|
||||||
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
|
||||||
for pid in &project_ids {
|
|
||||||
q = q.bind(pid);
|
|
||||||
}
|
|
||||||
if let Ok(results) = q.fetch_all(db).await {
|
|
||||||
let disabled_map: HashMap<i64, bool> =
|
|
||||||
results.into_iter().map(|(pid, d)| (pid, d == 1)).collect();
|
|
||||||
for row in rows.iter_mut() {
|
|
||||||
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
Ok(Json(json!({"total": total, "data": rows})))
|
||||||
// 设备实时状态
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 设备实时状态参数
|
/// 设备实时状态参数
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -422,17 +305,6 @@ pub struct DeviceStatusParams {
|
|||||||
pub imei: String,
|
pub imei: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误)
|
|
||||||
async fn open_redis_conn(state: &AppState) -> Result<redis::aio::MultiplexedConnection, AppError> {
|
|
||||||
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
|
||||||
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
|
||||||
})?;
|
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
|
||||||
client.get_multiplexed_async_connection().await.map_err(|e| {
|
|
||||||
AppError::Internal(format!("Redis连接失败: {}", e))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设备实时状态接口 — 返回板级+通道级状态
|
/// 设备实时状态接口 — 返回板级+通道级状态
|
||||||
pub async fn device_realtime_status(
|
pub async fn device_realtime_status(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -441,82 +313,36 @@ pub async fn device_realtime_status(
|
|||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:view")?;
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
// 校验设备归属
|
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
||||||
ensure_device_access(&state, &user, ¶ms.imei).await?;
|
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
||||||
|
})?;
|
||||||
|
let mut conn = client.get_async_connection().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Redis连接失败: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut conn = open_redis_conn(&state).await?;
|
let device_key = format!("device:{}", params.imei);
|
||||||
|
|
||||||
let device_status = load_device_status(&mut conn, ¶ms.imei).await;
|
|
||||||
let boards = load_boards_status(&mut conn, ¶ms.imei).await;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"device": device_status,
|
|
||||||
"boards": boards,
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织
|
|
||||||
async fn ensure_device_access(
|
|
||||||
state: &AppState,
|
|
||||||
user: &CurrentUser,
|
|
||||||
imei: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if user.role_level < 2 {
|
|
||||||
if let Some(org_id) = user.organization_id {
|
|
||||||
let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL)
|
|
||||||
.bind(imei)
|
|
||||||
.bind(org_id)
|
|
||||||
.fetch_one(&state.mysql)
|
|
||||||
.await
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !has_access {
|
|
||||||
return Err(AppError::Forbidden("无权访问该设备".into()));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(AppError::Forbidden("无权访问该设备".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取设备级字段(online / rssi / pow_fail_dc / pow_fail_ac)
|
|
||||||
async fn load_device_status(
|
|
||||||
conn: &mut redis::aio::MultiplexedConnection,
|
|
||||||
imei: &str,
|
|
||||||
) -> Value {
|
|
||||||
let device_key = format!("device:{}", imei);
|
|
||||||
|
|
||||||
// 获取设备级字段
|
// 获取设备级字段
|
||||||
let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"];
|
let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"];
|
||||||
let values: Vec<Option<String>> = redis::cmd("HMGET")
|
let values: Vec<Option<String>> = redis::cmd("HMGET")
|
||||||
.arg(&device_key)
|
.arg(&device_key)
|
||||||
.arg(&fields)
|
.arg(&fields)
|
||||||
.query_async(&mut *conn)
|
.query_async(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
json!({
|
let device_status = json!({
|
||||||
"online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
"online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
||||||
"rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()),
|
"rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()),
|
||||||
"pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
"pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
||||||
"pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
"pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
||||||
})
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描并组装全部仓控板状态(板级 + 通道级)
|
|
||||||
async fn load_boards_status(
|
|
||||||
conn: &mut redis::aio::MultiplexedConnection,
|
|
||||||
imei: &str,
|
|
||||||
) -> Value {
|
|
||||||
// 扫描板级key: device:{imei}:board:*
|
// 扫描板级key: device:{imei}:board:*
|
||||||
let board_pattern = format!("device:{}:board:*", imei);
|
let board_pattern = format!("device:{}:board:*", params.imei);
|
||||||
let board_keys: Vec<String> = redis::cmd("KEYS")
|
let board_keys: Vec<String> = redis::cmd("KEYS")
|
||||||
.arg(&board_pattern)
|
.arg(&board_pattern)
|
||||||
.query_async(&mut *conn)
|
.query_async(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@ -528,23 +354,12 @@ async fn load_boards_status(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
boards[board_idx] = load_single_board(conn, board_key).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
boards
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取单块仓控板的板级字段,并扫描其下的通道级 key
|
|
||||||
async fn load_single_board(
|
|
||||||
conn: &mut redis::aio::MultiplexedConnection,
|
|
||||||
board_key: &str,
|
|
||||||
) -> Value {
|
|
||||||
// 获取板级字段
|
// 获取板级字段
|
||||||
let board_fields = vec!["status_hex"];
|
let board_fields = vec!["status_hex"];
|
||||||
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
|
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
|
||||||
.arg(board_key)
|
.arg(board_key)
|
||||||
.arg(&board_fields)
|
.arg(&board_fields)
|
||||||
.query_async(&mut *conn)
|
.query_async(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@ -556,7 +371,7 @@ async fn load_single_board(
|
|||||||
let ch_pattern = format!("{}:ch:*", board_key);
|
let ch_pattern = format!("{}:ch:*", board_key);
|
||||||
let ch_keys: Vec<String> = redis::cmd("KEYS")
|
let ch_keys: Vec<String> = redis::cmd("KEYS")
|
||||||
.arg(&ch_pattern)
|
.arg(&ch_pattern)
|
||||||
.query_async(&mut *conn)
|
.query_async(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@ -567,42 +382,18 @@ async fn load_single_board(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
channels[ch_idx] = load_channel_status(conn, ch_key).await;
|
let ch_fields = vec!["on", "full", "fault", "hex"];
|
||||||
}
|
|
||||||
|
|
||||||
board_data["channels"] = channels;
|
|
||||||
board_data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取单个通道状态(状态映射优先级:fault > full > on > idle)
|
|
||||||
async fn load_channel_status(
|
|
||||||
conn: &mut redis::aio::MultiplexedConnection,
|
|
||||||
ch_key: &str,
|
|
||||||
) -> Value {
|
|
||||||
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
|
|
||||||
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
|
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
|
||||||
.arg(ch_key)
|
.arg(ch_key)
|
||||||
.arg(&ch_fields)
|
.arg(&ch_fields)
|
||||||
.query_async(&mut *conn)
|
.query_async(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// 状态映射:fault > full > on > idle
|
// 状态映射:fault > full > on > idle
|
||||||
let fault = ch_values
|
let fault = ch_values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
|
||||||
.get(0)
|
let full = ch_values.get(1).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
|
||||||
.and_then(|v| v.as_ref())
|
let on = ch_values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
|
||||||
.map(|v| v == "1")
|
|
||||||
.unwrap_or(false);
|
|
||||||
let full = ch_values
|
|
||||||
.get(1)
|
|
||||||
.and_then(|v| v.as_ref())
|
|
||||||
.map(|v| v == "1")
|
|
||||||
.unwrap_or(false);
|
|
||||||
let on = ch_values
|
|
||||||
.get(2)
|
|
||||||
.and_then(|v| v.as_ref())
|
|
||||||
.map(|v| v == "1")
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let status = if fault {
|
let status = if fault {
|
||||||
"fault"
|
"fault"
|
||||||
@ -614,12 +405,21 @@ async fn load_channel_status(
|
|||||||
"idle"
|
"idle"
|
||||||
};
|
};
|
||||||
|
|
||||||
json!({
|
channels[ch_idx] = json!({
|
||||||
"status": status,
|
"status": status,
|
||||||
"on": on,
|
"on": on,
|
||||||
"full": full,
|
"full": full,
|
||||||
"fault": fault,
|
"fault": fault,
|
||||||
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
|
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
|
||||||
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()),
|
});
|
||||||
})
|
}
|
||||||
|
|
||||||
|
board_data["channels"] = channels;
|
||||||
|
boards[board_idx] = board_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"device": device_status,
|
||||||
|
"boards": boards,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,18 +5,17 @@
|
|||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use redis;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
use super::organizations::{
|
use super::organizations::{
|
||||||
generate_auth_str,
|
generate_auth_str,
|
||||||
CabinBoardRow, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
|
CabinetDetail, CabinBoardRow, CabinBoardWithCompartments,
|
||||||
|
CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
use super::scope_guard;
|
|
||||||
|
|
||||||
/// 基础柜子行(只包含数据库字段,用于简单查询)
|
/// 基础柜子行(只包含数据库字段,用于简单查询)
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
@ -28,11 +27,7 @@ struct CabinetBasicRow {
|
|||||||
iccid: Option<String>,
|
iccid: Option<String>,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
address: Option<String>,
|
address: Option<String>,
|
||||||
latitude: Option<String>,
|
|
||||||
longitude: Option<String>,
|
|
||||||
status: i8,
|
status: i8,
|
||||||
/// 充电工作模式:0=平台指令模式 1=自动充电工作模式
|
|
||||||
work_mode: Option<i8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CabinetBasicRow> for super::organizations::CabinetRow {
|
impl From<CabinetBasicRow> for super::organizations::CabinetRow {
|
||||||
@ -56,8 +51,6 @@ impl From<CabinetBasicRow> for super::organizations::CabinetRow {
|
|||||||
pow_fail_dc: None,
|
pow_fail_dc: None,
|
||||||
pow_fail_ac: None,
|
pow_fail_ac: None,
|
||||||
is_online: None,
|
is_online: None,
|
||||||
org_disabled: None,
|
|
||||||
project_address: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,7 +108,7 @@ pub async fn list_cabinets(
|
|||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let sql_rows = sqlx::query_as::<_, CabinetBasicRow>(
|
let sql_rows = sqlx::query_as::<_, CabinetBasicRow>(
|
||||||
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode
|
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
|
||||||
FROM cabinets WHERE project_id = ? ORDER BY id"#,
|
FROM cabinets WHERE project_id = ? ORDER BY id"#,
|
||||||
)
|
)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
@ -123,7 +116,7 @@ pub async fn list_cabinets(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let rows: Vec<super::organizations::CabinetRow> = sql_rows.into_iter().map(Into::into).collect();
|
let rows: Vec<super::organizations::CabinetRow> = sql_rows.into_iter().map(Into::into).collect();
|
||||||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
Ok(Json(json!(rows)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets — 批量添加柜子
|
/// POST /api/cabinets — 批量添加柜子
|
||||||
@ -135,7 +128,6 @@ pub async fn create_cabinet(
|
|||||||
Json(body): Json<CreateCabinetBody>,
|
Json(body): Json<CreateCabinetBody>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:create")?;
|
auth::check_permission(&user, "device:create")?;
|
||||||
scope_guard::verify_project_ownership(&state.mysql, &user, body.project_id).await?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
if body.imeis.is_empty() {
|
if body.imeis.is_empty() {
|
||||||
@ -198,7 +190,7 @@ pub async fn create_cabinet(
|
|||||||
tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei);
|
tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": { "ids": created_ids }, "message": "ok" })))
|
Ok(Json(json!({ "ids": created_ids })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
|
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
|
||||||
@ -209,7 +201,6 @@ pub async fn update_cabinet(
|
|||||||
Json(body): Json<UpdateCabinetBody>,
|
Json(body): Json<UpdateCabinetBody>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:edit")?;
|
auth::check_permission(&user, "device:edit")?;
|
||||||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@ -232,96 +223,7 @@ pub async fn update_cabinet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("更新柜子 id={}", id);
|
tracing::info!("更新柜子 id={}", id);
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
Ok(Json(json!({})))
|
||||||
}
|
|
||||||
|
|
||||||
/// 柜子状态更新请求体
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SetCabinetStatusBody {
|
|
||||||
pub status: i8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用)
|
|
||||||
///
|
|
||||||
/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。
|
|
||||||
pub async fn set_cabinet_status(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<super::AppState>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
Json(body): Json<SetCabinetStatusBody>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
auth::check_permission(&user, "device:edit")?;
|
|
||||||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
// 禁/启用操作仅总管理员可执行
|
|
||||||
if user.role_level < 2 {
|
|
||||||
return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
|
||||||
let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?")
|
|
||||||
.bind(body.status)
|
|
||||||
.bind(id)
|
|
||||||
.execute(db)
|
|
||||||
.await?
|
|
||||||
.rows_affected();
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = if body.status == 2 { "禁用" } else { "启用" };
|
|
||||||
tracing::info!("{}柜子 id={}", action, id);
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 充电工作模式更新请求体(前端契约:字符串 "0" / "1")
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct UpdateWorkModeBody {
|
|
||||||
pub work_mode: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/cabinets/:id/work-mode — 设置充电工作模式
|
|
||||||
///
|
|
||||||
/// 0=平台指令模式(默认,需平台下发指令才充电)
|
|
||||||
/// 1=自动充电工作模式(设备自主充电)
|
|
||||||
/// 权限:device:edit(编辑设备),并校验柜子存在 + 组织归属。
|
|
||||||
pub async fn update_work_mode(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<super::AppState>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
Json(body): Json<UpdateWorkModeBody>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
auth::check_permission(&user, "device:edit")?;
|
|
||||||
// 组织隔离:非总管理员只能改本组织项目的柜子
|
|
||||||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
// 白名单取值,拒绝一切其它输入(含 "2"、"true" 等)
|
|
||||||
let mode: i8 = match body.work_mode.trim() {
|
|
||||||
"0" => 0,
|
|
||||||
"1" => 1,
|
|
||||||
other => {
|
|
||||||
return Err(AppError::BadRequest(format!(
|
|
||||||
"work_mode 只能为 \"0\"(平台指令模式) 或 \"1\"(自动充电模式),收到: {}",
|
|
||||||
other
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let rows = sqlx::query("UPDATE cabinets SET work_mode = ? WHERE id = ?")
|
|
||||||
.bind(mode)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&state.mysql)
|
|
||||||
.await?
|
|
||||||
.rows_affected();
|
|
||||||
|
|
||||||
// 总管理员路径下 verify_cabinet_ownership 直接放行,这里兜底不存在的情况
|
|
||||||
if rows == 0 {
|
|
||||||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("设置充电工作模式 id={} work_mode={}", id, mode);
|
|
||||||
Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "ok" })))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
|
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
|
||||||
@ -331,7 +233,6 @@ pub async fn delete_cabinet(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:delete")?;
|
auth::check_permission(&user, "device:delete")?;
|
||||||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let mut tx = db.begin().await.map_err(AppError::Database)?;
|
let mut tx = db.begin().await.map_err(AppError::Database)?;
|
||||||
@ -363,7 +264,7 @@ pub async fn delete_cabinet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("删除柜子 id={}", id);
|
tracing::info!("删除柜子 id={}", id);
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
Ok(Json(json!({})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets/:id/bind — 绑定平台抽象ID
|
/// POST /api/cabinets/:id/bind — 绑定平台抽象ID
|
||||||
@ -373,7 +274,6 @@ pub async fn bind_cabinet(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:edit")?;
|
auth::check_permission(&user, "device:edit")?;
|
||||||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
|
|
||||||
@ -413,7 +313,7 @@ pub async fn bind_cabinet(
|
|||||||
tx.commit().await.map_err(AppError::Database)?;
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
tracing::info!("绑定柜子 id={} abstract_id={}", id, abstract_id);
|
tracing::info!("绑定柜子 id={} abstract_id={}", id, abstract_id);
|
||||||
Ok(Json(json!({ "code": 0, "data": { "abstract_id": abstract_id }, "message": "ok" })))
|
Ok(Json(json!({ "abstract_id": abstract_id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组)
|
/// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组)
|
||||||
@ -424,7 +324,6 @@ pub async fn replace_imei(
|
|||||||
Json(body): Json<ReplaceImeiBody>,
|
Json(body): Json<ReplaceImeiBody>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:edit")?;
|
auth::check_permission(&user, "device:edit")?;
|
||||||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
let imei = body.imei.trim().to_string();
|
let imei = body.imei.trim().to_string();
|
||||||
if imei.len() != 15 || !imei.chars().all(|c| c.is_ascii_digit()) {
|
if imei.len() != 15 || !imei.chars().all(|c| c.is_ascii_digit()) {
|
||||||
@ -466,7 +365,7 @@ pub async fn replace_imei(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
tracing::info!("更换IMEI id={} new_imei={}", id, imei);
|
tracing::info!("更换IMEI id={} new_imei={}", id, imei);
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
Ok(Json(json!({})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
|
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
|
||||||
@ -475,55 +374,24 @@ pub async fn regenerate_auth(
|
|||||||
State(state): State<super::AppState>,
|
State(state): State<super::AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
// 仅总管理员可重置安全码:device:auth_str:reset 不在企业管理员/操作员的权限码表中,
|
auth::check_permission(&user, "device:edit")?;
|
||||||
// 且 check_permission 对 role_level >= 2 直接放行,因此等价于「仅总管理员可用」
|
|
||||||
auth::check_permission(&user, "device:auth_str:reset")?;
|
|
||||||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let auth_str = generate_auth_str();
|
let auth_str = generate_auth_str();
|
||||||
|
|
||||||
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ?, auth_str_issued = 0 WHERE id = ?"#)
|
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ? WHERE id = ?"#)
|
||||||
.bind(&auth_str)
|
.bind(&auth_str)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
|
|
||||||
// 同步 Redis:
|
|
||||||
// 1. 用新码覆盖 auth_str:{imei} —— 旧安全码【立即失效】,设备继续用旧码 login 会收到「签名错误」,
|
|
||||||
// 从而触发设备重新调用 auth_str 申领新码,形成自愈闭环
|
|
||||||
// 2. 清除 auth_str:issued:{imei} 标记 —— 允许设备重新申领
|
|
||||||
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(db)
|
|
||||||
.await?;
|
|
||||||
if let Some(imei) = imei {
|
|
||||||
let code_key = format!("auth_str:{}", imei);
|
|
||||||
let issued_key = format!("auth_str:issued:{}", imei);
|
|
||||||
let mut conn = state.redis.clone();
|
|
||||||
|
|
||||||
if let Err(e) = redis::cmd("SET")
|
|
||||||
.arg(&code_key)
|
|
||||||
.arg(&auth_str)
|
|
||||||
.query_async::<()>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!("[regenerate_auth] SET {} 失败: {}", code_key, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _: Result<(), _> = redis::cmd("DEL")
|
|
||||||
.arg(&issued_key)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("重新生成 auth_str id={}", id);
|
tracing::info!("重新生成 auth_str id={}", id);
|
||||||
Ok(Json(json!({ "code": 0, "data": { "auth_str": auth_str }, "message": "ok" })))
|
Ok(Json(json!({ "auth_str": auth_str })))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -535,12 +403,12 @@ pub async fn get_cabinet_detail(
|
|||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
State(state): State<super::AppState>,
|
State(state): State<super::AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<CabinetDetail>, AppError> {
|
||||||
auth::check_permission(&user, "device:view")?;
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let cabinet = sqlx::query_as::<_, CabinetBasicRow>(
|
let cabinet = sqlx::query_as::<_, CabinetBasicRow>(
|
||||||
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, status, work_mode
|
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
|
||||||
FROM cabinets WHERE id = ?"#,
|
FROM cabinets WHERE id = ?"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@ -553,87 +421,15 @@ pub async fn get_cabinet_detail(
|
|||||||
check_project_access(&user, db, pid).await?;
|
check_project_access(&user, db, pid).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仓控板 + 仓体(含实时字段占位),并收集通道 Redis key 所需的映射
|
|
||||||
let (mut cabin_boards, comp_ids, comp_to_board) = load_boards_with_compartments(db, id).await?;
|
|
||||||
|
|
||||||
// 一次 pipeline 读取柜级 + 通道级实时状态;Redis 异常一律降级,不让详情接口整体失败
|
|
||||||
let realtime =
|
|
||||||
fetch_cabinet_realtime(&state.redis_url, &cabinet.imei, &comp_ids, &comp_to_board).await;
|
|
||||||
apply_realtime(&mut cabin_boards, &realtime);
|
|
||||||
|
|
||||||
// 查询项目名称
|
|
||||||
let project_name: Option<String> = if let Some(pid) = cabinet.project_id {
|
|
||||||
sqlx::query_scalar("SELECT name FROM projects WHERE id = ?")
|
|
||||||
.bind(pid)
|
|
||||||
.fetch_optional(db)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"id": cabinet.id,
|
|
||||||
"project_id": cabinet.project_id,
|
|
||||||
"project_name": project_name,
|
|
||||||
"abstract_id": cabinet.abstract_id,
|
|
||||||
"imei": cabinet.imei,
|
|
||||||
"iccid": cabinet.iccid,
|
|
||||||
"name": cabinet.name,
|
|
||||||
"status": cabinet.status,
|
|
||||||
// 充电工作模式:字符串 "0"/"1"(前端契约),无值时为 null
|
|
||||||
"work_mode": cabinet.work_mode.map(|m| m.to_string()),
|
|
||||||
"rssi": realtime.rssi,
|
|
||||||
"pow_fail_dc": realtime.pow_fail_dc,
|
|
||||||
"pow_fail_ac": realtime.pow_fail_ac,
|
|
||||||
"address": cabinet.address,
|
|
||||||
"latitude": cabinet.latitude,
|
|
||||||
"longitude": cabinet.longitude,
|
|
||||||
"cabin_boards": cabin_boards,
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 设备详情 — Redis 实时状态(柜级 + 通道级)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// 详情页实时状态快照;Redis 不可用时整体为默认空值
|
|
||||||
#[derive(Default)]
|
|
||||||
struct CabinetRealtime {
|
|
||||||
rssi: Option<i32>,
|
|
||||||
pow_fail_dc: Option<bool>,
|
|
||||||
pow_fail_ac: Option<bool>,
|
|
||||||
/// 仓体 id -> (SOC, 电池ID)
|
|
||||||
soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>,
|
|
||||||
/// 仓体 id -> (温度, batt_status, pow_status)
|
|
||||||
ch_map: std::collections::HashMap<i64, (Option<i64>, Option<String>, Option<String>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询仓控板与仓体,构建带实时字段占位的 JSON
|
|
||||||
///
|
|
||||||
/// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`,
|
|
||||||
/// 后两者供 Redis 批量查询通道级状态使用(板号/通道号为十进制不补零)。
|
|
||||||
async fn load_boards_with_compartments(
|
|
||||||
db: &MySqlPool,
|
|
||||||
cabinet_id: i64,
|
|
||||||
) -> Result<(Vec<Value>, Vec<i64>, Vec<(i64, (i8, i8))>), AppError> {
|
|
||||||
let boards: Vec<CabinBoardRow> = sqlx::query_as(
|
let boards: Vec<CabinBoardRow> = sqlx::query_as(
|
||||||
r#"SELECT id, cabinet_id, board_id, status
|
r#"SELECT id, cabinet_id, board_id, status
|
||||||
FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"#,
|
FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"#,
|
||||||
)
|
)
|
||||||
.bind(cabinet_id)
|
.bind(id)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut cabin_boards: Vec<Value> = Vec::new();
|
let mut cabin_boards = Vec::new();
|
||||||
let mut comp_ids: Vec<i64> = Vec::new();
|
|
||||||
let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new();
|
|
||||||
|
|
||||||
for board in boards {
|
for board in boards {
|
||||||
let compartments: Vec<CompartmentRow> = sqlx::query_as(
|
let compartments: Vec<CompartmentRow> = sqlx::query_as(
|
||||||
r#"SELECT id, cabin_board_id, channel_id, status
|
r#"SELECT id, cabin_board_id, channel_id, status
|
||||||
@ -643,162 +439,23 @@ async fn load_boards_with_compartments(
|
|||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for c in &compartments {
|
cabin_boards.push(CabinBoardWithCompartments {
|
||||||
comp_ids.push(c.id);
|
id: board.id,
|
||||||
comp_to_board.push((c.id, (board.board_id, c.channel_id)));
|
board_id: board.board_id,
|
||||||
|
status: board.status,
|
||||||
|
compartments,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// batt_status / pow_status 为 2 位 hex 字符串,Redis 缺失时保持 null
|
Ok(Json(CabinetDetail {
|
||||||
let comp_values: Vec<Value> = compartments
|
id: cabinet.id,
|
||||||
.into_iter()
|
project_id: cabinet.project_id,
|
||||||
.map(|c| {
|
abstract_id: cabinet.abstract_id,
|
||||||
json!({
|
imei: cabinet.imei,
|
||||||
"id": c.id,
|
name: cabinet.name,
|
||||||
"channel_id": c.channel_id,
|
status: cabinet.status,
|
||||||
"status": c.status,
|
cabin_boards,
|
||||||
"batt_status": Value::Null,
|
}))
|
||||||
"pow_status": Value::Null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
cabin_boards.push(json!({
|
|
||||||
"id": board.id,
|
|
||||||
"board_id": board.board_id,
|
|
||||||
"status": board.status,
|
|
||||||
"compartments": comp_values,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((cabin_boards, comp_ids, comp_to_board))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
|
|
||||||
///
|
|
||||||
/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。
|
|
||||||
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
|
||||||
async fn fetch_cabinet_realtime(
|
|
||||||
redis_url: &str,
|
|
||||||
imei: &str,
|
|
||||||
comp_ids: &[i64],
|
|
||||||
comp_to_board: &[(i64, (i8, i8))],
|
|
||||||
) -> CabinetRealtime {
|
|
||||||
let client = match redis::Client::open(redis_url) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("[cabinet_detail] Redis 客户端创建失败,实时状态降级: {}", e);
|
|
||||||
return CabinetRealtime::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
|
||||||
let mut conn = match client.get_multiplexed_async_connection().await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("[cabinet_detail] Redis 连接失败,实时状态降级: {}", e);
|
|
||||||
return CabinetRealtime::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = format!("device:{}", imei);
|
|
||||||
let mut pipe = redis::pipe();
|
|
||||||
pipe.cmd("HGET").arg(&key).arg("rssi")
|
|
||||||
.cmd("HGET").arg(&key).arg("pow_fail_dc")
|
|
||||||
.cmd("HGET").arg(&key).arg("pow_fail_ac");
|
|
||||||
for cid in comp_ids {
|
|
||||||
pipe.cmd("GET").arg(format!("compartment:status:{}", cid));
|
|
||||||
}
|
|
||||||
// 通道级:板号/通道号为十进制不补零,与设备上报写入的 key 保持一致
|
|
||||||
for (_, (board_id, channel_id)) in comp_to_board {
|
|
||||||
let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id);
|
|
||||||
pipe.cmd("HGET").arg(&rkey).arg("temp")
|
|
||||||
.cmd("HGET").arg(&rkey).arg("batt_status")
|
|
||||||
.cmd("HGET").arg(&rkey).arg("pow_status");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用类型标注而非 turbofish:query_async 的泛型首参是连接类型
|
|
||||||
let queried: Result<Vec<Option<String>>, redis::RedisError> =
|
|
||||||
pipe.query_async(&mut conn).await;
|
|
||||||
match queried {
|
|
||||||
Ok(vals) => parse_cabinet_realtime(&vals, comp_ids, comp_to_board),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("[cabinet_detail] Redis pipeline 读取失败,实时状态降级 imei={} err={}", imei, e);
|
|
||||||
CabinetRealtime::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 pipeline 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status)
|
|
||||||
fn parse_cabinet_realtime(
|
|
||||||
vals: &[Option<String>],
|
|
||||||
comp_ids: &[i64],
|
|
||||||
comp_to_board: &[(i64, (i8, i8))],
|
|
||||||
) -> CabinetRealtime {
|
|
||||||
/// 取出第 i 项的非空字符串
|
|
||||||
fn pick(vals: &[Option<String>], i: usize) -> Option<String> {
|
|
||||||
vals.get(i).and_then(|v| v.as_ref()).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rt = CabinetRealtime {
|
|
||||||
rssi: pick(vals, 0).and_then(|v| v.parse().ok()),
|
|
||||||
pow_fail_dc: pick(vals, 1).map(|v| v == "1"),
|
|
||||||
pow_fail_ac: pick(vals, 2).map(|v| v == "1"),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
for (i, cid) in comp_ids.iter().enumerate() {
|
|
||||||
if let Some(data) = pick(vals, 3 + i) {
|
|
||||||
if let Ok(v) = serde_json::from_str::<Value>(&data) {
|
|
||||||
let soc = v.get("soc").and_then(|s| s.as_i64());
|
|
||||||
let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string);
|
|
||||||
rt.soc_map.insert(*cid, (soc, bid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通道级字段紧随 SOC 段之后,每通道 3 项
|
|
||||||
let base = 3 + comp_ids.len();
|
|
||||||
for (i, (cid, _)) in comp_to_board.iter().enumerate() {
|
|
||||||
let off = base + i * 3;
|
|
||||||
let temp = pick(vals, off).and_then(|v| v.parse::<i64>().ok());
|
|
||||||
let batt_status = pick(vals, off + 1);
|
|
||||||
let pow_status = pick(vals, off + 2);
|
|
||||||
rt.ch_map.insert(*cid, (temp, batt_status, pow_status));
|
|
||||||
}
|
|
||||||
|
|
||||||
rt
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串
|
|
||||||
fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) {
|
|
||||||
for board in cabin_boards.iter_mut() {
|
|
||||||
let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for comp in comps.iter_mut() {
|
|
||||||
let Some(cid) = comp.get("id").and_then(|i| i.as_i64()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some((soc, bid)) = rt.soc_map.get(&cid) {
|
|
||||||
if let Some(s) = soc {
|
|
||||||
comp["soc"] = json!(s);
|
|
||||||
}
|
|
||||||
if let Some(b) = bid {
|
|
||||||
comp["battery_id"] = json!(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some((temp, batt_status, pow_status)) = rt.ch_map.get(&cid) {
|
|
||||||
if let Some(t) = temp {
|
|
||||||
comp["temp"] = json!(t);
|
|
||||||
}
|
|
||||||
if let Some(bs) = batt_status {
|
|
||||||
comp["batt_status"] = json!(bs);
|
|
||||||
}
|
|
||||||
if let Some(ps) = pow_status {
|
|
||||||
comp["pow_status"] = json!(ps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -910,4 +567,3 @@ pub async fn list_compartment_options(
|
|||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,230 +0,0 @@
|
|||||||
//! 充电订单 API — 列表查询 + 详情
|
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use axum::Json;
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
|
||||||
|
|
||||||
/// 默认页码(预留 serde default 用)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn default_page() -> i64 { 1 }
|
|
||||||
/// 默认每页大小(预留 serde default 用)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn default_page_size() -> i64 { 20 }
|
|
||||||
|
|
||||||
fn status_text(status: i8) -> &'static str {
|
|
||||||
match status {
|
|
||||||
0 => "充电中",
|
|
||||||
1 => "已完成",
|
|
||||||
2 => "异常",
|
|
||||||
_ => "未知",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reason_text(reason: Option<&str>) -> &'static str {
|
|
||||||
match reason {
|
|
||||||
Some("normal") => "正常充满",
|
|
||||||
Some("user_stop") => "手动停止",
|
|
||||||
Some("forced") => "强制结束",
|
|
||||||
Some("timeout") => "超时结束",
|
|
||||||
_ => "未知",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ChargeOrderQuery {
|
|
||||||
pub cabinet_id: Option<i64>,
|
|
||||||
pub compartment_id: Option<i64>,
|
|
||||||
pub battery_id: Option<String>,
|
|
||||||
pub status: Option<i8>,
|
|
||||||
pub start_time: Option<String>,
|
|
||||||
pub end_time: Option<String>,
|
|
||||||
pub page: Option<i64>,
|
|
||||||
pub page_size: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ChargeOrderItem {
|
|
||||||
pub id: i64,
|
|
||||||
pub cabinet_id: i64,
|
|
||||||
pub cabinet_name: Option<String>,
|
|
||||||
pub compartment_id: i64,
|
|
||||||
pub battery_id: String,
|
|
||||||
pub start_time: Option<String>,
|
|
||||||
pub end_time: Option<String>,
|
|
||||||
pub start_soc: i64,
|
|
||||||
pub end_soc: Option<i64>,
|
|
||||||
pub energy: i64,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub reason_text: String,
|
|
||||||
pub status: i8,
|
|
||||||
pub status_text: String,
|
|
||||||
pub created_at: Option<NaiveDateTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct ChargeOrderRow {
|
|
||||||
id: i64,
|
|
||||||
cabinet_id: i64,
|
|
||||||
cabinet_name: Option<String>,
|
|
||||||
compartment_id: i64,
|
|
||||||
battery_id: String,
|
|
||||||
start_time: Option<NaiveDateTime>,
|
|
||||||
end_time: Option<NaiveDateTime>,
|
|
||||||
start_soc: i64,
|
|
||||||
end_soc: Option<i64>,
|
|
||||||
energy: i64,
|
|
||||||
reason: Option<String>,
|
|
||||||
status: i8,
|
|
||||||
created_at: Option<NaiveDateTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/charge-orders — 充电订单列表
|
|
||||||
pub async fn list_charge_orders(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<crate::commands::AppState>,
|
|
||||||
Query(params): Query<ChargeOrderQuery>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
auth::check_permission(&user, "charge_record:view")?;
|
|
||||||
|
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
|
||||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
|
||||||
let offset = (page - 1) * page_size;
|
|
||||||
|
|
||||||
let mut query =
|
|
||||||
"SELECT co.*, cab.name as cabinet_name FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1"
|
|
||||||
.to_string();
|
|
||||||
let mut count_query = "SELECT COUNT(*) FROM charge_orders WHERE 1=1".to_string();
|
|
||||||
|
|
||||||
// 按固定顺序追加 `?` 占位符,绑定顺序必须与下方 build_count_query / build_list_query 一致
|
|
||||||
if params.cabinet_id.is_some() {
|
|
||||||
query.push_str(" AND cabinet_id = ?");
|
|
||||||
count_query.push_str(" AND cabinet_id = ?");
|
|
||||||
}
|
|
||||||
if params.compartment_id.is_some() {
|
|
||||||
query.push_str(" AND compartment_id = ?");
|
|
||||||
count_query.push_str(" AND compartment_id = ?");
|
|
||||||
}
|
|
||||||
if params.battery_id.is_some() {
|
|
||||||
query.push_str(" AND battery_id LIKE ?");
|
|
||||||
count_query.push_str(" AND battery_id LIKE ?");
|
|
||||||
}
|
|
||||||
if params.status.is_some() {
|
|
||||||
query.push_str(" AND status = ?");
|
|
||||||
count_query.push_str(" AND status = ?");
|
|
||||||
}
|
|
||||||
if params.start_time.is_some() {
|
|
||||||
query.push_str(" AND start_time >= ?");
|
|
||||||
count_query.push_str(" AND start_time >= ?");
|
|
||||||
}
|
|
||||||
if params.end_time.is_some() {
|
|
||||||
query.push_str(" AND end_time <= ?");
|
|
||||||
count_query.push_str(" AND end_time <= ?");
|
|
||||||
}
|
|
||||||
|
|
||||||
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
|
|
||||||
|
|
||||||
let total: i64 = build_count_query(count_query, ¶ms, &state.mysql).await?;
|
|
||||||
let rows: Vec<ChargeOrderRow> = build_list_query(query, params, page_size, offset, &state.mysql).await?;
|
|
||||||
|
|
||||||
let items: Vec<ChargeOrderItem> = rows.into_iter().map(|r| ChargeOrderItem {
|
|
||||||
id: r.id,
|
|
||||||
cabinet_id: r.cabinet_id,
|
|
||||||
cabinet_name: r.cabinet_name,
|
|
||||||
compartment_id: r.compartment_id,
|
|
||||||
battery_id: r.battery_id,
|
|
||||||
start_time: r.start_time.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()),
|
|
||||||
end_time: r.end_time.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()),
|
|
||||||
start_soc: r.start_soc,
|
|
||||||
end_soc: r.end_soc,
|
|
||||||
energy: r.energy,
|
|
||||||
reason: r.reason.clone(),
|
|
||||||
reason_text: reason_text(r.reason.as_deref()).to_string(),
|
|
||||||
status: r.status,
|
|
||||||
status_text: status_text(r.status).to_string(),
|
|
||||||
created_at: r.created_at,
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": items,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行 COUNT 查询并返回总数。
|
|
||||||
///
|
|
||||||
/// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致:
|
|
||||||
/// cabinet_id → compartment_id → battery_id → status → start_time → end_time
|
|
||||||
async fn build_count_query(
|
|
||||||
query: String,
|
|
||||||
params: &ChargeOrderQuery,
|
|
||||||
mysql: &sqlx::MySqlPool,
|
|
||||||
) -> Result<i64, sqlx::Error> {
|
|
||||||
let mut q = sqlx::query_scalar::<_, i64>(&query);
|
|
||||||
if let Some(cid) = params.cabinet_id {
|
|
||||||
q = q.bind(cid);
|
|
||||||
}
|
|
||||||
if let Some(cid) = params.compartment_id {
|
|
||||||
q = q.bind(cid);
|
|
||||||
}
|
|
||||||
if let Some(ref bid) = params.battery_id {
|
|
||||||
// LIKE 值前后加 %,在 bind 值中拼接,不拼入 SQL 文本
|
|
||||||
q = q.bind(format!("%{}%", bid));
|
|
||||||
}
|
|
||||||
if let Some(s) = params.status {
|
|
||||||
q = q.bind(s);
|
|
||||||
}
|
|
||||||
if let Some(ref st) = params.start_time {
|
|
||||||
q = q.bind(st);
|
|
||||||
}
|
|
||||||
if let Some(ref et) = params.end_time {
|
|
||||||
q = q.bind(et);
|
|
||||||
}
|
|
||||||
q.fetch_one(mysql).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行列表查询并返回订单行。
|
|
||||||
///
|
|
||||||
/// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致:
|
|
||||||
/// cabinet_id → compartment_id → battery_id → status → start_time → end_time → LIMIT → OFFSET
|
|
||||||
async fn build_list_query(
|
|
||||||
query: String,
|
|
||||||
params: ChargeOrderQuery,
|
|
||||||
page_size: i64,
|
|
||||||
offset: i64,
|
|
||||||
mysql: &sqlx::MySqlPool,
|
|
||||||
) -> Result<Vec<ChargeOrderRow>, sqlx::Error> {
|
|
||||||
let mut q = sqlx::query_as::<_, ChargeOrderRow>(&query);
|
|
||||||
if let Some(cid) = params.cabinet_id {
|
|
||||||
q = q.bind(cid);
|
|
||||||
}
|
|
||||||
if let Some(cid) = params.compartment_id {
|
|
||||||
q = q.bind(cid);
|
|
||||||
}
|
|
||||||
if let Some(ref bid) = params.battery_id {
|
|
||||||
// LIKE 值前后加 %,在 bind 值中拼接,不拼入 SQL 文本
|
|
||||||
q = q.bind(format!("%{}%", bid));
|
|
||||||
}
|
|
||||||
if let Some(s) = params.status {
|
|
||||||
q = q.bind(s);
|
|
||||||
}
|
|
||||||
if let Some(ref st) = params.start_time {
|
|
||||||
q = q.bind(st);
|
|
||||||
}
|
|
||||||
if let Some(ref et) = params.end_time {
|
|
||||||
q = q.bind(et);
|
|
||||||
}
|
|
||||||
// 分页参数始终存在,放最后
|
|
||||||
q = q.bind(page_size).bind(offset);
|
|
||||||
q.fetch_all(mysql).await
|
|
||||||
}
|
|
||||||
@ -43,8 +43,8 @@ pub struct ChargeRecordItem {
|
|||||||
pub channel_id: i8,
|
pub channel_id: i8,
|
||||||
pub start_time: Option<String>,
|
pub start_time: Option<String>,
|
||||||
pub end_time: Option<String>,
|
pub end_time: Option<String>,
|
||||||
pub energy: Option<i64>,
|
pub energy: Option<f64>,
|
||||||
pub max_temperature: Option<i64>,
|
pub max_temperature: Option<f64>,
|
||||||
pub status: i8,
|
pub status: i8,
|
||||||
pub status_text: String,
|
pub status_text: String,
|
||||||
}
|
}
|
||||||
@ -62,7 +62,7 @@ fn status_text(status: i8) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 数据库行映射
|
/// 数据库行映射 — DECIMAL 列通过 CAST 转为 f64
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
struct ChargeRecordRow {
|
struct ChargeRecordRow {
|
||||||
id: i64,
|
id: i64,
|
||||||
@ -74,8 +74,8 @@ struct ChargeRecordRow {
|
|||||||
channel_id: Option<i8>,
|
channel_id: Option<i8>,
|
||||||
start_time: Option<NaiveDateTime>,
|
start_time: Option<NaiveDateTime>,
|
||||||
end_time: Option<NaiveDateTime>,
|
end_time: Option<NaiveDateTime>,
|
||||||
energy: Option<i64>,
|
energy: Option<f64>,
|
||||||
max_temperature: Option<i64>,
|
max_temperature: Option<f64>,
|
||||||
status: i8,
|
status: i8,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,8 +137,8 @@ async fn fetch_charge_rows(
|
|||||||
"SELECT cr.id, cr.cabinet_id, c.name AS cabinet_name, \
|
"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.compartment_id, cb.id AS cabin_board_id, cb.board_id, comp.channel_id, \
|
||||||
cr.start_time, cr.end_time, \
|
cr.start_time, cr.end_time, \
|
||||||
cr.energy, \
|
CAST(cr.energy AS DOUBLE) AS energy, \
|
||||||
cr.max_temperature, \
|
CAST(cr.max_temperature AS DOUBLE) AS max_temperature, \
|
||||||
cr.status \
|
cr.status \
|
||||||
FROM charge_records cr \
|
FROM charge_records cr \
|
||||||
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
|
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
|
||||||
@ -155,61 +155,6 @@ async fn fetch_charge_rows(
|
|||||||
query.fetch_all(pool).await
|
query.fetch_all(pool).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 追加组织数据隔离到 WHERE 子句与绑定值
|
|
||||||
fn append_org_scope(user: &CurrentUser, where_clause: &mut String, bind_values: &mut Vec<String>) {
|
|
||||||
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 链路需与数据查询保持一致)
|
|
||||||
async fn count_charge_records(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
bind_values: &[String],
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
Ok(count_query.fetch_one(pool).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将充电记录行映射为响应项列表
|
|
||||||
fn charge_rows_to_items(rows: Vec<ChargeRecordRow>) -> 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/charge-records — 充电记录分页列表
|
/// GET /api/charge-records — 充电记录分页列表
|
||||||
pub async fn list_charge_records(
|
pub async fn list_charge_records(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -228,10 +173,30 @@ pub async fn list_charge_records(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 追加组织数据隔离
|
// 追加组织数据隔离
|
||||||
append_org_scope(&user, &mut where_clause, &mut bind_values);
|
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 链路)
|
// 查询总数(需要与数据查询保持一致的 JOIN 链路)
|
||||||
let total = count_charge_records(&state.mysql, &where_clause, &bind_values).await?;
|
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(
|
let rows = fetch_charge_rows(
|
||||||
@ -243,7 +208,24 @@ pub async fn list_charge_records(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let list = charge_rows_to_items(rows);
|
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!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@ -267,13 +249,11 @@ pub async fn export_charge_records(
|
|||||||
let user_id = user.user_id;
|
let user_id = user.user_id;
|
||||||
let file_name = generate_export_filename(¶ms);
|
let file_name = generate_export_filename(¶ms);
|
||||||
|
|
||||||
// 序列化查询参数(含组织过滤信息)
|
// 序列化查询参数
|
||||||
let query_json = serde_json::to_string(&json!({
|
let query_json = serde_json::to_string(&json!({
|
||||||
"cabinet_id": params.cabinet_id,
|
"cabinet_id": params.cabinet_id,
|
||||||
"start_time": params.start_time,
|
"start_time": params.start_time,
|
||||||
"end_time": params.end_time,
|
"end_time": params.end_time,
|
||||||
"org_id": user.organization_id,
|
|
||||||
"role_level": user.role_level,
|
|
||||||
}))
|
}))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@ -325,28 +305,30 @@ fn generate_export_filename(params: &ChargeRecordExportQuery) -> String {
|
|||||||
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
|
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取导出任务的文件名与查询参数
|
/// 后台异步生成 Excel 文件
|
||||||
///
|
async fn generate_charge_records_excel(
|
||||||
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
|
pool: sqlx::MySqlPool,
|
||||||
async fn load_export_task(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
task_id: i64,
|
task_id: i64,
|
||||||
) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
|
) -> 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(
|
let row: Option<(String, String)> = sqlx::query_as(
|
||||||
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
|
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(task_id)
|
.bind(task_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (file_name, query_json) = row.ok_or("任务不存在")?;
|
let (file_name, query_json) = row.ok_or("任务不存在")?;
|
||||||
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
|
||||||
Ok((file_name, params))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
|
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
||||||
fn build_export_where(params: &Value) -> (String, Vec<String>) {
|
|
||||||
let (mut where_clause, mut bind_values) = build_where_clause(
|
let (where_clause, bind_values) = build_where_clause(
|
||||||
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -354,33 +336,22 @@ fn build_export_where(params: &Value) -> (String, Vec<String>) {
|
|||||||
params.get("end_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?;
|
||||||
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
||||||
let org_id = params.get("org_id").and_then(|v| v.as_i64());
|
|
||||||
if role_level < 2 {
|
|
||||||
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
|
|
||||||
if let Some(oid) = org_id {
|
|
||||||
where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
|
|
||||||
bind_values.push(oid.to_string());
|
|
||||||
} else {
|
|
||||||
where_clause.push_str(&format!("{}1=0", prefix));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(where_clause, bind_values)
|
// 更新进度 50%
|
||||||
}
|
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
|
||||||
|
.bind(task_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
/// 构建导出工作簿(表头 + 数据行)
|
// 生成 Excel
|
||||||
fn build_charge_workbook(
|
|
||||||
rows: &[ChargeRecordRow],
|
|
||||||
) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let mut workbook = rust_xlsxwriter::Workbook::new();
|
let mut workbook = rust_xlsxwriter::Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
sheet.set_name("充电记录")?;
|
sheet.set_name("充电记录")?;
|
||||||
|
|
||||||
let headers = [
|
let headers = [
|
||||||
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
|
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
|
||||||
"充电量(Wh)", "最高温度(K×100)", "状态",
|
"充电量(kWh)", "最高温度(°C)", "状态",
|
||||||
];
|
];
|
||||||
for (col, header) in headers.iter().enumerate() {
|
for (col, header) in headers.iter().enumerate() {
|
||||||
sheet.write_string(0, col as u16, *header)?;
|
sheet.write_string(0, col as u16, *header)?;
|
||||||
@ -400,59 +371,11 @@ fn build_charge_workbook(
|
|||||||
row, 5,
|
row, 5,
|
||||||
record.end_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
|
record.end_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
|
||||||
)?;
|
)?;
|
||||||
sheet.write_number(row, 6, record.energy.unwrap_or(0) as f64)?;
|
sheet.write_number(row, 6, record.energy.unwrap_or(0.0))?;
|
||||||
sheet.write_number(row, 7, record.max_temperature.unwrap_or(0) as f64)?;
|
sheet.write_number(row, 7, record.max_temperature.unwrap_or(0.0))?;
|
||||||
sheet.write_string(row, 8, status_text(record.status))?;
|
sheet.write_string(row, 8, status_text(record.status))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(workbook)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将导出任务标记为完成并记录文件路径
|
|
||||||
async fn mark_task_completed(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
task_id: i64,
|
|
||||||
file_path: &str,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
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?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 后台异步生成 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 (file_name, params) = load_export_task(&pool, task_id).await?;
|
|
||||||
|
|
||||||
// 组织过滤(非总管理员)+ 查询数据
|
|
||||||
let (where_clause, bind_values) = build_export_where(¶ms);
|
|
||||||
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 = build_charge_workbook(&rows)?;
|
|
||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
let file_dir = "./exports";
|
let file_dir = "./exports";
|
||||||
tokio::fs::create_dir_all(file_dir).await?;
|
tokio::fs::create_dir_all(file_dir).await?;
|
||||||
@ -460,7 +383,14 @@ async fn generate_charge_records_excel(
|
|||||||
workbook.save(&file_path)?;
|
workbook.save(&file_path)?;
|
||||||
|
|
||||||
// 更新任务状态为完成
|
// 更新任务状态为完成
|
||||||
mark_task_completed(&pool, task_id, &file_path).await?;
|
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);
|
tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -59,11 +59,16 @@ struct DeviceLogRow {
|
|||||||
created_at: Option<NaiveDateTime>,
|
created_at: Option<NaiveDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建 WHERE 条件与绑定值(含组织数据隔离)
|
/// GET /api/device-logs — 设备日志分页列表
|
||||||
fn build_device_log_filters(
|
pub async fn list_device_logs(
|
||||||
user: &CurrentUser,
|
user: CurrentUser,
|
||||||
params: &DeviceLogQuery,
|
State(state): State<super::AppState>,
|
||||||
) -> (String, Vec<String>) {
|
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 conditions = Vec::new();
|
||||||
let mut bind_values: Vec<String> = Vec::new();
|
let mut bind_values: Vec<String> = Vec::new();
|
||||||
|
|
||||||
@ -85,7 +90,7 @@ fn build_device_log_filters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 追加组织数据隔离
|
// 追加组织数据隔离
|
||||||
let (org_cond, org_binds) = auth::org_condition(user, "dl");
|
let (org_cond, org_binds) = auth::org_condition(&user, "dl");
|
||||||
if !org_cond.is_empty() {
|
if !org_cond.is_empty() {
|
||||||
conditions.push(org_cond.trim_start_matches(" AND "));
|
conditions.push(org_cond.trim_start_matches(" AND "));
|
||||||
bind_values.extend(org_binds);
|
bind_values.extend(org_binds);
|
||||||
@ -96,34 +101,19 @@ fn build_device_log_filters(
|
|||||||
} else {
|
} else {
|
||||||
format!("WHERE {}", conditions.join(" AND "))
|
format!("WHERE {}", conditions.join(" AND "))
|
||||||
};
|
};
|
||||||
(where_clause, bind_values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 统计符合条件的设备日志总数
|
// 查询总数
|
||||||
async fn count_device_logs(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
bind_values: &[String],
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM device_logs dl {}",
|
"SELECT COUNT(*) FROM device_logs dl {}",
|
||||||
where_clause
|
where_clause
|
||||||
);
|
);
|
||||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
for val in bind_values {
|
for val in &bind_values {
|
||||||
count_query = count_query.bind(val);
|
count_query = count_query.bind(val);
|
||||||
}
|
}
|
||||||
Ok(count_query.fetch_one(pool).await?)
|
let total = count_query.fetch_one(&state.mysql).await?;
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询设备日志行(关联柜名,分页)
|
// 查询列表
|
||||||
async fn fetch_device_log_rows(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
bind_values: &[String],
|
|
||||||
page_size: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<DeviceLogRow>, AppError> {
|
|
||||||
let list_sql = format!(
|
let list_sql = format!(
|
||||||
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
|
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
|
||||||
dl.log_type, dl.content, dl.created_at \
|
dl.log_type, dl.content, dl.created_at \
|
||||||
@ -134,17 +124,15 @@ async fn fetch_device_log_rows(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
|
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
|
||||||
for val in bind_values {
|
for val in &bind_values {
|
||||||
list_query = list_query.bind(val);
|
list_query = list_query.bind(val);
|
||||||
}
|
}
|
||||||
list_query = list_query.bind(page_size).bind(offset);
|
list_query = list_query.bind(params.page_size).bind(offset);
|
||||||
|
|
||||||
Ok(list_query.fetch_all(pool).await?)
|
let rows = list_query.fetch_all(&state.mysql).await?;
|
||||||
}
|
|
||||||
|
|
||||||
/// 将设备日志行映射为响应项
|
let list: Vec<DeviceLogItem> = rows
|
||||||
fn device_logs_to_items(rows: Vec<DeviceLogRow>) -> Vec<DeviceLogItem> {
|
.into_iter()
|
||||||
rows.into_iter()
|
|
||||||
.map(|r| DeviceLogItem {
|
.map(|r| DeviceLogItem {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
cabinet_id: r.cabinet_id,
|
cabinet_id: r.cabinet_id,
|
||||||
@ -154,35 +142,7 @@ fn device_logs_to_items(rows: Vec<DeviceLogRow>) -> Vec<DeviceLogItem> {
|
|||||||
content: r.content,
|
content: r.content,
|
||||||
created_at: r.created_at.map(|t| t.to_string()),
|
created_at: r.created_at.map(|t| t.to_string()),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 (where_clause, bind_values) = build_device_log_filters(&user, ¶ms);
|
|
||||||
|
|
||||||
// 查询总数
|
|
||||||
let total = count_device_logs(&state.mysql, &where_clause, &bind_values).await?;
|
|
||||||
|
|
||||||
// 查询列表
|
|
||||||
let rows = fetch_device_log_rows(
|
|
||||||
&state.mysql,
|
|
||||||
&where_clause,
|
|
||||||
&bind_values,
|
|
||||||
params.page_size,
|
|
||||||
offset,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let list = device_logs_to_items(rows);
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
//! 能耗管理 API — 总览、排行、趋势、导出
|
//! 能耗管理 API — 总览、排行、趋势、导出
|
||||||
//!
|
//!
|
||||||
//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。
|
//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。
|
||||||
//! 电费估算按 ¥0.80/kWh 计算(可配置),energy 单位为 Wh。
|
//! 电费估算按 ¥0.80/kWh 计算(可配置)。
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
@ -11,8 +11,8 @@ use serde_json::{json, Value};
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
|
|
||||||
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
|
/// 电费单价(元/kWh)
|
||||||
const COST_PER_WH_CENT: i64 = 80;
|
const COST_PER_KWH: f64 = 0.80;
|
||||||
|
|
||||||
/// 趋势查询参数
|
/// 趋势查询参数
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -40,8 +40,8 @@ struct CabinetRankRow {
|
|||||||
id: i64,
|
id: i64,
|
||||||
abstract_id: String,
|
abstract_id: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
today_energy: Option<i64>,
|
today_energy: Option<f64>,
|
||||||
month_energy: Option<i64>,
|
month_energy: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 项目统计行
|
/// 项目统计行
|
||||||
@ -50,15 +50,15 @@ struct ProjectStatRow {
|
|||||||
id: i64,
|
id: i64,
|
||||||
name: String,
|
name: String,
|
||||||
cabinet_count: i64,
|
cabinet_count: i64,
|
||||||
today_energy: Option<i64>,
|
today_energy: Option<f64>,
|
||||||
month_energy: Option<i64>,
|
month_energy: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 趋势数据行
|
/// 趋势数据行
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
struct TrendRow {
|
struct TrendRow {
|
||||||
period: String, // 日期或周/月标识
|
period: String, // 日期或周/月标识
|
||||||
energy: Option<i64>,
|
energy: Option<f64>,
|
||||||
charge_count: Option<i64>,
|
charge_count: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ struct EnergyDetailRow {
|
|||||||
cabinet_id: i64,
|
cabinet_id: i64,
|
||||||
cabinet_name: Option<String>,
|
cabinet_name: Option<String>,
|
||||||
project_name: Option<String>,
|
project_name: Option<String>,
|
||||||
total_energy: Option<i64>,
|
total_energy: Option<f64>,
|
||||||
charge_count: Option<i64>,
|
charge_count: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,22 +100,22 @@ pub async fn get_summary(
|
|||||||
|
|
||||||
// 今日总能耗
|
// 今日总能耗
|
||||||
let today_sql = format!(
|
let today_sql = format!(
|
||||||
"SELECT CAST(COALESCE(SUM(total_energy), 0) AS SIGNED) FROM energy_stats es WHERE stat_date = CURDATE(){}",
|
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es WHERE stat_date = CURDATE(){}",
|
||||||
org_filter
|
org_filter
|
||||||
);
|
);
|
||||||
let mut today_q = sqlx::query_scalar::<_, i64>(&today_sql);
|
let mut today_q = sqlx::query_scalar::<_, f64>(&today_sql);
|
||||||
for v in &org_binds { today_q = today_q.bind(v); }
|
for v in &org_binds { today_q = today_q.bind(v); }
|
||||||
let today_energy: i64 = today_q.fetch_one(pool).await?;
|
let today_energy: f64 = today_q.fetch_one(pool).await?;
|
||||||
|
|
||||||
// 本月总能耗
|
// 本月总能耗
|
||||||
let month_sql = format!(
|
let month_sql = format!(
|
||||||
"SELECT CAST(COALESCE(SUM(total_energy), 0) AS SIGNED) FROM energy_stats es \
|
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es \
|
||||||
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}",
|
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}",
|
||||||
org_filter
|
org_filter
|
||||||
);
|
);
|
||||||
let mut month_q = sqlx::query_scalar::<_, i64>(&month_sql);
|
let mut month_q = sqlx::query_scalar::<_, f64>(&month_sql);
|
||||||
for v in &org_binds { month_q = month_q.bind(v); }
|
for v in &org_binds { month_q = month_q.bind(v); }
|
||||||
let month_energy: i64 = month_q.fetch_one(pool).await?;
|
let month_energy: f64 = month_q.fetch_one(pool).await?;
|
||||||
|
|
||||||
// 柜子总数(含组织过滤)
|
// 柜子总数(含组织过滤)
|
||||||
let cabinet_count: i64 = if user.role_level >= 2 {
|
let cabinet_count: i64 = if user.role_level >= 2 {
|
||||||
@ -145,12 +145,15 @@ pub async fn get_summary(
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let today = today_energy;
|
||||||
|
let month = month_energy;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": {
|
"data": {
|
||||||
"today_energy": today_energy,
|
"today_energy": (today * 100.0).round() / 100.0,
|
||||||
"month_energy": month_energy,
|
"month_energy": (month * 100.0).round() / 100.0,
|
||||||
"estimated_cost": (month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0,
|
"estimated_cost": ((month * COST_PER_KWH) * 100.0).round() / 100.0,
|
||||||
"cabinet_count": cabinet_count,
|
"cabinet_count": cabinet_count,
|
||||||
"online_count": online_count,
|
"online_count": online_count,
|
||||||
},
|
},
|
||||||
@ -181,17 +184,14 @@ pub async fn get_cabinet_ranking(
|
|||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT c.id, c.abstract_id, c.name, \
|
"SELECT c.id, c.abstract_id, c.name, \
|
||||||
CAST(COALESCE(es_today.total_energy, 0) AS SIGNED) AS today_energy, \
|
COALESCE(es_today.total_energy, 0) AS today_energy, \
|
||||||
CAST(COALESCE(es_month.month_energy, 0) AS SIGNED) AS month_energy \
|
COALESCE(es_month.total_energy, 0) AS month_energy \
|
||||||
FROM cabinets c \
|
FROM cabinets c \
|
||||||
LEFT JOIN energy_stats es_today \
|
LEFT JOIN energy_stats es_today \
|
||||||
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
|
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
|
||||||
LEFT JOIN ( \
|
LEFT JOIN energy_stats es_month \
|
||||||
SELECT cabinet_id, SUM(total_energy) AS month_energy \
|
ON c.id = es_month.cabinet_id \
|
||||||
FROM energy_stats \
|
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
||||||
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
|
||||||
GROUP BY cabinet_id \
|
|
||||||
) es_month ON c.id = es_month.cabinet_id \
|
|
||||||
{} \
|
{} \
|
||||||
ORDER BY today_energy DESC, c.id ASC",
|
ORDER BY today_energy DESC, c.id ASC",
|
||||||
org_join
|
org_join
|
||||||
@ -205,8 +205,8 @@ pub async fn get_cabinet_ranking(
|
|||||||
"cabinet_id": r.id,
|
"cabinet_id": r.id,
|
||||||
"abstract_id": r.abstract_id,
|
"abstract_id": r.abstract_id,
|
||||||
"name": r.name,
|
"name": r.name,
|
||||||
"today_energy": r.today_energy.unwrap_or(0),
|
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
|
||||||
"month_energy": r.month_energy.unwrap_or(0),
|
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
|
||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
@ -238,18 +238,15 @@ pub async fn get_project_stats(
|
|||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT p.id, p.name, \
|
"SELECT p.id, p.name, \
|
||||||
COUNT(DISTINCT c.id) AS cabinet_count, \
|
COUNT(DISTINCT c.id) AS cabinet_count, \
|
||||||
CAST(COALESCE(SUM(es_today.total_energy), 0) AS SIGNED) AS today_energy, \
|
COALESCE(SUM(es_today.total_energy), 0) AS today_energy, \
|
||||||
CAST(COALESCE(SUM(es_month.month_energy), 0) AS SIGNED) AS month_energy \
|
COALESCE(SUM(es_month.total_energy), 0) AS month_energy \
|
||||||
FROM projects p \
|
FROM projects p \
|
||||||
LEFT JOIN cabinets c ON p.id = c.project_id \
|
LEFT JOIN cabinets c ON p.id = c.project_id \
|
||||||
LEFT JOIN energy_stats es_today \
|
LEFT JOIN energy_stats es_today \
|
||||||
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
|
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
|
||||||
LEFT JOIN ( \
|
LEFT JOIN energy_stats es_month \
|
||||||
SELECT cabinet_id, SUM(total_energy) AS month_energy \
|
ON c.id = es_month.cabinet_id \
|
||||||
FROM energy_stats \
|
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
||||||
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
|
||||||
GROUP BY cabinet_id \
|
|
||||||
) es_month ON c.id = es_month.cabinet_id \
|
|
||||||
{} \
|
{} \
|
||||||
GROUP BY p.id, p.name \
|
GROUP BY p.id, p.name \
|
||||||
ORDER BY today_energy DESC, p.id ASC",
|
ORDER BY today_energy DESC, p.id ASC",
|
||||||
@ -264,8 +261,8 @@ pub async fn get_project_stats(
|
|||||||
"project_id": r.id,
|
"project_id": r.id,
|
||||||
"name": r.name,
|
"name": r.name,
|
||||||
"cabinet_count": r.cabinet_count,
|
"cabinet_count": r.cabinet_count,
|
||||||
"today_energy": r.today_energy.unwrap_or(0),
|
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
|
||||||
"month_energy": r.month_energy.unwrap_or(0),
|
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
|
||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
@ -304,7 +301,7 @@ pub async fn get_trend(
|
|||||||
"week" => {
|
"week" => {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \
|
"SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \
|
||||||
CAST(SUM(total_energy) AS SIGNED) AS energy, CAST(SUM(charge_count) AS SIGNED) AS charge_count \
|
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
|
||||||
FROM energy_stats es \
|
FROM energy_stats es \
|
||||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
|
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
|
||||||
GROUP BY period ORDER BY period ASC",
|
GROUP BY period ORDER BY period ASC",
|
||||||
@ -317,7 +314,7 @@ pub async fn get_trend(
|
|||||||
"month" => {
|
"month" => {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \
|
"SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \
|
||||||
CAST(SUM(total_energy) AS SIGNED) AS energy, CAST(SUM(charge_count) AS SIGNED) AS charge_count \
|
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
|
||||||
FROM energy_stats es \
|
FROM energy_stats es \
|
||||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
|
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
|
||||||
GROUP BY period ORDER BY period ASC",
|
GROUP BY period ORDER BY period ASC",
|
||||||
@ -346,7 +343,7 @@ pub async fn get_trend(
|
|||||||
let list: Vec<Value> = rows.iter().map(|r| {
|
let list: Vec<Value> = rows.iter().map(|r| {
|
||||||
json!({
|
json!({
|
||||||
"period": r.period,
|
"period": r.period,
|
||||||
"energy": r.energy.unwrap_or(0),
|
"energy": (r.energy.unwrap_or(0.0) * 100.0).round() / 100.0,
|
||||||
"charge_count": r.charge_count.unwrap_or(0),
|
"charge_count": r.charge_count.unwrap_or(0),
|
||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
@ -371,14 +368,12 @@ pub async fn export_energy_stats(
|
|||||||
let user_id = user.user_id;
|
let user_id = user.user_id;
|
||||||
let file_name = generate_export_filename(¶ms);
|
let file_name = generate_export_filename(¶ms);
|
||||||
|
|
||||||
// 序列化查询参数(含组织过滤信息)
|
// 序列化查询参数
|
||||||
let query_json = serde_json::to_string(&json!({
|
let query_json = serde_json::to_string(&json!({
|
||||||
"cabinet_id": params.cabinet_id,
|
"cabinet_id": params.cabinet_id,
|
||||||
"project_id": params.project_id,
|
"project_id": params.project_id,
|
||||||
"start_date": params.start_date,
|
"start_date": params.start_date,
|
||||||
"end_date": params.end_date,
|
"end_date": params.end_date,
|
||||||
"org_id": user.organization_id,
|
|
||||||
"role_level": user.role_level,
|
|
||||||
}))
|
}))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@ -461,122 +456,6 @@ fn build_export_clause(
|
|||||||
(clause, bind_values)
|
(clause, bind_values)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取导出任务的文件名与查询参数
|
|
||||||
///
|
|
||||||
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
|
|
||||||
async fn load_export_task(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
task_id: i64,
|
|
||||||
) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
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!({}));
|
|
||||||
Ok((file_name, params))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
|
|
||||||
fn build_export_where(params: &Value) -> (String, Vec<String>) {
|
|
||||||
let (mut where_clause, mut bind_values) = build_export_clause(
|
|
||||||
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
|
||||||
params.get("project_id").and_then(|v| v.as_i64()),
|
|
||||||
params.get("start_date").and_then(|v| v.as_str()),
|
|
||||||
params.get("end_date").and_then(|v| v.as_str()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 组织过滤(非总管理员)
|
|
||||||
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
||||||
let org_id = params.get("org_id").and_then(|v| v.as_i64());
|
|
||||||
if role_level < 2 {
|
|
||||||
if let Some(oid) = org_id {
|
|
||||||
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
|
|
||||||
where_clause.push_str(&format!("{}es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
|
|
||||||
bind_values.push(oid.to_string());
|
|
||||||
} else {
|
|
||||||
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
|
|
||||||
where_clause.push_str(&format!("{}1=0", prefix));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(where_clause, bind_values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询能耗明细行
|
|
||||||
async fn fetch_energy_detail_rows(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
bind_values: &[String],
|
|
||||||
) -> Result<Vec<EnergyDetailRow>, sqlx::Error> {
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
|
|
||||||
p.name AS project_name, \
|
|
||||||
es.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);
|
|
||||||
}
|
|
||||||
query.fetch_all(pool).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建导出工作簿(表头 + 数据行)
|
|
||||||
fn build_energy_workbook(
|
|
||||||
rows: &[EnergyDetailRow],
|
|
||||||
) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let mut workbook = rust_xlsxwriter::Workbook::new();
|
|
||||||
let sheet = workbook.add_worksheet();
|
|
||||||
sheet.set_name("能耗统计")?;
|
|
||||||
|
|
||||||
let headers = [
|
|
||||||
"日期", "柜子ID", "柜子名称", "所属项目", "能耗(Wh)", "充电次数",
|
|
||||||
];
|
|
||||||
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) as f64)?;
|
|
||||||
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(workbook)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将导出任务标记为完成并记录文件路径
|
|
||||||
async fn mark_task_completed(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
task_id: i64,
|
|
||||||
file_path: &str,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
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?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 后台异步生成能耗 Excel 文件
|
/// 后台异步生成能耗 Excel 文件
|
||||||
async fn generate_energy_excel(
|
async fn generate_energy_excel(
|
||||||
pool: sqlx::MySqlPool,
|
pool: sqlx::MySqlPool,
|
||||||
@ -589,11 +468,41 @@ async fn generate_energy_excel(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 获取查询参数
|
// 获取查询参数
|
||||||
let (file_name, params) = load_export_task(&pool, task_id).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 (where_clause, bind_values) = build_export_where(¶ms);
|
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
||||||
let rows = fetch_energy_detail_rows(&pool, &where_clause, &bind_values).await?;
|
|
||||||
|
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%
|
// 更新进度 50%
|
||||||
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
|
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
|
||||||
@ -602,7 +511,26 @@ async fn generate_energy_excel(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 生成 Excel
|
// 生成 Excel
|
||||||
let mut workbook = build_energy_workbook(&rows)?;
|
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";
|
let file_dir = "./exports";
|
||||||
@ -611,7 +539,14 @@ async fn generate_energy_excel(
|
|||||||
workbook.save(&file_path)?;
|
workbook.save(&file_path)?;
|
||||||
|
|
||||||
// 更新任务状态为完成
|
// 更新任务状态为完成
|
||||||
mark_task_completed(&pool, task_id, &file_path).await?;
|
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);
|
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
//! 腾讯地图 API 代理(服务端签名,避免前端暴露 SK)
|
|
||||||
//!
|
|
||||||
//! 签名算法(MD5):
|
|
||||||
//! 1. 参数值用 RAW(不 URL 编码)拼成 query string
|
|
||||||
//! 2. 签名串 = path + "?" + query_string + secret
|
|
||||||
//! 3. sig = MD5(签名串) 32位小写
|
|
||||||
//! 4. URL 请求中的参数值需 URL 编码
|
|
||||||
//! 注意:签名用 RAW 值,URL 用 URL 编码值,两者不同!
|
|
||||||
|
|
||||||
use axum::extract::Query;
|
|
||||||
use axum::Json;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
const TMAP_KEY: &str = "A3YBZ-CIO6G-4F3QO-QEWPM-UQK3F-P7FXT";
|
|
||||||
const TMAP_SECRET: &str = "iIk8Z6CwPKaTgMAppAvtqhVq42CUUvYC";
|
|
||||||
const TMAP_BASE: &str = "https://apis.map.qq.com";
|
|
||||||
|
|
||||||
/// 计算腾讯地图签名(MD5)
|
|
||||||
/// 签名使用 RAW 值(不 URL 编码),URL 请求使用 URL 编码值
|
|
||||||
fn sign(path: &str, params: &BTreeMap<&str, String>) -> String {
|
|
||||||
let qs = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", k, v))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
let to_sign = format!("{}?{}{}", path, qs, TMAP_SECRET);
|
|
||||||
let digest = md5::compute(to_sign.as_bytes());
|
|
||||||
format!("{:x}", digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建完整请求 URL(含签名)
|
|
||||||
fn build_url(path: &str, params: BTreeMap<&str, String>) -> String {
|
|
||||||
let sig = sign(path, ¶ms);
|
|
||||||
let encoded_qs = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
format!("{}{}?{}&sig={}", TMAP_BASE, path, encoded_qs, sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 输入提示(地点搜索建议)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(crate) struct SuggestionQuery {
|
|
||||||
keyword: String,
|
|
||||||
region: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/maps/suggestion — 输入提示
|
|
||||||
pub async fn suggestion(
|
|
||||||
Query(query): Query<SuggestionQuery>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
if query.keyword.trim().is_empty() {
|
|
||||||
return Err(AppError::BadRequest("keyword 不能为空".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut params = BTreeMap::new();
|
|
||||||
params.insert("keyword", query.keyword.trim().to_string());
|
|
||||||
params.insert("key", TMAP_KEY.to_string());
|
|
||||||
if let Some(region) = &query.region {
|
|
||||||
params.insert("region", region.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = build_url("/ws/place/v1/suggestion", params);
|
|
||||||
let resp = reqwest::get(&url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?;
|
|
||||||
let body: Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(Json(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 逆地址解析(坐标→地址)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(crate) struct GeocoderBody {
|
|
||||||
lat: f64,
|
|
||||||
lng: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/maps/geocoder — 逆地理编码
|
|
||||||
pub async fn geocoder(
|
|
||||||
Json(body): Json<GeocoderBody>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
let location = format!("{},{}", body.lat, body.lng);
|
|
||||||
|
|
||||||
let mut params = BTreeMap::new();
|
|
||||||
params.insert("key", TMAP_KEY.to_string());
|
|
||||||
params.insert("location", location);
|
|
||||||
|
|
||||||
let url = build_url("/ws/geocoder/v1/", params);
|
|
||||||
let resp = reqwest::get(&url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?;
|
|
||||||
let body: Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(Json(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 地点搜索
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(crate) struct PlaceSearchQuery {
|
|
||||||
keyword: String,
|
|
||||||
region: Option<String>,
|
|
||||||
page_size: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/maps/place-search — 地点搜索
|
|
||||||
pub async fn place_search(
|
|
||||||
Query(query): Query<PlaceSearchQuery>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
if query.keyword.trim().is_empty() {
|
|
||||||
return Err(AppError::BadRequest("keyword 不能为空".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut params = BTreeMap::new();
|
|
||||||
params.insert("keyword", query.keyword.trim().to_string());
|
|
||||||
params.insert("key", TMAP_KEY.to_string());
|
|
||||||
params.insert("page_size", query.page_size.unwrap_or(10).to_string());
|
|
||||||
|
|
||||||
if let Some(region) = query.region.filter(|r| !r.is_empty()) {
|
|
||||||
params.insert("region", region);
|
|
||||||
} else {
|
|
||||||
params.insert("boundary", "rectangle(3.5,73.5,53.5,135.5)".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = build_url("/ws/place/v1/search", params);
|
|
||||||
let resp = reqwest::get(&url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?;
|
|
||||||
let body: Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(Json(body))
|
|
||||||
}
|
|
||||||
@ -3,22 +3,17 @@
|
|||||||
//! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。
|
//! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。
|
||||||
//! 权限校验在各路由处理器内部通过 `check_permission` 完成。
|
//! 权限校验在各路由处理器内部通过 `check_permission` 完成。
|
||||||
|
|
||||||
use axum::http::Method;
|
|
||||||
use axum::middleware;
|
use axum::middleware;
|
||||||
use axum::routing::{get, post, put};
|
use axum::routing::{get, post, put};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
|
|
||||||
use crate::commands::AppState;
|
use crate::commands::AppState;
|
||||||
use crate::middleware::auth::auth_middleware;
|
use crate::middleware::auth::auth_middleware;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod batteries;
|
|
||||||
mod cabinets;
|
mod cabinets;
|
||||||
mod cabinet_tree;
|
mod cabinet_tree;
|
||||||
mod charge_records;
|
mod charge_records;
|
||||||
mod charge_orders;
|
|
||||||
mod device_logs;
|
mod device_logs;
|
||||||
mod downloads;
|
mod downloads;
|
||||||
mod energy_stats;
|
mod energy_stats;
|
||||||
@ -29,180 +24,94 @@ mod organizations;
|
|||||||
mod projects;
|
mod projects;
|
||||||
mod roles;
|
mod roles;
|
||||||
mod users;
|
mod users;
|
||||||
mod scope_guard;
|
|
||||||
mod users_perm;
|
mod users_perm;
|
||||||
mod wechat;
|
|
||||||
mod maps;
|
|
||||||
|
|
||||||
/// 认证与账户相关路由(需登录)
|
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
|
||||||
fn auth_routes() -> Router<AppState> {
|
pub fn build(state: AppState) -> Router {
|
||||||
Router::<AppState>::new()
|
let protected = Router::<AppState>::new()
|
||||||
|
// 认证相关
|
||||||
.route("/api/auth/logout", post(auth::logout))
|
.route("/api/auth/logout", post(auth::logout))
|
||||||
.route("/api/auth/me", get(auth::me))
|
.route("/api/auth/me", get(auth::me))
|
||||||
.route("/api/auth/avatar", put(auth::update_avatar))
|
// 设备指令下发
|
||||||
.route("/api/auth/avatar/upload", post(auth::upload_avatar))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设备指令下发 + 设备服务节点管理路由
|
|
||||||
fn device_node_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/devices/{dev_id}/command", post(crate::commands::send_command))
|
.route("/api/devices/{dev_id}/command", post(crate::commands::send_command))
|
||||||
.route("/api/devices/stats", get(crate::commands::device_stats))
|
.route("/api/devices/stats", get(crate::commands::device_stats))
|
||||||
|
// 节点管理
|
||||||
.route("/api/nodes/register", post(crate::nodes::register_node))
|
.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}/heartbeat", post(crate::nodes::node_heartbeat))
|
||||||
.route("/api/nodes/{node_id}/deregister", post(crate::nodes::deregister_node))
|
.route("/api/nodes/{node_id}/deregister", post(crate::nodes::deregister_node))
|
||||||
.route("/api/nodes", get(crate::nodes::list_nodes))
|
.route("/api/nodes", get(crate::nodes::list_nodes))
|
||||||
}
|
// 角色管理
|
||||||
|
|
||||||
/// 角色、权限码与用户权限分配路由
|
|
||||||
fn rbac_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/roles", get(roles::list_roles).post(roles::create_role))
|
.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}", put(roles::update_role).delete(roles::delete_role))
|
||||||
.route("/api/roles/{id}/permissions", put(roles::set_role_permissions))
|
.route("/api/roles/{id}/permissions", put(roles::set_role_permissions))
|
||||||
|
// 权限码列表
|
||||||
.route("/api/permissions", get(roles::list_permissions))
|
.route("/api/permissions", get(roles::list_permissions))
|
||||||
|
// 用户权限管理
|
||||||
.route("/api/users/{id}/permissions", get(users_perm::get_user_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}/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/users/{id}/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes))
|
||||||
}
|
// 组织管理
|
||||||
|
|
||||||
/// 组织与项目路由
|
|
||||||
fn org_project_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization))
|
.route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization))
|
||||||
.route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization))
|
.route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization))
|
||||||
.route("/api/organizations/{id}/status", put(organizations::set_organization_status))
|
// 项目管理
|
||||||
.route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project))
|
.route("/api/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/{id}", put(projects::update_project).delete(projects::delete_project))
|
||||||
}
|
// 设备管理
|
||||||
|
|
||||||
/// 设备(充电柜)管理、柜级控制、组织树与级联下拉路由
|
|
||||||
fn cabinet_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets))
|
.route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets))
|
||||||
.route("/api/cabinets", post(cabinets::create_cabinet))
|
.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}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail))
|
||||||
.route("/api/cabinets/{id}/status", put(cabinets::set_cabinet_status))
|
|
||||||
.route("/api/cabinets/{id}/work-mode", put(cabinets::update_work_mode))
|
|
||||||
.route("/api/cabinets/{id}/regenerate-auth", post(cabinets::regenerate_auth))
|
.route("/api/cabinets/{id}/regenerate-auth", post(cabinets::regenerate_auth))
|
||||||
// 柜级设备控制:开门解锁 / 接触器通断 / 重启整柜;仓控板单独重启
|
|
||||||
.route("/api/cabinets/{id}/open", post(h5::cabinet_open))
|
|
||||||
.route("/api/cabinets/{id}/pow-on", post(h5::cabinet_pow_on))
|
|
||||||
.route("/api/cabinets/{id}/pow-off", post(h5::cabinet_pow_off))
|
|
||||||
.route("/api/cabinets/{id}/reset", post(h5::cabinet_reset))
|
|
||||||
.route("/api/cabin-boards/{id}/reset", post(h5::board_reset))
|
|
||||||
.route("/api/cabinets/{id}/bind", post(cabinets::bind_cabinet))
|
.route("/api/cabinets/{id}/bind", post(cabinets::bind_cabinet))
|
||||||
.route("/api/cabinets/{id}/replace-imei", post(cabinets::replace_imei))
|
.route("/api/cabinets/{id}/replace-imei", post(cabinets::replace_imei))
|
||||||
// 组织树
|
// 组织树
|
||||||
.route("/api/organization-tree", get(cabinet_tree::organization_tree))
|
.route("/api/organization-tree", get(cabinet_tree::organization_tree))
|
||||||
.route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets))
|
.route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets))
|
||||||
.route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status))
|
.route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status))
|
||||||
// 设备级联下拉
|
// 充电记录
|
||||||
.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))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 充电记录 / 充电订单 / 电池台账 / 设备日志路由
|
|
||||||
fn record_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/charge-records", get(charge_records::list_charge_records))
|
.route("/api/charge-records", get(charge_records::list_charge_records))
|
||||||
.route("/api/charge-records/export", get(charge_records::export_charge_records))
|
.route("/api/charge-records/export", get(charge_records::export_charge_records))
|
||||||
.route("/api/charge-orders", get(charge_orders::list_charge_orders))
|
// 设备日志
|
||||||
.route("/api/batteries", get(batteries::list_batteries))
|
|
||||||
.route("/api/device-logs", get(device_logs::list_device_logs))
|
.route("/api/device-logs", get(device_logs::list_device_logs))
|
||||||
}
|
// 下载中心
|
||||||
|
|
||||||
/// 下载中心路由
|
|
||||||
fn download_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/downloads", get(downloads::list_downloads))
|
.route("/api/downloads", get(downloads::list_downloads))
|
||||||
.route("/api/downloads/{id}", get(downloads::get_download))
|
.route("/api/downloads/{id}", get(downloads::get_download))
|
||||||
.route("/api/downloads/{id}/file", get(downloads::download_file))
|
.route("/api/downloads/{id}/file", get(downloads::download_file))
|
||||||
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
|
.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))
|
||||||
fn energy_routes() -> Router<AppState> {
|
.route("/api/compartments/options", get(cabinets::list_compartment_options))
|
||||||
Router::<AppState>::new()
|
// 能耗管理
|
||||||
.route("/api/energy-stats/summary", get(energy_stats::get_summary))
|
.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/cabinets", get(energy_stats::get_cabinet_ranking))
|
||||||
.route("/api/energy-stats/projects", get(energy_stats::get_project_stats))
|
.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/trend", get(energy_stats::get_trend))
|
||||||
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
|
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
|
||||||
}
|
// 用户管理
|
||||||
|
|
||||||
/// 用户管理与操作日志路由
|
|
||||||
fn user_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/users", get(users::list_users).post(users::create_user))
|
.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}", put(users::update_user).delete(users::delete_user))
|
||||||
.route("/api/users/{id}/reset-password", put(users::reset_password))
|
.route("/api/users/{id}/reset-password", put(users::reset_password))
|
||||||
.route("/api/users/{id}/status", put(users::set_user_status))
|
.route("/api/users/{id}/status", put(users::set_user_status))
|
||||||
|
// 操作日志
|
||||||
.route("/api/operation-logs", get(operation_logs::list_operation_logs))
|
.route("/api/operation-logs", get(operation_logs::list_operation_logs))
|
||||||
}
|
// H5 用户端接口(需认证)
|
||||||
|
.route("/api/h5/dashboard", get(h5::get_dashboard))
|
||||||
/// H5 用户端接口路由(与 PC 共用同一套 API 路由)+ 问题反馈
|
.route("/api/h5/projects", get(h5::get_projects))
|
||||||
fn h5_routes() -> Router<AppState> {
|
.route("/api/h5/cabinets", get(h5::get_cabinets))
|
||||||
Router::<AppState>::new()
|
.route("/api/h5/cabinets/{id}", get(h5::get_cabinet_detail))
|
||||||
.route("/api/dashboard", get(h5::get_dashboard))
|
.route("/api/h5/compartments/{id}", get(h5::get_compartment_detail))
|
||||||
.route("/api/cabinets", get(h5::get_cabinets))
|
.route("/api/h5/charge/start", post(h5::start_charge))
|
||||||
.route("/api/compartments/{id}", get(h5::get_compartment_detail))
|
.route("/api/h5/charge/stop", post(h5::stop_charge))
|
||||||
.route("/api/charge/start", post(h5::start_charge))
|
.route("/api/h5/door/open", post(h5::open_door))
|
||||||
.route("/api/charge/stop", post(h5::stop_charge))
|
// 认证中间件层
|
||||||
.route("/api/door/open", post(h5::open_door))
|
|
||||||
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
|
|
||||||
.route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback))
|
|
||||||
.route("/api/feedback/{id}", put(h5::update_feedback_status))
|
|
||||||
.route("/api/feedback/upload", post(h5::upload_feedback_image))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 合并全部受保护路由(不含认证中间件层,便于单测校验路由表无冲突)
|
|
||||||
fn protected_router() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.merge(auth_routes())
|
|
||||||
.merge(device_node_routes())
|
|
||||||
.merge(rbac_routes())
|
|
||||||
.merge(org_project_routes())
|
|
||||||
.merge(cabinet_routes())
|
|
||||||
.merge(record_routes())
|
|
||||||
.merge(download_routes())
|
|
||||||
.merge(energy_routes())
|
|
||||||
.merge(user_routes())
|
|
||||||
.merge(h5_routes())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 免认证的公开路由(健康检查、登录、微信回调、地图代理、静态头像目录)
|
|
||||||
fn public_routes() -> Router<AppState> {
|
|
||||||
Router::<AppState>::new()
|
|
||||||
.route("/api/health", get(health::health_check))
|
|
||||||
.route("/api/auth/challenge", get(auth::get_challenge))
|
|
||||||
.route("/api/auth/login", post(auth::login))
|
|
||||||
// 微信小程序手机号快速登录(免认证)
|
|
||||||
.route("/api/auth/wx-login", post(auth::wx_login))
|
|
||||||
// 扫码入口(免认证)
|
|
||||||
.route("/api/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id))
|
|
||||||
// 微信回调(免认证)
|
|
||||||
.route("/api/wechat/callback", get(wechat::verify).post(wechat::handle_message))
|
|
||||||
// 腾讯地图代理(免认证,前端需要)
|
|
||||||
.route("/api/maps/suggestion", get(maps::suggestion))
|
|
||||||
.route("/api/maps/geocoder", post(maps::geocoder))
|
|
||||||
.route("/api/maps/place-search", get(maps::place_search))
|
|
||||||
// 头像上传目录(免认证,仅用于公开访问静态文件)
|
|
||||||
.nest_service("/uploads", ServeDir::new("uploads"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
|
|
||||||
pub fn build(state: AppState) -> Router {
|
|
||||||
// 认证中间件层:仅作用于受保护路由
|
|
||||||
let protected = protected_router()
|
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
||||||
|
|
||||||
public_routes()
|
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))
|
||||||
|
// 扫码入口(免认证)
|
||||||
|
.route("/api/h5/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id))
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
.layer(CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
|
||||||
.allow_headers([axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE]))
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,23 +24,22 @@ pub(crate) struct LogQuery {
|
|||||||
page_size: Option<u32>,
|
page_size: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 操作日志数据库行映射
|
/// GET /api/operation-logs — 操作日志列表(分页)
|
||||||
#[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: chrono::NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建 WHERE 子句与绑定值
|
|
||||||
///
|
///
|
||||||
/// 动态拼装过滤条件(使用 ? 占位符防止 SQL 注入),并追加组织数据隔离。
|
/// 支持 user_id / action / start_time / end_time 过滤。
|
||||||
fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec<String>) {
|
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 conditions: Vec<String> = Vec::new();
|
||||||
let mut bind_values: Vec<String> = Vec::new();
|
let mut bind_values: Vec<String> = Vec::new();
|
||||||
|
|
||||||
@ -68,7 +67,7 @@ fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec<Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 追加组织过滤
|
// 追加组织过滤
|
||||||
let (org_cond, org_binds) = auth::org_condition_for_logs(user, "o");
|
let (org_cond, org_binds) = auth::org_condition_for_logs(&user, "o");
|
||||||
if !org_cond.is_empty() {
|
if !org_cond.is_empty() {
|
||||||
conditions.push(org_cond.trim_start_matches(" AND ").to_string());
|
conditions.push(org_cond.trim_start_matches(" AND ").to_string());
|
||||||
bind_values.extend(org_binds);
|
bind_values.extend(org_binds);
|
||||||
@ -79,31 +78,16 @@ fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec<Strin
|
|||||||
} else {
|
} else {
|
||||||
format!("WHERE {}", conditions.join(" AND "))
|
format!("WHERE {}", conditions.join(" AND "))
|
||||||
};
|
};
|
||||||
(where_clause, bind_values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 统计符合条件的操作日志总数
|
// 查询总数
|
||||||
async fn count_operation_logs(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
bind_values: &[String],
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
|
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
|
||||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
for val in bind_values {
|
for val in &bind_values {
|
||||||
count_query = count_query.bind(val);
|
count_query = count_query.bind(val);
|
||||||
}
|
}
|
||||||
Ok(count_query.fetch_one(pool).await?)
|
let total = count_query.fetch_one(pool).await?;
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询操作日志行(关联用户姓名,分页)
|
// 查询列表(关联用户姓名)
|
||||||
async fn fetch_operation_log_rows(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
bind_values: &[String],
|
|
||||||
page_size: u32,
|
|
||||||
offset: u32,
|
|
||||||
) -> Result<Vec<LogRow>, AppError> {
|
|
||||||
let list_sql = format!(
|
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
|
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
|
FROM operation_logs o
|
||||||
@ -114,20 +98,30 @@ async fn fetch_operation_log_rows(
|
|||||||
where_clause
|
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);
|
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
|
||||||
for val in bind_values {
|
for val in &bind_values {
|
||||||
list_query = list_query.bind(val);
|
list_query = list_query.bind(val);
|
||||||
}
|
}
|
||||||
Ok(list_query
|
let rows = list_query
|
||||||
.bind(page_size)
|
.bind(page_size)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?)
|
.await?;
|
||||||
}
|
|
||||||
|
|
||||||
/// 将操作日志行映射为响应 JSON 列表
|
let list: Vec<Value> = rows
|
||||||
fn operation_logs_to_json(rows: Vec<LogRow>) -> Vec<Value> {
|
.into_iter()
|
||||||
rows.into_iter()
|
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
json!({
|
json!({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@ -137,37 +131,10 @@ fn operation_logs_to_json(rows: Vec<LogRow>) -> Vec<Value> {
|
|||||||
"target_type": r.target_type,
|
"target_type": r.target_type,
|
||||||
"target_id": r.target_id,
|
"target_id": r.target_id,
|
||||||
"detail": r.detail,
|
"detail": r.detail,
|
||||||
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
"created_at": r.created_at,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 (where_clause, bind_values) = build_log_filters(&user, &query);
|
|
||||||
|
|
||||||
// 查询总数
|
|
||||||
let total = count_operation_logs(pool, &where_clause, &bind_values).await?;
|
|
||||||
|
|
||||||
// 查询列表(关联用户姓名)
|
|
||||||
let rows =
|
|
||||||
fetch_operation_log_rows(pool, &where_clause, &bind_values, page_size, offset).await?;
|
|
||||||
let list = operation_logs_to_json(rows);
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
|
|||||||
@ -54,13 +54,6 @@ pub struct OrganizationRow {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub parent_id: Option<i64>,
|
pub parent_id: Option<i64>,
|
||||||
#[serde(skip_serializing_if = "is_zero")]
|
|
||||||
pub disabled: Option<i8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 序列化辅助:0 时跳过 disabled 字段(保持旧前端兼容)
|
|
||||||
fn is_zero(v: &Option<i8>) -> bool {
|
|
||||||
matches!(v, Some(0) | None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 项目表行
|
/// 项目表行
|
||||||
@ -70,12 +63,6 @@ pub struct ProjectRow {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub organization_id: Option<i64>,
|
pub organization_id: Option<i64>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub address: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub latitude: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub longitude: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 柜子表行
|
/// 柜子表行
|
||||||
@ -109,10 +96,6 @@ pub struct CabinetRow {
|
|||||||
pub pow_fail_dc: Option<bool>,
|
pub pow_fail_dc: Option<bool>,
|
||||||
pub pow_fail_ac: Option<bool>,
|
pub pow_fail_ac: Option<bool>,
|
||||||
pub is_online: Option<bool>,
|
pub is_online: Option<bool>,
|
||||||
/// 所属组织是否被禁用(甲方禁用客户组织)
|
|
||||||
pub org_disabled: Option<bool>,
|
|
||||||
/// 所属项目的地址(柜子地址为空时使用)
|
|
||||||
pub project_address: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 树节点
|
/// 树节点
|
||||||
@ -128,14 +111,6 @@ pub struct TreeNode {
|
|||||||
pub imei: Option<String>,
|
pub imei: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub status: Option<i8>,
|
pub status: Option<i8>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub address: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub disabled: Option<i8>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub latitude: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub longitude: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仓控板行
|
/// 仓控板行
|
||||||
@ -156,10 +131,40 @@ pub struct CompartmentRow {
|
|||||||
pub status: 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>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub abstract_id: Option<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 位随机字母数字安全码
|
/// 生成 8 位随机字母数字安全码
|
||||||
pub fn generate_auth_str() -> String {
|
pub fn generate_auth_str() -> String {
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@ -181,16 +186,12 @@ pub async fn list_organizations(
|
|||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let rows = sqlx::query_as::<_, OrganizationRow>(
|
let rows = sqlx::query_as::<_, OrganizationRow>(
|
||||||
r#"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"#,
|
r#"SELECT id, name, parent_id FROM organizations ORDER BY id"#,
|
||||||
)
|
)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!(rows)))
|
||||||
"code": 0,
|
|
||||||
"data": rows,
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/organizations — 创建组织
|
/// POST /api/organizations — 创建组织
|
||||||
@ -215,7 +216,7 @@ pub async fn create_organization(
|
|||||||
let id = result.last_insert_id() as i64;
|
let id = result.last_insert_id() as i64;
|
||||||
tracing::info!("创建组织 id={} name={}", id, name);
|
tracing::info!("创建组织 id={} name={}", id, name);
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" })))
|
Ok(Json(json!({ "id": id, "name": name })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/organizations/:id — 更新组织
|
/// PUT /api/organizations/:id — 更新组织
|
||||||
@ -245,48 +246,7 @@ pub async fn update_organization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("更新组织 id={} name={}", id, name);
|
tracing::info!("更新组织 id={} name={}", id, name);
|
||||||
Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" })))
|
Ok(Json(json!({ "id": id, "name": name })))
|
||||||
}
|
|
||||||
|
|
||||||
/// 组织禁用请求体
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct OrgStatusBody {
|
|
||||||
pub disabled: i8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/organizations/:id/status — 禁用/启用组织(仅总管理员)
|
|
||||||
///
|
|
||||||
/// 禁用后该组织及其子组织的柜子全部不可用(指令链路拦截)。
|
|
||||||
pub async fn set_organization_status(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
Json(body): Json<OrgStatusBody>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
// 仅总管理员(甲方)可禁用组织
|
|
||||||
if user.role_level < 2 {
|
|
||||||
return Err(AppError::Forbidden("仅总管理员可禁用组织".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.disabled != 0 && body.disabled != 1 {
|
|
||||||
return Err(AppError::BadRequest("disabled 必须为 0 或 1".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
|
||||||
let rows = sqlx::query("UPDATE organizations SET disabled = ? WHERE id = ?")
|
|
||||||
.bind(body.disabled)
|
|
||||||
.bind(id)
|
|
||||||
.execute(db)
|
|
||||||
.await?
|
|
||||||
.rows_affected();
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
return Err(AppError::NotFound(format!("组织 {} 不存在", id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = if body.disabled == 1 { "禁用" } else { "启用" };
|
|
||||||
tracing::info!("{}组织 id={}", action, id);
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", action) })))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/organizations/:id — 删除组织
|
/// DELETE /api/organizations/:id — 删除组织
|
||||||
@ -298,48 +258,19 @@ pub async fn delete_organization(
|
|||||||
auth::check_permission(&user, "org:delete")?;
|
auth::check_permission(&user, "org:delete")?;
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
|
let count: (i64,) = sqlx::query_as(
|
||||||
// 组织下有项目,不允许删除
|
|
||||||
let project_count: (i64,) = sqlx::query_as(
|
|
||||||
r#"SELECT COUNT(*) FROM projects WHERE organization_id = ?"#,
|
r#"SELECT COUNT(*) FROM projects WHERE organization_id = ?"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(db)
|
.fetch_one(db)
|
||||||
.await?;
|
.await?;
|
||||||
if project_count.0 > 0 {
|
|
||||||
|
if count.0 > 0 {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
format!("组织 {} 下仍有项目,无法删除", id),
|
format!("组织 {} 下仍有项目,无法删除", id),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组织下有绑定用户,不允许删除
|
|
||||||
let user_count: (i64,) = sqlx::query_as(
|
|
||||||
r#"SELECT COUNT(*) FROM users WHERE organization_id = ?"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(db)
|
|
||||||
.await?;
|
|
||||||
if user_count.0 > 0 {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
format!("组织 {} 下仍有 {} 名用户,无法删除", id, user_count.0),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组织下有设备(通过 项目→柜子 链路),不允许删除
|
|
||||||
let cabinet_count: (i64,) = sqlx::query_as(
|
|
||||||
r#"SELECT COUNT(*) FROM cabinets cab
|
|
||||||
INNER JOIN projects p ON cab.project_id = p.id
|
|
||||||
WHERE p.organization_id = ?"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(db)
|
|
||||||
.await?;
|
|
||||||
if cabinet_count.0 > 0 {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
format!("组织 {} 下仍有 {} 台设备,无法删除", id, cabinet_count.0),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = sqlx::query(r#"DELETE FROM organizations WHERE id = ?"#)
|
let rows = sqlx::query(r#"DELETE FROM organizations WHERE id = ?"#)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
@ -351,5 +282,5 @@ pub async fn delete_organization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("删除组织 id={}", id);
|
tracing::info!("删除组织 id={}", id);
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
Ok(Json(json!({})))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,26 +4,13 @@
|
|||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use super::organizations::ProjectRow;
|
use super::organizations::{NameBody, ProjectRow};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
use crate::commands::AppState;
|
use crate::commands::AppState;
|
||||||
|
|
||||||
/// 项目请求体(创建/更新共用)
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ProjectBody {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub address: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub latitude: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub longitude: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/organizations/:org_id/projects — 按组织查询项目列表
|
/// GET /api/organizations/:org_id/projects — 按组织查询项目列表
|
||||||
pub async fn list_projects(
|
pub async fn list_projects(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -31,17 +18,16 @@ pub async fn list_projects(
|
|||||||
Path(org_id): Path<i64>,
|
Path(org_id): Path<i64>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "project:view")?;
|
auth::check_permission(&user, "project:view")?;
|
||||||
super::scope_guard::verify_org_ownership(&user, org_id)?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let rows = sqlx::query_as::<_, ProjectRow>(
|
let rows = sqlx::query_as::<_, ProjectRow>(
|
||||||
r#"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects WHERE organization_id = ? ORDER BY id"#,
|
r#"SELECT id, organization_id, name FROM projects WHERE organization_id = ? ORDER BY id"#,
|
||||||
)
|
)
|
||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
Ok(Json(json!(rows)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/organizations/:org_id/projects — 创建项目
|
/// POST /api/organizations/:org_id/projects — 创建项目
|
||||||
@ -49,10 +35,9 @@ pub async fn create_project(
|
|||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(org_id): Path<i64>,
|
Path(org_id): Path<i64>,
|
||||||
Json(body): Json<ProjectBody>,
|
Json(body): Json<NameBody>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "project:create")?;
|
auth::check_permission(&user, "project:create")?;
|
||||||
super::scope_guard::verify_org_ownership(&user, org_id)?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let name = body.name.trim().to_string();
|
let name = body.name.trim().to_string();
|
||||||
@ -71,32 +56,16 @@ pub async fn create_project(
|
|||||||
return Err(AppError::NotFound(format!("组织 {} 不存在", org_id)));
|
return Err(AppError::NotFound(format!("组织 {} 不存在", org_id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(r#"INSERT INTO projects (organization_id, name) VALUES (?, ?)"#)
|
||||||
r#"INSERT INTO projects (organization_id, name, address, latitude, longitude) VALUES (?, ?, ?, ?, ?)"#,
|
|
||||||
)
|
|
||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(&body.address)
|
|
||||||
.bind(&body.latitude)
|
|
||||||
.bind(&body.longitude)
|
|
||||||
.execute(db)
|
.execute(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let id = result.last_insert_id() as i64;
|
let id = result.last_insert_id() as i64;
|
||||||
tracing::info!("创建项目 id={} name={} org_id={}", id, name, org_id);
|
tracing::info!("创建项目 id={} name={} org_id={}", id, name, org_id);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({ "id": id, "organization_id": org_id, "name": name })))
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"id": id,
|
|
||||||
"organization_id": org_id,
|
|
||||||
"name": name,
|
|
||||||
"address": body.address,
|
|
||||||
"latitude": body.latitude,
|
|
||||||
"longitude": body.longitude,
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/projects/:id — 更新项目
|
/// PUT /api/projects/:id — 更新项目
|
||||||
@ -104,10 +73,9 @@ pub async fn update_project(
|
|||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
Json(body): Json<ProjectBody>,
|
Json(body): Json<NameBody>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "project:edit")?;
|
auth::check_permission(&user, "project:edit")?;
|
||||||
super::scope_guard::verify_project_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let name = body.name.trim().to_string();
|
let name = body.name.trim().to_string();
|
||||||
@ -115,13 +83,8 @@ pub async fn update_project(
|
|||||||
return Err(AppError::BadRequest("项目名称不能为空".into()));
|
return Err(AppError::BadRequest("项目名称不能为空".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(r#"UPDATE projects SET name = ? WHERE id = ?"#)
|
||||||
r#"UPDATE projects SET name = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?"#,
|
|
||||||
)
|
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(&body.address)
|
|
||||||
.bind(&body.latitude)
|
|
||||||
.bind(&body.longitude)
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
.await?
|
.await?
|
||||||
@ -131,18 +94,8 @@ pub async fn update_project(
|
|||||||
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
|
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("更新项目 id={} name={} address={:?}", id, name, body.address);
|
tracing::info!("更新项目 id={} name={}", id, name);
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({ "id": id, "name": name })))
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"id": id,
|
|
||||||
"name": name,
|
|
||||||
"address": body.address,
|
|
||||||
"latitude": body.latitude,
|
|
||||||
"longitude": body.longitude,
|
|
||||||
},
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/projects/:id — 删除项目
|
/// DELETE /api/projects/:id — 删除项目
|
||||||
@ -152,7 +105,6 @@ pub async fn delete_project(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "project:delete")?;
|
auth::check_permission(&user, "project:delete")?;
|
||||||
super::scope_guard::verify_project_ownership(&state.mysql, &user, id).await?;
|
|
||||||
|
|
||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
let count: (i64,) = sqlx::query_as(
|
let count: (i64,) = sqlx::query_as(
|
||||||
@ -179,5 +131,5 @@ pub async fn delete_project(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("删除项目 id={}", id);
|
tracing::info!("删除项目 id={}", id);
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
Ok(Json(json!({})))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
//! 组织级数据隔离校验 — 确保用户只能访问其所属组织的资源。
|
|
||||||
//!
|
|
||||||
//! 总管理员(role_level=2)跳过所有组织归属校验。
|
|
||||||
//! 企业管理员(role_level=1)和操作员(role_level=0)只能访问本组织资源。
|
|
||||||
|
|
||||||
use sqlx::MySqlPool;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::middleware::auth::CurrentUser;
|
|
||||||
|
|
||||||
/// 校验当前用户是否有权访问指定柜子。
|
|
||||||
pub async fn verify_cabinet_ownership(
|
|
||||||
pool: &MySqlPool,
|
|
||||||
user: &CurrentUser,
|
|
||||||
cabinet_id: i64,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if user.role_level >= 2 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?;
|
|
||||||
let has_access: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM cabinets c \
|
|
||||||
JOIN projects p ON c.project_id = p.id \
|
|
||||||
WHERE c.id = ? AND p.organization_id = ?)",
|
|
||||||
)
|
|
||||||
.bind(cabinet_id)
|
|
||||||
.bind(org_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::Forbidden("无权访问该资源".into()))?;
|
|
||||||
if !has_access {
|
|
||||||
return Err(AppError::Forbidden("无权访问该资源".into()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 校验当前用户是否有权访问指定项目。
|
|
||||||
pub async fn verify_project_ownership(
|
|
||||||
pool: &MySqlPool,
|
|
||||||
user: &CurrentUser,
|
|
||||||
project_id: i64,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if user.role_level >= 2 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?;
|
|
||||||
let has_access: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM projects WHERE id = ? AND organization_id = ?)",
|
|
||||||
)
|
|
||||||
.bind(project_id)
|
|
||||||
.bind(org_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::Forbidden("无权访问该资源".into()))?;
|
|
||||||
if !has_access {
|
|
||||||
return Err(AppError::Forbidden("无权访问该资源".into()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 校验当前用户是否有权访问指定组织。
|
|
||||||
pub fn verify_org_ownership(user: &CurrentUser, org_id: i64) -> Result<(), AppError> {
|
|
||||||
if user.role_level >= 2 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if user.organization_id != Some(org_id) {
|
|
||||||
return Err(AppError::Forbidden("无权访问该资源".into()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -17,6 +17,7 @@ use serde_json::{json, Value};
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
use crate::commands::AppState;
|
use crate::commands::AppState;
|
||||||
|
use crate::config::DEFAULT_PASSWORD;
|
||||||
|
|
||||||
/// 用户列表查询参数
|
/// 用户列表查询参数
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -36,7 +37,6 @@ struct UserRow {
|
|||||||
organization_id: Option<i64>,
|
organization_id: Option<i64>,
|
||||||
status: i32,
|
status: i32,
|
||||||
created_at: NaiveDateTime,
|
created_at: NaiveDateTime,
|
||||||
updated_at: NaiveDateTime,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建用户请求体
|
/// 创建用户请求体
|
||||||
@ -73,46 +73,6 @@ pub(crate) struct StatusInput {
|
|||||||
// 辅助函数: 记录操作日志
|
// 辅助函数: 记录操作日志
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 企业管理员操作校验:必须操作本组织的用户且不能操作自己
|
|
||||||
///
|
|
||||||
/// 返回目标用户的当前组织ID(用于后续业务判断)
|
|
||||||
async fn verify_org_admin_scope(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
actor: &CurrentUser,
|
|
||||||
target_user_id: i64,
|
|
||||||
) -> Result<Option<i64>, AppError> {
|
|
||||||
// 总管理员不需要此校验
|
|
||||||
if actor.role_level >= 2 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 企业管理员必须有组织
|
|
||||||
let actor_org = actor.organization_id
|
|
||||||
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
|
|
||||||
|
|
||||||
if target_user_id == actor.user_id {
|
|
||||||
return Err(AppError::BadRequest("不能操作自己的账户".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询目标用户所属组织
|
|
||||||
let target_org: Option<(Option<i64>,)> = sqlx::query_as(
|
|
||||||
"SELECT organization_id FROM users WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(target_user_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (target_org,) = target_org
|
|
||||||
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
|
|
||||||
|
|
||||||
// 目标必须属于同一组织
|
|
||||||
if target_org != Some(actor_org) {
|
|
||||||
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(target_org)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记录一条操作日志到 operation_logs 表
|
/// 记录一条操作日志到 operation_logs 表
|
||||||
async fn log_operation(
|
async fn log_operation(
|
||||||
pool: &sqlx::MySqlPool,
|
pool: &sqlx::MySqlPool,
|
||||||
@ -134,7 +94,7 @@ async fn log_operation(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
tracing::error!(err = %e, "记录操作日志失败");
|
tracing::error!("记录操作日志失败: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,126 +102,6 @@ async fn log_operation(
|
|||||||
// 用户列表
|
// 用户列表
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 构建用户列表的 WHERE 子句与需要绑定的组织ID
|
|
||||||
///
|
|
||||||
/// 返回 (where_clause, bind_org):where_clause 用于 COUNT 与列表查询,
|
|
||||||
/// bind_org 为需要追加绑定的组织过滤值(无过滤则为 None)。
|
|
||||||
fn build_user_list_where(has_keyword: bool, org_filter: Option<i64>) -> (String, Option<i64>) {
|
|
||||||
match (has_keyword, org_filter) {
|
|
||||||
(true, Some(org_id)) => (
|
|
||||||
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
|
|
||||||
Some(org_id),
|
|
||||||
),
|
|
||||||
(true, None) => (
|
|
||||||
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(false, Some(org_id)) => (
|
|
||||||
"WHERE organization_id = ?".to_string(),
|
|
||||||
Some(org_id),
|
|
||||||
),
|
|
||||||
(false, None) => (String::new(), None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 统计符合条件的用户总数
|
|
||||||
async fn count_users(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
has_keyword: bool,
|
|
||||||
like_pattern: &str,
|
|
||||||
bind_org: Option<i64>,
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
|
|
||||||
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
|
|
||||||
if has_keyword {
|
|
||||||
q = q.bind(like_pattern).bind(like_pattern);
|
|
||||||
}
|
|
||||||
if let Some(org_id) = bind_org {
|
|
||||||
q = q.bind(org_id);
|
|
||||||
}
|
|
||||||
let total: (i64,) = q.fetch_one(pool).await?;
|
|
||||||
Ok(total.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询当前页的用户行(分页 + 排序)
|
|
||||||
async fn fetch_user_rows(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
where_clause: &str,
|
|
||||||
has_keyword: bool,
|
|
||||||
like_pattern: &str,
|
|
||||||
bind_org: Option<i64>,
|
|
||||||
page_size: u32,
|
|
||||||
offset: u32,
|
|
||||||
) -> Result<Vec<UserRow>, AppError> {
|
|
||||||
let sql = format!(
|
|
||||||
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
|
||||||
FROM users u {}
|
|
||||||
ORDER BY u.id DESC
|
|
||||||
LIMIT ? OFFSET ?"#,
|
|
||||||
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE")
|
|
||||||
);
|
|
||||||
let mut q = sqlx::query_as::<_, UserRow>(&sql);
|
|
||||||
if has_keyword {
|
|
||||||
q = q.bind(like_pattern).bind(like_pattern);
|
|
||||||
}
|
|
||||||
if let Some(org_id) = bind_org {
|
|
||||||
q = q.bind(org_id);
|
|
||||||
}
|
|
||||||
Ok(q.bind(page_size).bind(offset).fetch_all(pool).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量查询用户所属组织的名称映射(id → name)
|
|
||||||
async fn load_org_name_map(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
rows: &[UserRow],
|
|
||||||
) -> Result<std::collections::HashMap<i64, String>, AppError> {
|
|
||||||
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
|
|
||||||
if org_ids.is_empty() {
|
|
||||||
return Ok(std::collections::HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 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);
|
|
||||||
}
|
|
||||||
let org_names: Vec<(i64, String)> = q.fetch_all(pool).await?;
|
|
||||||
Ok(org_names.into_iter().collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将用户行映射为响应 JSON 列表
|
|
||||||
fn users_to_json(
|
|
||||||
rows: Vec<UserRow>,
|
|
||||||
org_map: &std::collections::HashMap<i64, String>,
|
|
||||||
) -> Vec<Value> {
|
|
||||||
// 构建角色名称映射
|
|
||||||
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
|
|
||||||
|
|
||||||
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(),
|
|
||||||
"updated_at": r.updated_at.to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/users — 用户列表(分页)
|
/// GET /api/users — 用户列表(分页)
|
||||||
///
|
///
|
||||||
/// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。
|
/// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。
|
||||||
@ -299,31 +139,130 @@ pub async fn list_users(
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
|
|
||||||
let (where_clause, bind_org) = build_user_list_where(has_keyword, org_filter);
|
|
||||||
|
|
||||||
// 查询总数
|
// 查询总数
|
||||||
let total = count_users(pool, &where_clause, has_keyword, &like_pattern, bind_org).await?;
|
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 = fetch_user_rows(
|
let rows: Vec<UserRow> = if has_keyword {
|
||||||
pool,
|
let mut q = sqlx::query_as::<_, UserRow>(
|
||||||
&where_clause,
|
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at
|
||||||
has_keyword,
|
FROM users u
|
||||||
&like_pattern,
|
WHERE u.phone LIKE ? OR u.name LIKE ?
|
||||||
bind_org,
|
ORDER BY u.id DESC
|
||||||
page_size,
|
LIMIT ? OFFSET ?"#,
|
||||||
offset,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.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并批量查询组织名称
|
// 收集组织ID并批量查询组织名称
|
||||||
let org_map = load_org_name_map(pool, &rows).await?;
|
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
|
||||||
let list = users_to_json(rows, &org_map);
|
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!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": { "list": list, "total": total },
|
"data": { "list": list, "total": total.0 },
|
||||||
"message": "ok"
|
"message": "ok"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@ -332,70 +271,7 @@ pub async fn list_users(
|
|||||||
// 创建用户
|
// 创建用户
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 校验手机号格式(11 位数字)
|
|
||||||
fn validate_phone(phone: &str) -> Result<(), AppError> {
|
|
||||||
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 校验组织存在(不存在返回参数错误)
|
|
||||||
async fn ensure_organization_exists(pool: &sqlx::MySqlPool, oid: i64) -> Result<(), AppError> {
|
|
||||||
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)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 校验手机号未被占用
|
|
||||||
async fn ensure_phone_available(pool: &sqlx::MySqlPool, phone: &str) -> Result<(), AppError> {
|
|
||||||
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)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按操作者身份与目标角色确定创建用户时的组织归属
|
|
||||||
fn resolve_create_org_id(
|
|
||||||
current_user: &CurrentUser,
|
|
||||||
role: i32,
|
|
||||||
input_org_id: Option<i64>,
|
|
||||||
) -> Result<Option<i64>, AppError> {
|
|
||||||
if current_user.role_level >= 2 {
|
|
||||||
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
|
|
||||||
if role == 2 {
|
|
||||||
Ok(None)
|
|
||||||
} else {
|
|
||||||
Ok(Some(input_org_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
|
|
||||||
if role == 2 {
|
|
||||||
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
|
|
||||||
}
|
|
||||||
Ok(Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/users — 创建用户
|
/// POST /api/users — 创建用户
|
||||||
///
|
|
||||||
/// 权限模型:
|
|
||||||
/// - 总管理管理员(role_level>=2):可创建任意角色;角色0/1必须指定组织,角色2强制无组织
|
|
||||||
/// - 企业管理员(role_level=1):仅可创建操作员/企业管理员,组织强制为本组织
|
|
||||||
/// - 操作员(role_level=0):无 user:create 权限,被拦截
|
|
||||||
pub async fn create_user(
|
pub async fn create_user(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@ -407,27 +283,48 @@ pub async fn create_user(
|
|||||||
|
|
||||||
// 校验手机号
|
// 校验手机号
|
||||||
let phone = input.phone.trim();
|
let phone = input.phone.trim();
|
||||||
validate_phone(phone)?;
|
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 name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
|
||||||
let role = input.role.unwrap_or(0);
|
let role = input.role.unwrap_or(0);
|
||||||
if !(0..=2).contains(&role) {
|
if !(0..=2).contains(&role) {
|
||||||
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定最终组织归属(按角色和操作者身份)
|
// 企业管理员必须指定所属组织
|
||||||
let org_id = resolve_create_org_id(¤t_user, role, input.organization_id)?;
|
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 {
|
if let Some(oid) = org_id {
|
||||||
ensure_organization_exists(pool, oid).await?;
|
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()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
|
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 password = auth::hash_password(&plain_password)?;
|
||||||
|
|
||||||
// 检查手机号是否已存在
|
// 检查手机号是否已存在
|
||||||
ensure_phone_available(pool, phone).await?;
|
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(
|
let result = sqlx::query(
|
||||||
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
|
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
|
||||||
@ -446,7 +343,7 @@ pub async fn create_user(
|
|||||||
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-"));
|
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-"));
|
||||||
log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await;
|
log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await;
|
||||||
|
|
||||||
tracing::info!(user_id, phone = %phone, role, "创建用户");
|
tracing::info!("创建用户 id={} phone={} role={}", user_id, phone, role);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@ -459,50 +356,6 @@ pub async fn create_user(
|
|||||||
// 编辑用户
|
// 编辑用户
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 按操作者身份与目标角色确定编辑后的组织归属
|
|
||||||
///
|
|
||||||
/// 企业管理员(role_level<2)只能编辑本组织用户、不能改自己、不能授予总管理员;
|
|
||||||
/// 总管理员可自由指定,角色2强制无组织。
|
|
||||||
async fn resolve_update_org_id(
|
|
||||||
pool: &sqlx::MySqlPool,
|
|
||||||
current_user: &CurrentUser,
|
|
||||||
user_id: i64,
|
|
||||||
old_org_id: Option<i64>,
|
|
||||||
new_role: i32,
|
|
||||||
input_org_id: Option<i64>,
|
|
||||||
) -> Result<Option<i64>, AppError> {
|
|
||||||
if current_user.role_level < 2 {
|
|
||||||
// 企业管理员作用域校验:组织锁定为本组织
|
|
||||||
let actor_org = current_user.organization_id
|
|
||||||
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
|
|
||||||
if user_id == current_user.user_id {
|
|
||||||
return Err(AppError::BadRequest("不能操作自己的账户".into()));
|
|
||||||
}
|
|
||||||
// 目标必须属于同一组织
|
|
||||||
if old_org_id != Some(actor_org) {
|
|
||||||
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
|
|
||||||
}
|
|
||||||
// 不能改成总管理员
|
|
||||||
if new_role == 2 {
|
|
||||||
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
|
|
||||||
}
|
|
||||||
Ok(Some(actor_org))
|
|
||||||
} else {
|
|
||||||
let org_id = if new_role == 2 {
|
|
||||||
None // 总管理员强制无组织
|
|
||||||
} else {
|
|
||||||
Some(input_org_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 校验组织存在(组织未变更时无需重复校验)
|
|
||||||
if let Some(oid) = org_id
|
|
||||||
&& Some(oid) != old_org_id {
|
|
||||||
ensure_organization_exists(pool, oid).await?;
|
|
||||||
}
|
|
||||||
Ok(org_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/users/:id — 编辑用户
|
/// PUT /api/users/:id — 编辑用户
|
||||||
pub async fn update_user(
|
pub async fn update_user(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@ -527,19 +380,28 @@ pub async fn update_user(
|
|||||||
let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
|
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);
|
let new_role = input.role.unwrap_or(old_role);
|
||||||
if !(0..=2).contains(&new_role) {
|
if !(0..=2).contains(&new_role) {
|
||||||
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into()));
|
||||||
|
}
|
||||||
|
let new_org_id = input.organization_id.or(old_org_id);
|
||||||
|
|
||||||
|
// 企业管理员必须指定所属组织
|
||||||
|
if new_role == 1 && new_org_id.is_none() {
|
||||||
|
return Err(AppError::BadRequest("企业管理员必须指定所属组织".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组织归属与作用域校验
|
// 校验组织存在
|
||||||
let new_org_id = resolve_update_org_id(
|
if let Some(oid) = new_org_id
|
||||||
pool,
|
&& Some(oid) != old_org_id {
|
||||||
¤t_user,
|
let org_exists: (i64,) = sqlx::query_as(
|
||||||
user_id,
|
"SELECT COUNT(*) FROM organizations WHERE id = ?"
|
||||||
old_org_id,
|
|
||||||
new_role,
|
|
||||||
input.organization_id,
|
|
||||||
)
|
)
|
||||||
|
.bind(oid)
|
||||||
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
if org_exists.0 == 0 {
|
||||||
|
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
|
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
|
||||||
@ -566,7 +428,7 @@ pub async fn update_user(
|
|||||||
);
|
);
|
||||||
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
|
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
|
||||||
|
|
||||||
tracing::info!(user_id, "编辑用户");
|
tracing::info!("编辑用户 id={}", user_id);
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
|
||||||
}
|
}
|
||||||
@ -585,10 +447,7 @@ pub async fn reset_password(
|
|||||||
auth::check_permission(¤t_user, "user:edit")?;
|
auth::check_permission(¤t_user, "user:edit")?;
|
||||||
|
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
// 企业管理员只能重置本组织用户密码
|
let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
|
||||||
verify_org_admin_scope(pool, ¤t_user, user_id).await?;
|
|
||||||
|
|
||||||
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
|
|
||||||
let new_password = auth::hash_password(&plain_password)?;
|
let new_password = auth::hash_password(&plain_password)?;
|
||||||
|
|
||||||
let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?")
|
let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?")
|
||||||
@ -606,7 +465,7 @@ pub async fn reset_password(
|
|||||||
let detail = format!("重置密码: 用户ID={}", user_id);
|
let detail = format!("重置密码: 用户ID={}", user_id);
|
||||||
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
|
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
|
||||||
|
|
||||||
tracing::info!(user_id, "重置密码");
|
tracing::info!("重置密码 user_id={}", user_id);
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
|
||||||
}
|
}
|
||||||
@ -626,8 +485,10 @@ pub async fn set_user_status(
|
|||||||
|
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 企业管理员作用域校验(含不能操作自己+同组织限制)
|
// 不能禁用自己
|
||||||
verify_org_admin_scope(pool, ¤t_user, user_id).await?;
|
if user_id == current_user.user_id {
|
||||||
|
return Err(AppError::BadRequest("不能修改自己的状态".into()));
|
||||||
|
}
|
||||||
|
|
||||||
if ![0, 1].contains(&input.status) {
|
if ![0, 1].contains(&input.status) {
|
||||||
return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into()));
|
return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into()));
|
||||||
@ -650,7 +511,7 @@ pub async fn set_user_status(
|
|||||||
let detail = format!("{}用户: 用户ID={}", status_text, user_id);
|
let detail = format!("{}用户: 用户ID={}", status_text, user_id);
|
||||||
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
|
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
|
||||||
|
|
||||||
tracing::info!(user_id, action = %status_text, "{}用户", status_text);
|
tracing::info!("{}用户 user_id={}", status_text, user_id);
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
|
||||||
}
|
}
|
||||||
@ -669,8 +530,10 @@ pub async fn delete_user(
|
|||||||
|
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 企业管理员作用域校验(含不能操作自己+同组织限制)
|
// 不能删除自己
|
||||||
verify_org_admin_scope(pool, ¤t_user, user_id).await?;
|
if user_id == current_user.user_id {
|
||||||
|
return Err(AppError::BadRequest("不能删除自己的账户".into()));
|
||||||
|
}
|
||||||
|
|
||||||
// 查询用户信息用于日志
|
// 查询用户信息用于日志
|
||||||
let user_info: Option<(String,)> = sqlx::query_as(
|
let user_info: Option<(String,)> = sqlx::query_as(
|
||||||
@ -695,7 +558,7 @@ pub async fn delete_user(
|
|||||||
let detail = format!("删除用户: ID={}, 手机号={}", user_id, phone);
|
let detail = format!("删除用户: ID={}, 手机号={}", user_id, phone);
|
||||||
log_operation(pool, current_user.user_id, "delete", "user", Some(user_id), &detail).await;
|
log_operation(pool, current_user.user_id, "delete", "user", Some(user_id), &detail).await;
|
||||||
|
|
||||||
tracing::info!(user_id, phone = %phone, "删除用户");
|
tracing::info!("删除用户 id={} phone={}", user_id, phone);
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
//! 微信公众号回调 API
|
|
||||||
//!
|
|
||||||
//! - GET /api/wechat/callback — 服务器地址验证(echostr)
|
|
||||||
//! - POST /api/wechat/callback — 接收推送消息
|
|
||||||
//!
|
|
||||||
//! 配置参数:
|
|
||||||
//! - Token: 7GW16XdnFyak0mE32rc8ZPij(与微信后台一致)
|
|
||||||
//! - EncodingAESKey: kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu
|
|
||||||
//! - 加解密方式: 明文模式
|
|
||||||
//! - 数据格式: XML
|
|
||||||
|
|
||||||
use axum::extract::Query;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use sha1::{Digest, Sha1};
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
/// 微信验证请求参数
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct WechatVerifyQuery {
|
|
||||||
pub signature: String,
|
|
||||||
pub timestamp: String,
|
|
||||||
pub nonce: String,
|
|
||||||
pub echostr: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 WeChat Token,优先从环境变量读取
|
|
||||||
fn get_wechat_token() -> String {
|
|
||||||
std::env::var("WECHAT_TOKEN")
|
|
||||||
.unwrap_or_else(|_| "7GW16XdnFyak0mE32rc8ZPij".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 验证签名:将 token、timestamp、nonce 字典序排序后拼接,做 SHA1 加密
|
|
||||||
/// 与 signature 比较,一致则返回 true
|
|
||||||
fn verify_signature(signature: &str, timestamp: &str, nonce: &str) -> bool {
|
|
||||||
let token = get_wechat_token();
|
|
||||||
let mut parts = vec![token, timestamp.to_string(), nonce.to_string()];
|
|
||||||
parts.sort();
|
|
||||||
let joined = parts.concat();
|
|
||||||
let mut hasher = Sha1::new();
|
|
||||||
hasher.update(joined.as_bytes());
|
|
||||||
let digest = hex::encode(hasher.finalize());
|
|
||||||
digest == signature
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/wechat/callback — 微信服务器地址验证
|
|
||||||
pub async fn verify(
|
|
||||||
Query(query): Query<WechatVerifyQuery>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
if verify_signature(&query.signature, &query.timestamp, &query.nonce) {
|
|
||||||
(StatusCode::OK, query.echostr)
|
|
||||||
} else {
|
|
||||||
(StatusCode::FORBIDDEN, "signature verification failed".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/wechat/callback — 接收微信推送的消息
|
|
||||||
pub async fn handle_message(body: String) -> impl IntoResponse {
|
|
||||||
info!("微信消息推送: {}", body);
|
|
||||||
// 明文模式,返回 success 即表示收到
|
|
||||||
(StatusCode::OK, "success".to_string())
|
|
||||||
}
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
//! 订单电量积分(**待确认第 13 项的兜底方案**)
|
|
||||||
//!
|
|
||||||
//! 背景:交付文档把 `energy`(单订单累计充电量 Wh)列入「待确认事项第 13 项」——
|
|
||||||
//! **协议暂不包含该字段**。文档同时写明兜底方案:
|
|
||||||
//! > **若无该数据源,订单电量由平台侧按 `电压 × 电流 × 时间` 积分计算**
|
|
||||||
//!
|
|
||||||
//! 本模块就是那个兜底:
|
|
||||||
//! - 每次 `status_post`(协议固定每 60 秒一次)拿到通道的**电压(mV)/电流(mA)/温度**,
|
|
||||||
//! 按 `Wh = mV × mA × Δt / 3.6e9` 累加到该通道的**订单电量累加器**;
|
|
||||||
//! - 订单结束时读取累加值 → 写入 `charge_orders.energy` 并生成 `charge_records`;
|
|
||||||
//! - 新订单创建时**清零**累加器。
|
|
||||||
//!
|
|
||||||
//! 设计取舍:
|
|
||||||
//! - **累加器放在 Redis 且以"通道"为键**(不是订单)——避免每条 `status_post` 都去查库确认
|
|
||||||
//! "该通道当前有没有活跃订单"(36 通道 × 每秒多柜会造成大量无谓查询)。新订单创建时清零、
|
|
||||||
//! 订单结束时读取并清零,语义等价而开销恒定。
|
|
||||||
//! - 用 `HINCRBY` 累加**毫瓦时(mWh)**而不是瓦时,避免 60 秒小增量被取整成 0;落库时再换算成 Wh。
|
|
||||||
//! - 温度取**历史最大值**(`charge_records.max_temperature` 要的是"本次充电最高温度")。
|
|
||||||
//! 用 **Lua 脚本**把「累加 + 更新最高温 + 记时间戳」做成**一次原子往返**;若环境禁用了
|
|
||||||
//! `EVAL`,降级为普通命令(电量照常累加,仅放弃最高温跟踪),**不影响订单电量**。
|
|
||||||
//! - 单次积分的时间间隔 Δt 取**协议约定的 60 秒**并夹取到 `[0, MAX_DT]`:`status_post` 是固定节奏,
|
|
||||||
//! 但断线/停电后首个采样不应把"离线期间"也算成充电(否则会虚增电量)。
|
|
||||||
|
|
||||||
use redis::aio::ConnectionManager;
|
|
||||||
|
|
||||||
/// 协议约定的上报间隔(秒)——积分用的 Δt。
|
|
||||||
///
|
|
||||||
/// **为什么用固定 60 秒而不是"两次采样的真实间隔"**:
|
|
||||||
/// - 协议 §5.3 规定 `status_post` **固定每 60 秒一次**,正常运行时它就是真实间隔;
|
|
||||||
/// - 用真实间隔需要在每个通道上多读一次 Redis 存上次时间戳(36 通道 × 每分钟 × 每柜),
|
|
||||||
/// 为极小的精度收益付出可观的往返开销;
|
|
||||||
/// - 断线/停电后补报时,固定 60 秒**只会少算**(少算的那段本就没有可信采样),
|
|
||||||
/// 符合"宁可少算、不可虚增"的口径 —— 电量直接关系电费,虚增比少算更糟。
|
|
||||||
pub const ASSUMED_INTERVAL_SECS: i64 = 60;
|
|
||||||
|
|
||||||
/// 换算系数:`mV × mA × s` → **mWh**。
|
|
||||||
///
|
|
||||||
/// 推导:`W = (mV/1000) × (mA/1000)`;`Wh = W × s/3600`;
|
|
||||||
/// 换成 mWh 再乘 1000 → `mWh = mV × mA × s / 3_600_000`。
|
|
||||||
const MV_MA_S_TO_MWH_DIVISOR: i128 = 3_600_000;
|
|
||||||
|
|
||||||
/// 计算一次采样的电量增量(**mWh**)。
|
|
||||||
///
|
|
||||||
/// - `voltage_mv` / `current_ma`:协议换算后的平台单位(见交付文档 §5.3)
|
|
||||||
/// - `dt_secs`:本次采样覆盖的秒数(已由调用方夹取到 `[0, MAX_DT_SECS]`)
|
|
||||||
///
|
|
||||||
/// 电流为 0(未充电)时增量为 0;任何非正输入都返回 0,不做无意义的负数累加。
|
|
||||||
/// 用 `i128` 中间量避免 `mV × mA × s` 溢出(54000 × 8000 × 300 = 1.296e11 > i32,也接近 i64 无需担心,
|
|
||||||
/// 但统一用宽类型更稳妥)。
|
|
||||||
pub fn mwh_delta(voltage_mv: i64, current_ma: i64, dt_secs: i64) -> i64 {
|
|
||||||
if voltage_mv <= 0 || current_ma <= 0 || dt_secs <= 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let product = voltage_mv as i128 * current_ma as i128 * dt_secs as i128;
|
|
||||||
(product / MV_MA_S_TO_MWH_DIVISOR) as i64
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把 mWh 换算成落库用的 **Wh**(四舍五入到整数,因为 `charge_orders.energy` 是 INT/BIGINT 的 Wh)。
|
|
||||||
pub fn mwh_to_wh(mwh: i64) -> i64 {
|
|
||||||
if mwh <= 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
(mwh + 500) / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通道订单累加器键:`device:{imei}:board:{板号}:ch:{通道号}:order`
|
|
||||||
///
|
|
||||||
/// 字段:`mwh`(累计毫瓦时)、`maxtemp`(本次充电最高温度 K×100)、`ts`(上次采样秒级时间戳)
|
|
||||||
pub fn order_energy_key(imei: &str, board_id: i64, channel_id: i64) -> String {
|
|
||||||
format!("device:{}:board:{}:ch:{}:order", imei, board_id, channel_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lua:原子地「累加电量 + 取最高温 + 记时间戳 + 维护"连续无电池"计数」,避免多次往返与竞态。
|
|
||||||
///
|
|
||||||
/// KEYS[1] 累加器键;ARGV[1] mwh 增量;ARGV[2] 本次温度(K×100);ARGV[3] 当前时间戳(秒);
|
|
||||||
/// ARGV[4] `1`=本通道有电池 / `0`=无电池(用于"连续无电池"兜底判定,见 [`consecutive_no_battery`])
|
|
||||||
const ACCUMULATE_LUA: &str = r#"
|
|
||||||
local cur = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0')
|
|
||||||
local t = tonumber(ARGV[2])
|
|
||||||
if t > cur then redis.call('HSET', KEYS[1], 'maxtemp', ARGV[2]) end
|
|
||||||
redis.call('HINCRBY', KEYS[1], 'mwh', ARGV[1])
|
|
||||||
redis.call('HSET', KEYS[1], 'ts', ARGV[3])
|
|
||||||
if ARGV[4] == '1' then redis.call('HSET', KEYS[1], 'nobat', '0')
|
|
||||||
else redis.call('HINCRBY', KEYS[1], 'nobat', 1) end
|
|
||||||
return 1
|
|
||||||
"#;
|
|
||||||
|
|
||||||
/// 累加一次采样的电量与最高温(由 `status_post` 的通道写入路径调用)。
|
|
||||||
///
|
|
||||||
/// `battery_present` = 该通道 `batt_status` 的 **bit7(有电池)**,用于维护"连续无电池"计数:
|
|
||||||
/// 待确认第 18 项的兜底方案之一就是「**靠 `status_post` 的 bit7 归零判断电池已被取走**」,
|
|
||||||
/// 由 [`crate::workers::report_worker`] 的周期对账在连续 [`NO_BATTERY_STRIKES`] 次后提前结单。
|
|
||||||
///
|
|
||||||
/// 失败只记 WARN:**电量统计是增值功能,不能因为 Redis 抖动影响状态上报主流程**。
|
|
||||||
pub async fn accumulate(
|
|
||||||
redis: &ConnectionManager,
|
|
||||||
imei: &str,
|
|
||||||
board_id: i64,
|
|
||||||
channel_id: i64,
|
|
||||||
voltage_mv: i64,
|
|
||||||
current_ma: i64,
|
|
||||||
temp_k100: i64,
|
|
||||||
battery_present: bool,
|
|
||||||
) {
|
|
||||||
let delta = mwh_delta(voltage_mv, current_ma, ASSUMED_INTERVAL_SECS);
|
|
||||||
let key = order_energy_key(imei, board_id, channel_id);
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
|
||||||
let present = if battery_present { "1" } else { "0" };
|
|
||||||
let mut conn = redis.clone();
|
|
||||||
|
|
||||||
// 优先用 Lua 一次往返完成全部更新
|
|
||||||
let script = redis::Script::new(ACCUMULATE_LUA);
|
|
||||||
match script
|
|
||||||
.key(&key)
|
|
||||||
.arg(delta)
|
|
||||||
.arg(temp_k100.max(0))
|
|
||||||
.arg(now)
|
|
||||||
.arg(present)
|
|
||||||
.invoke_async::<i64>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
// 环境可能禁用 EVAL(或脚本被 RDS 策略拦):降级为普通命令,**电量照常累加**,
|
|
||||||
// 仅放弃"最高温"与"连续无电池"跟踪(缺最高温只是记录不完整,不影响电费口径)。
|
|
||||||
tracing::warn!("[energy] Lua 累加失败,降级为普通命令 key={} err={}", key, e);
|
|
||||||
let mut conn2 = redis.clone();
|
|
||||||
let mut pipe = redis::pipe();
|
|
||||||
pipe.cmd("HINCRBY").arg(&key).arg("mwh").arg(delta).ignore();
|
|
||||||
pipe.cmd("HSET").arg(&key).arg("ts").arg(now).ignore();
|
|
||||||
if battery_present {
|
|
||||||
pipe.cmd("HSET").arg(&key).arg("nobat").arg(0).ignore();
|
|
||||||
} else {
|
|
||||||
pipe.cmd("HINCRBY").arg(&key).arg("nobat").arg(1).ignore();
|
|
||||||
}
|
|
||||||
if let Err(e2) = pipe.query_async::<()>(&mut conn2).await {
|
|
||||||
tracing::warn!("[energy] 电量累加失败 key={} err={}", key, e2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 「连续无电池」达到该次数即视为电池已被取走(协议 §5.6 状态兜底的抖动保护)。
|
|
||||||
///
|
|
||||||
/// `status_post` 固定每 60 秒一次,故 2 次 ≈ **连续 2 分钟**无电池才判定,
|
|
||||||
/// 避免单次瞬时读数为 0 就误结单(待确认第 16 项:板离线时的表现尚未约定,
|
|
||||||
/// 因此阈值宁高不低,且真正的兜底是 10 小时订单超时)。
|
|
||||||
pub const NO_BATTERY_STRIKES: i64 = 2;
|
|
||||||
|
|
||||||
/// 读取该通道"连续无电池"的采样次数(0 表示最近一次有电池)。
|
|
||||||
pub async fn consecutive_no_battery(
|
|
||||||
redis: &ConnectionManager,
|
|
||||||
imei: &str,
|
|
||||||
board_id: i64,
|
|
||||||
channel_id: i64,
|
|
||||||
) -> i64 {
|
|
||||||
let key = order_energy_key(imei, board_id, channel_id);
|
|
||||||
let mut conn = redis.clone();
|
|
||||||
let value: Option<i64> = redis::cmd("HGET")
|
|
||||||
.arg(&key)
|
|
||||||
.arg("nobat")
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
value.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取并**清零**通道累加器,返回 `(累计毫瓦时, 本次最高温 K×100)`。
|
|
||||||
///
|
|
||||||
/// 用于订单结束时结算:先取值再删除,保证下一单从 0 开始。
|
|
||||||
/// 读取失败返回 `(0, 0)` 并记 WARN —— 结算拿不到电量时宁可记 0,也不能让订单结束流程失败。
|
|
||||||
pub async fn take_and_reset(
|
|
||||||
redis: &ConnectionManager,
|
|
||||||
imei: &str,
|
|
||||||
board_id: i64,
|
|
||||||
channel_id: i64,
|
|
||||||
) -> (i64, i64) {
|
|
||||||
let key = order_energy_key(imei, board_id, channel_id);
|
|
||||||
|
|
||||||
// 用 Lua 保证"读+删"原子(避免读到之后、删除之前又来一次累加造成跨单串账)
|
|
||||||
const TAKE_LUA: &str = r#"
|
|
||||||
local mwh = tonumber(redis.call('HGET', KEYS[1], 'mwh') or '0')
|
|
||||||
local temp = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0')
|
|
||||||
redis.call('DEL', KEYS[1])
|
|
||||||
return {mwh, temp}
|
|
||||||
"#;
|
|
||||||
let mut conn = redis.clone();
|
|
||||||
let script = redis::Script::new(TAKE_LUA);
|
|
||||||
match script
|
|
||||||
.key(&key)
|
|
||||||
.invoke_async::<(i64, i64)>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("[energy] 读取累加器失败,按 0 结算 key={} err={}", key, e);
|
|
||||||
// 仍尝试清理,避免脏数据带到下一单
|
|
||||||
let mut conn2 = redis.clone();
|
|
||||||
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn2).await;
|
|
||||||
(0, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清零通道累加器(新订单创建时调用)。
|
|
||||||
pub async fn reset(redis: &ConnectionManager, imei: &str, board_id: i64, channel_id: i64) {
|
|
||||||
let key = order_energy_key(imei, board_id, channel_id);
|
|
||||||
let mut conn = redis.clone();
|
|
||||||
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// 换算自检:54.000V × 8.000A 持续 60 秒 = 432W × (1/60)h = **7.2 Wh = 7200 mWh**
|
|
||||||
#[test]
|
|
||||||
fn test_mwh_delta_real_world_case() {
|
|
||||||
assert_eq!(mwh_delta(54_000, 8_000, 60), 7_200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 未充电(电流 0)不累加 —— 空仓/停止充电时不该产生电量
|
|
||||||
#[test]
|
|
||||||
fn test_mwh_delta_zero_current_no_accumulation() {
|
|
||||||
assert_eq!(mwh_delta(54_000, 0, 60), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 非法/异常输入一律返回 0,不做负数累加
|
|
||||||
#[test]
|
|
||||||
fn test_mwh_delta_rejects_non_positive_inputs() {
|
|
||||||
assert_eq!(mwh_delta(0, 8_000, 60), 0);
|
|
||||||
assert_eq!(mwh_delta(-1, 8_000, 60), 0);
|
|
||||||
assert_eq!(mwh_delta(54_000, -1, 60), 0);
|
|
||||||
assert_eq!(mwh_delta(54_000, 8_000, 0), 0);
|
|
||||||
assert_eq!(mwh_delta(54_000, 8_000, -5), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 小电流长时间不因取整丢成 0(这正是用 mWh 而非 Wh 累加的原因)
|
|
||||||
/// 例:54V × 0.1A = 5.4W,60 秒 = 0.09 Wh = 90 mWh(若按 Wh 取整会变成 0)
|
|
||||||
#[test]
|
|
||||||
fn test_mwh_delta_small_current_not_lost_by_rounding() {
|
|
||||||
assert_eq!(mwh_delta(54_000, 100, 60), 90);
|
|
||||||
assert_eq!(mwh_to_wh(90), 0, "不足 0.5Wh 时落库为 0 是可接受的舍入");
|
|
||||||
// 但多次累加后不会丢:10 次 = 900 mWh → 1 Wh
|
|
||||||
assert_eq!(mwh_to_wh(900), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mWh → Wh 四舍五入
|
|
||||||
#[test]
|
|
||||||
fn test_mwh_to_wh_rounding() {
|
|
||||||
assert_eq!(mwh_to_wh(0), 0);
|
|
||||||
assert_eq!(mwh_to_wh(-5), 0);
|
|
||||||
assert_eq!(mwh_to_wh(499), 0);
|
|
||||||
assert_eq!(mwh_to_wh(500), 1);
|
|
||||||
assert_eq!(mwh_to_wh(1_499), 1);
|
|
||||||
assert_eq!(mwh_to_wh(1_500), 2);
|
|
||||||
assert_eq!(mwh_to_wh(7_200), 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一轮完整充电的量级检查:54V/8A 充 1 小时应为 432 Wh
|
|
||||||
#[test]
|
|
||||||
fn test_full_hour_charge_magnitude() {
|
|
||||||
let mwh: i64 = (0..60).map(|_| mwh_delta(54_000, 8_000, 60)).sum();
|
|
||||||
assert_eq!(mwh, 432_000);
|
|
||||||
assert_eq!(mwh_to_wh(mwh), 432);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 累加器键格式(跨模块契约:`report_worker` 写入、`order_timeout` 结算读取)
|
|
||||||
#[test]
|
|
||||||
fn test_order_energy_key_format() {
|
|
||||||
assert_eq!(
|
|
||||||
order_energy_key("864720562557021", 1, 3),
|
|
||||||
"device:864720562557021:board:1:ch:3:order"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 固定间隔的取向:断线补报时按 60 秒计(少算),而不是按真实空洞(虚增)
|
|
||||||
#[test]
|
|
||||||
fn test_fixed_interval_is_conservative() {
|
|
||||||
let as_fixed = mwh_delta(54_000, 8_000, ASSUMED_INTERVAL_SECS);
|
|
||||||
let as_hour_gap = mwh_delta(54_000, 8_000, 3_600);
|
|
||||||
assert_eq!(as_fixed, 7_200);
|
|
||||||
assert!(
|
|
||||||
as_fixed < as_hour_gap,
|
|
||||||
"用固定 60 秒时必须显著小于按真实空洞计算,避免补报虚增电量"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
//! 设备日志保留策略协程
|
|
||||||
//!
|
|
||||||
//! 业务背景:`device_logs` 是**只增不减**的表 —— `status_post` 每台柜子每 60 秒写一条,
|
|
||||||
//! 加上 `bat_in`/`bat_out`/停电/回执等事件,量级随时间线性增长且**原本没有任何清理策略**,
|
|
||||||
//! 长期会把 RDS 撑满(磁盘告警、备份变慢、查询变慢)。
|
|
||||||
//!
|
|
||||||
//! 本协程按**保留天数**定期分批删除过期日志:
|
|
||||||
//! - **分批 `DELETE ... LIMIT`**:避免一条大删除造成长事务、锁表、主从延迟;
|
|
||||||
//! - 单轮有**最大批数上限**:防止积压过多时一轮跑太久;
|
|
||||||
//! - 所有异常只记日志,**绝不让循环退出**(清理是运维保障,不能反过来拖垮服务)。
|
|
||||||
//!
|
|
||||||
//! 依赖索引 `device_logs(created_at)`(由 `db::migrate` 幂等创建)—— 否则每次删除都会全表扫描。
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use sqlx::MySqlPool;
|
|
||||||
|
|
||||||
/// 日志保留天数:超过该天数的 `device_logs` 会被清理。
|
|
||||||
///
|
|
||||||
/// 取 90 天的理由:设备日志主要服务于"最近出了什么问题"的排障,
|
|
||||||
/// 3 个月足够回溯;再久的数据排障价值低,但存储成本持续累积。
|
|
||||||
const RETENTION_DAYS: i64 = 90;
|
|
||||||
|
|
||||||
/// 单批删除行数:控制单条 SQL 的影响行数,避免长事务
|
|
||||||
const BATCH_LIMIT: i64 = 5000;
|
|
||||||
|
|
||||||
/// 单轮最多删多少批:积压很多时一轮也不会跑太久,剩下的等下一轮
|
|
||||||
const MAX_BATCHES_PER_ROUND: usize = 20;
|
|
||||||
|
|
||||||
/// 执行间隔:6 小时一次即可(保留策略对时效性不敏感,低频更省资源)
|
|
||||||
const SCAN_INTERVAL_SECS: u64 = 6 * 3600;
|
|
||||||
|
|
||||||
/// 启动设备日志保留协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
|
|
||||||
pub async fn start(pool: MySqlPool) {
|
|
||||||
tracing::info!(
|
|
||||||
"[log_retention] 启动设备日志清理,保留 {} 天,执行间隔 {}s,单批 {} 行",
|
|
||||||
RETENTION_DAYS,
|
|
||||||
SCAN_INTERVAL_SECS,
|
|
||||||
BATCH_LIMIT
|
|
||||||
);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// 单轮失败不退出:记 error 后等下一个周期继续
|
|
||||||
if let Err(e) = purge_once(&pool).await {
|
|
||||||
tracing::error!("[log_retention] 本轮清理失败,等待下轮重试: {}", e);
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 单轮清理:分批删除 `created_at` 早于 `NOW() - RETENTION_DAYS` 的日志。
|
|
||||||
///
|
|
||||||
/// 返回删掉的总行数(便于日志与测试断言);SQL 失败向上抛,由调用方记录并继续循环。
|
|
||||||
async fn purge_once(pool: &MySqlPool) -> Result<u64, sqlx::Error> {
|
|
||||||
let mut total: u64 = 0;
|
|
||||||
|
|
||||||
for _ in 0..MAX_BATCHES_PER_ROUND {
|
|
||||||
// 阈值交给 MySQL 计算(created_at 也是 MySQL 写的),避免本地时区换算偏差;
|
|
||||||
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
|
|
||||||
let affected = sqlx::query(
|
|
||||||
"DELETE FROM device_logs \
|
|
||||||
WHERE created_at < TIMESTAMPADD(DAY, ?, NOW()) \
|
|
||||||
LIMIT ?",
|
|
||||||
)
|
|
||||||
.bind(-RETENTION_DAYS)
|
|
||||||
.bind(BATCH_LIMIT)
|
|
||||||
.execute(pool)
|
|
||||||
.await?
|
|
||||||
.rows_affected();
|
|
||||||
|
|
||||||
total += affected;
|
|
||||||
|
|
||||||
// 本批不足一批 → 已删完,提前结束
|
|
||||||
if affected < BATCH_LIMIT as u64 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批间短暂让出,避免长时间占满数据库连接
|
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if total > 0 {
|
|
||||||
tracing::info!(
|
|
||||||
"[log_retention] 已清理过期设备日志 {} 行(保留 {} 天)",
|
|
||||||
total,
|
|
||||||
RETENTION_DAYS
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
tracing::debug!("[log_retention] 无过期设备日志需要清理");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// 保留策略的常量约束:必须是正数、批大小与批数上限在合理区间,
|
|
||||||
/// 避免误改成 0 天(清空全部)或超大值(一次锁表)
|
|
||||||
#[test]
|
|
||||||
fn test_retention_constants_are_sane() {
|
|
||||||
assert!(RETENTION_DAYS > 0, "保留天数必须为正,否则会清空全部日志");
|
|
||||||
assert!(BATCH_LIMIT > 0 && BATCH_LIMIT <= 50_000, "单批行数需在合理区间");
|
|
||||||
assert!(MAX_BATCHES_PER_ROUND > 0, "每轮至少允许一批");
|
|
||||||
assert!(SCAN_INTERVAL_SECS >= 60, "清理间隔不应过于频繁");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
//! 后台协程 — 处理 Redis 队列中的设备消息
|
//! 后台协程 — 处理 Redis 队列中的设备消息
|
||||||
|
|
||||||
pub mod energy;
|
|
||||||
pub mod log_retention;
|
|
||||||
pub mod node_checker;
|
pub mod node_checker;
|
||||||
pub mod order_timeout;
|
|
||||||
pub mod report_worker;
|
pub mod report_worker;
|
||||||
pub mod reply_worker;
|
pub mod reply_worker;
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
//! 订单超时兜底协程
|
|
||||||
//!
|
|
||||||
//! 业务背景:设备可能因断电/断网未上报充电结束,导致 `charge_orders` 卡在
|
|
||||||
//! `status = 0`(充电中)。本协程周期性把**超过 10 小时**仍未结束的订单
|
|
||||||
//! 自动收尾为已完成,避免"幽灵订单"永久占用仓体和统计口径。
|
|
||||||
//!
|
|
||||||
//! 职责边界:本协程只负责「定时扫描 + 落库收尾」,不做停止充电的指令下发
|
|
||||||
//! (设备侧指令由其它流程负责);所有异常只记日志,绝不让循环退出。
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use redis::aio::ConnectionManager;
|
|
||||||
use sqlx::MySqlPool;
|
|
||||||
|
|
||||||
use crate::workers::report_worker;
|
|
||||||
|
|
||||||
/// 超时阈值:订单持续充电超过该小时数即视为异常,自动结束
|
|
||||||
const TIMEOUT_HOURS: i64 = 10;
|
|
||||||
|
|
||||||
/// 扫描间隔:5 分钟扫一次,兼顾及时性与数据库压力
|
|
||||||
const SCAN_INTERVAL_SECS: u64 = 300;
|
|
||||||
|
|
||||||
/// 单轮最多处理订单数,避免一次扫描锁过多行
|
|
||||||
const BATCH_LIMIT: i64 = 200;
|
|
||||||
|
|
||||||
/// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
|
|
||||||
///
|
|
||||||
/// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单
|
|
||||||
/// **同样会写回平台侧积分电量、并生成一条充电记录**,不会在统计口径里凭空消失。
|
|
||||||
pub async fn start(pool: MySqlPool, redis: ConnectionManager) {
|
|
||||||
tracing::info!(
|
|
||||||
"[order_timeout] 启动订单超时兜底,扫描间隔={}s,超时阈值={}h",
|
|
||||||
SCAN_INTERVAL_SECS,
|
|
||||||
TIMEOUT_HOURS
|
|
||||||
);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// 单轮失败不退出:记 error 后等下一个周期继续
|
|
||||||
if let Err(e) = scan_once(&pool, &redis).await {
|
|
||||||
tracing::error!("[order_timeout] 本轮扫描失败,等待下轮重试: {}", e);
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 单轮扫描:找出 status=0 且 start_time 早于 NOW()-10h 的订单并结单
|
|
||||||
///
|
|
||||||
/// 返回 `Result` 而不是直接 panic,让调用方决定是否继续循环。
|
|
||||||
async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sqlx::Error> {
|
|
||||||
// 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的,
|
|
||||||
// 交给数据库算差值可避免本地时区换算偏差;
|
|
||||||
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
|
|
||||||
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as(
|
|
||||||
"SELECT id, compartment_id, start_time FROM charge_orders \
|
|
||||||
WHERE status = 0 AND start_time IS NOT NULL \
|
|
||||||
AND start_time < TIMESTAMPADD(HOUR, ?, NOW()) \
|
|
||||||
ORDER BY start_time ASC LIMIT ?",
|
|
||||||
)
|
|
||||||
.bind(TIMEOUT_HOURS)
|
|
||||||
.bind(BATCH_LIMIT)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if expired.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (order_id, compartment_id, start_time) in expired {
|
|
||||||
// 走统一结算:写回电量(平台侧积分)+ 生成充电记录(status=0 条件保证幂等,
|
|
||||||
// 避免与设备上报的正常结束竞争覆盖)
|
|
||||||
let affected = match report_worker::finalize_timeout_order(pool, redis, compartment_id).await {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(e) => {
|
|
||||||
// 单条失败不影响其余订单,下一轮扫描会再次命中
|
|
||||||
tracing::error!("[order_timeout] 结算订单失败 order_id={} err={}", order_id, e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if affected > 0 {
|
|
||||||
tracing::info!(
|
|
||||||
"[order_timeout] 订单超时自动结束 order_id={} compartment_id={} start_time={:?}",
|
|
||||||
order_id,
|
|
||||||
compartment_id,
|
|
||||||
start_time
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("[order_timeout] 本轮超时结单处理完成");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -7,24 +7,22 @@ use redis::aio::ConnectionManager;
|
|||||||
|
|
||||||
use crate::commands::PendingCommands;
|
use crate::commands::PendingCommands;
|
||||||
|
|
||||||
/// 响应队列的轮询间隔(毫秒):**改用非阻塞轮询**,原因见
|
|
||||||
/// [`crate::workers::report_worker::REDIS_POLL_INTERVAL_MS`](`BRPOP` 在本项目的
|
|
||||||
/// 代理 + 多路复用连接组合下延迟不可控,实测 13~45 秒)。
|
|
||||||
const REDIS_POLL_INTERVAL_MS: u64 = 200;
|
|
||||||
|
|
||||||
/// 启动指令响应处理协程
|
/// 启动指令响应处理协程
|
||||||
///
|
///
|
||||||
/// 轮询 `device:replies`,把回执按 `msg_id` 派发给等待者。
|
/// 使用 BRPOP 阻塞等待所有 `device:reply:*` 队列的响应消息。
|
||||||
pub async fn start(redis: ConnectionManager, pending: PendingCommands) {
|
pub async fn start(redis: ConnectionManager, pending: PendingCommands) {
|
||||||
tracing::info!("[reply_worker] 启动");
|
tracing::info!("[reply_worker] 启动");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut conn = redis.clone();
|
let mut conn = redis.clone();
|
||||||
let result: Result<Option<String>, redis::RedisError> =
|
let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP")
|
||||||
redis::cmd("RPOP").arg("device:replies").query_async(&mut conn).await;
|
.arg("device:replies")
|
||||||
|
.arg(0)
|
||||||
|
.query_async(&mut conn)
|
||||||
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Some(reply_json)) => {
|
Ok((_key, reply_json)) => {
|
||||||
// 解析 msg_id
|
// 解析 msg_id
|
||||||
if let Ok(reply) = serde_json::from_str::<serde_json::Value>(&reply_json) {
|
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 msg_id = reply["msg_id"].as_u64().unwrap_or(0) as u32;
|
||||||
@ -40,12 +38,8 @@ pub async fn start(redis: ConnectionManager, pending: PendingCommands) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 队列为空:短暂让出
|
|
||||||
Ok(None) => {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(REDIS_POLL_INTERVAL_MS)).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[reply_worker] RPOP 错误: {}", e);
|
tracing::error!("[reply_worker] BRPOP 错误: {}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
# 设备 TCP 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值)
|
|
||||||
# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库
|
|
||||||
|
|
||||||
# Redis 连接(阿里云 Redis,与 api-server 同库)
|
|
||||||
REDIS_URL=redis://:CHANGE_ME@HOST:6379/5
|
|
||||||
|
|
||||||
# TCP 监听端口(测试 3001 / 正式 9001)
|
|
||||||
TCP_PORT=9001
|
|
||||||
|
|
||||||
# 本节点 ID(多节点部署时区分)
|
|
||||||
NODE_ID=device-node-1
|
|
||||||
|
|
||||||
# API 服务地址(节点注册/心跳用)
|
|
||||||
API_SERVER_URL=http://127.0.0.1:9000
|
|
||||||
|
|
||||||
# 心跳间隔(秒)
|
|
||||||
HEARTBEAT_INTERVAL=30
|
|
||||||
|
|
||||||
# 设备在线注册 TTL(秒)
|
|
||||||
DEVICE_REGISTER_TTL=300
|
|
||||||
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=9001 # 正式环境TCP
|
||||||
|
|
||||||
|
# 节点ID
|
||||||
|
NODE_ID=node-1
|
||||||
|
|
||||||
|
# API服务地址
|
||||||
|
API_SERVER_URL=http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# 心跳间隔(秒)
|
||||||
|
HEARTBEAT_INTERVAL=60
|
||||||
|
|
||||||
|
# 设备注册TTL(秒)
|
||||||
|
DEVICE_REGISTER_TTL=180
|
||||||
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=3001 # 测试环境TCP
|
||||||
|
|
||||||
|
# 节点ID
|
||||||
|
NODE_ID=node-1
|
||||||
|
|
||||||
|
# API服务地址
|
||||||
|
API_SERVER_URL=http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# 心跳间隔(秒)
|
||||||
|
HEARTBEAT_INTERVAL=60
|
||||||
|
|
||||||
|
# 设备注册TTL(秒)
|
||||||
|
DEVICE_REGISTER_TTL=180
|
||||||
1
software/device-server/Cargo.lock
generated
@ -540,7 +540,6 @@ dependencies = [
|
|||||||
"redis",
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"socket2 0.5.10",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@ -16,4 +16,3 @@ bytes = "1"
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
socket2 = "0.5"
|
|
||||||
|
|||||||
@ -32,10 +32,10 @@ impl Config {
|
|||||||
.unwrap_or_else(|_| "60".to_string())
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.expect("HEARTBEAT_INTERVAL must be a valid number"),
|
.expect("HEARTBEAT_INTERVAL must be a valid number"),
|
||||||
register_ttl: std::env::var("DEVICE_REGISTER_TTL")
|
register_ttl: std::env::var("REGISTER_TTL")
|
||||||
.unwrap_or_else(|_| "300".to_string())
|
.unwrap_or_else(|_| "300".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.expect("DEVICE_REGISTER_TTL must be a valid number"),
|
.expect("REGISTER_TTL must be a valid number"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,11 +18,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
|
||||||
/// 内部路由字段名:api-server 在推入 `device:commands` 时附加,
|
|
||||||
/// 指明"这条指令发给哪台设备"(值 = IMEI)。device-server 读取后**下发前摘除**,
|
|
||||||
/// 设备侧不可见(与 `_close` 同类)。
|
|
||||||
const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
@ -42,8 +37,8 @@ async fn main() {
|
|||||||
.expect("Redis 连接失败");
|
.expect("Redis 连接失败");
|
||||||
tracing::info!("Redis 连接成功");
|
tracing::info!("Redis 连接成功");
|
||||||
|
|
||||||
// 初始化设备连接池(注入 Redis:下发指令时写挂起登记 `pending:{dev_id}`)
|
// 初始化设备连接池
|
||||||
let device_pool = tcp::connection::ConnectionPool::new(redis_conn.clone());
|
let device_pool = tcp::connection::ConnectionPool::new();
|
||||||
|
|
||||||
// 通过 Redis 注册节点信息
|
// 通过 Redis 注册节点信息
|
||||||
register_node(&redis_conn, &cfg).await;
|
register_node(&redis_conn, &cfg).await;
|
||||||
@ -64,15 +59,14 @@ async fn main() {
|
|||||||
// 启动指令消费协程(从 Redis 读取平台指令并发送到设备)
|
// 启动指令消费协程(从 Redis 读取平台指令并发送到设备)
|
||||||
let cmd_redis = redis_conn.clone();
|
let cmd_redis = redis_conn.clone();
|
||||||
let cmd_pool = device_pool.clone();
|
let cmd_pool = device_pool.clone();
|
||||||
let cmd_node_id = cfg.node_id.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
consume_commands(cmd_redis, cmd_pool, cmd_node_id).await;
|
consume_commands(cmd_redis, cmd_pool).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动 TCP 监听
|
// 启动 TCP 监听
|
||||||
let tcp_addr = format!("{}:{}", cfg.host, cfg.tcp_port);
|
let tcp_addr = format!("{}:{}", cfg.host, cfg.tcp_port);
|
||||||
tracing::info!("TCP 设备服务启动于 {}", tcp_addr);
|
tracing::info!("TCP 设备服务启动于 {}", tcp_addr);
|
||||||
tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn, cfg.node_id).await;
|
tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通过 Redis 注册节点信息(HSET hash 结构,与 heartbeat 保持一致)
|
/// 通过 Redis 注册节点信息(HSET hash 结构,与 heartbeat 保持一致)
|
||||||
@ -134,124 +128,48 @@ async fn heartbeat(redis: &redis::aio::ConnectionManager, node_id: &str, registe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 和本节点队列读取指令,
|
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 读取平台指令并发送到设备
|
||||||
/// 查询路由后发送到正确节点。
|
|
||||||
async fn consume_commands(
|
async fn consume_commands(
|
||||||
redis: redis::aio::ConnectionManager,
|
redis: redis::aio::ConnectionManager,
|
||||||
pool: tcp::connection::ConnectionPool,
|
pool: tcp::connection::ConnectionPool,
|
||||||
node_id: String,
|
|
||||||
) {
|
) {
|
||||||
tracing::info!("[cmd_consumer] 启动 node_id={}", node_id);
|
tracing::info!("[cmd_consumer] 启动");
|
||||||
let my_queue = format!("device:commands:{}", node_id);
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut conn = redis.clone();
|
let mut conn = redis.clone();
|
||||||
// **非阻塞轮询**取一条待下发指令,先取本节点专属队列、再取统一队列。
|
let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP")
|
||||||
//
|
|
||||||
// 为什么不用 `BRPOP`(2026-09 实测结论):Redis 经 nginx stream 代理访问阿里云 RDS,
|
|
||||||
// 客户端又是多路复用的 `ConnectionManager`,这种组合下 `BRPOP` 的阻塞语义**不可靠**:
|
|
||||||
// - `BRPOP key 0`(无限阻塞)→ 代理掐掉空闲连接后**消费永久卡死**,且无任何错误日志;
|
|
||||||
// - `BRPOP key N`(有限超时)→ **"超时才醒"而非"数据到达即醒"**,实测下行指令
|
|
||||||
// (含 `login` 应答)延迟 15~45 秒,超过设备的等待窗口后会断开,平台侧表现为
|
|
||||||
// 「发送失败 设备不在线 → 入死信」。
|
|
||||||
//
|
|
||||||
// 轮询把投递延迟稳定在 200ms 内,且不依赖阻塞命令语义。
|
|
||||||
// (生产者用 `LPUSH`,这里用 `RPOP`,FIFO 顺序与原先一致。)
|
|
||||||
const POLL_INTERVAL_MS: u64 = 200;
|
|
||||||
let picked: Result<Option<String>, redis::RedisError> = match redis::cmd("RPOP")
|
|
||||||
.arg(&my_queue)
|
|
||||||
.query_async::<Option<String>>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(v)) => Ok(Some(v)),
|
|
||||||
// 本节点队列为空时再取统一队列(多节点场景下的广播队列)
|
|
||||||
Ok(None) => {
|
|
||||||
let mut conn2 = redis.clone();
|
|
||||||
redis::cmd("RPOP")
|
|
||||||
.arg("device:commands")
|
.arg("device:commands")
|
||||||
.query_async::<Option<String>>(&mut conn2)
|
.arg(0)
|
||||||
.await
|
.query_async(&mut conn)
|
||||||
}
|
.await;
|
||||||
Err(e) => Err(e),
|
|
||||||
};
|
|
||||||
|
|
||||||
match picked {
|
match result {
|
||||||
Ok(Some(cmd_json)) => {
|
Ok((_key, cmd_json)) => {
|
||||||
// 内部路由字段 `_dev_id`(由 api-server 附加):它才是"这条指令发给哪台设备"。
|
// 从 JSON 中提取 dev_id 用于路由
|
||||||
// ⚠️ 不能再用协议 `id` 推导 —— 协议定稿后 `id` 只表示**动作目标**
|
let dev_id = serde_json::from_str::<serde_json::Value>(&cmd_json)
|
||||||
// (柜级省略 / 一段=单板 / 两段=仓体级),**不含 IMEI**。
|
.ok()
|
||||||
let parsed: Option<serde_json::Value> = serde_json::from_str(&cmd_json).ok();
|
.and_then(|v| v.get("dev_id").and_then(|d| d.as_str()).map(String::from));
|
||||||
let dev_id = parsed
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|v| v.get(ROUTE_DEV_ID_FIELD).and_then(|d| d.as_str()))
|
|
||||||
.map(String::from);
|
|
||||||
|
|
||||||
let dev_id = match dev_id {
|
let dev_id = match dev_id {
|
||||||
Some(s) if !s.is_empty() => s,
|
Some(id) if !id.is_empty() => id,
|
||||||
_ => {
|
_ => {
|
||||||
tracing::warn!(
|
tracing::warn!("[cmd_consumer] 指令缺少 dev_id,丢弃");
|
||||||
"[cmd_consumer] 指令缺少内部路由字段 {},丢弃:{}",
|
|
||||||
ROUTE_DEV_ID_FIELD,
|
|
||||||
cmd_json
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 协议 `id`(动作目标)—— 仅用于日志;柜级指令不带此字段
|
// 发送到设备
|
||||||
let proto_id = parsed
|
match pool.send_command(&dev_id, &cmd_json).await {
|
||||||
.as_ref()
|
|
||||||
.and_then(|v| v.get("id").and_then(|d| d.as_str()))
|
|
||||||
.unwrap_or("-")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// 查询路由:HGET device:{dev_id} node_id
|
|
||||||
let mut route_conn = redis.clone();
|
|
||||||
let target_node: Option<String> = redis::cmd("HGET")
|
|
||||||
.arg(format!("device:{}", dev_id))
|
|
||||||
.arg("node_id")
|
|
||||||
.query_async(&mut route_conn)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
|
|
||||||
match target_node {
|
|
||||||
Some(ref nid) if nid != &node_id => {
|
|
||||||
// 设备在其他节点,转发到该节点专属队列
|
|
||||||
tracing::debug!("[cmd] 转发 dev_id={} 到节点 {}", dev_id, nid);
|
|
||||||
let mut reroute_conn = redis.clone();
|
|
||||||
let _: Result<(), _> = redis::cmd("LPUSH")
|
|
||||||
.arg(format!("device:commands:{}", nid))
|
|
||||||
.arg(&cmd_json)
|
|
||||||
.query_async(&mut reroute_conn)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景)
|
|
||||||
match pool.send_with_msgid(&dev_id, &cmd_json).await {
|
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id);
|
tracing::debug!("[cmd] 已发送到设备 dev_id={}", dev_id);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[cmd] 发送失败 dev_id={} err={} 已入死信", dev_id, e);
|
tracing::warn!("[cmd] 发送失败 dev_id={} err={}", dev_id, e);
|
||||||
// ponytail: 丢入死信队列,不重试(重试语义和次数待业务明确)
|
|
||||||
let mut dl_conn = redis.clone();
|
|
||||||
let _: Result<(), _> = redis::cmd("LPUSH")
|
|
||||||
.arg("device:commands:dead")
|
|
||||||
.arg(&cmd_json)
|
|
||||||
.query_async(&mut dl_conn)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// 两个队列都为空:短暂让出,避免空转打满 CPU(延迟上限 = 本间隔)
|
|
||||||
tracing::trace!("[cmd_consumer] 队列为空,继续等待");
|
|
||||||
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[cmd_consumer] RPOP 错误: {}", e);
|
tracing::error!("[cmd_consumer] BRPOP 错误: {}", e);
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
//! 设备连接池管理。
|
//! 设备连接池管理。
|
||||||
//!
|
//!
|
||||||
//! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。
|
//! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。
|
||||||
@ -6,65 +7,43 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{mpsc, RwLock};
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
/// 敏感指令列表(未认证设备不得执行)
|
|
||||||
const SENSITIVE_ACTS: &[&str] = &["open", "pow_on", "pow_off", "reset", "auth_str"];
|
|
||||||
|
|
||||||
/// 挂起登记(`pending:{dev_id}`)的 TTL(秒)。
|
|
||||||
///
|
|
||||||
/// 取值 120 = 协议 60 秒等待窗口的 2 倍:既保证超时后自动作废,
|
|
||||||
/// 又留出余量避免登记项在回执到达前先过期(api-server 命中时会 `HDEL`)。
|
|
||||||
const PENDING_TTL_SECS: u32 = 120;
|
|
||||||
|
|
||||||
/// 单台设备的连接状态。
|
/// 单台设备的连接状态。
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct DeviceConnection {
|
pub struct DeviceConnection {
|
||||||
|
pub dev_id: String,
|
||||||
/// 向设备发送指令的通道。
|
/// 向设备发送指令的通道。
|
||||||
pub tx: mpsc::Sender<String>,
|
pub tx: mpsc::UnboundedSender<String>,
|
||||||
|
/// 连接建立时间。
|
||||||
|
pub connected_at: Instant,
|
||||||
/// 最后活跃时间(收到任意消息时更新)。
|
/// 最后活跃时间(收到任意消息时更新)。
|
||||||
pub last_seen: Instant,
|
pub last_seen: Instant,
|
||||||
/// 是否已完成登录验证。
|
/// 是否已完成登录验证。
|
||||||
pub authenticated: bool,
|
pub authenticated: bool,
|
||||||
/// 下行指令 msg_id 自增计数(**每连接独立**,按协议 §3.4 从 `1` 起、`0` 不使用;
|
|
||||||
/// 连接重建后重新计数)
|
|
||||||
next_msg_id: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceConnection {
|
impl DeviceConnection {
|
||||||
/// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。
|
pub fn new(dev_id: String, tx: mpsc::UnboundedSender<String>) -> Self {
|
||||||
pub fn new(tx: mpsc::Sender<String>) -> Self {
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
// 初值 1:协议定稿「两个方向各自独立计数,都每连接从 1 起自增,0 不使用」;
|
Self { dev_id, tx, connected_at: now, last_seen: now, authenticated: false }
|
||||||
// 且 api-server 多处用 `unwrap_or(0)` 兜底,用 0 起会与"缺号"混淆
|
|
||||||
Self { tx, last_seen: now, authenticated: false, next_msg_id: 1 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 标记活跃时间。
|
/// 标记活跃时间。
|
||||||
pub fn touch(&mut self) {
|
pub fn touch(&mut self) {
|
||||||
self.last_seen = Instant::now();
|
self.last_seen = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取下一个 msg_id(从 `1` 起自增,`0` 不使用)。
|
|
||||||
fn alloc_msg_id(&mut self) -> u32 {
|
|
||||||
let id = self.next_msg_id;
|
|
||||||
self.next_msg_id += 1;
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 线程安全的设备连接池。
|
/// 线程安全的设备连接池。
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ConnectionPool {
|
pub struct ConnectionPool {
|
||||||
inner: Arc<RwLock<HashMap<String, DeviceConnection>>>,
|
inner: Arc<RwLock<HashMap<String, DeviceConnection>>>,
|
||||||
/// 挂起登记用的 Redis 连接(`pending:{dev_id}`)。
|
|
||||||
/// 仅用于辅助登记,**任何 Redis 失败都不得影响指令下发**。
|
|
||||||
redis: redis::aio::ConnectionManager,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionPool {
|
impl ConnectionPool {
|
||||||
pub fn new(redis: redis::aio::ConnectionManager) -> Self {
|
pub fn new() -> Self {
|
||||||
Self { inner: Arc::new(RwLock::new(HashMap::new())), redis }
|
Self { inner: Arc::new(RwLock::new(HashMap::new())) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 注册设备连接。如果已存在则替换旧连接。
|
/// 注册设备连接。如果已存在则替换旧连接。
|
||||||
@ -79,89 +58,22 @@ impl ConnectionPool {
|
|||||||
map.remove(dev_id);
|
map.remove(dev_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询该设备连接是否已通过 `login` 认证。
|
/// 向指定设备发送 JSON 指令。
|
||||||
/// 连接不存在或尚未认证时返回 `false`。
|
/// 返回 `Ok(())` 表示已投递到通道;设备可能因缓冲区满而断开。
|
||||||
pub async fn is_authenticated(&self, dev_id: &str) -> bool {
|
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;
|
let map = self.inner.read().await;
|
||||||
map.get(dev_id).map(|c| c.authenticated).unwrap_or(false)
|
map.get(dev_id).map(|c| c.authenticated).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 向指定设备发送指令并注入自增 msg_id。
|
|
||||||
/// 每连接独立计数(从 `1` 起),重连后重新计数。
|
|
||||||
/// 未认证连接只能接收非敏感指令(如 status 查询)。
|
|
||||||
///
|
|
||||||
/// 区分两类出站消息(判据 = **有没有 `suc`**,见交付文档 §3.5):
|
|
||||||
/// - **应答**(ServerResponse,**带 `suc`**,同时也带 `act`):设备请求的回复,
|
|
||||||
/// **透传原始 `msg_id`**(协议要求"应答的 msg_id 必须等于请求的 msg_id")
|
|
||||||
/// - **指令**(DeviceCommand,**不带 `suc`**):平台主动下发,**注入自增 `msg_id`**
|
|
||||||
///
|
|
||||||
/// 指令在注入 `msg_id` 后写入 Redis 挂起表 `pending:{dev_id}`,
|
|
||||||
/// 供 api-server 把设备回执配对回具体指令(见 `register_pending`)。
|
|
||||||
pub async fn send_with_msgid(&self, dev_id: &str, json: &str) -> Result<(), &'static str> {
|
|
||||||
let pending = self.prepare_outbound(dev_id, json).await?;
|
|
||||||
|
|
||||||
// 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。
|
|
||||||
// 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。
|
|
||||||
if let Some((msg_id, act)) = pending {
|
|
||||||
register_pending(&self.redis, dev_id, msg_id, &act).await;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 出站报文的校验与投递:跑安全门控、按需注入 `msg_id`,最后写入设备写通道。
|
|
||||||
///
|
|
||||||
/// 返回 `Some((msg_id, act))` 表示这是**指令**(需登记挂起项);
|
|
||||||
/// 返回 `None` 表示这是**应答**(无平台挂起项,不登记)。
|
|
||||||
async fn prepare_outbound(
|
|
||||||
&self,
|
|
||||||
dev_id: &str,
|
|
||||||
json: &str,
|
|
||||||
) -> Result<Option<(u32, String)>, &'static str> {
|
|
||||||
let mut map = self.inner.write().await;
|
|
||||||
let conn = map.get_mut(dev_id).ok_or("设备不在线")?;
|
|
||||||
let mut cmd: serde_json::Value =
|
|
||||||
serde_json::from_str(json).map_err(|_| "JSON 解析失败")?;
|
|
||||||
|
|
||||||
// 「是否应答」的判据:**只有应答带 `suc`**(协议 §3.5)。
|
|
||||||
// 不能再用 `act` 判断 —— 协议定稿后应答也必带 `act`(§3.1)。
|
|
||||||
let is_response = cmd.get("suc").is_some();
|
|
||||||
|
|
||||||
// 检查敏感指令门控(**只拦指令**,应答不拦)
|
|
||||||
// 否则 `auth_str` 的应答会被自己拦掉 → 首次配网永久死锁
|
|
||||||
if !conn.authenticated && !is_response {
|
|
||||||
if let Some(act) = cmd.get("act").and_then(|a| a.as_str()) {
|
|
||||||
if SENSITIVE_ACTS.contains(&act) {
|
|
||||||
tracing::warn!("[安全] 拦截敏感指令 act={} dev_id={} 连接未认证", act, dev_id);
|
|
||||||
return Err("连接未认证,拒绝敏感指令");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 认证触发:login 成功响应的唯一特征是 spec 字段存在 + suc==1
|
|
||||||
// (2026-09 协议定稿:login 应答的柜子规格字段由 cell 改为 spec,此处必须同步)
|
|
||||||
if cmd.get("spec").is_some() {
|
|
||||||
if let Some(suc) = cmd.get("suc").and_then(|s| s.as_u64()) {
|
|
||||||
if suc == 1 {
|
|
||||||
conn.authenticated = true;
|
|
||||||
tracing::info!("[安全] 设备 {} 登录认证通过", dev_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pending = None;
|
|
||||||
if !is_response {
|
|
||||||
// 平台主动下发的指令,注入自增 msg_id,并登记挂起项
|
|
||||||
let msg_id = conn.alloc_msg_id();
|
|
||||||
cmd["msg_id"] = serde_json::json!(msg_id);
|
|
||||||
let act = cmd.get("act").and_then(|a| a.as_str()).unwrap_or("").to_string();
|
|
||||||
pending = Some((msg_id, act));
|
|
||||||
}
|
|
||||||
// else: 应答,保持原始 msg_id 不动(透传设备发起的请求的编号)
|
|
||||||
|
|
||||||
conn.tx.try_send(cmd.to_string()).map_err(|_| "通道已关闭或已满")?;
|
|
||||||
Ok(pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新设备活跃时间。
|
/// 更新设备活跃时间。
|
||||||
pub async fn touch(&self, dev_id: &str) {
|
pub async fn touch(&self, dev_id: &str) {
|
||||||
let mut map = self.inner.write().await;
|
let mut map = self.inner.write().await;
|
||||||
@ -169,86 +81,33 @@ impl ConnectionPool {
|
|||||||
conn.touch();
|
conn.touch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入挂起登记:`HSET pending:{dev_id} {msg_id} {act}` + `EXPIRE pending:{dev_id} 120`。
|
/// 标记设备已验证。
|
||||||
///
|
pub async fn set_authenticated(&self, dev_id: &str) {
|
||||||
/// **为什么由 device-server 登记**:`msg_id` 是本服务分配的,api-server 只有拿到
|
let mut map = self.inner.write().await;
|
||||||
/// 「`msg_id` → `act`」映射,才能把设备回执配对回具体的下发指令(§3.5 要求
|
if let Some(conn) = map.get_mut(dev_id) {
|
||||||
/// `msg_id` + `act` 双条件配对);平台对每条指令只等 60 秒,超时即作废。
|
conn.authenticated = true;
|
||||||
///
|
|
||||||
/// 失败处理:Redis 异常只记 WARN —— 挂起登记是**辅助功能**,
|
|
||||||
/// 绝不能让一条已成功投递到设备的指令因为登记失败而报错(那会误导上层重发)。
|
|
||||||
async fn register_pending(
|
|
||||||
redis: &redis::aio::ConnectionManager,
|
|
||||||
dev_id: &str,
|
|
||||||
msg_id: u32,
|
|
||||||
act: &str,
|
|
||||||
) {
|
|
||||||
if act.is_empty() {
|
|
||||||
tracing::warn!("[pending] 指令缺少 act,跳过挂起登记 dev_id={} msg_id={}", dev_id, msg_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = format!("pending:{}", dev_id);
|
|
||||||
let mut conn = redis.clone();
|
|
||||||
let result: Result<(), _> = redis::cmd("HSET")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(msg_id)
|
|
||||||
.arg(act)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => tracing::debug!("[pending] 已登记 dev_id={} msg_id={} act={}", dev_id, msg_id, act),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"[pending] 挂起登记失败 dev_id={} msg_id={} err={}(指令已下发,仅影响配对)",
|
|
||||||
dev_id,
|
|
||||||
msg_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: HSET 不支持同时携带 TTL,需单独 EXPIRE(与 main.rs 的节点注册写法一致)
|
/// 清理超时未活跃的连接(心跳检测)。
|
||||||
let mut ttl_conn = redis.clone();
|
pub async fn cleanup_timeout(&self, timeout: std::time::Duration) -> Vec<String> {
|
||||||
let ttl_result: Result<(), _> = redis::cmd("EXPIRE")
|
let mut map = self.inner.write().await;
|
||||||
.arg(&key)
|
let now = Instant::now();
|
||||||
.arg(PENDING_TTL_SECS)
|
let mut removed = Vec::new();
|
||||||
.query_async(&mut ttl_conn)
|
map.retain(|dev_id, conn| {
|
||||||
.await;
|
if now.duration_since(conn.last_seen) > timeout {
|
||||||
if let Err(e) = ttl_result {
|
removed.push(dev_id.clone());
|
||||||
tracing::warn!("[pending] 挂起登记 TTL 设置失败 dev_id={} err={}", dev_id, e);
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/// 返回当前在线设备数量。
|
||||||
// 单元测试:下行编号与内部字段(协议 §3.4 / §3.5)
|
pub async fn online_count(&self) -> usize {
|
||||||
// ---------------------------------------------------------------------------
|
self.inner.read().await.len()
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
fn conn() -> DeviceConnection {
|
|
||||||
let (tx, _rx) = mpsc::channel(4);
|
|
||||||
DeviceConnection::new(tx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 协议定稿:两个方向各自独立计数,**都每连接从 `1` 起自增**(`0` 不使用)
|
|
||||||
#[test]
|
|
||||||
fn test_alloc_msg_id_starts_at_one_and_increments() {
|
|
||||||
let mut c = conn();
|
|
||||||
assert_eq!(c.alloc_msg_id(), 1);
|
|
||||||
assert_eq!(c.alloc_msg_id(), 2);
|
|
||||||
assert_eq!(c.alloc_msg_id(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 新建连接默认未认证 —— 未认证时敏感指令会被拦、业务报文会被丢弃
|
|
||||||
#[test]
|
|
||||||
fn test_new_connection_is_unauthenticated() {
|
|
||||||
assert!(!conn().authenticated);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
//! 设备消息处理逻辑(轻量化)。
|
//! 设备消息处理逻辑(轻量化)。
|
||||||
//!
|
//!
|
||||||
//! 设备服务只做**协议透传**,不处理业务逻辑:
|
//! 设备服务只处理通信和指令解析,不处理业务逻辑:
|
||||||
//! - `status_post` 的通道数据在设备侧已是 **18 字符 hex 明文**,本服务**原样透传**,
|
|
||||||
//! 不再做任何拆解/位域解析(解析归 api-server 的业务层)
|
|
||||||
//! - 所有设备上报消息统一转发到 Redis `device:reports` 队列
|
//! - 所有设备上报消息统一转发到 Redis `device:reports` 队列
|
||||||
//! - 不写 DB、不验证签名、不处理业务规则
|
//! - 不写 DB、不验证签名、不处理业务规则
|
||||||
//! - auth_str/login 等协议级消息也由 API 服务处理后通过 `device:commands` 回复
|
//! - auth_str/login 等协议级消息也由 API 服务处理后通过 `device:commands` 回复
|
||||||
@ -10,57 +8,34 @@
|
|||||||
use super::connection::ConnectionPool;
|
use super::connection::ConnectionPool;
|
||||||
use super::protocol::DeviceMessage;
|
use super::protocol::DeviceMessage;
|
||||||
|
|
||||||
/// 分发消息:补写设备身份后转发到 Redis 上报队列。
|
/// 分发消息:统一转发到 Redis 上报队列。
|
||||||
///
|
///
|
||||||
/// `current_dev_id` = 本 TCP 连接绑定的设备身份(由 `auth_str`/`login` 建立)。
|
/// 设备服务不做任何业务处理,仅负责解析 JSON 并转发。
|
||||||
/// 为什么需要它:新协议里业务上行**不带 `dev_id`**(设备身份由连接确定),
|
|
||||||
/// 而 api-server 依赖 `dev_id` 识别设备,故转发前必须补上。
|
|
||||||
///
|
|
||||||
/// 本服务**不直接回复设备**:应答由 api-server 经 Redis `device:commands` 下发,
|
|
||||||
/// 因此这里没有返回值(历史上曾返回 `Option<ServerResponse>` 但恒为 `None`,
|
|
||||||
/// 那个结构体副本已删除,避免与 api-server 的真实定义漂移)。
|
|
||||||
pub async fn handle_message(
|
pub async fn handle_message(
|
||||||
mut msg: DeviceMessage,
|
msg: DeviceMessage,
|
||||||
current_dev_id: Option<&str>,
|
|
||||||
_pool: &ConnectionPool,
|
_pool: &ConnectionPool,
|
||||||
redis: &redis::aio::ConnectionManager,
|
redis: &redis::aio::ConnectionManager,
|
||||||
) {
|
) -> Option<super::protocol::ServerResponse> {
|
||||||
attach_dev_id(&mut msg, current_dev_id);
|
|
||||||
|
|
||||||
// 所有消息统一转发到 Redis,由 API 服务的 report_worker 处理
|
// 所有消息统一转发到 Redis,由 API 服务的 report_worker 处理
|
||||||
forward_to_redis(redis, &msg).await;
|
forward_to_redis(redis, &msg).await;
|
||||||
|
|
||||||
// 不直接回复设备,等待 API 服务通过 device:commands 队列回复
|
// 不直接回复设备,等待 API 服务通过 device:commands 队列回复
|
||||||
}
|
None
|
||||||
|
|
||||||
/// 把连接绑定的设备身份写入待转发报文。
|
|
||||||
///
|
|
||||||
/// 以**连接**为准(而非报文自带值):设备身份由 TCP 连接确定,
|
|
||||||
/// 这样即使设备漏带/错带 `dev_id`,api-server 侧也不会认错设备。
|
|
||||||
fn attach_dev_id(msg: &mut DeviceMessage, current_dev_id: Option<&str>) {
|
|
||||||
match current_dev_id {
|
|
||||||
Some(dev_id) if !dev_id.is_empty() => msg.dev_id = Some(dev_id.to_string()),
|
|
||||||
// 理论上不会出现:未登录连接的业务报文已被 server.rs 的门控丢弃。
|
|
||||||
// 此时仍照常转发(不丢数据),仅记 WARN 便于排查。
|
|
||||||
_ => tracing::warn!(
|
|
||||||
"[forward] 连接未绑定 dev_id,转发报文将不含设备身份, act={}",
|
|
||||||
msg.act
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将设备消息转发到 Redis 统一上报队列 `device:reports`
|
/// 将设备消息转发到 Redis 统一上报队列 `device:reports`
|
||||||
async fn forward_to_redis(redis: &redis::aio::ConnectionManager, msg: &DeviceMessage) {
|
async fn forward_to_redis(
|
||||||
let json_str = match serde_json::to_string(msg) {
|
redis: &redis::aio::ConnectionManager,
|
||||||
Ok(json) => json,
|
msg: &DeviceMessage,
|
||||||
Err(e) => {
|
) {
|
||||||
// 序列化失败说明报文结构异常,记 ERROR 并丢弃(不推送半截数据)
|
if msg.dev_id.is_none() {
|
||||||
tracing::error!("[forward] 消息序列化失败 act={} err={}", msg.act, e);
|
tracing::warn!("[forward] 消息缺少 dev_id, act={}", msg.act);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
let json_str = serde_json::to_string(msg).unwrap_or_default();
|
||||||
let mut conn = redis.clone();
|
let mut conn = redis.clone();
|
||||||
|
|
||||||
let result: Result<(), _> = redis::cmd("LPUSH")
|
let result: Result<(), _> = redis::cmd("LPUSH")
|
||||||
.arg("device:reports")
|
.arg("device:reports")
|
||||||
.arg(&json_str)
|
.arg(&json_str)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
//! 设备通讯协议消息类型定义与序列化。
|
//! 设备通讯协议消息类型定义与序列化。
|
||||||
//!
|
//!
|
||||||
//! 设备与平台间采用 JSON 格式通讯,LF (`\n`) 作为消息边界。
|
//! 设备与平台间采用 JSON 格式通讯,LF (`\n`) 作为消息边界。
|
||||||
@ -11,15 +12,8 @@ use serde_json::Value;
|
|||||||
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
|
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct DeviceMessage {
|
pub struct DeviceMessage {
|
||||||
/// 动作类型(上行全集,见交付文档 §4.1):
|
/// 动作类型:login / auth_str / status_post / pow_fail / off
|
||||||
/// auth_str / login / status_post / pow_fail_dc / pow_fail_ac / bat_in / bat_out / on / off,
|
|
||||||
/// 以及下行指令的执行回执 open / pow_on / pow_off / reset
|
|
||||||
pub act: String,
|
pub act: String,
|
||||||
/// 设备身份(柜控板 IMEI)。
|
|
||||||
///
|
|
||||||
/// **新协议里只有 `auth_str` / `login` 携带 `dev_id`**(用于把 TCP 连接与设备绑定,§3.1);
|
|
||||||
/// **其余上行一律不带** —— 连接建立后设备身份即由该 TCP 连接确定。
|
|
||||||
/// 内部转发到 Redis `device:reports` 时由 handler 补写该字段(api-server 依赖它识别设备)。
|
|
||||||
pub dev_id: Option<String>,
|
pub dev_id: Option<String>,
|
||||||
pub msg_id: Option<u32>,
|
pub msg_id: Option<u32>,
|
||||||
/// 登录签名时的时间戳(秒级)
|
/// 登录签名时的时间戳(秒级)
|
||||||
@ -28,120 +22,63 @@ pub struct DeviceMessage {
|
|||||||
pub sign: Option<String>,
|
pub sign: Option<String>,
|
||||||
/// 485 子设备 ID(仓控板 ID 1-6)
|
/// 485 子设备 ID(仓控板 ID 1-6)
|
||||||
pub sub_device_id: Option<u8>,
|
pub sub_device_id: Option<u8>,
|
||||||
|
/// 状态上报时的 HEX 数据
|
||||||
|
pub status: Option<String>,
|
||||||
/// 4G 信号强度
|
/// 4G 信号强度
|
||||||
pub rssi: Option<i32>,
|
pub rssi: Option<i32>,
|
||||||
/// `status_post` 的**柜体块**:`{status1, status2, temp, meter}`(见交付文档 §5.3)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cabinet: Option<Value>,
|
|
||||||
/// `status_post` 的**仓控板数组**:`[{board, channels: ["<18hex>", ...]}]`(见交付文档 §5.3)。
|
|
||||||
///
|
|
||||||
/// 通道数据在设备侧已是 **18 字符 hex 明文**,本服务**只透传、不拆解位域**。
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub boards: Option<Value>,
|
|
||||||
/// `bat_in` / `bat_out` / `off` 的**电池块**(见交付文档 §5.5 / §5.6 / §5.7):
|
|
||||||
/// `{battery_id, brand_code, model_code, material_code, cell_count,
|
|
||||||
/// rated_voltage, rated_capacity, info, cells}`
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub battery: Option<Value>,
|
|
||||||
/// 未在顶层定义的扩展字段
|
/// 未在顶层定义的扩展字段
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extra: Option<Value>,
|
pub extra: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/// 平台回复设备的消息。
|
||||||
// 关于「平台回复设备」与「平台下发指令」两类报文:
|
#[derive(Debug, Serialize)]
|
||||||
//
|
pub struct ServerResponse {
|
||||||
// 本服务**只做透传** —— 回复走 Redis `device:commands`(由 api-server 用
|
/// 1=成功,0=失败
|
||||||
// `api-server/src/protocol.rs::ServerResponse` 构造),指令同样由 api-server 用
|
pub suc: u8,
|
||||||
// `api-server/src/commands.rs::DeviceCommand` 构造,本服务按 `serde_json::Value`
|
pub msg_id: Option<u32>,
|
||||||
// 读写、只负责"摘内部字段 + 注入 msg_id/登记挂起 + 投递"。
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
//
|
pub err: Option<String>,
|
||||||
// 因此这里**不再保留** `ServerResponse` / `DeviceCommand` 的结构体副本:
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
// 有了副本反而会与 api-server 的真实定义漂移(历史上 api-server 里那份
|
pub auth_str: Option<String>,
|
||||||
// `DeviceMessage` 副本就是这样带着废弃字段误导了很久)。
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
pub data: Option<Value>,
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 单元测试:上行报文反序列化(协议 §3.1 / §5.3 / §5.5 / §3.5)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::DeviceMessage;
|
|
||||||
|
|
||||||
/// `status_post` 新结构:`cabinet` + `boards[].channels[]`(18 字符 hex 透传)
|
|
||||||
#[test]
|
|
||||||
fn test_device_message_status_post_new_shape() {
|
|
||||||
let raw = r#"{"act":"status_post","msg_id":3,"rssi":85,
|
|
||||||
"cabinet":{"status1":"00","status2":"00","temp":29800,"meter":125500},
|
|
||||||
"boards":[{"board":1,"channels":["828255152c01f40143","008200000000000000"]}]}"#;
|
|
||||||
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
|
||||||
assert_eq!(msg.act, "status_post");
|
|
||||||
assert_eq!(msg.rssi, Some(85));
|
|
||||||
assert_eq!(msg.cabinet.as_ref().unwrap()["status1"], "00");
|
|
||||||
let boards = msg.boards.as_ref().unwrap().as_array().unwrap();
|
|
||||||
assert_eq!(boards.len(), 1);
|
|
||||||
assert_eq!(boards[0]["board"], 1);
|
|
||||||
assert_eq!(boards[0]["channels"][1], "008200000000000000");
|
|
||||||
// 新协议业务上行不带 dev_id
|
|
||||||
assert!(msg.dev_id.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `bat_in` 的 `battery` 块(属性 + 18hex info + cells)
|
impl ServerResponse {
|
||||||
#[test]
|
/// 创建成功响应。
|
||||||
fn test_device_message_bat_in_battery_block() {
|
pub fn success(msg_id: Option<u32>) -> Self {
|
||||||
let raw = r#"{"act":"bat_in","msg_id":5,"id":"01-3","battery":{
|
Self { suc: 1, msg_id, err: None, auth_str: None, data: None }
|
||||||
"battery_id":"SN123456","brand_code":"1","cell_count":15,
|
|
||||||
"info":"80825515180320012a","cells":"0e100e15"}}"#;
|
|
||||||
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
|
||||||
let battery = msg.battery.as_ref().unwrap();
|
|
||||||
assert_eq!(battery["battery_id"], "SN123456");
|
|
||||||
assert_eq!(battery["cell_count"], 15);
|
|
||||||
assert_eq!(battery["info"], "80825515180320012a");
|
|
||||||
// `id` 未在结构体声明 → 必须落进 `extra`,否则 api-server 拿不到动作目标
|
|
||||||
let extra = msg.extra.as_ref().unwrap();
|
|
||||||
assert_eq!(extra["id"], "01-3");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **关键契约**:设备回执的 `suc` 必须原样落到 `extra`。
|
/// 创建失败响应。
|
||||||
/// api-server 用 `msg.get("suc").is_some()` 判定"回执 vs 设备上报"(协议 §3.5),
|
pub fn error(msg_id: Option<u32>, err: impl Into<String>) -> Self {
|
||||||
/// 若这里把 `suc` 丢掉,回执会被误当成新请求处理。
|
Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None, data: None }
|
||||||
#[test]
|
|
||||||
fn test_device_message_receipt_suc_preserved_in_extra() {
|
|
||||||
let raw = r#"{"act":"on","msg_id":1,"id":"01-3","suc":1}"#;
|
|
||||||
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
|
||||||
let extra = msg.extra.as_ref().unwrap();
|
|
||||||
assert_eq!(extra["suc"], 1);
|
|
||||||
assert_eq!(extra["id"], "01-3");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有 `suc=0` + `err` 的失败回执:`err` 也必须透传(api-server 要落库,见 A6)
|
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
|
||||||
#[test]
|
pub fn to_json(&self) -> String {
|
||||||
fn test_device_message_failed_receipt_error_preserved() {
|
serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
|
||||||
let raw = r#"{"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}"#;
|
}
|
||||||
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
|
||||||
let extra = msg.extra.as_ref().unwrap();
|
|
||||||
assert_eq!(extra["suc"], 0);
|
|
||||||
assert_eq!(extra["err"], "door lock not engaged");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `auth_str` / `login` 是仅有的两条带 `dev_id` 的上行
|
/// 平台下发至设备的指令。
|
||||||
#[test]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
fn test_device_message_auth_str_carries_dev_id() {
|
pub struct DeviceCommand {
|
||||||
let raw = r#"{"act":"auth_str","msg_id":1,"dev_id":"864720562557021"}"#;
|
pub act: String,
|
||||||
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
pub msg_id: u32,
|
||||||
assert_eq!(msg.dev_id.as_deref(), Some("864720562557021"));
|
#[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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停电上报无附加字段(协议定稿:`pow_fail_dc` / `pow_fail_ac` 均不带 payload)
|
impl DeviceCommand {
|
||||||
#[test]
|
/// 序列化为 JSON 字符串(紧凑格式)。
|
||||||
fn test_device_message_pow_fail_has_no_payload() {
|
pub fn to_json(&self) -> String {
|
||||||
for act in ["pow_fail_dc", "pow_fail_ac"] {
|
serde_json::to_string(self).expect("DeviceCommand 序列化不应失败")
|
||||||
let raw = format!(r#"{{"act":"{act}","msg_id":4}}"#);
|
|
||||||
let msg: DeviceMessage = serde_json::from_str(&raw).unwrap();
|
|
||||||
assert_eq!(msg.act, act);
|
|
||||||
assert!(msg.cabinet.is_none());
|
|
||||||
assert!(msg.boards.is_none());
|
|
||||||
assert!(msg.battery.is_none());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +1,52 @@
|
|||||||
//! TCP 服务端:监听端口、接收设备连接、按 LF 分割 JSON 消息。
|
//! TCP 服务端:监听端口、接收设备连接、按 LF 分割 JSON 消息。
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::time;
|
||||||
use tokio_util::codec::{FramedRead, LinesCodec};
|
use tokio_util::codec::{FramedRead, LinesCodec};
|
||||||
|
|
||||||
use super::connection::{ConnectionPool, DeviceConnection};
|
use super::connection::{ConnectionPool, DeviceConnection};
|
||||||
use super::handler::handle_message;
|
use super::handler::handle_message;
|
||||||
use super::protocol::DeviceMessage;
|
use super::protocol::DeviceMessage;
|
||||||
|
|
||||||
/// 未登录连接**允许**发送的唯一两个 `act`(协议定稿:其余指令必须先 `login` 成功)。
|
/// 心跳超时:5000ms 未收到消息则断开连接。
|
||||||
const UNAUTH_ALLOWED_ACTS: &[&str] = &["auth_str", "login"];
|
const HEARTBEAT_TIMEOUT: Duration = Duration::from_millis(5000);
|
||||||
|
/// 清理超时连接的间隔。
|
||||||
/// 连接建立后必须在此秒数内完成 `login` 认证,否则平台主动断开。
|
const CLEANUP_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
/// 依据:协议规定"login 成功这个 TCP 才可以保持"。
|
|
||||||
const UNAUTH_TIMEOUT_SECS: u64 = 30;
|
|
||||||
|
|
||||||
/// 未登录超时的巡检间隔(秒)。
|
|
||||||
const UNAUTH_CHECK_INTERVAL_SECS: u64 = 5;
|
|
||||||
|
|
||||||
/// 启动 TCP 监听循环。
|
/// 启动 TCP 监听循环。
|
||||||
pub async fn run_tcp_server(
|
pub async fn run_tcp_server(
|
||||||
addr: String,
|
addr: String,
|
||||||
pool: ConnectionPool,
|
pool: ConnectionPool,
|
||||||
redis: redis::aio::ConnectionManager,
|
redis: redis::aio::ConnectionManager,
|
||||||
node_id: String,
|
|
||||||
) {
|
) {
|
||||||
let listener = TcpListener::bind(&addr)
|
let listener = TcpListener::bind(&addr)
|
||||||
.await
|
.await
|
||||||
.expect("TCP 端口绑定失败");
|
.expect("TCP 端口绑定失败");
|
||||||
tracing::info!("TCP 设备服务启动: {}", addr);
|
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 {
|
loop {
|
||||||
match listener.accept().await {
|
match listener.accept().await {
|
||||||
Ok((stream, peer)) => {
|
Ok((stream, peer)) => {
|
||||||
tracing::info!("[新连接] peer={}", peer);
|
tracing::info!("[新连接] peer={}", peer);
|
||||||
// ponytail: 不需要设 TCP keepalive,我们是 server,设备才是 client。
|
|
||||||
// 设备每 60s 发 status_post 就是应用层保活。
|
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let redis = redis.clone();
|
let redis = redis.clone();
|
||||||
let nid = node_id.clone();
|
tokio::spawn(handle_connection(stream, peer, pool, redis));
|
||||||
tokio::spawn(handle_connection(stream, peer, pool, redis, nid));
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("accept 失败: {}", e);
|
tracing::error!("accept 失败: {}", e);
|
||||||
@ -55,19 +61,15 @@ async fn handle_connection(
|
|||||||
peer: std::net::SocketAddr,
|
peer: std::net::SocketAddr,
|
||||||
pool: ConnectionPool,
|
pool: ConnectionPool,
|
||||||
redis: redis::aio::ConnectionManager,
|
redis: redis::aio::ConnectionManager,
|
||||||
node_id: String,
|
|
||||||
) {
|
) {
|
||||||
let (reader, writer) = tokio::io::split(stream);
|
let (reader, writer) = tokio::io::split(stream);
|
||||||
// 单行上限 64 KB:status_post 为「每 60 秒全量上报」
|
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(4096));
|
||||||
// (当前 6板×6组 ≈ 8 KB;设计上限 15板×8组 ≈ 25 KB,含电芯数据约 40 KB),留足余量
|
|
||||||
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(65536));
|
|
||||||
|
|
||||||
// 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积
|
// 写通道:HTTP 命令下发通过此通道写入 TCP
|
||||||
let (tx, mut rx) = mpsc::channel::<String>(100);
|
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||||
|
|
||||||
let mut current_dev_id: Option<String> = None;
|
let mut current_dev_id: Option<String> = None;
|
||||||
let mut writer = writer;
|
let mut writer = writer;
|
||||||
let connected_at = std::time::Instant::now();
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@ -88,47 +90,29 @@ async fn handle_connection(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 如果是 login/auth_str,需要 dev_id 来关联连接
|
// 如果是 login/auth_str,需要 dev_id 来关联连接
|
||||||
// 注意:注册在签名验证之前,因为 login 响应(auth_str/cell)必须通过
|
|
||||||
// 连接池下发。先注册才能回复。纯内网 TCP + 真实设备重连快,攻击面有限。
|
|
||||||
// 审查编号:I3(已确认不修,协议约束)
|
|
||||||
if (msg.act == "auth_str" || msg.act == "login")
|
if (msg.act == "auth_str" || msg.act == "login")
|
||||||
&& let Some(ref dev_id) = msg.dev_id
|
&& let Some(ref dev_id) = msg.dev_id
|
||||||
&& current_dev_id.is_none() {
|
&& current_dev_id.is_none() {
|
||||||
let conn = DeviceConnection::new(tx.clone());
|
let conn = DeviceConnection::new(dev_id.clone(), tx.clone());
|
||||||
pool.register(dev_id.clone(), conn).await;
|
pool.register(dev_id.clone(), conn).await;
|
||||||
current_dev_id = Some(dev_id.clone());
|
current_dev_id = Some(dev_id.clone());
|
||||||
// 首次识别设备时写入 route
|
|
||||||
update_device_route(&redis, dev_id, &node_id).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新活跃时间,同时刷新 route(处理重连切换到其他节点的情况)
|
// 更新活跃时间
|
||||||
if let Some(ref dev_id) = current_dev_id {
|
if let Some(ref dev_id) = current_dev_id {
|
||||||
pool.touch(dev_id).await;
|
pool.touch(dev_id).await;
|
||||||
update_device_route(&redis, dev_id, &node_id).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安全规则(协议定稿):除 auth_str / login 外,其余指令必须先 login 成功
|
// 处理消息
|
||||||
// 未登录连接发来的业务报文一律丢弃(连接尚未与 dev_id 建立可信绑定,
|
let resp = handle_message(msg, &pool, &redis).await;
|
||||||
// 平台侧也无法路由应答)
|
|
||||||
if !UNAUTH_ALLOWED_ACTS.contains(&msg.act.as_str()) {
|
|
||||||
let authed = match current_dev_id.as_deref() {
|
|
||||||
Some(dev_id) => pool.is_authenticated(dev_id).await,
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
if !authed {
|
|
||||||
tracing::warn!(
|
|
||||||
"[安全] 连接未登录,丢弃报文 act={} peer={}",
|
|
||||||
msg.act,
|
|
||||||
peer
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理消息(传入连接绑定的设备身份:新协议业务上行不带 dev_id,
|
if let Some(resp) = resp {
|
||||||
// 转发到 Redis 前由 handler 补写,api-server 依赖它识别设备)。
|
let json = resp.to_json();
|
||||||
// 无返回值:本服务不直接回复设备,应答由 api-server 经 device:commands 下发。
|
if let Err(e) = tx.send(json) {
|
||||||
handle_message(msg, current_dev_id.as_deref(), &pool, &redis).await;
|
tracing::error!("[{}] 响应发送失败: {}", peer, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
tracing::warn!("[{}] 读取错误: {}", peer, e);
|
tracing::warn!("[{}] 读取错误: {}", peer, e);
|
||||||
@ -140,49 +124,14 @@ async fn handle_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 未登录超时巡检:连接建立后 UNAUTH_TIMEOUT_SECS 秒内未通过 login 即断开
|
|
||||||
// (协议规定:login 成功这条 TCP 才可以保持)
|
|
||||||
_ = tokio::time::sleep(std::time::Duration::from_secs(UNAUTH_CHECK_INTERVAL_SECS)) => {
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
|
|
||||||
let authed = match current_dev_id.as_deref() {
|
|
||||||
Some(dev_id) => pool.is_authenticated(dev_id).await,
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
if !authed && connected_at.elapsed().as_secs() >= UNAUTH_TIMEOUT_SECS {
|
|
||||||
tracing::warn!(
|
|
||||||
"[{}] 未在 {} 秒内完成 login,平台主动断开连接",
|
|
||||||
peer,
|
|
||||||
UNAUTH_TIMEOUT_SECS
|
|
||||||
);
|
|
||||||
let _ = writer.shutdown().await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 向设备写指令
|
// 向设备写指令
|
||||||
Some(json) = rx.recv() => {
|
Some(json) = rx.recv() => {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let framed = format!("{}\n", json);
|
||||||
// api-server 会附加内部标记 `_close`(login 失败后断开)与
|
|
||||||
// `_dev_id`(路由目标):两者仅供服务端使用,下发前摘除,设备侧不可见
|
|
||||||
let (payload, close_after) = split_internal_flags(&json);
|
|
||||||
|
|
||||||
let framed = format!("{}\n", payload);
|
|
||||||
if let Err(e) = writer.write_all(framed.as_bytes()).await {
|
if let Err(e) = writer.write_all(framed.as_bytes()).await {
|
||||||
tracing::error!("[{}] 写入失败: {}", peer, e);
|
tracing::error!("[{}] 写入失败: {}", peer, e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 规范:login 失败即断开 TCP,强制设备重连并重新走握手流程
|
|
||||||
if close_after {
|
|
||||||
tracing::warn!(
|
|
||||||
"[{}] login 失败,主动断开连接 dev_id={:?}",
|
|
||||||
peer,
|
|
||||||
current_dev_id
|
|
||||||
);
|
|
||||||
let _ = writer.shutdown().await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -193,119 +142,3 @@ async fn handle_connection(
|
|||||||
tracing::info!("[断连] dev_id={}", dev_id);
|
tracing::info!("[断连] dev_id={}", dev_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新设备路由:在 `device:{imei}` hash 中写入 `node_id`,并刷新 TTL。
|
|
||||||
/// 路由信息用于多节点时指令消费方判断设备连接在哪个节点上。
|
|
||||||
async fn update_device_route(
|
|
||||||
redis: &redis::aio::ConnectionManager,
|
|
||||||
dev_id: &str,
|
|
||||||
node_id: &str,
|
|
||||||
) {
|
|
||||||
let route_key = format!("device:{}", dev_id);
|
|
||||||
let mut conn = redis.clone();
|
|
||||||
let _: Result<(), _> = redis::cmd("HSET")
|
|
||||||
.arg(&route_key)
|
|
||||||
.arg("node_id")
|
|
||||||
.arg(node_id)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await;
|
|
||||||
// ponytail: 与设备在线 TTL 保持一致(300s),HSET 不支持同时设置 TTL
|
|
||||||
let _: Result<(), _> = redis::cmd("EXPIRE")
|
|
||||||
.arg(&route_key)
|
|
||||||
.arg(300_u32)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 摘除 api-server 附加的**内部标记**(`_close` / `_dev_id`)。
|
|
||||||
///
|
|
||||||
/// 返回 `(实际下发给设备的报文, 是否在发送后断开连接)`。
|
|
||||||
/// 这两个字段只用于服务端内部:
|
|
||||||
/// - `_close`:`login` 失败后主动断开连接
|
|
||||||
/// - `_dev_id`:指令的路由目标(设备身份),下发前必须摘除 ——
|
|
||||||
/// 否则设备会把它当成协议字段,且协议定稿后 `dev_id` 只在 `auth_str`/`login` 携带
|
|
||||||
///
|
|
||||||
/// 两者**都不会下发到设备**。
|
|
||||||
fn split_internal_flags(json: &str) -> (String, bool) {
|
|
||||||
match serde_json::from_str::<serde_json::Value>(json) {
|
|
||||||
Ok(mut value) => {
|
|
||||||
let close_after = value
|
|
||||||
.get("_close")
|
|
||||||
.and_then(|flag| flag.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if let Some(obj) = value.as_object_mut() {
|
|
||||||
obj.remove("_close");
|
|
||||||
obj.remove("_dev_id");
|
|
||||||
}
|
|
||||||
(value.to_string(), close_after)
|
|
||||||
}
|
|
||||||
// 非 JSON(理论上不会出现):原样下发,不断开
|
|
||||||
Err(_) => (json.to_string(), false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 单元测试:内部字段摘除(`_close` / `_dev_id` 绝不能下发到设备)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::split_internal_flags;
|
|
||||||
|
|
||||||
/// 普通指令:无内部字段时原样返回、不断开
|
|
||||||
#[test]
|
|
||||||
fn test_split_internal_flags_plain_command_untouched() {
|
|
||||||
let raw = r#"{"act":"on","msg_id":1,"id":"01-3"}"#;
|
|
||||||
let (payload, close) = split_internal_flags(raw);
|
|
||||||
assert!(!close);
|
|
||||||
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
|
||||||
assert_eq!(v["act"], "on");
|
|
||||||
assert_eq!(v["msg_id"], 1);
|
|
||||||
assert_eq!(v["id"], "01-3");
|
|
||||||
assert!(v.get("_dev_id").is_none());
|
|
||||||
assert!(v.get("_close").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `_dev_id`(路由目标)必须被摘除 —— 它是服务端内部字段,
|
|
||||||
/// 且协议定稿后 `dev_id` 只在 auth_str/login 携带,泄漏会让设备误解报文语义
|
|
||||||
#[test]
|
|
||||||
fn test_split_internal_flags_removes_route_dev_id() {
|
|
||||||
let raw = r#"{"act":"open","msg_id":2,"_dev_id":"864720562557021"}"#;
|
|
||||||
let (payload, close) = split_internal_flags(raw);
|
|
||||||
assert!(!close);
|
|
||||||
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
|
||||||
assert_eq!(v["act"], "open");
|
|
||||||
assert_eq!(v["msg_id"], 2);
|
|
||||||
assert!(v.get("_dev_id").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `_close` 被摘除并回报"发送后断开"(login 失败即断连)
|
|
||||||
#[test]
|
|
||||||
fn test_split_internal_flags_reports_and_removes_close() {
|
|
||||||
let raw = r#"{"act":"login","msg_id":2,"suc":0,"err":"invalid signature","_close":true}"#;
|
|
||||||
let (payload, close) = split_internal_flags(raw);
|
|
||||||
assert!(close);
|
|
||||||
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
|
||||||
assert_eq!(v["act"], "login");
|
|
||||||
assert_eq!(v["err"], "invalid signature");
|
|
||||||
assert!(v.get("_close").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 两个内部字段同时出现时都要摘掉
|
|
||||||
#[test]
|
|
||||||
fn test_split_internal_flags_removes_both() {
|
|
||||||
let raw = r#"{"act":"login","suc":0,"_dev_id":"864720562557021","_close":true}"#;
|
|
||||||
let (payload, close) = split_internal_flags(raw);
|
|
||||||
assert!(close);
|
|
||||||
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
|
||||||
assert!(v.get("_dev_id").is_none());
|
|
||||||
assert!(v.get("_close").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 非 JSON 原样下发且不断开(理论上不会出现,但不能因此丢失报文)
|
|
||||||
#[test]
|
|
||||||
fn test_split_internal_flags_non_json_passthrough() {
|
|
||||||
let (payload, close) = split_internal_flags("not-json");
|
|
||||||
assert_eq!(payload, "not-json");
|
|
||||||
assert!(!close);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
9
software/h5-taro/babel.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// babel-preset-taro 更多配置和用法:https://docs.taro.zone/docs/next/babel-config
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['taro', {
|
||||||
|
framework: 'react',
|
||||||
|
ts: true,
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
}
|
||||||
17
software/h5-taro/config/dev.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
NODE_ENV: '"development"',
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
devServer: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/h5/api': {
|
||||||
|
target: 'http://10.8.0.252:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/h5': '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
58
software/h5-taro/config/index.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
projectName: 'h5-taro',
|
||||||
|
date: '2026-07-03',
|
||||||
|
designWidth: 750,
|
||||||
|
deviceRatio: {
|
||||||
|
640: 2.34 / 2,
|
||||||
|
750: 1,
|
||||||
|
828: 1.81 / 2,
|
||||||
|
375: 2 / 1,
|
||||||
|
},
|
||||||
|
sourceRoot: 'src',
|
||||||
|
outputRoot: 'dist',
|
||||||
|
plugins: [],
|
||||||
|
defineConstants: {},
|
||||||
|
copy: {
|
||||||
|
patterns: [],
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
framework: 'react',
|
||||||
|
// compiler: 'webpack4', (default)
|
||||||
|
sass: {
|
||||||
|
data: '',
|
||||||
|
},
|
||||||
|
mini: {
|
||||||
|
postcss: {
|
||||||
|
pxtransform: { enable: true, config: {} },
|
||||||
|
url: { enable: true, config: { limit: 1024 } },
|
||||||
|
cssModules: {
|
||||||
|
enable: false,
|
||||||
|
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
publicPath: '/h5/',
|
||||||
|
staticDirectory: 'static',
|
||||||
|
router: {
|
||||||
|
mode: 'browser',
|
||||||
|
basename: '/h5/',
|
||||||
|
},
|
||||||
|
postcss: {
|
||||||
|
autoprefixer: { enable: true, config: {} },
|
||||||
|
cssModules: {
|
||||||
|
enable: false,
|
||||||
|
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function (merge) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return merge({}, config, require('./dev'))
|
||||||
|
}
|
||||||
|
return merge({}, config, require('./prod'))
|
||||||
|
}
|
||||||
6
software/h5-taro/config/prod.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
NODE_ENV: '"production"',
|
||||||
|
},
|
||||||
|
outputRoot: 'dist',
|
||||||
|
}
|
||||||
32
software/h5-taro/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "h5-taro",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "安知充 H5 用户端 (Taro)",
|
||||||
|
"scripts": {
|
||||||
|
"dev:h5": "taro build --type h5 --watch",
|
||||||
|
"build:h5": "taro build --type h5",
|
||||||
|
"dev:weapp": "taro build --type weapp --watch",
|
||||||
|
"build:weapp": "taro build --type weapp"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.0",
|
||||||
|
"@tarojs/components": "3.6.32",
|
||||||
|
"@tarojs/plugin-framework-react": "^3.6.32",
|
||||||
|
"@tarojs/plugin-platform-h5": "3.6.32",
|
||||||
|
"@tarojs/react": "3.6.32",
|
||||||
|
"@tarojs/runtime": "3.6.32",
|
||||||
|
"@tarojs/taro": "3.6.32",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.23.0",
|
||||||
|
"@tarojs/cli": "3.6.32",
|
||||||
|
"@tarojs/webpack-runner": "3.6.32",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"babel-preset-taro": "3.6.32",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
software/h5-taro/project.config.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"miniprogramRoot": "./dist",
|
||||||
|
"projectname": "h5-taro",
|
||||||
|
"description": "安知充 H5 用户端",
|
||||||
|
"appid": "touristappid",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": true,
|
||||||
|
"es6": false,
|
||||||
|
"enhance": false,
|
||||||
|
"postcss": false,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"minified": false,
|
||||||
|
"newFeature": true,
|
||||||
|
"coverView": true,
|
||||||
|
"nodeModules": false,
|
||||||
|
"autoAudits": false,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"scopeDataCheck": false,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"checkInvalidKey": true,
|
||||||
|
"checkSiteMap": true,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"compileHotReLoad": false,
|
||||||
|
"lazyloadPlaceholderEnable": false,
|
||||||
|
"useMultiFrameRuntime": true,
|
||||||
|
"useApiHook": true,
|
||||||
|
"useApiHostProcess": true
|
||||||
|
},
|
||||||
|
"compileType": "miniprogram"
|
||||||
|
}
|
||||||
161
software/h5-taro/src/api/index.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
|
||||||
|
const BASE_URL = '/h5/api'
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: { method?: string; body?: unknown; params?: Record<string, string> } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const token = Taro.getStorageSync('h5_token')
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = `${BASE_URL}${path}`
|
||||||
|
if (options.params) {
|
||||||
|
const qs = new URLSearchParams(options.params).toString()
|
||||||
|
url += `?${qs}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await Taro.request({
|
||||||
|
url,
|
||||||
|
method: (options.method as 'GET' | 'POST' | 'PUT' | 'DELETE') || 'GET',
|
||||||
|
header: headers,
|
||||||
|
data: options.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ponytail: single error shape, add wrapper if backend uses inconsistent envelopes
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败'
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
phone: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string
|
||||||
|
user: H5User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5User {
|
||||||
|
id: number
|
||||||
|
phone: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
online_cabinets: number
|
||||||
|
charging_count: number
|
||||||
|
idle_channels: number
|
||||||
|
fault_channels: number
|
||||||
|
today_charge_count: number
|
||||||
|
today_energy_kwh: number
|
||||||
|
today_charge_hours: number
|
||||||
|
alerts: { project: string; cabinet: string; channel: number }[]
|
||||||
|
projects: { id: number; name: string; cabinet_count: number; charging: number; idle: number; fault: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabinetItem {
|
||||||
|
id: number
|
||||||
|
abstract_id: string
|
||||||
|
name?: string
|
||||||
|
status: number // 1=在线 0=离线
|
||||||
|
project_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompartmentInfo {
|
||||||
|
id: number
|
||||||
|
channel_id: number
|
||||||
|
status: number // 0=空闲 1=充电中 2=故障 3=离线
|
||||||
|
voltage?: number
|
||||||
|
current?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabinBoard {
|
||||||
|
id: number
|
||||||
|
board_id: number
|
||||||
|
status: number
|
||||||
|
compartments: CompartmentInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabinetDetail {
|
||||||
|
id: number
|
||||||
|
abstract_id: string
|
||||||
|
name?: string
|
||||||
|
imei?: string
|
||||||
|
status: number
|
||||||
|
cabin_boards: CabinBoard[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompartmentDetail {
|
||||||
|
id: number
|
||||||
|
channel_id: number
|
||||||
|
status: number
|
||||||
|
voltage?: number
|
||||||
|
current?: number
|
||||||
|
power?: number
|
||||||
|
energy?: number
|
||||||
|
soc?: number
|
||||||
|
soh?: number
|
||||||
|
cell_voltages?: number[]
|
||||||
|
cell_temps?: number[]
|
||||||
|
bms_alerts?: string[]
|
||||||
|
charge_records?: ChargeRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeRecord {
|
||||||
|
id: number
|
||||||
|
start_time: string
|
||||||
|
end_time?: string
|
||||||
|
energy_kwh: number
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- API ----
|
||||||
|
|
||||||
|
export const h5Api = {
|
||||||
|
// Auth
|
||||||
|
login: (params: LoginParams) =>
|
||||||
|
request<{ data: LoginResult }>('/h5/auth/login', { method: 'POST', body: params }),
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: () =>
|
||||||
|
request<{ data: DashboardData }>('/h5/dashboard'),
|
||||||
|
|
||||||
|
// Cabinets
|
||||||
|
getCabinets: (params?: { project_id?: number }) =>
|
||||||
|
request<{ data: CabinetItem[] }>('/h5/cabinets', { params: params as Record<string, string> }),
|
||||||
|
|
||||||
|
// Cabinet detail (by abstract_id for QR scan — public endpoint)
|
||||||
|
getCabinetByAbstract: (abstractId: string) =>
|
||||||
|
request<{ data: CabinetDetail }>(`/h5/cabinets/abstract/${abstractId}`),
|
||||||
|
|
||||||
|
// Cabinet detail by DB id
|
||||||
|
getCabinetDetail: (id: number) =>
|
||||||
|
request<{ data: CabinetDetail }>(`/h5/cabinets/${id}`),
|
||||||
|
|
||||||
|
// Compartment detail
|
||||||
|
getCompartmentDetail: (id: number) =>
|
||||||
|
request<{ data: CompartmentDetail }>(`/h5/compartments/${id}`),
|
||||||
|
|
||||||
|
// Actions — match backend POST /api/h5/charge/start, /stop, /door/open with {compartment_id}
|
||||||
|
startCharge: (compartmentId: number) =>
|
||||||
|
request<{ code: number }>('/h5/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }),
|
||||||
|
|
||||||
|
stopCharge: (compartmentId: number) =>
|
||||||
|
request<{ code: number }>('/h5/charge/stop', { method: 'POST', body: { compartment_id: compartmentId } }),
|
||||||
|
|
||||||
|
openDoor: (compartmentId: number) =>
|
||||||
|
request<{ code: number }>('/h5/door/open', { method: 'POST', body: { compartment_id: compartmentId } }),
|
||||||
|
}
|
||||||
43
software/h5-taro/src/app.config.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
export default {
|
||||||
|
pages: [
|
||||||
|
'pages/home/index',
|
||||||
|
'pages/devices/index',
|
||||||
|
'pages/me/index',
|
||||||
|
'pages/login/index',
|
||||||
|
'pages/device-detail/index',
|
||||||
|
'pages/compartment-detail/index',
|
||||||
|
],
|
||||||
|
window: {
|
||||||
|
backgroundTextStyle: 'light',
|
||||||
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
|
navigationBarTitleText: '安知充',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
color: '#999999',
|
||||||
|
selectedColor: '#2563eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderStyle: 'black',
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
pagePath: 'pages/home/index',
|
||||||
|
text: '首页',
|
||||||
|
iconPath: 'assets/home.svg',
|
||||||
|
selectedIconPath: 'assets/home-active.svg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/devices/index',
|
||||||
|
text: '设备',
|
||||||
|
iconPath: 'assets/device.svg',
|
||||||
|
selectedIconPath: 'assets/device-active.svg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/me/index',
|
||||||
|
text: '我的',
|
||||||
|
iconPath: 'assets/me.svg',
|
||||||
|
selectedIconPath: 'assets/me-active.svg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
66
software/h5-taro/src/app.css
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/* 全局样式 */
|
||||||
|
page {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用重置 */
|
||||||
|
view,
|
||||||
|
text,
|
||||||
|
image,
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 灰色卡片容器 */
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态标签 */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 16px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.online {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.charging {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.idle {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.fault {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 占位图标(用纯CSS画简单的tab图标) */
|
||||||
|
.tab-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
44
software/h5-taro/src/app.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useDidShow, useDidHide } from '@tarojs/taro'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { useH5AuthStore } from './store/auth'
|
||||||
|
import './app.css'
|
||||||
|
|
||||||
|
function App(props: { children: React.ReactNode }) {
|
||||||
|
const restore = useH5AuthStore((s) => s.restore)
|
||||||
|
const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore()
|
||||||
|
}, [restore])
|
||||||
|
|
||||||
|
// Handle QR scan entry: URL rewritten by index.html script
|
||||||
|
useEffect(() => {
|
||||||
|
const abstractId: string | undefined = (window as any).__SCAN_ABSTRACT_ID__
|
||||||
|
if (!abstractId) return
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
import('./api').then(({ h5Api }) => {
|
||||||
|
h5Api.getCabinetByAbstract(abstractId)
|
||||||
|
.then((res) => Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` }))
|
||||||
|
.catch(() => Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` }))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` })
|
||||||
|
}
|
||||||
|
// ponytail: runs once on mount, auth changes handled by redirect
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
// ponytail: analytics hook, add when needed
|
||||||
|
})
|
||||||
|
|
||||||
|
useDidHide(() => {
|
||||||
|
// ponytail: cleanup hook, add when needed
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
software/h5-taro/src/assets/device-active.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M12 4C10.9 4 10 4.9 10 6v36c0 1.1.9 2 2 2h24c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H12zm0 6h24v28H12V10z"/></svg>
|
||||||
|
After Width: | Height: | Size: 191 B |