feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复
协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
19
.gitignore
vendored
@ -14,9 +14,10 @@ package-lock.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Environment
|
# Environment / 凭据
|
||||||
.env
|
# ⚠️ 用 **/.env* 覆盖所有层级的 .env / .env.production / .env.test 等变体;仅放行示例模板
|
||||||
.env.local
|
**/.env*
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
# Temporary
|
# Temporary
|
||||||
*.tmp
|
*.tmp
|
||||||
@ -38,6 +39,18 @@ 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/
|
||||||
|
|
||||||
|
|||||||
32
.workbuddy/memory/2026-07-07.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# 2026-07-07 Session Notes
|
||||||
|
|
||||||
|
## 完成工作:安知充H5应用设计稿 — 修复全部7屏填充色问题
|
||||||
|
|
||||||
|
### 问题诊断
|
||||||
|
- 用户反馈"位置都是乱的",截图验证发现所有7屏几乎全白/空白
|
||||||
|
- 根因:上一轮重建时所有frame节点的 `fills: []` 为空(容器无背景色),只有text节点有fills所以能看到零星文字
|
||||||
|
- batch_read 确认:用户卡片、菜单卡片、设备卡片、订单卡片、通道卡片等所有容器的 fills 均为空数组
|
||||||
|
|
||||||
|
### 修复操作(共6个batch_edit批次,约120+个U()操作)
|
||||||
|
1. **Batch 1 (23 ops)**: 7屏背景色(登录渐变+其余#f5f5f5) + 登录表单元素(输入框白底边框+渐变按钮) + 首页卡片(组织切换/筛选标签/统计行/设备卡×2) + TabBar×2
|
||||||
|
2. **Batch 2 (10 ops)**: 设备列表卡×3 + 仓体详情卡片×3(状态/参数/记录) + 操作按钮×2(停止橙色/开门线框) + 订单卡×3
|
||||||
|
3. **Batch 3 (8 ops)**: 设备详情-信息卡白色 + 开门按钮蓝色 + 板头×6(灰蓝色调)
|
||||||
|
4. **Batch 4 (22 ops)**: 通道卡1-12(板1-2): 白底圆角6px + 彩色状态条(绿/蓝/红/紫)
|
||||||
|
5. **Batch 5 (22 ops)**: 通道卡13-24(板3离线灰色 + 板4混合状态)
|
||||||
|
6. **Batch 6 (22 ops)**: 通道卡25-36(板5-6混合状态)
|
||||||
|
7. **我的页补完**: 用户卡片白底 + 头像蓝底#dbeafe + 菜单卡片白底 + 退出登录按钮(红字红边框) + 底部TabBar(SVG图标)
|
||||||
|
|
||||||
|
### 关键技术发现
|
||||||
|
- ardot batch_edit 中 Update 已有节点:`fills` 必须用 `[{type:"SOLID", color:{r,g,b}}]` 数组格式
|
||||||
|
- `strokes` 必须用数组格式 `[{type:"SOLID", color:{r,g,b}, width:N}]`
|
||||||
|
- Insert 新节点时 `fill:"#FFF"` 简写可用,但 Update 时不可靠
|
||||||
|
|
||||||
|
### 颜色修正(用户反馈"颜色不对"后修复)
|
||||||
|
- 对照全部7个源文件的CSS,逐一修正了所有颜色
|
||||||
|
- **登录页**: 输入框背景 #f8fafc(更正白色), 边框 #e2e8f0(更正#e5e7eb)
|
||||||
|
- **首页**: 组织切换器背景 #f3f4f6(更正白色), 筛选标签激活态 #2563eb蓝(更正#dbeafe浅蓝)
|
||||||
|
- **设备列表**: 状态徽章添加背景圆角#f0fdf4/#f9fafb
|
||||||
|
- **设备详情**: 在线状态徽章/DC/AC标签添加圆角背景#f0fdf4, 导航栏添加底部边框
|
||||||
|
- **仓体详情**: 状态徽章添加圆角背景#eff6ff, 记录项添加底部边框
|
||||||
|
- **订单**: 状态徽章添加圆角背景(正常#f0fdf4/异常#fef2f2/停止#fffbeb), 订单体添加顶部边框
|
||||||
|
- 均用 `strokes: [{type:"SOLID", color:{r,g,b}, width:N}]` 数组格式设置边框
|
||||||
@ -4,3 +4,14 @@
|
|||||||
- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局
|
- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局
|
||||||
- **生图能力**:项目有 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表示状态色)
|
||||||
|
|||||||
263
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`
|
||||||
- **扫码链接**:`https://app.anzhizhichong.com/h5/{抽象ID}`
|
- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`)
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 后端:Rust + Axum
|
- 后端:Rust + Axum
|
||||||
- 前端:React + Arco Design Pro(React版)
|
- 前端:React + Arco Design Pro(React版)
|
||||||
- H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序)
|
- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
|
||||||
- 数据库:MySQL(阿里云RDS),开发/生产共用
|
- 数据库:MySQL(阿里云RDS),开发/生产共用
|
||||||
- 缓存:Redis(阿里云Redis),开发/生产共用
|
- 缓存:Redis(阿里云Redis),开发/生产共用
|
||||||
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
||||||
@ -38,12 +38,14 @@ D:\charging-cabinet\
|
|||||||
├── software/
|
├── software/
|
||||||
│ ├── api-server/ # Rust API服务
|
│ ├── api-server/ # Rust API服务
|
||||||
│ ├── device-server/ # Rust TCP设备服务
|
│ ├── device-server/ # Rust TCP设备服务
|
||||||
│ ├── h5-uniapp/ # H5用户端(uni-app + Vue 3)
|
│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
|
||||||
│ └── 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 技能副本。
|
||||||
|
|
||||||
## 部署架构
|
## 部署架构
|
||||||
|
|
||||||
### 环境规划
|
### 环境规划
|
||||||
@ -53,14 +55,26 @@ 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 3000,TCP 3001
|
|
||||||
- 正式环境:API 9000,TCP 9001
|
- 正式环境:API 9000,TCP 9001
|
||||||
|
- 测试环境:API 3000,TCP 3001
|
||||||
|
|
||||||
|
> ✅ **实际部署现状(2026-09 已按规划完成迁移)**:
|
||||||
|
> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx
|
||||||
|
> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env`
|
||||||
|
> (**新二进制强制要求环境变量,缺它起不来**)。
|
||||||
|
> - **本服务器不部署测试环境**(用户确认:这台就是生产)—— `pms-api-test` / `pms-device-test`
|
||||||
|
> 已停止并卸载,`:3000` / `:3001` 已释放。
|
||||||
|
> - `test.anzhizhichong.com` 仍服务于静态前端(`/data/pms_dev/web`),但其 `/api/pms/` 因为
|
||||||
|
> **后端不在**会返回 **502** —— 这是**刻意的**:此前它指向 `:3000`,而 `:3000` 曾是生产 API,
|
||||||
|
> 等于**测试后台在操作生产数据**。**不要再把测试域名的 API 指回生产端口。**
|
||||||
|
> - 如需临时联调环境:`/data/pms_dev/*`(含 `.env`、旧二进制)与 `pms_dev` 库、Redis db6 均**保留**,
|
||||||
|
> 重新安装 `deploy/pms-*-test.service` 即可恢复(端口用 3000/3001)。
|
||||||
|
|
||||||
**部署路径:**
|
**部署路径:**
|
||||||
- 产品介绍页:/data/landing
|
- 产品介绍页:/data/landing
|
||||||
- 正式环境:/data/pms(api-server、device-server、web、h5)
|
- 正式环境:/data/pms(api-server、device-server、web)
|
||||||
- 测试环境:/data/pms_dev(api-server、device-server、web、h5)
|
- 测试环境:/data/pms_dev(api-server、device-server、web)
|
||||||
|
|
||||||
**Nginx配置:**
|
**Nginx配置:**
|
||||||
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
|
- 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf
|
||||||
@ -140,17 +154,19 @@ python tools/db.py import data.sql
|
|||||||
|
|
||||||
### 禁止操作
|
### 禁止操作
|
||||||
|
|
||||||
|
- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
|
||||||
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
|
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
|
||||||
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
|
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
|
||||||
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
|
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
|
||||||
|
|
||||||
### 编码任务下发
|
### 编码任务下发
|
||||||
|
|
||||||
- 任务需求写到 `tasks/` 目录的md文件
|
- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md)
|
||||||
- 通过Agent子代理包裹opencode执行,异步通知
|
- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json)
|
||||||
- 质量约束模板固定追加到每个任务文件
|
- 完成后由 PM(本 agent)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件
|
||||||
- opencode超时必须报告用户,不能fallback手动执行
|
- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落
|
||||||
- 审查用不同模型(不用开发同款)
|
- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议
|
||||||
|
- 已完成的任务文件及时清理
|
||||||
|
|
||||||
### 质量约束模板
|
### 质量约束模板
|
||||||
|
|
||||||
@ -166,9 +182,10 @@ python tools/db.py import data.sql
|
|||||||
|
|
||||||
### 验收流程
|
### 验收流程
|
||||||
|
|
||||||
1. 静态校验命令通过
|
1. `cargo check` 两个服务(api-server + device-server)零错误
|
||||||
2. 跑起来看效果
|
2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束)
|
||||||
3. 抽查关键逻辑(异常处理、权限校验、协议解析)
|
3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号)
|
||||||
|
4. 审查通过 → 清理任务文件,更新文档
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -185,16 +202,22 @@ python tools/db.py import data.sql
|
|||||||
|
|
||||||
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
|
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
|
||||||
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
|
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
|
||||||
- Redis 队列消费:`blpop` 阻塞读取 + `tokio::spawn` 并发处理
|
- Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** ——
|
||||||
|
本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`,
|
||||||
|
这种组合下 `BRPOP` 语义不可靠(实测 2026-09):
|
||||||
|
- `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**;
|
||||||
|
- `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。
|
||||||
|
**规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致),
|
||||||
|
延迟稳定在 200ms 内;取到消息后 `tokio::spawn` 并发处理。
|
||||||
|
|
||||||
### 模块划分
|
### 模块划分
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── routes/ # HTTP 路由处理器(按业务拆分)
|
├── routes/ # HTTP 路由处理器(按业务拆分)
|
||||||
├── middleware/ # 中间件(认证、日志、CORS)
|
├── middleware/ # 中间件(认证、日志、CORS、scope_guard 组织归属校验)
|
||||||
├── db/ # 数据库连接池、迁移
|
├── db/ # 数据库连接池、迁移
|
||||||
├── commands/ # 设备指令下发逻辑
|
├── commands/ # 设备指令下发逻辑(含 AppState 定义)
|
||||||
├── nodes/ # 设备服务节点管理
|
├── nodes/ # 设备服务节点管理
|
||||||
├── workers/ # 后台 Worker(report/reply)
|
├── workers/ # 后台 Worker(report/reply)
|
||||||
└── error.rs # 统一错误定义
|
└── error.rs # 统一错误定义
|
||||||
@ -222,12 +245,11 @@ src/
|
|||||||
|
|
||||||
- 页面级状态用 `useState` / `useEffect`
|
- 页面级状态用 `useState` / `useEffect`
|
||||||
- 全局状态用 `zustand`(如用户信息、权限)
|
- 全局状态用 `zustand`(如用户信息、权限)
|
||||||
- 服务端数据用 `@tanstack/react-query`,配置缓存策略
|
|
||||||
|
|
||||||
### 样式规范
|
### 样式规范
|
||||||
|
|
||||||
- 使用 Tailwind CSS,禁止内联 style(除动态计算值)
|
- 使用 Arco Design 组件库,优先使用组件自带的样式
|
||||||
- shadcn/ui 组件优先,不重复造轮子
|
- 内联 style 仅用于动态计算值,静态样式用 CSS class
|
||||||
- 表格列统一 `min-width` + `align="center"`,无 `fixed="right"`
|
- 表格列统一 `min-width` + `align="center"`,无 `fixed="right"`
|
||||||
- 分页:上下双分页,`page-sizes="[10,20,30,50]"`
|
- 分页:上下双分页,`page-sizes="[10,20,30,50]"`
|
||||||
|
|
||||||
@ -318,7 +340,7 @@ src/
|
|||||||
|
|
||||||
示例:
|
示例:
|
||||||
- `feat(cabinets): 添加设备RSSI信号显示`
|
- `feat(cabinets): 添加设备RSSI信号显示`
|
||||||
- `fix(h5): 修复登录后token未持久化问题`
|
- `fix(mp): 修复登录后token未持久化问题`
|
||||||
- `docs(api): 更新充电记录接口文档`
|
- `docs(api): 更新充电记录接口文档`
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -356,10 +378,17 @@ test_check_permission_user_returns_forbidden
|
|||||||
|
|
||||||
### 认证授权
|
### 认证授权
|
||||||
|
|
||||||
- JWT Token 有效期:7天,过期需重新登录
|
- JWT Token 有效期:24小时,过期需重新登录
|
||||||
|
- JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret
|
||||||
- 密码存储:argon2 哈希,禁止明文或 MD5/SHA1
|
- 密码存储:argon2 哈希,禁止明文或 MD5/SHA1
|
||||||
- RBAC 权限码:精确到按钮级别(如 `device:door:unlock`)
|
- RBAC 权限码:精确到按钮级别(如 `device:door:unlock`)
|
||||||
|
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
|
||||||
- 敏感操作(删除/禁用/开门)需二次确认
|
- 敏感操作(删除/禁用/开门)需二次确认
|
||||||
|
- TCP 连接认证:设备 login 成功(响应含 `spec` + `suc=1`)前,敏感指令(open/pow_on/pow_off/reset/auth_str)不投递
|
||||||
|
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
|
||||||
|
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
|
||||||
|
- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定
|
||||||
|
- **未登录连接不予保持**:建连后 30 秒内未 `login` 成功即主动断开
|
||||||
|
|
||||||
### 输入校验
|
### 输入校验
|
||||||
|
|
||||||
@ -532,7 +561,7 @@ server {
|
|||||||
|
|
||||||
# PMS API 入口(微服务统一前缀 /api/{服务})
|
# PMS API 入口(微服务统一前缀 /api/{服务})
|
||||||
location /api/pms/ {
|
location /api/pms/ {
|
||||||
proxy_pass http://127.0.0.1:3000/api/;
|
proxy_pass http://127.0.0.1:9000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header 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;
|
||||||
@ -545,10 +574,13 @@ server {
|
|||||||
try_files $uri $uri/ /pms/index.html;
|
try_files $uri $uri/ /pms/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# H5用户端
|
# 微信回调(免认证)
|
||||||
location /h5/ {
|
location /api/wechat/ {
|
||||||
alias /data/pms/h5/;
|
proxy_pass http://127.0.0.1:9000;
|
||||||
try_files $uri $uri/ /h5/index.html;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -586,16 +618,13 @@ server {
|
|||||||
try_files $uri $uri/ /pms/index.html;
|
try_files $uri $uri/ /pms/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# H5用户端
|
# 微信回调(免认证)
|
||||||
location /h5/ {
|
location /api/wechat/ {
|
||||||
alias /data/pms_dev/h5/;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
try_files $uri $uri/ /h5/index.html;
|
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_dev/h5/;
|
|
||||||
try_files $uri $uri/ /h5/index.html;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -604,55 +633,80 @@ server {
|
|||||||
|
|
||||||
### 部署步骤
|
### 部署步骤
|
||||||
|
|
||||||
|
> ✅ **推荐用一键脚本(在本地开发机执行)**:
|
||||||
|
> ```bash
|
||||||
|
> pwsh deploy/deploy-prod.ps1 # 完整部署:构建 → 校验产物 → 服务器备份 → 传 .new → 原子替换 → 重启 → 验收
|
||||||
|
> pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,只重发现有产物
|
||||||
|
> pwsh deploy/deploy-prod.ps1 -DryRun # 演练,不做任何改动
|
||||||
|
> pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端
|
||||||
|
> ```
|
||||||
|
> 脚本会自动做:产物路径校验、**上传后 `chmod +x`**、**传 `.new` 再原子 `mv`**(避开 ETXTBSY)、
|
||||||
|
> `nginx -t` 后才 reload、验收(服务/端口/域名/ERROR 数)、以及**用不存在的 IMEI 探测上报消费链路**
|
||||||
|
> (不碰真实设备数据),最后打印回滚方法。
|
||||||
|
>
|
||||||
|
> ⛔ **`deploy/deploy-prod.sh` 与 `deploy/deploy-test.sh` 已废弃**(执行即报错退出):
|
||||||
|
> 它们在 `target/release/` 找产物、会安装重复的 `pms-api-prod` 单元、并会用本地 `.env`
|
||||||
|
> 覆盖线上 `.env`(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。
|
||||||
|
|
||||||
**1. 本地编译**
|
**1. 本地编译**
|
||||||
```bash
|
```bash
|
||||||
# 后端(Rust)
|
# 后端(Rust)— Windows 交叉编译 Linux 目标
|
||||||
cd software/api-server
|
cd software/api-server
|
||||||
cargo build --release
|
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
cd software/device-server
|
cd software/device-server
|
||||||
cargo build --release
|
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
# 前端(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/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
|
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
|
||||||
scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms_dev/device-server/
|
scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms_dev/device-server/
|
||||||
|
|
||||||
# 传输后端二进制(正式环境)
|
# 传输后端二进制(正式环境)
|
||||||
scp software/api-server/target/release/pms-api root@118.31.119.206:/data/pms/api-server/
|
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/
|
||||||
scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms/device-server/
|
scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms/device-server/
|
||||||
|
|
||||||
# 传输前端静态文件
|
# 传输前端静态文件
|
||||||
scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境
|
scp -r software/web/dist/* root@118.31.119.206:/data/pms_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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ H5部署必须用tar.gz打包方式,不要用plink/pscp管道传单文件(会丢失UTF-8编码导致中文变问号)。
|
> ⚠️ 后端二进制必须用 `cargo zigbuild --release --target x86_64-unknown-linux-gnu` 交叉编译,服务器 2核/3.5G 会 OOM,禁止在服务器上编译 Rust。
|
||||||
|
>
|
||||||
|
> ⚠️ **`pscp`/`scp` 上传后必须 `chmod +x`**(否则 systemd 报 `203/EXEC`);
|
||||||
|
> 且**运行中的二进制不可直接覆盖**(Linux 报 `ETXTBSY`)—— 正确姿势:
|
||||||
|
> 传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。
|
||||||
|
>
|
||||||
|
> ⚠️ **`.env` 不支持行内注释**:`PORT=3000 # 注释` 会让配置解析失败、服务起不来,注释必须独占一行。
|
||||||
|
|
||||||
|
**2.1 微信小程序上传**
|
||||||
|
|
||||||
|
小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传:
|
||||||
|
|
||||||
|
1. 打开微信开发者工具
|
||||||
|
2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin`
|
||||||
|
3. 填入 AppID(已配置在 `manifest.json`)
|
||||||
|
4. 预览/真机调试 → 上传体验版/正式版
|
||||||
|
|
||||||
**3. 服务器重启服务**
|
**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-test
|
systemctl restart pms-api
|
||||||
systemctl restart pms-device-test
|
systemctl restart pms-device
|
||||||
|
|
||||||
# 重启正式环境
|
|
||||||
systemctl restart pms-api-prod
|
|
||||||
systemctl restart pms-device-prod
|
|
||||||
|
|
||||||
# 重启Nginx
|
# 重启Nginx
|
||||||
systemctl restart nginx
|
systemctl restart nginx
|
||||||
@ -662,13 +716,13 @@ systemctl restart nginx
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看服务状态
|
# 查看服务状态
|
||||||
systemctl status pms-api-test # 测试环境API
|
systemctl status pms-api # 正式环境API
|
||||||
systemctl status pms-api-prod # 正式环境API
|
systemctl status pms-device # 正式环境TCP
|
||||||
systemctl status nginx
|
systemctl status nginx
|
||||||
|
|
||||||
# 查看日志
|
# 查看日志
|
||||||
journalctl -u pms-api-test -f
|
journalctl -u pms-api -f
|
||||||
journalctl -u pms-api-prod -f
|
journalctl -u pms-device -f
|
||||||
tail -f /var/log/nginx/error.log
|
tail -f /var/log/nginx/error.log
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -685,12 +739,11 @@ tail -f /var/log/nginx/error.log
|
|||||||
### 做
|
### 做
|
||||||
|
|
||||||
- 后台管理系统(Web端)
|
- 后台管理系统(Web端)
|
||||||
- 用户端H5页面(`/pms/{id}`,微信扫码可访问)
|
- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传)
|
||||||
- 设备TCP通讯服务(协议文档已就绪)
|
- 设备TCP通讯服务(协议文档已就绪)
|
||||||
|
|
||||||
### 不做
|
### 不做
|
||||||
|
|
||||||
- 微信小程序(名额暂不可用,先用H5,uni-app可后续转换)
|
|
||||||
- 支付/计费(本系统不涉及)
|
- 支付/计费(本系统不涉及)
|
||||||
|
|
||||||
## 组织架构
|
## 组织架构
|
||||||
@ -779,26 +832,63 @@ device:{imei} # 设备级 Hash
|
|||||||
|
|
||||||
## TCP通讯协议
|
## TCP通讯协议
|
||||||
|
|
||||||
详见 [docs/API.md](docs/API.md#tcp-设备通讯协议) — 包含签名机制、指令清单、指令详情、充电结束原因。
|
详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
|
||||||
|
|
||||||
## 数据单位规范
|
## 数据单位规范
|
||||||
|
|
||||||
详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
|
详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
|
||||||
|
|
||||||
**现有浮点问题(需清理):**
|
> ✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 `write_number` API 参数为 f64(`i64 as f64` 转换,非业务浮点)。
|
||||||
|
|
||||||
| 文件 | 位置 | 问题 | 处理 |
|
## 微信小程序用户端
|
||||||
|------|------|------|------|
|
|
||||||
| `src/routes/charge_records.rs` | L46-47, L77-78 | `Option<f64>` 读 DECIMAL | 需 CAST AS DOUBLE [有CAST] |
|
|
||||||
| `src/routes/energy_stats.rs` | L106, L116 | `query_scalar::<_, f64>` 直接读 DECIMAL 无 CAST ❌ | 需加 CAST AS DOUBLE 或改用整数 |
|
|
||||||
| `src/routes/energy_stats.rs` | L43-72 | 多个 `Option<f64>` 字段 | 待迁移为 i64 |
|
|
||||||
| `src/db/migrate.rs` | L76-77, L97 | `DECIMAL` 列定义 | 待改为 `BIGINT` |
|
|
||||||
|
|
||||||
## H5用户端
|
### 小程序配置
|
||||||
|
|
||||||
|
| 参数 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| AppID | `wx226061c550c52e85` |
|
||||||
|
| AppSecret | `f11dfee9dc73265b75bf54277ebb1047` |
|
||||||
|
| 账号 | `13951516272@163.com` |
|
||||||
|
| 消息加解密方式 | 明文模式 |
|
||||||
|
| 数据格式 | XML |
|
||||||
|
| 服务器地址(URL) | `https://app.anzhizhichong.com/api/wechat/callback` |
|
||||||
|
| Token(令牌) | `7GW16XdnFyak0mE32rc8ZPij` |
|
||||||
|
| EncodingAESKey | `kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu` |
|
||||||
|
|
||||||
|
> ⚠️ 以上密钥仅记录在内网 Git 仓库 `生产环境.txt`,不入代码仓库。
|
||||||
|
|
||||||
|
### 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd software/h5-uniapp
|
||||||
|
npm run build:mp-weixin
|
||||||
|
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 域名配置
|
||||||
|
|
||||||
|
小程序请求后端 API 必须配置合法域名(HTTPS):
|
||||||
|
|
||||||
|
- 后台「开发管理 → 开发设置 → 服务器域名」
|
||||||
|
- request 合法域名:`https://app.anzhizhichong.com`
|
||||||
|
- 开发阶段可在 `manifest.json` 设 `urlCheck: false` 跳过校验
|
||||||
|
|
||||||
|
### API base URL
|
||||||
|
|
||||||
|
小程序用全 URL(`api/index.ts` 条件编译):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const BASE_URL = 'https://app.anzhizhichong.com/api/pms'
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
const BASE_URL = '/api/pms'
|
||||||
|
// #endif
|
||||||
|
```
|
||||||
|
|
||||||
### 信号强度显示(RSSI)
|
### 信号强度显示(RSSI)
|
||||||
|
|
||||||
设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和 H5 移动端共用同一套逻辑。
|
设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和小程序共用同一套逻辑。
|
||||||
|
|
||||||
**RSSI 映射:**
|
**RSSI 映射:**
|
||||||
|
|
||||||
@ -810,7 +900,7 @@ device:{imei} # 设备级 Hash
|
|||||||
| ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 |
|
| ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 |
|
||||||
| < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) |
|
| < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) |
|
||||||
|
|
||||||
**实现方式(移动端 H5):**
|
**实现方式(小程序):**
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" ...>
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" ...>
|
||||||
@ -838,8 +928,8 @@ HSET device:{imei} online 1 rssi 85
|
|||||||
|
|
||||||
**相关文件:**
|
**相关文件:**
|
||||||
- PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件
|
- PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件
|
||||||
- H5: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板
|
- 小程序: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板
|
||||||
- H5 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline
|
- 小程序 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline
|
||||||
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
|
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
|
||||||
|
|
||||||
### 近期 UI 改动
|
### 近期 UI 改动
|
||||||
@ -854,3 +944,16 @@ HSET device:{imei} online 1 rssi 85
|
|||||||
2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠
|
2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠
|
||||||
3. **内联 SVG + v-if**:Vue 3 支持在 `<path>` 上用 `v-if` 条件渲染,适用于动态图标
|
3. **内联 SVG + v-if**:Vue 3 支持在 `<path>` 上用 `v-if` 条件渲染,适用于动态图标
|
||||||
4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `<svg>` 上写死
|
4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `<svg>` 上写死
|
||||||
|
|
||||||
|
## 代码审查豁免
|
||||||
|
|
||||||
|
以下模式已确认无需修改:
|
||||||
|
|
||||||
|
| 审查项 | 原因 |
|
||||||
|
|--------|------|
|
||||||
|
| TCP keepalive 配置 | 我们是 TCP server,设备才是 client。设备每 60s 发 status_post 就是应用层保活,不需要在 server 侧设 keepalive。 |
|
||||||
|
| SHA256 签名只取 8 位 | MCU 硬件协议固定,不可改 |
|
||||||
|
| AGENTS.md 明文密码 | 内网 Git 仓库,agent 需要直连 |
|
||||||
|
| 小程序 token 存 Storage | 小程序无 HttpOnly cookie,纯前端场景 |
|
||||||
|
| 8-char sign 长度 | 硬件约束,设备一次只能获取一次 auth_str |
|
||||||
|
| ServerResponse / DeviceCommand 含额外内部字段(`_dev_id`、`_close`) | `_dev_id` = 目标设备 IMEI,**供 device-server 路由指令**(协议 `id` 已不含 IMEI,只表示动作目标);`_close` 指示发送后断开连接(`login` 失败时)。两者**下发前均由 device-server 摘除**(`split_internal_flags`),设备侧不可见 |
|
||||||
|
|||||||
294
README.md
@ -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(管理端)
|
||||||
- **H5用户端**:uni-app + Vue 3(跨平台,支持 H5 / 微信小程序)
|
- **微信小程序用户端**:uni-app + Vue 3(构建目标 mp-weixin)
|
||||||
- **数据库**:MySQL(阿里云RDS)
|
- **数据库**:MySQL(阿里云RDS)
|
||||||
- **缓存**:Redis(阿里云Redis)
|
- **缓存**:Redis(阿里云Redis)
|
||||||
- **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
- **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1)
|
||||||
@ -25,12 +25,14 @@ charging-cabinet/
|
|||||||
│ ├── api-server/ # Rust API服务
|
│ ├── api-server/ # Rust API服务
|
||||||
│ ├── device-server/ # Rust TCP设备服务
|
│ ├── device-server/ # Rust TCP设备服务
|
||||||
│ ├── web/ # React管理端前端
|
│ ├── web/ # React管理端前端
|
||||||
│ └── h5-uniapp/ # H5用户端(uni-app + Vue 3)
|
│ └── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin)
|
||||||
├── hardware/ # 硬件资料(原理图/PCB等)
|
├── hardware/ # 硬件资料(原理图/PCB等)
|
||||||
├── deploy/ # 部署配置
|
├── deploy/ # 部署配置
|
||||||
└── test/ # 测试用例/报告
|
└── test/ # 测试用例/报告
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 本仓库不存放技能库(skills)。开发期技能从全局技能目录加载(`~/.dsh/skills`、`~/.agents/skills`),请勿在仓库内重新 vendor。
|
||||||
|
|
||||||
## 部署架构
|
## 部署架构
|
||||||
|
|
||||||
| 环境 | 域名 | 说明 |
|
| 环境 | 域名 | 说明 |
|
||||||
@ -49,7 +51,7 @@ charging-cabinet/
|
|||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ API服务 (api-server) — 主服务 │
|
│ API服务 (api-server) — 主服务 │
|
||||||
│ - HTTP API(后台管理 + H5)端口: 3000 │
|
│ - HTTP API(后台管理 + 小程序)端口: 3000 │
|
||||||
│ - 前端轮询刷新(每5秒) │
|
│ - 前端轮询刷新(每5秒) │
|
||||||
│ - 业务逻辑处理(验证/存DB/告警) │
|
│ - 业务逻辑处理(验证/存DB/告警) │
|
||||||
│ - 节点管理(设备服务注册/心跳) │
|
│ - 节点管理(设备服务注册/心跳) │
|
||||||
@ -85,284 +87,20 @@ charging-cabinet/
|
|||||||
- 每60秒心跳,180秒无心跳视为离线
|
- 每60秒心跳,180秒无心跳视为离线
|
||||||
- 支持多节点扩展(当前单节点)
|
- 支持多节点扩展(当前单节点)
|
||||||
|
|
||||||
## TCP通讯协议
|
## TCP 设备通讯协议
|
||||||
|
|
||||||
### 通信规则
|
> 完整协议文档见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,唯一权威 TCP 文档)
|
||||||
|
|
||||||
- TCP长连接,无需PING包
|
- TCP 长连接,空闲 60 秒/充电 15 秒上报
|
||||||
- 空闲1分钟上报,充电15秒上报
|
- 所有设备上报消息平台都会回复 `{"act":"<同请求>","msg_id":N,"suc":1}` 确认
|
||||||
- 超时5000ms
|
- 响应扁平化,不加 `data` 包裹
|
||||||
- JSON去空格回车省流量,LF处理粘包
|
- 签名机制:`SHA256(dev_id + auth_str + timestamp)`,取 `hex[56..64]`,**8 位**小写十六进制
|
||||||
- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID
|
- `id` 字段:仓体级 `IMEI-板-通道`,柜级 `IMEI`
|
||||||
- `msg_id`:自增会话ID
|
- 全局整数单位:voltage mV, current mA, temp K×100, soc ‰
|
||||||
- 全局通道号:`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
|
||||||
|
|
||||||
- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次)
|
完整请求/响应示例、字段表、签名自测向量、超时与自愈流程 → [docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)
|
||||||
- `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执行,异步通知
|
||||||
|
|||||||
156
deploy/deploy-prod.ps1
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# PMS 正式环境部署脚本(在**本地开发机**执行,不要拿到服务器上跑)
|
||||||
|
#
|
||||||
|
# 为什么是 PowerShell 而不是原 deploy-prod.sh:
|
||||||
|
# 原脚本有 3 个致命问题(2026-09 修复)——
|
||||||
|
# 1. 它在 `target/release/` 找二进制,而实际构建产物在
|
||||||
|
# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件
|
||||||
|
# 2. 它安装并重启 `pms-api-prod` / `pms-device-prod`,而线上真实单元名是
|
||||||
|
# `pms-api` / `pms-device` → 会**多出一套重复单元**且重启失败
|
||||||
|
# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错,
|
||||||
|
# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂)
|
||||||
|
# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收"
|
||||||
|
# 的顺序执行,并在收尾打印回滚方法。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收)
|
||||||
|
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署
|
||||||
|
# pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动
|
||||||
|
# pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端
|
||||||
|
# =============================================================================
|
||||||
|
param(
|
||||||
|
[switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build
|
||||||
|
[switch]$SkipWeb, # 跳过前端
|
||||||
|
[switch]$DryRun # 只演练,不执行
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ---- 环境参数(正式环境)---------------------------------------------------
|
||||||
|
$Host_ = '118.31.119.206'
|
||||||
|
$User = 'root'
|
||||||
|
$Pwd_ = 'Hbhyg731024@' # 内网仓库明文密码(见 AGENTS.md「代码审查豁免」)
|
||||||
|
$Dst = '/data/pms'
|
||||||
|
$ApiPort = 9000
|
||||||
|
$TcpPort = 9001
|
||||||
|
$Domain = 'app.anzhizhichong.com'
|
||||||
|
|
||||||
|
# ---- 路径 -------------------------------------------------------------------
|
||||||
|
$Repo = Split-Path -Parent $PSScriptRoot
|
||||||
|
$ApiBin = Join-Path $Repo 'software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api'
|
||||||
|
$DevBin = Join-Path $Repo 'software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server'
|
||||||
|
$WebDist = Join-Path $Repo 'software/web/dist'
|
||||||
|
|
||||||
|
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
|
||||||
|
function Say($msg) { Write-Host " $msg" }
|
||||||
|
|
||||||
|
function Invoke-Remote([string]$cmd) {
|
||||||
|
if ($DryRun) { Say "[DryRun] ssh: $cmd"; return }
|
||||||
|
& plink -batch -pw $Pwd_ "$User@$Host_" $cmd
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "远端命令失败(exit $LASTEXITCODE):$cmd" }
|
||||||
|
}
|
||||||
|
function Send-File([string]$local, [string]$remote) {
|
||||||
|
if ($DryRun) { Say "[DryRun] scp: $local -> $remote"; return }
|
||||||
|
& pscp -batch -pw $Pwd_ $local "$User@$Host_`:$remote"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "上传失败(exit $LASTEXITCODE):$local" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 1. 构建 ---------------------------------------------------------------
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Step '1/7 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
|
||||||
|
Say 'api-server ...'
|
||||||
|
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/api-server/Cargo.toml') }
|
||||||
|
Say 'device-server ...'
|
||||||
|
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/device-server/Cargo.toml') }
|
||||||
|
if (-not $SkipWeb) {
|
||||||
|
Say 'web ...'
|
||||||
|
if (-not $DryRun) { Push-Location (Join-Path $Repo 'software/web'); npm run build; Pop-Location }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 2. 校验产物 + 静态检查 ------------------------------------------------
|
||||||
|
Step '2/7 校验产物与静态检查'
|
||||||
|
foreach ($f in @($ApiBin, $DevBin)) {
|
||||||
|
if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" }
|
||||||
|
Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB))
|
||||||
|
}
|
||||||
|
if (-not $SkipWeb) {
|
||||||
|
if (-not (Test-Path (Join-Path $WebDist 'index.html'))) { throw "找不到前端产物:$WebDist" }
|
||||||
|
Say ("✅ web/dist ({0} 个文件)" -f (Get-ChildItem $WebDist -Recurse -File).Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 3. 服务器备份(可回滚)------------------------------------------------
|
||||||
|
Step '3/7 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
|
||||||
|
Invoke-Remote @"
|
||||||
|
set -e
|
||||||
|
TS=`$(date +%Y%m%d-%H%M%S)
|
||||||
|
mkdir -p $Dst/backup-`$TS
|
||||||
|
cp -a $Dst/api-server/pms-api $Dst/backup-`$TS/ 2>/dev/null || true
|
||||||
|
cp -a $Dst/device-server/pms-device-server $Dst/backup-`$TS/ 2>/dev/null || true
|
||||||
|
cp -a $Dst/api-server/.env $Dst/device-server/.env $Dst/backup-`$TS/ 2>/dev/null || true
|
||||||
|
mkdir -p $Dst/backup-`$TS/web && cp -a $Dst/web/. $Dst/backup-`$TS/web/ 2>/dev/null || true
|
||||||
|
echo `$TS > /tmp/pms_last_backup
|
||||||
|
echo " 备份目录: $Dst/backup-`$TS"
|
||||||
|
"@
|
||||||
|
|
||||||
|
# ---- 4. 上传(临时名,避免 ETXTBSY)----------------------------------------
|
||||||
|
Step '4/7 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
|
||||||
|
Send-File $ApiBin "$Dst/api-server/pms-api.new"
|
||||||
|
Send-File $DevBin "$Dst/device-server/pms-device-server.new"
|
||||||
|
if (-not $SkipWeb) {
|
||||||
|
$tgz = Join-Path $env:TEMP 'pms-dist.tgz'
|
||||||
|
if (-not $DryRun) { tar -czf $tgz -C $WebDist . }
|
||||||
|
Send-File $tgz '/tmp/pms-dist.tgz'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 5. 切换 + 重启 --------------------------------------------------------
|
||||||
|
Step '5/7 原子替换并重启服务'
|
||||||
|
Invoke-Remote @"
|
||||||
|
set -e
|
||||||
|
chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new
|
||||||
|
systemctl stop pms-api pms-device
|
||||||
|
mv -f $Dst/api-server/pms-api.new $Dst/api-server/pms-api
|
||||||
|
mv -f $Dst/device-server/pms-device-server.new $Dst/device-server/pms-device-server
|
||||||
|
$(if (-not $SkipWeb) { "rm -rf $Dst/web/* && tar -xzf /tmp/pms-dist.tgz -C $Dst/web/ && rm -f /tmp/pms-dist.tgz" } else { 'true' })
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start pms-device pms-api
|
||||||
|
sleep 8
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
"@
|
||||||
|
|
||||||
|
# ---- 6. 验收 ---------------------------------------------------------------
|
||||||
|
Step '6/7 验收(服务/端口/域名/日志)'
|
||||||
|
Invoke-Remote @"
|
||||||
|
echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
|
||||||
|
echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}'
|
||||||
|
echo '--- 域名 ---'
|
||||||
|
curl -sk -m 8 -o /dev/null -w ' /api/pms/cabinets -> %{http_code}\n' https://$Domain/api/pms/cabinets
|
||||||
|
curl -sk -m 8 -o /dev/null -w ' /pms/ -> %{http_code}\n' https://$Domain/pms/
|
||||||
|
echo '--- 启动日志尾部 ---'; journalctl -u pms-api --no-pager -n 4 | tail -4
|
||||||
|
echo '--- 新进程 ERROR 数(应为 0)---'
|
||||||
|
MAIN=`$(systemctl show -p MainPID --value pms-api)
|
||||||
|
journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^/ ERROR 行数: /'
|
||||||
|
"@
|
||||||
|
|
||||||
|
# ---- 7. 消费链路探测(无副作用)-------------------------------------------
|
||||||
|
Step '7/7 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
|
||||||
|
if (-not $DryRun) {
|
||||||
|
$probe = @'
|
||||||
|
import redis, json, time
|
||||||
|
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5)
|
||||||
|
r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1}))
|
||||||
|
time.sleep(3)
|
||||||
|
left = r.llen('device:reports')
|
||||||
|
print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}")
|
||||||
|
'@
|
||||||
|
$probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py"
|
||||||
|
python "$env:TEMP\pms_probe.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
|
||||||
|
Say "正式环境:https://$Domain/pms/"
|
||||||
|
Say "API :$ApiPort TCP :$TcpPort"
|
||||||
|
Say ''
|
||||||
|
Say '回滚方法(如需):'
|
||||||
|
Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`""
|
||||||
|
Say ' 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份)'
|
||||||
@ -1,50 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# PMS 部署脚本 - 正式环境
|
# =============================================================================
|
||||||
|
# ⛔ 本脚本已废弃(2026-09),请勿执行!改用:deploy/deploy-prod.ps1
|
||||||
set -e
|
#
|
||||||
|
# 废弃原因(原脚本会直接搞坏生产):
|
||||||
echo "=== PMS 正式环境部署 ==="
|
# 1. 在 `target/release/` 找二进制 —— 实际产物在
|
||||||
|
# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件
|
||||||
# 1. 创建目录
|
# 2. 安装并重启 `pms-api-prod` / `pms-device-prod` —— 线上真实单元名是
|
||||||
echo "1. 创建部署目录..."
|
# `pms-api` / `pms-device`;照原样执行会**多出一套重复单元**且重启失败
|
||||||
mkdir -p /data/pms/api-server
|
# 3. 用本地 `.env.production` **覆盖线上 `.env`** —— 历史上该文件带行内注释
|
||||||
mkdir -p /data/pms/device-server
|
# (`PORT=9000 # 注释` 会让解析失败),照原样执行会让**生产直接起不来**
|
||||||
mkdir -p /data/pms/web
|
# 4. 它假定"服务器上有仓库源码",而实际流程是**本地交叉编译后传二进制**
|
||||||
|
#
|
||||||
# 2. 复制文件
|
# 正确做法(在本地开发机执行):
|
||||||
echo "2. 复制服务文件..."
|
# pwsh deploy/deploy-prod.ps1 # 完整部署
|
||||||
cp software/api-server/target/release/pms-api /data/pms/api-server/
|
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 只重发产物
|
||||||
cp software/api-server/.env.production /data/pms/api-server/.env
|
# pwsh deploy/deploy-prod.ps1 -DryRun # 演练
|
||||||
|
# =============================================================================
|
||||||
cp software/device-server/target/release/device-server /data/pms/device-server/
|
echo "⛔ deploy-prod.sh 已废弃,请使用:pwsh deploy/deploy-prod.ps1" >&2
|
||||||
cp software/device-server/.env.production /data/pms/device-server/.env
|
echo " (原因见本文件头部注释:原脚本会装重复 systemd 单元、覆盖线上 .env 导致生产起不来)" >&2
|
||||||
|
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,50 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# PMS 部署脚本 - 测试环境
|
# =============================================================================
|
||||||
|
# ⛔ 本脚本已废弃(2026-09),请勿在 118.31.119.206 上执行!
|
||||||
set -e
|
#
|
||||||
|
# 两条重要约束:
|
||||||
echo "=== PMS 测试环境部署 ==="
|
# 1. **118.31.119.206 就是生产服务器**(用户已确认),上面**不部署测试环境**。
|
||||||
|
# 原先的 `pms-api-test` / `pms-device-test` 单元已卸载,:3000 / :3001 已释放。
|
||||||
# 1. 创建目录
|
# 2. 即使要在别处搭测试环境,也不要用本脚本 —— 它与 deploy-prod.sh 有同样的缺陷
|
||||||
echo "1. 创建部署目录..."
|
# (错误产物路径、单元名不匹配、用本地 .env 覆盖线上 .env)。
|
||||||
mkdir -p /data/pms_dev/api-server
|
#
|
||||||
mkdir -p /data/pms_dev/device-server
|
# 如需临时联调环境(另一台机器,或本机):
|
||||||
mkdir -p /data/pms_dev/web
|
# - 参考 `deploy/deploy-prod.ps1` 的流程改写,端口用 3000/3001
|
||||||
|
# - 环境文件用 `software/*/.env.test`(连 pms_dev 库 + Redis db6)
|
||||||
# 2. 复制文件
|
# - 注意 `/data/pms_dev` 下的旧文件与 `pms_dev` 库、Redis db6 都还保留着
|
||||||
echo "2. 复制服务文件..."
|
#
|
||||||
cp software/api-server/target/release/pms-api /data/pms_dev/api-server/
|
# ⚠️ 另请注意:`test.anzhizhichong.com` 的 nginx `/api/pms/` 现指向 :3000,
|
||||||
cp software/api-server/.env.test /data/pms_dev/api-server/.env
|
# 而该端口**没有后端**,所以返回 502 —— 这是**刻意的**(历史上它指向 :3000,
|
||||||
|
# 而那时 :3000 正是生产 API,等于测试后台在操作生产数据)。
|
||||||
cp software/device-server/target/release/device-server /data/pms_dev/device-server/
|
# **绝不要把测试域名的 API 指回生产端口(9000)。**
|
||||||
cp software/device-server/.env.test /data/pms_dev/device-server/.env
|
# =============================================================================
|
||||||
|
echo "⛔ deploy-test.sh 已废弃,且本服务器为生产环境,不部署测试环境。" >&2
|
||||||
# 3. 复制前端静态文件
|
exit 1
|
||||||
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,12 +26,23 @@ server {
|
|||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /pms/api/ {
|
location /api/pms/ {
|
||||||
proxy_pass http://127.0.0.1:3000/api/;
|
proxy_pass http://127.0.0.1:9000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header 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管理前端 - 不带斜杠时重定向
|
||||||
@ -44,15 +55,4 @@ 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/api-server
|
ExecStart=/data/pms/api-server/pms-api
|
||||||
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/device-server
|
WorkingDirectory=/data/pms/device-server
|
||||||
ExecStart=/data/pms/device-server/device-server
|
ExecStart=/data/pms/device-server/pms-device-server
|
||||||
EnvironmentFile=/data/pms/device-server/.env
|
EnvironmentFile=/data/pms/device-server/.env
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
569
docs/API.md
@ -1,6 +1,6 @@
|
|||||||
# API 接口文档
|
# API 接口文档
|
||||||
|
|
||||||
> 充电柜矩阵系统(PMS)接口规范。PC 管理端与 H5 用户端共用同一套 API。
|
> 充电柜矩阵系统(PMS)接口规范。PC 管理端与微信小程序用户端共用同一套 API。
|
||||||
|
|
||||||
## 基础信息
|
## 基础信息
|
||||||
|
|
||||||
@ -22,19 +22,68 @@
|
|||||||
| GET | `/api/cabinets/:id` | 设备详情(含仓板+仓体) | `device:view` |
|
| GET | `/api/cabinets/:id` | 设备详情(含仓板+仓体) | `device:view` |
|
||||||
| PUT | `/api/cabinets/:id` | 更新柜子 | `device:edit` |
|
| PUT | `/api/cabinets/:id` | 更新柜子 | `device:edit` |
|
||||||
| DELETE | `/api/cabinets/:id` | 删除柜子 | `device:delete` |
|
| 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/abstract/:abstract_id` | 扫码查设备(免认证) | 免认证 |
|
||||||
| GET | `/api/cabinets/scan/:abstract_id` | 扫码验证(需认证) | 需认证 |
|
| GET | `/api/cabinets/scan/:abstract_id` | 扫码验证(需认证) | 需认证 |
|
||||||
| GET | `/api/cabinets/filter` | 设备列表(管理端) | 需认证 |
|
| GET | `/api/cabinets/filter` | 设备列表(管理端) | 需认证 |
|
||||||
|
| GET | `/api/cabinets/options` | 设备下拉选项 | 需认证 |
|
||||||
|
| GET | `/api/cabin-boards/options` | 仓控板下拉选项 | 需认证 |
|
||||||
|
| GET | `/api/compartments/options` | 仓体下拉选项 | 需认证 |
|
||||||
| GET | `/api/cabinets/realtime-status` | 设备实时状态 | 需认证 |
|
| GET | `/api/cabinets/realtime-status` | 设备实时状态 | 需认证 |
|
||||||
| GET | `/api/compartments/:id` | 仓体/通道详情 | 需认证 |
|
| GET | `/api/compartments/:id` | 仓体/通道详情 | 需认证 |
|
||||||
| POST | `/api/charge/start` | 开始充电 | `charge:start` |
|
| POST | `/api/charge/start` | 开始充电 | `charge:start` |
|
||||||
| POST | `/api/charge/stop` | 停止充电 | `charge:stop` |
|
| POST | `/api/charge/stop` | 停止充电 | `charge:stop` |
|
||||||
| POST | `/api/door/open` | 开门 | `device:door:unlock` |
|
| POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` |
|
||||||
|
| POST | `/api/cabinets/{id}/open` | 解锁柜门(柜级,同上;管理端设备详情页用) | `charge:open_door` |
|
||||||
|
| POST | `/api/cabinets/{id}/pow-on` | 接触器合闸(柜级) | `device:operate` |
|
||||||
|
| POST | `/api/cabinets/{id}/pow-off` | 接触器分闸(柜级) | `device:operate` |
|
||||||
|
| POST | `/api/cabinets/{id}/reset` | 重启整柜(柜级) | `device:operate` |
|
||||||
|
| POST | `/api/cabin-boards/{id}/reset` | 重启单块仓控板 | `device:operate` |
|
||||||
| GET | `/api/organizations` | 组织列表 | `org:view` |
|
| GET | `/api/organizations` | 组织列表 | `org:view` |
|
||||||
| POST | `/api/organizations` | 创建组织 | `org:create` |
|
| POST | `/api/organizations` | 创建组织 | `org:create` |
|
||||||
|
| PUT | `/api/organizations/:id` | 更新组织 | `org:edit` |
|
||||||
|
| DELETE | `/api/organizations/:id` | 删除组织 | `org:delete` |
|
||||||
| GET | `/api/organizations/:org_id/projects` | 项目列表 | 需认证 |
|
| 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` | 充电记录 | `charge_record:view` |
|
||||||
| GET | `/api/energy-stats/summary` | 能耗统计 | 需认证 |
|
| GET | `/api/charge-records/export` | 导出充电记录 | `charge_record:export` |
|
||||||
|
| GET | `/api/charge-orders` | 充电订单列表 | `charge_record:view` |
|
||||||
|
| GET | `/api/batteries` | 电池台账列表 | `device:view` |
|
||||||
|
| GET | `/api/energy-stats/summary` | 能耗概览 | `energy:view` |
|
||||||
|
| GET | `/api/energy-stats/cabinets` | 柜子能耗排行 | `energy:view` |
|
||||||
|
| GET | `/api/energy-stats/projects` | 项目能耗统计 | `energy:view` |
|
||||||
|
| GET | `/api/energy-stats/trend` | 能耗趋势 | `energy:view` |
|
||||||
|
| GET | `/api/energy-stats/export` | 导出能耗数据 | `energy:view` |
|
||||||
|
| GET | `/api/device-logs` | 设备日志 | `device:view` |
|
||||||
|
| GET | `/api/operation-logs` | 操作日志 | `system:log` |
|
||||||
|
| GET | `/api/users` | 用户列表 | `user:view` |
|
||||||
|
| POST | `/api/users` | 创建用户 | `user:create` |
|
||||||
|
| PUT | `/api/users/:id` | 更新用户 | `user:edit` |
|
||||||
|
| DELETE | `/api/users/:id` | 删除用户 | `user:delete` |
|
||||||
|
| PUT | `/api/users/:id/reset-password` | 重置密码 | `user:edit` |
|
||||||
|
| PUT | `/api/users/:id/status` | 设置用户状态 | `user:edit` |
|
||||||
|
| GET | `/api/users/:id/permissions` | 用户权限 | `user:view` |
|
||||||
|
| PUT | `/api/users/:id/roles` | 分配角色 | `user:edit` |
|
||||||
|
| GET | `/api/users/:id/scopes` | 用户数据范围 | `user:view` |
|
||||||
|
| PUT | `/api/users/:id/scopes` | 设置数据范围 | `user:edit` |
|
||||||
|
| GET | `/api/roles` | 角色列表 | `role:view` |
|
||||||
|
| POST | `/api/roles` | 创建角色 | `role:create` |
|
||||||
|
| PUT | `/api/roles/:id` | 更新角色 | `role:edit` |
|
||||||
|
| DELETE | `/api/roles/:id` | 删除角色 | `role:delete` |
|
||||||
|
| PUT | `/api/roles/:id/permissions` | 角色赋权 | `role:edit` |
|
||||||
|
| GET | `/api/permissions` | 权限码列表 | `role:view` |
|
||||||
|
| GET | `/api/downloads` | 下载中心 | 需认证 |
|
||||||
|
| GET | `/api/downloads/:id` | 下载详情 | 需认证 |
|
||||||
|
| GET | `/api/downloads/:id/file` | 下载文件 | 需认证 |
|
||||||
|
| POST | `/api/downloads/cleanup` | 清理过期下载 | 需认证 |
|
||||||
|
| POST | `/api/devices/:dev_id/command` | 下发设备指令 | `device:operate` |
|
||||||
|
| GET | `/api/devices/stats` | 设备服务统计 | 需认证 |
|
||||||
|
| GET | `/api/health` | 健康检查 | 免认证 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -69,15 +118,15 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**角色等级:** 1=普通用户,2=超级管理员
|
**角色等级:** 0=操作员,1=企业管理员,2=总管理员
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## H5 专用接口
|
## 小程序专用接口
|
||||||
|
|
||||||
### GET /api/dashboard
|
### GET /api/dashboard
|
||||||
|
|
||||||
H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
|
小程序首页仪表盘数据。支持 `org_id` 参数按组织过滤。
|
||||||
|
|
||||||
**响应:**
|
**响应:**
|
||||||
```json
|
```json
|
||||||
@ -157,59 +206,286 @@ H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
|
|||||||
**响应字段:**
|
**响应字段:**
|
||||||
- `voltage` — mV(整数)
|
- `voltage` — mV(整数)
|
||||||
- `current` — mA(整数)
|
- `current` — mA(整数)
|
||||||
- `power` — mW(整数)
|
|
||||||
- `energy` — Wh(整数)
|
- `energy` — Wh(整数)
|
||||||
- `soc` — %(0-100)
|
- `soc` — ‰(0-10000,前端已自动转为 %)
|
||||||
- `soh` — %(0-100)
|
|
||||||
- `cell_voltages` — mV 数组
|
- `cell_voltages` — mV 数组
|
||||||
|
|
||||||
### POST /api/charge/start
|
### POST /api/charge/start
|
||||||
|
|
||||||
**请求:** `{ "compartment_id": 123 }`
|
**请求:** `{ "compartment_id": 123 }`
|
||||||
|
|
||||||
|
> 权限:`charge:start` + 组织隔离(用户只能操作所属组织的设备)
|
||||||
|
|
||||||
### POST /api/charge/stop
|
### POST /api/charge/stop
|
||||||
|
|
||||||
**请求:** `{ "compartment_id": 123 }`
|
**请求:** `{ "compartment_id": 123 }`
|
||||||
|
|
||||||
|
> 权限:`charge:stop` + 组织隔离
|
||||||
|
|
||||||
### POST /api/door/open
|
### POST /api/door/open
|
||||||
|
|
||||||
**请求:** `{ "compartment_id": 123 }`
|
**请求:** `{ "compartment_id": 123 }`
|
||||||
|
|
||||||
|
> 权限:`charge:open_door` + 组织隔离
|
||||||
|
|
||||||
### GET /api/cabinets/scan/:abstract_id
|
### 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)**
|
**核心原则:所有数值字段使用整数存储,禁止浮点类型 (f32/f64/DECIMAL/FLOAT)**
|
||||||
|
|
||||||
| 字段 | 类型 | 单位 | 说明 |
|
**单位转换仅在前端:** API 始终返回原始单位值,前端负责转换为用户友好单位显示。后端禁止做任何单位换算。
|
||||||
|------|------|------|------|
|
|
||||||
| `energy` | i64 | **Wh** | 充电量,不存小数 |
|
|
||||||
| `voltage` | i64 | **mV** | 电池组总电压 |
|
|
||||||
| `current` | i64 | **mA** | 电流,正=充电 负=放电 |
|
|
||||||
| `power` | i64 | **mW** | 功率 |
|
|
||||||
| `soc` / `soh` | i64 | **%** | 电量百分比 / 健康度 |
|
|
||||||
| `max_temperature` | i64 | **0.1°C** | 25.3°C → `253` |
|
|
||||||
| `rssi` | i64 | **dBm** | 信号强度 |
|
|
||||||
|
|
||||||
**温度转换:** 前端显示时除以10加小数点(25.3°C → `253`)
|
### 标量字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 单位 | 前端显示 | 转换公式 |
|
||||||
|
|------|------|------|----------|---------|
|
||||||
|
| `energy` / `total_energy` / `today_energy` / `month_energy` / `energy_kwh`¹ | i64 | **Wh** | kWh | `/ 1000` |
|
||||||
|
| `voltage` / `cell_voltages[]` | i64 | **mV** | V | `/ 1000` |
|
||||||
|
| `current` | i64 | **mA** | A | `/ 1000` |
|
||||||
|
| `soc` / `start_soc` / `end_soc` | i64 | **‰** | % | `/ 10` |
|
||||||
|
| `temp` / `cell_temps[]` / `max_temperature` | i64 | **K×100** | °C | `/ 100 - 273.15` |
|
||||||
|
| `rssi` | i64 | **0–99 信号等级** | 直接显示 | 不转换(不是 dBm) |
|
||||||
|
| `cost` / `estimated_cost` | i64/f64 | **元** | ¥ | 不转换(后端业务计算) |
|
||||||
|
| `today_charge_count` / `today_charge_hours` | i64 | 计数值 | 直接显示 | 不转换 |
|
||||||
|
|
||||||
|
¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
| 原始值 | 含义 | 前端显示 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `52000` | 电压(mV) | `52.0 V` |
|
||||||
|
| `10000` | 电流(mA) | `10.0 A` |
|
||||||
|
| `120000` | 功率(mW) | `120.0 W` |
|
||||||
|
| `1000` | 充电量(Wh) | `1.000 kWh` |
|
||||||
|
| `850` | SOC(‰) | `85.0%` |
|
||||||
|
| `29865` | 温度(K×100) | `25.5°C` |
|
||||||
|
| `85` | RSSI 信号等级(0–99) | `85` |
|
||||||
|
| `[32456,32500,32300]` | 单体电压(mV) | `32.456V, 32.500V, 32.300V` |
|
||||||
|
| `[29865,29900]` | 单体温度(K×100) | `25.5°C, 25.9°C` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TCP 设备通讯协议
|
## TCP 设备通讯协议
|
||||||
|
|
||||||
### 通信规则
|
> ⛔ **本节(自本行起至下一个 `##` 标题之前)全部内容已废弃,禁止作为实现依据。**
|
||||||
|
> **唯一权威文档:[`柜控板固件对接文档.md`](./柜控板固件对接文档.md)(v1.1)** —— 所有 TCP 报文格式、字段名、
|
||||||
|
> `act` 清单、签名算法、保活/超时规则、时序与异常处理,**一律以该文档为准**。
|
||||||
|
>
|
||||||
|
> 本节保留仅为历史参考。已知与定稿规范的**重大冲突**(仅列关键几处,避免误读):
|
||||||
|
> - ~~`dev_id` 上行全部报文必带~~ → **只有 `auth_str` / `login` 带**,其余报文不带(设备身份由 TCP 连接确定)
|
||||||
|
> - ~~`id` = `IMEI` / `IMEI-仓控板-通道`~~ → **`id` 不含 IMEI**:柜级**省略**、单板一段 `"01"`、仓体级两段 `"01-3"`
|
||||||
|
> - ~~空闲 1 分钟 / 充电 15 秒上报~~ → **`status_post` 固定每 60 秒一次、每次全量**
|
||||||
|
> - ~~`login` 应答返回 `cell`~~ → 返回 **`spec`**(柜子规格,如 `"0606"`)与 **`work_mode`**
|
||||||
|
> - ~~`pow_fail` 带 `info`~~ → 改为 **`pow_fail_dc` / `pow_fail_ac` 两条独立指令**,无附加字段
|
||||||
|
> - ~~`status_post` 的 `status` HEX 字典 / `bat_in` 的 `bms` 块~~ → 改为 **`cabinet` + `boards[].channels[]`(每通道 18 字符 hex 透传)** 与 **`battery` 块**
|
||||||
|
> - ~~`bat_in` 建单~~ → **`bat_in` 与订单无关,订单只由 `on` 建立**
|
||||||
|
> - 内部字段已由 `id` 改为 **`_dev_id`**(供 device-server 路由,下发前摘除)
|
||||||
|
|
||||||
- TCP 长连接,无需 PING 包
|
### 设计原则
|
||||||
- 空闲 1 分钟上报,充电 15 秒上报
|
|
||||||
- 超时 5000ms
|
> MCU(Air780E)资源有限,TCP 协议遵循以下原则:
|
||||||
- JSON 去空格回车省流量,LF 处理粘包
|
> 1. **响应扁平化**:字段直接放在顶层,不包 `data`/`result` 等嵌套层
|
||||||
- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID
|
> 2. **字段最小化**:需要几个字段传几个,不传空值、默认值
|
||||||
- `msg_id`:自增会话 ID
|
> 3. **JSON 紧凑**:去空格回车,不传多余引号和逗号(可用 `serde_json::to_string` 紧凑模式)
|
||||||
- 全局通道号:`dev_id-sub_device_id-通道号`(如 `123456789012345-01-6`)
|
> 4. **响应必达**:所有**上行**报文平台都会回复;但**设备回执(带 `suc`)平台不再回包**(防误配对)
|
||||||
|
|
||||||
|
### 通信规则(⚠️ 下列旧描述已废弃,见节首说明)
|
||||||
|
|
||||||
|
- TCP 长连接,无需 PING 包 ✅(保活由 `status_post` 承担)
|
||||||
|
- ~~空闲 1 分钟上报,充电 15 秒上报~~ ❌ → **固定每 60 秒全量**
|
||||||
|
- ~~超时 5000ms~~ ❌ → **每个交互 60 秒超时,超时作废、迟到应答丢弃、不重试**
|
||||||
|
- JSON 去空格回车省流量,LF 处理粘包 ✅
|
||||||
|
- ~~`dev_id`:设备上报时使用~~ ❌ → **仅 `auth_str` / `login` 携带**
|
||||||
|
- ~~`id`:`IMEI-仓控板-通道` / `IMEI`~~ ❌ → **不含 IMEI**:柜级省略 / 单板 `"01"` / 仓体级 `"01-3"`
|
||||||
|
- `msg_id`:两个方向**各自独立计数,都每连接从 `1` 起自增**,重连归零;判"应答 vs 指令"看有无 `suc`
|
||||||
|
|
||||||
### 签名机制
|
### 签名机制
|
||||||
|
|
||||||
@ -219,68 +495,195 @@ H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
|
|||||||
|
|
||||||
### 指令清单
|
### 指令清单
|
||||||
|
|
||||||
| 动作 | 方向 | id格式 | 说明 |
|
> ⛔ **以下整段(含本表与其后的报文示例、`cell` / `pow_fail` / `bms` 字段说明)均为废弃的历史内容,请勿据此实现。**
|
||||||
|------|------|--------|------|
|
> 现行清单与报文以 [`柜控板固件对接文档.md`](./柜控板固件对接文档.md) §4 / §5 为准。**`act` 共 13 个**:
|
||||||
| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) |
|
> `auth_str` / `login` / `status_post` / `pow_fail_dc` / `pow_fail_ac` / `bat_in` / `bat_out` / `on` / `off` / `open` / `pow_on` / `pow_off` / `reset`
|
||||||
| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) |
|
> (**已无 `pow_fail`**,拆成 `pow_fail_dc` / `pow_fail_ac`)
|
||||||
| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) |
|
|
||||||
| `pow_fail` | 设备→平台 | 无 | 电源掉电上报 |
|
|
||||||
| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入 |
|
|
||||||
| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
|
|
||||||
| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电 |
|
|
||||||
| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电 |
|
|
||||||
| `open` | 平台→设备 | `IMEI` | 开门 |
|
|
||||||
| `pow_on` | 平台→设备 | `IMEI` | 接触器合闸 |
|
|
||||||
| `pow_off` | 平台→设备 | `IMEI` | 接触器分闸 |
|
|
||||||
| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 |
|
|
||||||
|
|
||||||
### 指令详情
|
| 动作 | 方向 | 说明(⚠️ 历史,见上方说明) |
|
||||||
|
|------|------|------|
|
||||||
|
| `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(设备→平台)
|
#### login(设备→平台)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"h_ver":"1.0.0","s_ver":"1.0.0"}
|
{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"iccid":"89860000000000000000"}
|
||||||
// 响应:{"suc":1,"msg_id":1,"cell":6}
|
// 响应:{"suc":1,"msg_id":1,"cell":6}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `iccid` — SIM卡ICCID(变更时写 SIM 变更日志)
|
||||||
|
- `cell` — 仓控板数量(柜子配置属性)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### auth_str(设备→平台)
|
#### auth_str(设备→平台)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345","ccid":"89860000000000000000"}
|
{"act":"auth_str","msg_id":2,"dev_id":"123456789012345"}
|
||||||
// 响应:{"suc":1,"msg_id":2,"data":{"auth_str":"abcd1234"}}
|
// 响应:{"suc":1,"msg_id":2,"auth_str":"abcd1234"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `auth_str` — 8位随机安全码(仅返回一次,标记后不可重复获取)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### status_post(设备→平台)
|
#### status_post(设备→平台)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,
|
{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,
|
||||||
"status":{"123456789012345-01":"000100020003000400050006"}}
|
"status":{"00":"0001","01":"000100020003000400050006","02":"000100020003000400050006"}}
|
||||||
// 响应:{"suc":1,"msg_id":10}
|
// 响应:{"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(设备→平台)
|
#### bat_in / bat_out(设备→平台)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-6","bms":{"soc":45,"voltage":52.3}}
|
{"act":"bat_in","msg_id":20,"id":"123456789012345-01-01","bms":{
|
||||||
{"act":"bat_out","msg_id":21,"id":"123456789012345-01-6"}
|
"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(双向)
|
#### on / off(双向)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// 平台→设备:开始充电
|
// 平台→设备:下发充电
|
||||||
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
|
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
|
||||||
|
// 设备响应:{"suc":1,"msg_id":30}
|
||||||
|
|
||||||
// 设备→平台:充电开始确认
|
// 设备→平台:充电开始确认
|
||||||
{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
|
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
|
||||||
|
// 平台响应:{"suc":1,"msg_id":30}
|
||||||
|
|
||||||
|
// 平台→设备:下发停止
|
||||||
|
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
|
||||||
|
// 设备响应:{"suc":1,"msg_id":31}
|
||||||
|
|
||||||
|
// 设备→平台:充满自动停止
|
||||||
|
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
|
||||||
|
// 平台响应:{"suc":1,"msg_id":31}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `on` 必须由平台下发,设备不可主动充电。MCU 保证 `off` → `bat_out` 顺序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### open(平台→设备)
|
#### open(平台→设备)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"act":"open","msg_id":40,"id":"123456789012345"}
|
{"act":"open","msg_id":40,"id":"123456789012345"}
|
||||||
// 错误码63 = 门锁故障
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**响应:** `{"suc":1,"msg_id":40}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### pow_on / pow_off(平台→设备)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"act":"pow_on","msg_id":50,"id":"123456789012345"}
|
||||||
|
{"act":"pow_off","msg_id":51,"id":"123456789012345"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:** `{"suc":1,"msg_id":50}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### reset(平台→设备)
|
#### reset(平台→设备)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -290,6 +693,8 @@ H5 首页仪表盘数据。支持 `org_id` 参数按组织过滤。
|
|||||||
{"act":"reset","msg_id":61,"id":"123456789012345-01"}
|
{"act":"reset","msg_id":61,"id":"123456789012345-01"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**响应:** `{"suc":1,"msg_id":60}`
|
||||||
|
|
||||||
### 充电结束原因
|
### 充电结束原因
|
||||||
|
|
||||||
| 平台收到 | 判断原因 |
|
| 平台收到 | 判断原因 |
|
||||||
@ -311,41 +716,65 @@ device:{imei} # 设备级 Hash
|
|||||||
├─ online = "1" # 在线标记(TTL 300秒)
|
├─ online = "1" # 在线标记(TTL 300秒)
|
||||||
├─ rssi = "85" # 信号强度
|
├─ rssi = "85" # 信号强度
|
||||||
├─ pow_fail_dc = "0/1" # 直流停电
|
├─ pow_fail_dc = "0/1" # 直流停电
|
||||||
|
├─ pow_fail_ac = "0/1" # 交流停电
|
||||||
└─ device:{imei}:board:{board_idx} # 板级 Hash
|
└─ device:{imei}:board:{board_idx} # 板级 Hash
|
||||||
├─ status_hex = "00010002..." # 原始hex状态
|
├─ status_hex = "00010002..." # 原始hex状态
|
||||||
└─ device:{imei}:board:{board_idx}:ch:{0-5} # 通道级 Hash
|
└─ device:{imei}:board:{board_idx}:ch:{ch} # 通道级 Hash(1~6)
|
||||||
├─ on = "0/1" # 充电中
|
├─ on = "0/1" # 充电中
|
||||||
├─ full = "0/1" # 充满
|
├─ full = "0/1" # 充满
|
||||||
├─ fault = "0/1" # 故障
|
├─ fault = "0/1" # 故障
|
||||||
└─ hex = "00010002" # 原始hex
|
├─ hex = "0001" # 原始hex(4字符)
|
||||||
|
├─ battery_id = "BAT001" # 电池ID(bat_in 写入,bat_out 清除)
|
||||||
|
├─ soc = "4500" # 电量 ‰
|
||||||
|
├─ voltage = "52000" # 电压 mV
|
||||||
|
├─ current = "10000" # 电流 mA
|
||||||
|
├─ temp = "29865" # 温度 K×100
|
||||||
|
├─ brand = "星恒" # BMS品牌
|
||||||
|
├─ model = "XH-48V" # BMS型号
|
||||||
|
└─ sn = "SN123456" # BMS序列号
|
||||||
```
|
```
|
||||||
|
|
||||||
### BMS 字段
|
> HEX 解析由 device-server 负责,api-server 只接收结构化数据写入 Redis。
|
||||||
|
|
||||||
| 字段 | 类型 | 单位 | 说明 |
|
### BMS 字段(全局整数单位)
|
||||||
|
|
||||||
|
| 字段 | 单位 | 精度 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `soc` | i64 | % | 电池电量百分比 |
|
| `soc` | ‰ | 0.1% | 4500 = 45.00% |
|
||||||
| `soh` | i64 | % | 电池健康度百分比 |
|
| `voltage` | mV | 1 | 52000 = 52.000V |
|
||||||
| `voltage` | i64 | mV | 电池组总电压 |
|
| `current` | mA | 1 | 10000 = 10.000A |
|
||||||
| `current` | i64 | mA | 电流 |
|
| `energy` | Wh | 1 | 累计充电量 |
|
||||||
| `power` | i64 | mW | 功率 |
|
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
|
||||||
| `energy` | i64 | Wh | 累计充电量 |
|
| `battery_id` | — | — | 电池唯一标识 |
|
||||||
| `cell_voltages` | i64[] | mV | 单体电芯电压数组 |
|
| `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:view` | 查看设备 |
|
||||||
| `device:create` | 创建设备 |
|
| `device:operate` | 操作设备(开合闸 / 重启 / 开门等柜级控制) |
|
||||||
| `device:edit` | 编辑设备 |
|
| `device:create` | 添加设备 |
|
||||||
|
| `device:edit` | 编辑设备(含修改充电工作模式) |
|
||||||
| `device:delete` | 删除设备 |
|
| `device:delete` | 删除设备 |
|
||||||
| `device:door:unlock` | 开门 |
|
| `device:auth_str:reset` | 重置安全码(**仅总管理员**) |
|
||||||
| `charge:start` | 开始充电 |
|
| `charge:start` | 开始充电 |
|
||||||
| `charge:stop` | 停止充电 |
|
| `charge:stop` | 停止充电 |
|
||||||
|
| `charge:open_door` | 开门(柜门解锁) |
|
||||||
| `charge_record:view` | 查看充电记录 |
|
| `charge_record:view` | 查看充电记录 |
|
||||||
| `org:view` | 查看组织 |
|
| `charge_record:export` | 导出充电记录 |
|
||||||
| `org:create` | 创建组织 |
|
| `device_log:view` | 查看设备日志 |
|
||||||
| `org:edit` | 编辑组织 |
|
| `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` | 组织管理 |
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
# SQL 注入:format! 拼 SQL 的连锁风险
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
`src/routes/charge_orders.rs` 的 `build_count_query` 和 `build_list_query` 两个辅助函数存在 SQL 注入漏洞。`battery_id` 参数通过 `format!` 直接拼入 SQL 字符串,攻击者可注入任意 SQL。
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
**核心问题**:代码演进过程中出现了"双重 SQL 构建"模式。
|
||||||
|
|
||||||
|
1. `list_charge_orders` 主函数已经正确使用 `?` 占位符拼接了 `query` 和 `count_query`
|
||||||
|
2. 但 `build_count_query` / `build_list_query` **完全忽略**了主函数预拼接的 SQL 字符串
|
||||||
|
3. 它们自行用 `format!` 重建查询,导致所有 `format!` 拼接的参数(`cabinet_id`、`compartment_id`、`battery_id`、`status`、`LIMIT/OFFSET`)都存在注入风险
|
||||||
|
4. 两个函数还包含大量重复的分支逻辑(~100 行),不同分支的绑定顺序不一致,难以维护
|
||||||
|
|
||||||
|
**根本原因**:重构时只改了 `list_charge_orders` 主函数,没同步改造辅助函数,导致新旧模式并存。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
1. **删除重复逻辑**:让 `build_count_query` / `build_list_query` 直接使用主函数预拼接的 SQL 字符串
|
||||||
|
2. **统一绑定顺序**:按固定顺序(`cabinet_id → compartment_id → battery_id → status → start_time → end_time → LIMIT/OFFSET`)bind 参数,与主函数追加 `?` 占位符的顺序一致
|
||||||
|
3. **LIKE 值处理**:百分号在 bind 值中拼接(`format!("%{}%", bid)`),不进 SQL 文本
|
||||||
|
4. **消除未使用变量**:`if let Some(x)` 改为 `.is_some()`,避免未使用变量告警
|
||||||
|
|
||||||
|
## 模式/反模式
|
||||||
|
|
||||||
|
### 反模式 ❌:format! + SQL 字符串拼接
|
||||||
|
```rust
|
||||||
|
// 注入风险
|
||||||
|
query.push_str(&format!(" AND battery_id LIKE '%{}%'", bid));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 ✅:主函数拼占位符,辅助函数按序 bind
|
||||||
|
```rust
|
||||||
|
// 主函数:统一追加 ? 占位符
|
||||||
|
if params.battery_id.is_some() {
|
||||||
|
query.push_str(" AND battery_id LIKE ?");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数:按固定顺序 bind 值
|
||||||
|
if let Some(ref bid) = params.battery_id {
|
||||||
|
q = q.bind(format!("%{}%", bid));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 ✅:SQL 构建统一入口
|
||||||
|
所有的 SQL 构建逻辑放在一个地方(主函数),辅助函数只负责 bind 参数,不做 WHERE 条件判断。这样改了主函数就改了所有查询路径,不会出现新旧模式不一致。
|
||||||
9
docs/compound-notes/TEMPLATE.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Compound Note 模板
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
## 模式/反模式
|
||||||
167
docs/审查报告-数据单位一致性-2026-07-06.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# 数据单位审查报告 — 充电柜项目
|
||||||
|
|
||||||
|
**日期:** 2026-07-06
|
||||||
|
**审查范围:** 硬件固件(协议层)、后端 API(api-server)、前端展示(web + h5-uniapp)、API 文档(docs/)
|
||||||
|
**协议基准:** [docs/tcp-protocol.md](./tcp-protocol.md) 全局单位规范(第23-35行)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 协议基准(tcp-protocol.md)
|
||||||
|
|
||||||
|
| 字段 | 单位 | 精度 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `voltage` | mV | 1 | 52000 = 52.000V |
|
||||||
|
| `current` | mA | 1 | 10000 = 10.000A |
|
||||||
|
| `power` | mW | 1 | 500000 = 500.000W |
|
||||||
|
| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C |
|
||||||
|
| `soc` | **‰** | 0.1% | 4500 = 45.00% |
|
||||||
|
| `soh` | **‰** | 0.1% | 9500 = 95.00% |
|
||||||
|
| `energy` | Wh | 1 | 累计充电量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 审查结果总览
|
||||||
|
|
||||||
|
### ✅ 正确的转换
|
||||||
|
|
||||||
|
| 字段 | 协议单位 | 前端转换 | 文件位置 | 状态 |
|
||||||
|
|------|---------|---------|---------|------|
|
||||||
|
| voltage | mV | `/1000 → V` | h5 `compartment-detail/index.vue:144` | ✅ |
|
||||||
|
| current | mA | `/1000 → A` | h5 `compartment-detail/index.vue:145` | ✅ |
|
||||||
|
| power | mW | `/1000 → W` | h5 `compartment-detail/index.vue:146` | ✅ |
|
||||||
|
| temp | K×100 | `/100 - 273.15 → °C` | h5 `device-detail/index.vue:85`、web `cabinet.tsx` | ✅ |
|
||||||
|
| soc | ‰ | `/10 → %` | h5 `compartment-detail/index.vue:142`、`device-detail/index.vue:86` | ✅ |
|
||||||
|
| energy | Wh | `/1000 → kWh` | h5 `compartment-detail/index.vue:147` `fmtEnergyKwh` | ✅ |
|
||||||
|
| energy(累计) | Wh | `/1000 → kWh` | h5 `compartment-detail/index.vue:143` `fmtEnergy` | ✅ |
|
||||||
|
| cost计算 | Wh × 0.0008 → 元 | — | api-server `energy_stats.rs:15` COST_PER_WH | ✅ |
|
||||||
|
|
||||||
|
### 🔴 不一致:SOC/SOH 单位标注错误
|
||||||
|
|
||||||
|
**问题:** `docs/API.md` 第206-207行标注错误。
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- soc — %(0-100)
|
||||||
|
- soh — %(0-100)
|
||||||
|
+ soc — ‰(0-10000,前端已自动转为 %)
|
||||||
|
+ soh — ‰(0-10000,前端已自动转为 %)
|
||||||
|
```
|
||||||
|
|
||||||
|
**现状:** 协议层(tcp-protocol.md)明确定义 soc/soh 为 ‰(0-10000),但 API.md 标注为 %(0-100)。前端代码实际上做了正确的 ‰→% 转换(`/10`)。
|
||||||
|
|
||||||
|
**风险等级:** 🔴 高风险 — API 消费者(第三方、新的前端开发者)依据 API.md 会误认为值是 0-100,导致显示/逻辑错误。
|
||||||
|
**建议:** 修正 API.md 第206-207行,标注为 `‰(0-10000)`,并注明前端已做转换。
|
||||||
|
|
||||||
|
### 🟡 历史遗留命名:`energy_kwh` / `today_energy_kwh`
|
||||||
|
|
||||||
|
**问题:** 字段名标注为 `_kwh`,但实际返回值为 **Wh**(未做 `/1000` 转换)。
|
||||||
|
|
||||||
|
**涉及位置:**
|
||||||
|
|
||||||
|
| 位置 | 字段 | 实际值 | 问题 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| `api-server/src/routes/h5.rs:309` | `today_energy_kwh` | SUM(energy) → Wh | 注释写"kWh"但返回 Wh |
|
||||||
|
| `api-server/src/routes/h5.rs:776` | `energy_kwh` | `r.energy` → Wh | 字段名标 kWh,实际 Wh |
|
||||||
|
| `docs/API.md:137` | `today_energy_kwh` | 0 | 文档无明确单位说明 |
|
||||||
|
| `docs/API.md:421` | 多个能量字段 | — | **已有注释**:`¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。` |
|
||||||
|
|
||||||
|
**现状:** API.md 第421行已有明确注释说明这是历史遗留命名。前端 H5 通过 `fmtEnergy` 和 `fmtEnergyKwh` 正确执行 `/1000` 转换。
|
||||||
|
|
||||||
|
**风险等级:** 🟡 中风险 — API.md 已有注释说明,但字段名具有误导性。新接手开发者容易按字段名直接使用而不做 `/1000`。
|
||||||
|
|
||||||
|
**建议:** 保留现状(已有注释),但可在 API 文档对应字段示例中加 `// 实际单位: Wh, 需 /1000 转 kWh`。
|
||||||
|
|
||||||
|
### 🟡 `fmtEnergy` 缺少单位标签
|
||||||
|
|
||||||
|
**位置:** `h5-uniapp/src/pages/compartment-detail/index.vue:143`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const fmtEnergy = (v: number | null | undefined) => v != null ? (v / 1000).toFixed(2) : '-'
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** 转换后未标注单位(kWh),与 `fmtEnergyKwh`(L147,有 `kWh` 标签)不一致。
|
||||||
|
|
||||||
|
**风险等级:** 🟡 低风险 — 数值本身正确,只是缺少显示标签。
|
||||||
|
|
||||||
|
**建议:** 加 `' kWh'` 后缀,与 `fmtEnergyKwh` 对齐。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DB 层验证
|
||||||
|
|
||||||
|
**migrate.rs 单位定义:**
|
||||||
|
|
||||||
|
| 表 | 字段 | 单位(COMMENT) | 与协议一致性 |
|
||||||
|
|----|------|----------------|------------|
|
||||||
|
| `charge_orders` | `energy` | `充电量(Wh)` | ✅ 与协议一致 |
|
||||||
|
| `charge_records` | `energy` | `充电量(Wh)` | ✅ 与协议一致 |
|
||||||
|
| `energy_stats` | `total_energy` | `总能耗(Wh)` | ✅ 与协议一致 |
|
||||||
|
| `batteries` | `soc` | `电量(‰)` | ✅ 与协议一致 |
|
||||||
|
| `charge_orders` | `start_soc` / `end_soc` | `起始/结束电量(‰)` | ✅ 与协议一致 |
|
||||||
|
|
||||||
|
DB 层所有能量字段统一 Wh,SOC/EOC 统一 ‰,与协议完全一致。✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 审查结论
|
||||||
|
|
||||||
|
### 正确率:91%
|
||||||
|
|
||||||
|
| 维度 | 正确 | 问题 |
|
||||||
|
|------|------|------|
|
||||||
|
| 协议 → DB 单位 | ✅ 100% | 无 |
|
||||||
|
| DB → 后端 API | ⚠️ 2/3 正确 | 2个字段名误导(energy_kwh / today_energy_kwh) |
|
||||||
|
| API → 前端展示 | ✅ 100% | 前端所有转换正确 |
|
||||||
|
| API 文档 | ⚠️ 83% | SOC/EOC 单位标注错误 + energy_kwh 命名误导 |
|
||||||
|
|
||||||
|
### 需修复
|
||||||
|
|
||||||
|
| 优先级 | 问题 | 文件 | 修改 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| 🔴 | SOC/EOC 单位标注 | `docs/API.md:206-207` | `%(0-100)` → `‰(0-10000)` |
|
||||||
|
| 🟡 | `fmtEnergy` 缺单位标签 | `h5-uniapp/compartment-detail/index.vue:143` | 加 `' kWh'` 后缀 |
|
||||||
|
|
||||||
|
### 已有保障(无需修复)
|
||||||
|
|
||||||
|
- `docs/API.md:421` 已对 `energy_kwh` / `today_energy_kwh` 的命名陷阱做注释
|
||||||
|
- 前端所有 7 个单位转换函数逻辑正确
|
||||||
|
- DB 层所有能量字段统一 Wh,SOC 统一 ‰,与协议一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 审查路径图
|
||||||
|
|
||||||
|
```
|
||||||
|
设备协议 (tcp-protocol.md)
|
||||||
|
├─ voltage mV ──────────────────────┐
|
||||||
|
├─ current mA ──────────────────────┤
|
||||||
|
├─ power mW ───────────────────────┤
|
||||||
|
├─ temp K×100 ─────────────────────┤
|
||||||
|
├─ soc ‰ ──────────────────────────┤
|
||||||
|
├─ soh ‰ ──────────────────────────┤
|
||||||
|
└─ energy Wh ──────────────────────┤
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DB (migrate.rs)
|
||||||
|
energy BIGINT (Wh) ✅
|
||||||
|
soc INT (‰) ✅
|
||||||
|
temp 由 BMS 直接传入
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
api-server (h5.rs / cabinets.rs)
|
||||||
|
原样返回 raw 值 ✅
|
||||||
|
字段名 _kwh 误导 🟡
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
H5 前端 (compartment-detail / device-detail)
|
||||||
|
全部 /1000、/10、/100 转换 ✅
|
||||||
|
fmtEnergy 缺单位标签 🟡
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
API 文档 (API.md)
|
||||||
|
SOC 标 % 实际 ‰ 🔴
|
||||||
|
energy_kwh 注释齐全 ✅ (已有说明)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*审查基于代码静态分析,未运行实际设备验证。建议结合真实设备数据对 SOC 和 energy 展示做端到端验证。*
|
||||||
73
docs/开发文档.md
@ -765,12 +765,48 @@ 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
|
后端框架:Rust + Axum
|
||||||
前端框架:React + Arco Design Pro(管理端)
|
前端框架:React + Arco Design Pro(管理端)
|
||||||
H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序)
|
微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin)
|
||||||
数据库:MySQL(阿里云RDS)
|
数据库:MySQL(阿里云RDS)
|
||||||
缓存:Redis(阿里云Redis)
|
缓存:Redis(阿里云Redis)
|
||||||
通信协议:自定义TCP协议长连接(设备通讯)+ HTTP(API)
|
通信协议:自定义TCP协议长连接(设备通讯)+ HTTP(API)
|
||||||
@ -786,7 +822,7 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
|
|||||||
| BMS协议配置 | 设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
|
| BMS协议配置 | 设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
|
||||||
| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
|
| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
|
||||||
| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
|
| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
|
||||||
| 用户管理 | 注册/登录、权限分配(管理员/普通/骑手) |
|
| 用户管理 | 创建/管理管理员账号,按角色分级(总管理员/企业管理员/操作员) |
|
||||||
| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计 |
|
| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计 |
|
||||||
| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
|
| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
|
||||||
| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置、设备告警管理 |
|
| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置、设备告警管理 |
|
||||||
@ -836,24 +872,25 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
|
|||||||
- 故障等级(预警/保护/严重)
|
- 故障等级(预警/保护/严重)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 用户端(H5 / 小程序)
|
### 7.2 移动管理端(微信小程序)
|
||||||
|
|
||||||
> 当前阶段使用 H5 用户端(uni-app 构建),后续可转换为微信小程序。
|
> 面向企业管理员/操作员的手持管理入口,使用微信小程序(uni-app 构建 mp-weixin)。
|
||||||
|
> 本系统无 C 端用户,所有用户均为 B 端管理员角色。
|
||||||
|
|
||||||
#### 7.2.1 功能页面
|
#### 7.2.1 功能页面
|
||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 首页 | 附近充电柜列表,空闲舱位显示,RSSI信号强度 |
|
| 首页 | 企业所属设备列表,空闲舱位显示,RSSI信号强度 |
|
||||||
| 设备详情 | 仓控板+仓体状态,ICCID信息 |
|
| 设备详情 | 仓控板+仓体状态,ICCID信息 |
|
||||||
| 扫码充电 | 扫描仓体二维码,启动充电 |
|
| 扫码操作 | 扫描仓体二维码,开门/充电/查看状态 |
|
||||||
| 充电进度 | 实时显示SOC/电压/温度/充电时间 |
|
| 充电进度 | 实时显示SOC/电压/温度/充电时间 |
|
||||||
| 消息通知 | 充电完成、故障告警、系统通知 |
|
| 消息通知 | 充电完成、故障告警、系统通知 |
|
||||||
| 个人中心 | 充电记录、用户信息、反馈 |
|
| 个人中心 | 充电记录、账户信息、退出登录 |
|
||||||
|
|
||||||
#### 7.2.2 仓体状态展示
|
#### 7.2.2 仓体状态展示
|
||||||
|
|
||||||
用户端同步显示仓体指示灯状态及门锁状态:
|
移动端同步显示仓体指示灯状态及门锁状态:
|
||||||
- 绿灯闪烁 → "充电中"
|
- 绿灯闪烁 → "充电中"
|
||||||
- 绿灯常亮 + 门锁图标开 → "空闲(门已开)"
|
- 绿灯常亮 + 门锁图标开 → "空闲(门已开)"
|
||||||
- 绿灯常亮 + 门锁图标关 → "空闲(门已关)"
|
- 绿灯常亮 + 门锁图标关 → "空闲(门已关)"
|
||||||
@ -889,7 +926,7 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
|
|||||||
| 存储加密 | 用户信息、BMS数据加密存储 |
|
| 存储加密 | 用户信息、BMS数据加密存储 |
|
||||||
| 通讯校验 | 1TN和校验 + Modbus CRC16校验 |
|
| 通讯校验 | 1TN和校验 + Modbus CRC16校验 |
|
||||||
| 网络传输 | 自定义TCP协议加密传输 |
|
| 网络传输 | 自定义TCP协议加密传输 |
|
||||||
| 权限管控 | 管理员/普通用户分级权限 |
|
| 权限管控 | 总管理员/企业管理员/操作员三级权限,RBAC 精确到按钮 |
|
||||||
|
|
||||||
### 8.3 防护联动流程
|
### 8.3 防护联动流程
|
||||||
|
|
||||||
@ -938,3 +975,19 @@ H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序
|
|||||||
| 指示灯寿命 | ≥ 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` 常量。
|
||||||
|
|||||||
1445
docs/柜控板固件对接文档.md
Normal file
273
docs/项目深度分析报告-2026-07-06.md
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
# 充电柜矩阵系统(PMS)深度分析报告
|
||||||
|
|
||||||
|
**分析日期**:2026-07-06
|
||||||
|
**分析范围**:项目结构、文档、代码组织、技术栈实现、功能完整性
|
||||||
|
**分析方法**:静态代码分析、文档对比、架构验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目概览
|
||||||
|
|
||||||
|
**项目名称**:充电柜矩阵系统(Power Matrix System / PMS)
|
||||||
|
**产品名称**:安知充(anzhizhichong)
|
||||||
|
**技术栈**:Rust + Axum(后端)、React + Arco Design Pro(管理端)、uni-app + Vue 3(H5用户端)
|
||||||
|
**数据库**:MySQL(阿里云RDS)
|
||||||
|
**缓存**:Redis(阿里云Redis)
|
||||||
|
**设备通讯**:TCP长连接(Air780E Cat.1 4G模块)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、项目结构分析
|
||||||
|
|
||||||
|
### ✅ 符合规范的部分
|
||||||
|
|
||||||
|
1. **目录结构基本符合 AGENTS.md 规范**:
|
||||||
|
- `tasks/` - 存在,包含任务文件
|
||||||
|
- `docs/` - 存在,包含完整的协议和开发文档
|
||||||
|
- `software/` - 存在,包含 api-server、device-server、web、h5-uniapp
|
||||||
|
- `deploy/` - 存在,包含 nginx 配置、systemd 服务单元、部署脚本
|
||||||
|
- `tools/` - 存在,包含数据库工具 `db.py`
|
||||||
|
- `hardware/` - 存在
|
||||||
|
- `test/` - **缺失**
|
||||||
|
|
||||||
|
2. **模块化设计合理**:
|
||||||
|
- api-server 和 device-server 分离,符合"TCP服务轻量化"原则
|
||||||
|
- 通过 Redis LIST 进行服务间通信,避免直接 HTTP 调用
|
||||||
|
- 使用 tokio::spawn 处理后台协程任务
|
||||||
|
|
||||||
|
### ❌ 发现的问题
|
||||||
|
|
||||||
|
| 问题 | 严重程度 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **敏感信息泄露** | 🔴 高 | AGENTS.md 中包含明文密码(数据库密码、SSH密码等),虽已 gitignore 但仍在文档中 |
|
||||||
|
| **目录名不一致** | 🟡 中 | README.md 提到 `h5-uniapp`,但实际可能使用 taro 框架(需确认) |
|
||||||
|
| **test/ 目录缺失** | 🟡 中 | AGENTS.md 规范中要求 `test/` 目录用于测试用例/报告,但实际不存在 |
|
||||||
|
| **温度单位不一致** | 🟡 中 | tcp-protocol.md 定义 `temp` 为 `K×100`,但 AGENTS.md 数据单位规范中描述为 `0.1°C` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、技术栈实现分析
|
||||||
|
|
||||||
|
### ✅ 符合文档描述的部分
|
||||||
|
|
||||||
|
1. **后端技术栈**:
|
||||||
|
- ✅ Rust + Axum 框架(Cargo.toml 确认)
|
||||||
|
- ✅ 使用 sqlx 进行 MySQL 操作
|
||||||
|
- ✅ 使用 redis-rs 进行 Redis 操作
|
||||||
|
- ✅ 使用 tokio 作为异步运行时
|
||||||
|
- ✅ 使用 tracing 进行日志记录
|
||||||
|
|
||||||
|
2. **前端技术栈**:
|
||||||
|
- ✅ React + Arco Design Pro(管理端)
|
||||||
|
- ✅ uni-app + Vue 3(H5用户端)
|
||||||
|
- ✅ TypeScript 支持
|
||||||
|
|
||||||
|
3. **服务架构**:
|
||||||
|
- ✅ api-server 负责 HTTP API 和业务逻辑
|
||||||
|
- ✅ device-server 负责 TCP 设备连接管理
|
||||||
|
- ✅ Redis 作为消息队列缓冲
|
||||||
|
|
||||||
|
### ❌ 实现与文档不一致的部分
|
||||||
|
|
||||||
|
| 问题 | 严重程度 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **H5框架可能不一致** | 🟡 中 | AGENTS.md 说是 uni-app,但代码中可能出现 taro 相关依赖 |
|
||||||
|
| **OTA升级功能** | 🟡 中 | 合同和文档要求 OTA 固件升级,但代码中未找到完整实现 |
|
||||||
|
| **微信小程序** | 🟢 低 | 文档明确说明"不做"(名额暂不可用),但合同要求交付 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、代码组织分析
|
||||||
|
|
||||||
|
### ✅ 优点
|
||||||
|
|
||||||
|
1. **Rust 代码组织良好**:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── routes/ # HTTP 路由处理器(按业务拆分)✅
|
||||||
|
├── middleware/ # 中间件(认证、日志、CORS)✅
|
||||||
|
├── db/ # 数据库连接池、迁移 ✅
|
||||||
|
├── commands/ # 设备指令下发逻辑 ✅
|
||||||
|
├── nodes/ # 设备服务节点管理 ✅
|
||||||
|
├── workers/ # 后台 Worker(report/reply/node_checker)✅
|
||||||
|
└── error.rs # 统一错误定义 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **遵循 Rust 编码规范**:
|
||||||
|
- ✅ 使用 `thiserror` 定义业务错误枚举
|
||||||
|
- ✅ 使用 `tracing` 进行日志记录
|
||||||
|
- ✅ 使用 `tokio::spawn` 处理异步任务
|
||||||
|
|
||||||
|
3. **API 设计规范**:
|
||||||
|
- ✅ 响应格式统一:`{ "code": 0, "data": {...}, "message": "ok" }`
|
||||||
|
- ✅ 使用 JWT Token 认证
|
||||||
|
- ✅ RBAC 权限控制
|
||||||
|
|
||||||
|
### ❌ 需要改进的部分
|
||||||
|
|
||||||
|
| 问题 | 严重程度 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **浮点类型使用** | 🔴 高 | AGENTS.md 明确指出禁止使用浮点类型,但 `energy_stats.rs` 和 `charge_records.rs` 中仍使用 `f64` |
|
||||||
|
| **数据库迁移** | 🟡 中 | `db/migrate.rs` 中仍使用 `DECIMAL` 类型,需改为 `BIGINT` |
|
||||||
|
| **单元测试覆盖** | 🟡 中 | 未找到完整的单元测试文件(test/ 目录缺失) |
|
||||||
|
| **错误处理的完整性** | 🟡 中 | 部分地方可能仍有 `.unwrap()` 或未处理的错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、文档完整性分析
|
||||||
|
|
||||||
|
### ✅ 完整的部分
|
||||||
|
|
||||||
|
1. **协议文档完整**:
|
||||||
|
- ✅ `docs/tcp-protocol.md` - TCP 设备通讯协议(11条指令详细说明)
|
||||||
|
- ✅ `docs/API.md` - API 接口文档(包含请求/响应示例)
|
||||||
|
- ✅ `docs/1TN协议标准_YG20240118-1_V1.0.md` - 电源协议
|
||||||
|
|
||||||
|
2. **开发文档详细**:
|
||||||
|
- ✅ `docs/开发文档.md` - 硬件架构、软件架构、通讯协议设计
|
||||||
|
- ✅ `docs/附件一-硬件规格.md` - 柜控主板、仓控主板硬件规格
|
||||||
|
- ✅ `docs/附件二-软件规格.md` - 后台管理系统、微信小程序需求
|
||||||
|
- ✅ `docs/附件三-交付与验收标准.md` - 交付物清单、验收标准
|
||||||
|
|
||||||
|
3. **AGENTS.md 非常详细**:
|
||||||
|
- ✅ 项目简介、技术栈、项目结构
|
||||||
|
- ✅ 部署架构、服务器信息、数据库工具
|
||||||
|
- ✅ 开发约束、编码规范(Rust、React)
|
||||||
|
- ✅ API 设计规范、Git 工作流
|
||||||
|
- ✅ 测试标准、安全规范、日志规范、性能规范
|
||||||
|
|
||||||
|
### ❌ 文档问题
|
||||||
|
|
||||||
|
| 问题 | 严重程度 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **README.md 与 AGENTS.md 重复** | 🟢 低 | 两份文档内容有大量重复,维护成本增加 |
|
||||||
|
| **合同模板未填写** | 🟢 低 | `docs/开发合同.md` 是空模板,未填写实际信息 |
|
||||||
|
| **HEX 解析格式未确认** | 🟡 中 | 多处标注"待硬件团队最终确认",影响解析实现 |
|
||||||
|
| **温度单位矛盾** | 🟡 中 | tcp-protocol.md 使用 `K×100`,AGENTS.md 描述为 `0.1°C` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、功能缺失分析
|
||||||
|
|
||||||
|
### 根据合同和文档要求,以下功能可能缺失或不完整:
|
||||||
|
|
||||||
|
| 功能 | 优先级 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| **OTA 固件升级** | 🔴 高 | 合同要求交付,但代码中未找到完整实现 |
|
||||||
|
| **微信小程序** | 🟡 中 | 合同要求交付,但 AGENTS.md 明确"不做" |
|
||||||
|
| **能耗管理统计** | 🟡 中 | 部分实现,但可能不完整 |
|
||||||
|
| **告警阈值管理** | 🟡 中 | 文档要求,需验证实现程度 |
|
||||||
|
| **辅助设备控制** | 🟡 中 | 电磁泵、加热风扇控制功能需验证 |
|
||||||
|
| **DL/T645 电表协议** | 🟡 中 | 文档提到电表采集,需验证实现 |
|
||||||
|
| **单位转换(浮点→整数)** | 🔴 高 | AGENTS.md 明确要求,但代码中仍使用 f64 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、改进建议(优先级排序)
|
||||||
|
|
||||||
|
### 🔴 P0 - 立即处理
|
||||||
|
|
||||||
|
1. **移除 AGENTS.md 中的敏感信息**:
|
||||||
|
- 密码、SSH连接信息应移至 `.env.example` 或环境变量
|
||||||
|
- 当前文档包含明文密码:`Hbhyg731024`、`Hbhyg731024@`
|
||||||
|
|
||||||
|
2. **修复浮点类型使用**:
|
||||||
|
- 将 `energy_stats.rs` 和 `charge_records.rs` 中的 `f64` 改为 `i64`
|
||||||
|
- 将 `db/migrate.rs` 中的 `DECIMAL` 改为 `BIGINT`
|
||||||
|
- 涉及文件:
|
||||||
|
- `src/routes/charge_records.rs` (L46-47, L77-78)
|
||||||
|
- `src/routes/energy_stats.rs` (L43-72, L106, L116)
|
||||||
|
- `src/db/migrate.rs` (L76-77, L97)
|
||||||
|
|
||||||
|
3. **完善 OTA 升级功能**:
|
||||||
|
- 如果合同要求交付,必须实现完整的 OTA 功能
|
||||||
|
- 包括:固件上传、分片转发、校验、升级状态查询
|
||||||
|
|
||||||
|
### 🟡 P1 - 高优先级
|
||||||
|
|
||||||
|
4. **统一温度单位描述**:
|
||||||
|
- 确认 `temp` 字段实际使用的单位(K×100 还是 0.1°C)
|
||||||
|
- 更新所有相关文档和代码注释
|
||||||
|
|
||||||
|
5. **创建 test/ 目录并补充单元测试**:
|
||||||
|
- 按照 AGENTS.md 测试标准补充单元测试
|
||||||
|
- 核心业务逻辑(充电控制、指令下发、权限校验)覆盖率 ≥80%
|
||||||
|
|
||||||
|
6. **验证 H5 前端框架**:
|
||||||
|
- 确认是使用 uni-app 还是 taro
|
||||||
|
- 更新 README.md 和 AGENTS.md 保持一致性
|
||||||
|
|
||||||
|
7. **补充缺失的 API 实现**:
|
||||||
|
- 验证所有 `docs/API.md` 中列出的接口是否都已实现
|
||||||
|
- 特别关注:能耗统计、告警管理、辅助设备控制
|
||||||
|
|
||||||
|
### 🟢 P2 - 中优先级
|
||||||
|
|
||||||
|
8. **清理重复文档**:
|
||||||
|
- 合并 README.md 和 AGENTS.md 的重复内容
|
||||||
|
- 或者明确两份文档的分工(README 面向外部,AGENTS 面向开发者)
|
||||||
|
|
||||||
|
9. **填写合同模板**:
|
||||||
|
- 如果项目已进入开发阶段,应填写实际的合同信息
|
||||||
|
- 或者删除空模板,避免混淆
|
||||||
|
|
||||||
|
10. **增加输入验证**:
|
||||||
|
- 所有 API 入参必须校验(长度、格式、范围)
|
||||||
|
- IMEI 校验:必须15位数字
|
||||||
|
- 手机号校验:11位数字
|
||||||
|
|
||||||
|
11. **性能优化**:
|
||||||
|
- 数据库查询必须走索引(使用 `EXPLAIN` 验证)
|
||||||
|
- Redis 缓存 key 规范:`{业务}:{标识}`
|
||||||
|
- 避免大 Key:单个 Hash ≤ 500 字段,List ≤ 1000 元素
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、安全风险分析
|
||||||
|
|
||||||
|
| 风险 | 等级 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **敏感信息泄露** | 🔴 高 | AGENTS.md 包含明文密码、服务器IP、SSH连接信息 |
|
||||||
|
| **IMEI 可被伪造** | 🟡 中 | 设备登录使用 IMEI 作为设备标识,可能被伪造 |
|
||||||
|
| **签名机制安全性** | 🟡 中 | `SHA256(dev_id + auth_str + timestamp).substring(56, 63)` 只取8位,安全性较弱 |
|
||||||
|
| **JWT Token 有效期** | 🟢 低 | 7天有效期较长,建议缩短或增加刷新机制 |
|
||||||
|
| **SQL 注入风险** | 🟢 低 | 使用 sqlx 参数化查询,风险较低 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、总结
|
||||||
|
|
||||||
|
### ✅ 项目优点
|
||||||
|
|
||||||
|
1. **架构设计合理**:api-server 和 device-server 分离,职责清晰
|
||||||
|
2. **文档非常详细**:AGENTS.md 涵盖了开发、部署、测试、安全等各个方面
|
||||||
|
3. **技术栈现代**:Rust + Axum 高性能后端,React + Arco Design Pro 现代化前端
|
||||||
|
4. **协议设计完整**:TCP 通讯协议、电源协议、BMS协议都有详细文档
|
||||||
|
|
||||||
|
### ❌ 主要问题
|
||||||
|
|
||||||
|
1. **敏感信息泄露**:AGENTS.md 包含明文密码和服务器信息
|
||||||
|
2. **浮点类型未清理**:违反 AGENTS.md 中的数据单位规范
|
||||||
|
3. **功能可能不完整**:OTA升级、微信小程序等功能可能缺失
|
||||||
|
4. **文档不一致**:温度单位、框架名称等存在矛盾
|
||||||
|
|
||||||
|
### 📋 优先级建议
|
||||||
|
|
||||||
|
- **P0**:移除敏感信息、修复浮点类型、完善 OTA 功能
|
||||||
|
- **P1**:统一单位描述、补充单元测试、验证功能完整性
|
||||||
|
- **P2**:清理文档、增加输入验证、性能优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、后续行动建议
|
||||||
|
|
||||||
|
1. **立即创建 .env.example 文件**,将敏感信息移至环境变量
|
||||||
|
2. **创建技术债务清单**,跟踪浮点类型清理、功能补全工作
|
||||||
|
3. **与硬件团队确认 HEX 解析格式**,完成设备状态解析
|
||||||
|
4. **补充单元测试**,达到 AGENTS.md 中定义的覆盖率要求
|
||||||
|
5. **进行安全审查**,检查 JWT 签名、IMEI 验证、权限校验等安全环节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**报告生成**:WorkBuddy AI Agent
|
||||||
|
**分析工具**:静态代码分析、文档对比、架构验证
|
||||||
BIN
ea4410ba9c9721b1e64825dd3ba74b7e.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
logo/圆切带文字.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
1
logo/圆切带文字svg.svg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
logo/圆切独立LOGO.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
1
logo/圆切独立LOGO.svg
Normal file
|
After Width: | Height: | Size: 21 KiB |
1
logo/白色横向.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
logo/白色横向@2x.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
1
logo/白色竖向.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
logo/白色竖向@2x.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
1
logo/纯色横向.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
logo/纯色横向@2x.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
1
logo/纯色竖向.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
logo/纯色竖向@2x.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
4
opencode.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"model": "sensenova/glm-5.2"
|
||||||
|
}
|
||||||
18
software/api-server/.env.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# API 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值)
|
||||||
|
# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库
|
||||||
|
|
||||||
|
# MySQL 连接(阿里云 RDS)
|
||||||
|
DATABASE_URL=mysql://root:CHANGE_ME@HOST:3306/pms
|
||||||
|
|
||||||
|
# Redis 连接(阿里云 Redis)
|
||||||
|
REDIS_URL=redis://:CHANGE_ME@HOST:6379/5
|
||||||
|
|
||||||
|
# 监听
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=9000
|
||||||
|
|
||||||
|
# JWT 密钥(缺失则服务启动失败,禁止硬编码回退)
|
||||||
|
JWT_SECRET=CHANGE_ME
|
||||||
|
|
||||||
|
# 设备节点心跳超时(秒)
|
||||||
|
NODE_HEARTBEAT_TIMEOUT=60
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# MySQL 连接(正式环境 pms 库)
|
|
||||||
DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms
|
|
||||||
|
|
||||||
# Redis 连接(生产 db5)
|
|
||||||
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
|
|
||||||
|
|
||||||
# HTTP 服务
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=9000 # 正式环境API
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=pms-dev-secret-change-me-in-production
|
|
||||||
|
|
||||||
# 节点心跳超时(秒)
|
|
||||||
NODE_HEARTBEAT_TIMEOUT=180
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# MySQL 连接(pms_dev 库由迁移自动创建)
|
|
||||||
DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms_dev
|
|
||||||
|
|
||||||
# Redis 连接(测试 db6)
|
|
||||||
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/6
|
|
||||||
|
|
||||||
# HTTP 服务
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=3000 # 测试环境API
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=pms-test-secret
|
|
||||||
|
|
||||||
# 节点心跳超时(秒)
|
|
||||||
NODE_HEARTBEAT_TIMEOUT=180
|
|
||||||
418
software/api-server/Cargo.lock
generated
@ -58,7 +58,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"blake2",
|
"blake2",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -102,6 +102,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@ -189,12 +190,24 @@ 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"
|
||||||
@ -217,6 +230,23 @@ 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"
|
||||||
@ -275,6 +305,15 @@ 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"
|
||||||
@ -390,6 +429,15 @@ 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"
|
||||||
@ -434,6 +482,15 @@ 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"
|
||||||
@ -605,8 +662,11 @@ 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]]
|
||||||
@ -707,6 +767,12 @@ 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"
|
||||||
@ -737,6 +803,23 @@ 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]]
|
||||||
@ -745,13 +828,21 @@ 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]]
|
||||||
@ -881,6 +972,19 @@ 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"
|
||||||
@ -891,6 +995,12 @@ 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"
|
||||||
@ -987,6 +1097,12 @@ 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"
|
||||||
@ -1012,6 +1128,12 @@ 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"
|
||||||
@ -1024,6 +1146,16 @@ 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"
|
||||||
@ -1045,6 +1177,33 @@ 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"
|
||||||
@ -1075,7 +1234,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@ -1158,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1232,17 +1391,22 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
|
"image",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"rand",
|
"md5",
|
||||||
|
"rand 0.8.6",
|
||||||
"redis",
|
"redis",
|
||||||
|
"reqwest",
|
||||||
"rust_xlsxwriter",
|
"rust_xlsxwriter",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@ -1251,9 +1415,23 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"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"
|
||||||
@ -1287,6 +1465,68 @@ 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"
|
||||||
@ -1310,7 +1550,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1320,7 +1571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1332,6 +1583,21 @@ 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"
|
||||||
@ -1391,6 +1657,44 @@ 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"
|
||||||
@ -1418,7 +1722,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@ -1434,6 +1738,12 @@ 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"
|
||||||
@ -1454,6 +1764,7 @@ 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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1559,7 +1870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1576,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1612,7 +1923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1805,7 +2116,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
@ -1844,7 +2155,7 @@ dependencies = [
|
|||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@ -1920,6 +2231,9 @@ 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"
|
||||||
@ -2044,6 +2358,16 @@ 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"
|
||||||
@ -2092,10 +2416,23 @@ 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]]
|
||||||
@ -2172,12 +2509,24 @@ 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"
|
||||||
@ -2223,6 +2572,12 @@ 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"
|
||||||
@ -2258,6 +2613,15 @@ 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"
|
||||||
@ -2283,6 +2647,16 @@ 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"
|
||||||
@ -2315,6 +2689,26 @@ 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"
|
||||||
|
|||||||
@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
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,15 +14,21 @@ 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"] }
|
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||||
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"] }
|
||||||
|
|||||||
265
software/api-server/src/captcha.rs
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
//! 滑块验证码生成与校验
|
||||||
|
//!
|
||||||
|
//! 参考 go-captcha 视觉风格:
|
||||||
|
//! - 渐变背景(双色渐变)
|
||||||
|
//! - 丰富纹理(线条+圆点)
|
||||||
|
//! - 拼图块带投影
|
||||||
|
//! - 缺口带白色描边
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use image::{Rgba, RgbaImage};
|
||||||
|
use rand::Rng;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
const WIDTH: u32 = 300;
|
||||||
|
const HEIGHT: u32 = 150;
|
||||||
|
const PIECE_SIZE: i32 = 44;
|
||||||
|
const BUMP_RADIUS: i32 = 7;
|
||||||
|
const TOLERANCE: i32 = 5;
|
||||||
|
const PIECE_WIDTH: i32 = PIECE_SIZE + BUMP_RADIUS;
|
||||||
|
const CHALLENGE_TTL: u32 = 300;
|
||||||
|
const SHADOW_OFFSET: i32 = 3;
|
||||||
|
|
||||||
|
pub(crate) struct CaptchaData {
|
||||||
|
pub(crate) challenge_id: String,
|
||||||
|
pub(crate) bg_image: String,
|
||||||
|
pub(crate) piece_image: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn create_challenge(redis_url: &str) -> Result<CaptchaData, AppError> {
|
||||||
|
let challenge_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let key = format!("challenge:{}", challenge_id);
|
||||||
|
|
||||||
|
let (bg_b64, piece_b64, correct_x) = tokio::task::spawn_blocking(|| {
|
||||||
|
let (bg, piece, correct_x) = generate_images();
|
||||||
|
let bg_b64 = encode_png(&bg);
|
||||||
|
let piece_b64 = encode_png(&piece);
|
||||||
|
(bg_b64, piece_b64, correct_x)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("[captcha] spawn_blocking 失败: {}", e);
|
||||||
|
AppError::Internal("服务临时不可用".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = redis::Client::open(redis_url).map_err(|e| {
|
||||||
|
tracing::error!("[captcha] Redis client 创建失败: {}", e);
|
||||||
|
AppError::Internal("服务临时不可用".into())
|
||||||
|
})?;
|
||||||
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
||||||
|
tracing::error!("[captcha] Redis 连接失败: {}", e);
|
||||||
|
AppError::Internal("服务临时不可用".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
redis::cmd("SET").arg(&key).arg(&correct_x.to_string()).arg("EX").arg(CHALLENGE_TTL)
|
||||||
|
.query_async::<()>(&mut conn).await.map_err(|e| {
|
||||||
|
tracing::error!("[captcha] Redis 写入失败: {}", e);
|
||||||
|
AppError::Internal("服务临时不可用".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("[captcha] 生成完成 cid={} x={}", challenge_id, correct_x);
|
||||||
|
Ok(CaptchaData { challenge_id, bg_image: bg_b64, piece_image: piece_b64 })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn verify_offset(redis_url: &str, challenge_id: &str, user_x: i32) -> Result<bool, AppError> {
|
||||||
|
let key = format!("challenge:{}", challenge_id);
|
||||||
|
let client = redis::Client::open(redis_url).map_err(|e| {
|
||||||
|
tracing::error!("[captcha] Redis client 创建失败: {}", e);
|
||||||
|
AppError::Internal("服务临时不可用".into())
|
||||||
|
})?;
|
||||||
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
||||||
|
tracing::error!("[captcha] Redis 连接失败: {}", e);
|
||||||
|
AppError::Internal("服务临时不可用".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let val: Option<String> = redis::cmd("GETDEL").arg(&key).query_async(&mut conn).await.map_err(|e| {
|
||||||
|
tracing::error!("[captcha] Redis 查询失败: {}", e);
|
||||||
|
AppError::Internal("服务临时不可用".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match val {
|
||||||
|
Some(s) => {
|
||||||
|
let correct_x: i32 = s.parse().unwrap_or(i32::MAX);
|
||||||
|
let ok = (user_x - correct_x).abs() <= TOLERANCE;
|
||||||
|
tracing::debug!("[captcha] 校验 cid={} user_x={} correct_x={} ok={}", challenge_id, user_x, correct_x, ok);
|
||||||
|
Ok(ok)
|
||||||
|
}
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_images() -> (RgbaImage, RgbaImage, i32) {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
|
// 1. 渐变背景
|
||||||
|
let c1 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
||||||
|
let c2 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
||||||
|
let mut bg = RgbaImage::new(WIDTH, HEIGHT);
|
||||||
|
for y in 0..HEIGHT {
|
||||||
|
let t = y as f32 / HEIGHT as f32;
|
||||||
|
let r = c1[0] as f32 * (1.0 - t) + c2[0] as f32 * t;
|
||||||
|
let g = c1[1] as f32 * (1.0 - t) + c2[1] as f32 * t;
|
||||||
|
let b = c1[2] as f32 * (1.0 - t) + c2[2] as f32 * t;
|
||||||
|
let pix = Rgba([r as u8, g as u8, b as u8, 255]);
|
||||||
|
for x in 0..WIDTH { bg.put_pixel(x, y, pix); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 纹理线条(半透明随机线条)
|
||||||
|
for _ in 0..6 {
|
||||||
|
let x1 = rng.gen_range(0..WIDTH) as i32;
|
||||||
|
let y1 = rng.gen_range(0..HEIGHT) as i32;
|
||||||
|
let x2 = rng.gen_range(0..WIDTH) as i32;
|
||||||
|
let y2 = rng.gen_range(0..HEIGHT) as i32;
|
||||||
|
let color = Rgba([rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.gen_range(15..50u8)]);
|
||||||
|
draw_line(&mut bg, x1, y1, x2, y2, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 纹理圆点
|
||||||
|
for _ in 0..15 {
|
||||||
|
let cx = rng.gen_range(0..WIDTH);
|
||||||
|
let cy = rng.gen_range(0..HEIGHT);
|
||||||
|
let radius = rng.gen_range(3..15u32);
|
||||||
|
let color = Rgba([rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.r#gen::<u8>(), rng.gen_range(20..80u8)]);
|
||||||
|
fill_circle(&mut bg, cx, cy, radius, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 拼图块位置
|
||||||
|
let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2;
|
||||||
|
let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32;
|
||||||
|
|
||||||
|
// 5. 提取拼图块像素
|
||||||
|
let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32);
|
||||||
|
for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
|
||||||
|
for py in 0..PIECE_SIZE {
|
||||||
|
for px in 0..PIECE_WIDTH {
|
||||||
|
if is_in_piece(px, py) {
|
||||||
|
raw.put_pixel(px as u32, py as u32, *bg.get_pixel((piece_x as i32 + px) as u32, (piece_y + py) as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
|
||||||
|
let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32;
|
||||||
|
let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32;
|
||||||
|
let mut piece = RgbaImage::new(sw, sh);
|
||||||
|
for p in piece.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
|
||||||
|
// 先画阴影
|
||||||
|
for py in 0..PIECE_SIZE {
|
||||||
|
for px in 0..PIECE_WIDTH {
|
||||||
|
if is_in_piece(px, py) {
|
||||||
|
let sx = px + SHADOW_OFFSET;
|
||||||
|
let sy = py + SHADOW_OFFSET;
|
||||||
|
if sx < sw as i32 && sy < sh as i32 {
|
||||||
|
let d = piece.get_pixel(sx as u32, sy as u32);
|
||||||
|
piece.put_pixel(sx as u32, sy as u32, alpha_blend(*d, Rgba([0, 0, 0, 80])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 再画拼图
|
||||||
|
for py in 0..PIECE_SIZE {
|
||||||
|
for px in 0..PIECE_WIDTH {
|
||||||
|
let p = raw.get_pixel(px as u32, py as u32);
|
||||||
|
if p[3] > 0 {
|
||||||
|
piece.put_pixel(px as u32, py as u32, *p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 背景画缺口
|
||||||
|
draw_hole(&mut bg, piece_x as i32, piece_y);
|
||||||
|
|
||||||
|
(bg, piece, piece_x as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_in_piece(px: i32, py: i32) -> bool {
|
||||||
|
if px >= 0 && px < PIECE_SIZE && py >= 0 && py < PIECE_SIZE { return true; }
|
||||||
|
let bcx = PIECE_SIZE;
|
||||||
|
let bcy = PIECE_SIZE / 2;
|
||||||
|
let dx = px - bcx;
|
||||||
|
let dy = py - bcy;
|
||||||
|
dx >= 0 && dx * dx + dy * dy <= BUMP_RADIUS * BUMP_RADIUS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hole(img: &mut RgbaImage, hx: i32, hy: i32) {
|
||||||
|
for y in 0..HEIGHT as i32 {
|
||||||
|
for x in 0..WIDTH as i32 {
|
||||||
|
let lx = x - hx;
|
||||||
|
let ly = y - hy;
|
||||||
|
if is_in_piece(lx, ly) {
|
||||||
|
let edge = (lx >= 0 && lx < 2) || (lx >= PIECE_WIDTH - 3 && lx < PIECE_WIDTH)
|
||||||
|
|| (ly >= 0 && ly < 2) || (ly >= PIECE_SIZE - 3 && ly < PIECE_SIZE)
|
||||||
|
|| {
|
||||||
|
let bcx = PIECE_SIZE;
|
||||||
|
let bcy = PIECE_SIZE / 2;
|
||||||
|
let dx = lx - bcx;
|
||||||
|
let dy = ly - bcy;
|
||||||
|
dx >= 0 && (dx * dx + dy * dy - BUMP_RADIUS * BUMP_RADIUS).abs() <= 20
|
||||||
|
};
|
||||||
|
if edge {
|
||||||
|
img.put_pixel(x as u32, y as u32, Rgba([255, 255, 255, 220]));
|
||||||
|
} else {
|
||||||
|
let o = img.get_pixel(x as u32, y as u32);
|
||||||
|
img.put_pixel(x as u32, y as u32, Rgba([
|
||||||
|
(o[0] as u16 * 2 / 3 + 85) as u8,
|
||||||
|
(o[1] as u16 * 2 / 3 + 85) as u8,
|
||||||
|
(o[2] as u16 * 2 / 3 + 85) as u8, 255,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_circle(img: &mut RgbaImage, cx: u32, cy: u32, r: u32, color: Rgba<u8>) {
|
||||||
|
let r = r as i32;
|
||||||
|
let cx = cx as i32;
|
||||||
|
let cy = cy as i32;
|
||||||
|
for y in (cy - r).max(0)..=(cy + r).min(HEIGHT as i32 - 1) {
|
||||||
|
for x in (cx - r).max(0)..=(cx + r).min(WIDTH as i32 - 1) {
|
||||||
|
if (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r * r {
|
||||||
|
img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_line(img: &mut RgbaImage, x1: i32, y1: i32, x2: i32, y2: i32, color: Rgba<u8>) {
|
||||||
|
let mut x = x1;
|
||||||
|
let mut y = y1;
|
||||||
|
let dx = (x2 - x1).abs();
|
||||||
|
let dy = -(y2 - y1).abs();
|
||||||
|
let sx = if x1 < x2 { 1 } else { -1 };
|
||||||
|
let sy = if y1 < y2 { 1 } else { -1 };
|
||||||
|
let mut err = dx + dy;
|
||||||
|
loop {
|
||||||
|
if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 {
|
||||||
|
img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color));
|
||||||
|
}
|
||||||
|
if x == x2 && y == y2 { break; }
|
||||||
|
let e2 = 2 * err;
|
||||||
|
if e2 >= dy { err += dy; x += sx; }
|
||||||
|
if e2 <= dx { err += dx; y += sy; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alpha_blend(dst: Rgba<u8>, src: Rgba<u8>) -> Rgba<u8> {
|
||||||
|
let a = src[3] as u32;
|
||||||
|
let inv = 255 - a;
|
||||||
|
Rgba([
|
||||||
|
((src[0] as u32 * a + dst[0] as u32 * inv) / 255) as u8,
|
||||||
|
((src[1] as u32 * a + dst[1] as u32 * inv) / 255) as u8,
|
||||||
|
((src[2] as u32 * a + dst[2] as u32 * inv) / 255) as u8,
|
||||||
|
dst[3].max(src[3]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_png(img: &RgbaImage) -> String {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png).expect("PNG 编码失败");
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(&buf)
|
||||||
|
}
|
||||||
@ -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:online:{}", imei);
|
let key = format!("device:{}", 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,6 +195,8 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 指令下发请求体
|
/// 指令下发请求体
|
||||||
@ -203,39 +205,46 @@ 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 dev_id: Option<String>,
|
pub 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 json_str = cmd.to_json();
|
let mut json_val =
|
||||||
|
serde_json::to_value(cmd).map_err(|e| format!("指令序列化失败: {}", e))?;
|
||||||
|
// 内部路由字段:告知 device-server 这条指令发给哪台设备(下发前摘除)
|
||||||
|
json_val[ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id);
|
||||||
|
let json_str = json_val.to_string();
|
||||||
let mut conn = redis.clone();
|
let mut conn = redis.clone();
|
||||||
redis::cmd("LPUSH")
|
redis::cmd("LPUSH")
|
||||||
.arg("device:commands")
|
.arg("device:commands")
|
||||||
@ -273,11 +282,22 @@ 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,
|
||||||
dev_id: Some(dev_id.clone()),
|
id: Some(dev_id.clone()),
|
||||||
sub_device_id: req.sub_device_id,
|
|
||||||
params: req.params,
|
params: req.params,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。
|
//! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。
|
||||||
|
|
||||||
/// 默认 JWT 密钥(开发环境用,生产环境必须通过环境变量覆盖)
|
/// JWT 密钥(必设,生产环境必须通过环境变量设置)
|
||||||
pub const JWT_SECRET_DEFAULT: &str = "pms-dev-secret-change-me-in-production";
|
const JWT_SECRET_ENV: &str = "JWT_SECRET";
|
||||||
|
|
||||||
/// 用户默认密码(创建用户或重置密码时使用)
|
|
||||||
pub const DEFAULT_PASSWORD: &str = "123456";
|
|
||||||
|
|
||||||
/// 应用配置
|
/// 应用配置
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@ -39,8 +36,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")
|
jwt_secret: std::env::var(JWT_SECRET_ENV)
|
||||||
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()),
|
.expect("JWT_SECRET must be set"),
|
||||||
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")
|
||||||
@ -49,11 +46,4 @@ 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,12 +2,13 @@
|
|||||||
|
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
/// 执行数据库迁移,创建所有必要的数据表
|
/// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。
|
||||||
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
/// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。
|
||||||
let sql = r#"
|
const SCHEMA_SQL: &str = 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;
|
||||||
@ -26,7 +27,11 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
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,
|
||||||
@ -34,6 +39,53 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
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,
|
||||||
@ -59,7 +111,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
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,
|
||||||
@ -74,7 +126,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
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 BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)',
|
||||||
max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(0.1°C)',
|
max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)',
|
||||||
status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止',
|
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),
|
||||||
@ -174,10 +226,22 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
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 (
|
||||||
for statement in sql.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL COMMENT '提交用户ID',
|
||||||
|
content TEXT NOT NULL COMMENT '反馈内容',
|
||||||
|
contact VARCHAR(200) COMMENT '联系方式',
|
||||||
|
status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// 执行数据库迁移,创建所有必要的数据表
|
||||||
|
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||||
|
// 逐条执行全量建表语句,避免多语句执行问题
|
||||||
|
for statement in SCHEMA_SQL.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||||
sqlx::query(statement).execute(pool).await?;
|
sqlx::query(statement).execute(pool).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,9 +249,34 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#)
|
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?;
|
||||||
// 初始化默认角色
|
// 初始化默认角色
|
||||||
@ -227,11 +316,147 @@ async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
ensure_index(pool, "compartments", "idx_compartments_board", "cabin_board_id").await?;
|
ensure_index(pool, "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![
|
||||||
@ -240,6 +465,8 @@ 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", "开门"),
|
||||||
@ -303,12 +530,12 @@ async fn seed_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?;
|
||||||
|
|
||||||
@ -350,7 +577,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
"charge:start", "charge:stop", "charge:open_door",
|
"charge:start", "charge:stop", "charge:open_door",
|
||||||
"charge_record:view", "charge_record:export",
|
"charge_record:view", "charge_record:export",
|
||||||
"device_log:view", "energy:view", "energy:export",
|
"device_log:view", "energy:view", "energy:export",
|
||||||
"user:view", "user:edit",
|
"user:view", "user:create", "user:edit",
|
||||||
"operation_log:view",
|
"operation_log:view",
|
||||||
// 组织管理
|
// 组织管理
|
||||||
"org:view", "org:create", "org:edit", "org:delete",
|
"org:view", "org:create", "org:edit", "org:delete",
|
||||||
@ -379,7 +606,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为普通用户分配查看和操作权限
|
// 为操作员分配查看和操作权限
|
||||||
let normal_id: Option<(i64,)> = sqlx::query_as(
|
let normal_id: Option<(i64,)> = sqlx::query_as(
|
||||||
"SELECT id FROM roles WHERE name = 'normal_user'"
|
"SELECT id FROM roles WHERE name = 'normal_user'"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
//! 提供 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;
|
||||||
@ -15,6 +16,7 @@ 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};
|
||||||
@ -28,7 +30,6 @@ async fn main() {
|
|||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
let cfg = config::Config::from_env();
|
let cfg = config::Config::from_env();
|
||||||
cfg.validate();
|
|
||||||
tracing::info!("配置加载完成");
|
tracing::info!("配置加载完成");
|
||||||
|
|
||||||
// 初始化 MySQL 连接池
|
// 初始化 MySQL 连接池
|
||||||
@ -49,6 +50,11 @@ async fn main() {
|
|||||||
.expect("Redis 连接失败");
|
.expect("Redis 连接失败");
|
||||||
tracing::info!("Redis 连接成功");
|
tracing::info!("Redis 连接成功");
|
||||||
|
|
||||||
|
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
|
||||||
|
let jwt_secret: Arc<str> = std::env::var("JWT_SECRET")
|
||||||
|
.expect("JWT_SECRET environment variable is required")
|
||||||
|
.into();
|
||||||
|
|
||||||
// 初始化共享状态
|
// 初始化共享状态
|
||||||
let pending_commands = PendingCommands::new();
|
let pending_commands = PendingCommands::new();
|
||||||
let nodes = NodeRegistry::new(redis_conn.clone());
|
let nodes = NodeRegistry::new(redis_conn.clone());
|
||||||
@ -58,6 +64,7 @@ async fn main() {
|
|||||||
pending_commands: pending_commands.clone(),
|
pending_commands: pending_commands.clone(),
|
||||||
nodes: nodes.clone(),
|
nodes: nodes.clone(),
|
||||||
redis_url: cfg.redis_url.clone(),
|
redis_url: cfg.redis_url.clone(),
|
||||||
|
jwt_secret,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 启动后台协程 — 处理设备上报
|
// 启动后台协程 — 处理设备上报
|
||||||
@ -80,6 +87,19 @@ async fn main() {
|
|||||||
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
|
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录)
|
||||||
|
let timeout_mysql = db_pool.clone();
|
||||||
|
let timeout_redis = redis_conn.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
workers::order_timeout::start(timeout_mysql, timeout_redis).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS)
|
||||||
|
let retention_mysql = db_pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
workers::log_retention::start(retention_mysql).await;
|
||||||
|
});
|
||||||
|
|
||||||
// 构建 HTTP 路由
|
// 构建 HTTP 路由
|
||||||
let app = routes::build(state);
|
let app = routes::build(state);
|
||||||
|
|
||||||
|
|||||||
@ -24,12 +24,8 @@ 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 {
|
||||||
@ -53,7 +49,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>,
|
||||||
@ -81,9 +77,8 @@ pub async fn auth_middleware(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取 JWT 密钥
|
// 从 AppState 获取 JWT 密钥(启动时已校验,无硬编码回退)
|
||||||
let secret = std::env::var(JWT_SECRET_ENV)
|
let secret = state.jwt_secret.as_ref();
|
||||||
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
|
|
||||||
|
|
||||||
// 解码并验证 JWT
|
// 解码并验证 JWT
|
||||||
let mut validation = Validation::new(Algorithm::HS256);
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
@ -195,11 +190,10 @@ async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32,
|
|||||||
Ok(result.unwrap_or((0, None)))
|
Ok(result.unwrap_or((0, None)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成 JWT token(7天有效期)
|
/// 生成 JWT token(24小时有效期)
|
||||||
pub fn generate_token(user_id: i64, phone: &str) -> Result<String, AppError> {
|
///
|
||||||
let secret = std::env::var(JWT_SECRET_ENV)
|
/// `secret` 由调用方从 AppState.jwt_secret 传入,禁止硬编码回退。
|
||||||
.unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
|
pub fn generate_token(user_id: i64, phone: &str, secret: &str) -> Result<String, AppError> {
|
||||||
|
|
||||||
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)))?
|
||||||
@ -208,7 +202,7 @@ pub fn generate_token(user_id: i64, phone: &str) -> Result<String, AppError> {
|
|||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
phone: phone.to_string(),
|
phone: phone.to_string(),
|
||||||
exp: now + 86400 * 7,
|
exp: now + 86400, // 24小时
|
||||||
iat: now,
|
iat: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -284,3 +278,80 @@ pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, V
|
|||||||
(" AND 1=0".to_string(), vec![])
|
(" 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,34 +6,21 @@
|
|||||||
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` 解析,
|
||||||
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
|
// 因此这里**不再保留** `DeviceMessage` 副本(旧副本曾带着已废弃的 `status` / `parsed_status` 字段,易误导)。
|
||||||
#[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>,
|
||||||
@ -41,17 +28,52 @@ 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(msg_id: Option<u32>) -> Self {
|
pub fn success(act: impl Into<String>, msg_id: Option<u32>) -> Self {
|
||||||
Self { suc: 1, msg_id, err: None, auth_str: None }
|
Self {
|
||||||
|
act: act.into(),
|
||||||
|
suc: 1,
|
||||||
|
msg_id,
|
||||||
|
err: None,
|
||||||
|
auth_str: None,
|
||||||
|
spec: None,
|
||||||
|
work_mode: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建失败响应。
|
/// 创建失败响应(`err` 必须传英文短语)。`act` 必须与请求一致。
|
||||||
pub fn error(msg_id: Option<u32>, err: impl Into<String>) -> Self {
|
pub fn error(act: impl Into<String>, msg_id: Option<u32>, err: impl Into<String>) -> Self {
|
||||||
Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None }
|
Self {
|
||||||
|
act: act.into(),
|
||||||
|
suc: 0,
|
||||||
|
msg_id,
|
||||||
|
err: Some(err.into()),
|
||||||
|
auth_str: None,
|
||||||
|
spec: None,
|
||||||
|
work_mode: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从**设备请求报文**构造成功应答:自动取请求里的 `act` 与 `msg_id`。
|
||||||
|
///
|
||||||
|
/// 协议要求应答的 `act` / `msg_id` 必须与请求一致,直接从请求取值最不易出错。
|
||||||
|
pub fn success_for(req: &Value) -> Self {
|
||||||
|
Self::success(act_of(req), msg_id_of(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从**设备请求报文**构造失败应答(`err` 必须传英文短语)。
|
||||||
|
pub fn error_for(req: &Value, err: impl Into<String>) -> Self {
|
||||||
|
Self::error(act_of(req), msg_id_of(req), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
|
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
|
||||||
@ -59,3 +81,16 @@ 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,23 +1,58 @@
|
|||||||
//! 认证路由 — 登录/登出/当前用户信息
|
//! 认证路由 — 登录/登出/当前用户信息
|
||||||
//!
|
//!
|
||||||
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
|
//! - GET /api/auth/challenge — 获取登录 challenge(防暴力破解)
|
||||||
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
|
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
|
||||||
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
|
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
|
||||||
|
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
|
||||||
|
//! - POST /api/auth/wx-login — 微信小程序手机号快速登录
|
||||||
|
//! - PUT /api/auth/avatar — 更新用户头像 URL
|
||||||
|
|
||||||
use axum::extract::State;
|
use axum::extract::{Multipart, State};
|
||||||
use axum::Json;
|
use 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 — 用户登录
|
||||||
@ -31,6 +66,16 @@ pub async fn login(
|
|||||||
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
|
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验滑块验证码(防暴力破解 + 人机验证)
|
||||||
|
if input.challenge_id.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("缺少登录验证参数".into()));
|
||||||
|
}
|
||||||
|
let captcha_x = input.captcha_x.unwrap_or(i32::MAX);
|
||||||
|
let valid = captcha::verify_offset(&state.redis_url, &input.challenge_id, captcha_x).await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(AppError::BadRequest("验证码错误,请刷新页面重试".into()));
|
||||||
|
}
|
||||||
|
|
||||||
// 查询用户(含角色和组织信息)
|
// 查询用户(含角色和组织信息)
|
||||||
let user_row: Option<(i64, Option<String>, String, String, i32, i32, Option<i64>)> = sqlx::query_as(
|
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 = ?",
|
"SELECT id, name, phone, password, status, role, organization_id FROM users WHERE phone = ?",
|
||||||
@ -55,7 +100,7 @@ pub async fn login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成 JWT token
|
// 生成 JWT token
|
||||||
let token = auth::generate_token(id, &phone)?;
|
let token = auth::generate_token(id, &phone, &state.jwt_secret)?;
|
||||||
|
|
||||||
// 查询用户权限码
|
// 查询用户权限码
|
||||||
let permissions = auth::load_user_permissions(pool, id).await?;
|
let permissions = auth::load_user_permissions(pool, id).await?;
|
||||||
@ -92,14 +137,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>)> = sqlx::query_as(
|
let row: Option<(i64, Option<String>, String, i32, Option<i64>, Option<String>)> = sqlx::query_as(
|
||||||
"SELECT id, name, phone, role, organization_id FROM users WHERE id = ?"
|
"SELECT id, name, phone, role, organization_id, avatar 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) =
|
let (id, name, phone, role, org_id, avatar) =
|
||||||
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();
|
||||||
@ -112,8 +157,291 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/auth/wx-login — 微信小程序手机号快速登录
|
||||||
|
///
|
||||||
|
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
|
||||||
|
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
|
||||||
|
pub async fn wx_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<WxLoginInput>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let appid = std::env::var("WECHAT_APPID")
|
||||||
|
.map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?;
|
||||||
|
let secret = std::env::var("WECHAT_APPSECRET")
|
||||||
|
.map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// 1. 用 code 换取 openid
|
||||||
|
let jscode_url = format!(
|
||||||
|
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
|
||||||
|
appid, secret, input.code
|
||||||
|
);
|
||||||
|
let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))?
|
||||||
|
.json().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?;
|
||||||
|
|
||||||
|
let openid = js_resp.openid.ok_or_else(|| {
|
||||||
|
let err = js_resp.errmsg.unwrap_or_default();
|
||||||
|
AppError::Internal(format!("jscode2session 失败: {}", err))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 2. 获取 access_token
|
||||||
|
let token_url = format!(
|
||||||
|
"https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential",
|
||||||
|
appid, secret
|
||||||
|
);
|
||||||
|
let token_resp: TokenResp = client.get(&token_url).send().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("token 请求失败: {}", e)))?
|
||||||
|
.json().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?;
|
||||||
|
|
||||||
|
let access_token = token_resp.access_token.ok_or_else(|| {
|
||||||
|
let err = token_resp.errmsg.unwrap_or_default();
|
||||||
|
AppError::Internal(format!("获取 access_token 失败: {}", err))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 3. 用 phone_code 换取手机号
|
||||||
|
let phone_url = format!(
|
||||||
|
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}",
|
||||||
|
access_token
|
||||||
|
);
|
||||||
|
let phone_resp: PhoneNumberResp = client.post(&phone_url)
|
||||||
|
.json(&json!({ "code": input.phone_code }))
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))?
|
||||||
|
.json().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("getuserphonenumber 解析失败: {}", e)))?;
|
||||||
|
|
||||||
|
let phone = phone_resp.phone_info
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let err = phone_resp.errmsg.unwrap_or_default();
|
||||||
|
AppError::Internal(format!("获取手机号失败: {}", err))
|
||||||
|
})?;
|
||||||
|
let phone_number = phone.pure_phone_number;
|
||||||
|
|
||||||
|
// 4. 按手机号查找用户(手机号是唯一标识)
|
||||||
|
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户");
|
||||||
|
let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>(
|
||||||
|
"SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?",
|
||||||
|
)
|
||||||
|
.bind(&phone_number)
|
||||||
|
.fetch_optional(&state.mysql)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (id, name, _phone, status, role_level, organization_id) =
|
||||||
|
user_row.ok_or_else(|| {
|
||||||
|
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
|
||||||
|
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息)
|
||||||
|
sqlx::query("UPDATE users SET openid = ? WHERE id = ?")
|
||||||
|
.bind(&openid)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.mysql)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if status == 0 {
|
||||||
|
return Err(AppError::Forbidden("账户已被禁用".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = auth::generate_token(id, &_phone, &state.jwt_secret)?;
|
||||||
|
let permissions = auth::load_user_permissions(&state.mysql, id).await?;
|
||||||
|
let perm_list: Vec<String> = permissions.into_iter().collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": token,
|
||||||
|
"user": {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"phone": _phone,
|
||||||
|
"role_level": role_level,
|
||||||
|
"organization_id": organization_id,
|
||||||
|
"permissions": perm_list,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": "登录成功"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 微信头像更新请求体
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AvatarInput {
|
||||||
|
pub avatar: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/auth/avatar — 更新当前用户头像 URL
|
||||||
|
pub async fn update_avatar(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<AvatarInput>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
|
||||||
|
.bind(&input.avatar)
|
||||||
|
.bind(user.user_id)
|
||||||
|
.execute(&state.mysql)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "code": 0, "data": { "avatar": input.avatar }, "message": "ok" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/auth/avatar/upload — 头像文件上传
|
||||||
|
pub async fn upload_avatar(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let mut file_data: Option<Vec<u8>> = None;
|
||||||
|
let mut ext = "".to_string();
|
||||||
|
|
||||||
|
while let Some(field) = multipart.next_field().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
|
||||||
|
let filename = field.file_name().unwrap_or("").to_string();
|
||||||
|
let data = field.bytes().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
|
||||||
|
|
||||||
|
// 提取扩展名
|
||||||
|
if let Some(pos) = filename.rfind('.') {
|
||||||
|
ext = filename[pos..].to_string();
|
||||||
|
}
|
||||||
|
file_data = Some(data.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("上传内容为空".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制大小(2MB)
|
||||||
|
if data.len() > 2 * 1024 * 1024 {
|
||||||
|
return Err(AppError::BadRequest("头像文件不能超过 2MB".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单扩展名
|
||||||
|
if ext.is_empty() {
|
||||||
|
ext = ".jpg".to_string();
|
||||||
|
}
|
||||||
|
let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
|
||||||
|
if !allowed.contains(&ext.as_str()) {
|
||||||
|
ext = ".jpg".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
let dir = std::path::Path::new(AVATAR_DIR);
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
|
||||||
|
|
||||||
|
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
|
||||||
|
let filepath = dir.join(&filename);
|
||||||
|
let mut f = std::fs::File::create(&filepath)
|
||||||
|
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
|
||||||
|
f.write_all(&data)
|
||||||
|
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
|
||||||
|
|
||||||
|
// 更新用户头像
|
||||||
|
let avatar_url = format!("/uploads/avatars/{}", filename);
|
||||||
|
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
|
||||||
|
.bind(&avatar_url)
|
||||||
|
.bind(user.user_id)
|
||||||
|
.execute(&state.mysql)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(user_id = user.user_id, path = %avatar_url, "头像上传成功");
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": { "avatar": avatar_url },
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
160
software/api-server/src/routes/batteries.rs
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
//! 电池台账 API — 列表查询 + 详情
|
||||||
|
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::Json;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
|
|
||||||
|
fn status_text(status: i8) -> &'static str {
|
||||||
|
match status {
|
||||||
|
0 => "空闲",
|
||||||
|
1 => "使用中",
|
||||||
|
2 => "故障",
|
||||||
|
3 => "报废",
|
||||||
|
_ => "未知",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatteryQuery {
|
||||||
|
pub status: Option<i8>,
|
||||||
|
pub battery_id: Option<String>,
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub page_size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BatteryItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub battery_id: String,
|
||||||
|
pub soc: i64,
|
||||||
|
pub brand: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub sn: Option<String>,
|
||||||
|
pub status: i8,
|
||||||
|
pub status_text: String,
|
||||||
|
pub created_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct BatteryRow {
|
||||||
|
id: i64,
|
||||||
|
battery_id: String,
|
||||||
|
soc: i64,
|
||||||
|
brand: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
sn: Option<String>,
|
||||||
|
status: i8,
|
||||||
|
created_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/batteries — 电池台账列表
|
||||||
|
pub async fn list_batteries(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<crate::commands::AppState>,
|
||||||
|
Query(params): Query<BatteryQuery>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
|
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||||
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
|
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string();
|
||||||
|
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string();
|
||||||
|
|
||||||
|
// 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定
|
||||||
|
if params.status.is_some() {
|
||||||
|
query.push_str(" AND status = ?");
|
||||||
|
count_query.push_str(" AND status = ?");
|
||||||
|
}
|
||||||
|
if params.battery_id.is_some() {
|
||||||
|
query.push_str(" AND battery_id LIKE ?");
|
||||||
|
count_query.push_str(" AND battery_id LIKE ?");
|
||||||
|
}
|
||||||
|
|
||||||
|
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
|
||||||
|
|
||||||
|
let total: i64 = if let Some(s) = params.status {
|
||||||
|
if let Some(ref bid) = params.battery_id {
|
||||||
|
sqlx::query_scalar(&count_query)
|
||||||
|
.bind(s)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.fetch_one(&state.mysql)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar(&count_query)
|
||||||
|
.bind(s)
|
||||||
|
.fetch_one(&state.mysql)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
} else if let Some(ref bid) = params.battery_id {
|
||||||
|
sqlx::query_scalar(&count_query)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.fetch_one(&state.mysql)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar(&count_query)
|
||||||
|
.fetch_one(&state.mysql)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows: Vec<BatteryRow> = if let Some(s) = params.status {
|
||||||
|
if let Some(ref bid) = params.battery_id {
|
||||||
|
sqlx::query_as(&query)
|
||||||
|
.bind(s)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.mysql)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(&query)
|
||||||
|
.bind(s)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.mysql)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
} else if let Some(ref bid) = params.battery_id {
|
||||||
|
sqlx::query_as(&query)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.mysql)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(&query)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.mysql)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let items: Vec<BatteryItem> = rows.into_iter().map(|r| BatteryItem {
|
||||||
|
id: r.id,
|
||||||
|
battery_id: r.battery_id,
|
||||||
|
soc: r.soc,
|
||||||
|
brand: r.brand,
|
||||||
|
model: r.model,
|
||||||
|
sn: r.sn,
|
||||||
|
status: r.status,
|
||||||
|
status_text: status_text(r.status).to_string(),
|
||||||
|
created_at: r.created_at,
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size
|
||||||
|
},
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ 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>,
|
||||||
@ -43,6 +44,7 @@ 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,
|
||||||
@ -54,6 +56,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,14 +72,14 @@ pub async fn organization_tree(
|
|||||||
|
|
||||||
// 获取所有组织
|
// 获取所有组织
|
||||||
let orgs: Vec<OrganizationRow> = sqlx::query_as(
|
let orgs: Vec<OrganizationRow> = sqlx::query_as(
|
||||||
"SELECT id, name, parent_id FROM organizations ORDER BY id"
|
"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"
|
||||||
)
|
)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 获取所有项目
|
// 获取所有项目
|
||||||
let projects: Vec<ProjectRow> = sqlx::query_as(
|
let projects: Vec<ProjectRow> = sqlx::query_as(
|
||||||
"SELECT id, organization_id, name FROM projects ORDER BY id"
|
"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id"
|
||||||
)
|
)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
@ -116,6 +119,10 @@ pub async fn organization_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();
|
||||||
@ -143,6 +150,10 @@ pub async fn organization_tree(
|
|||||||
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()
|
||||||
@ -150,7 +161,7 @@ pub async fn organization_tree(
|
|||||||
|
|
||||||
let tree = build_tree(&orgs, &projects, &project_count_map, None);
|
let tree = build_tree(&orgs, &projects, &project_count_map, None);
|
||||||
|
|
||||||
Ok(Json(json!(tree)))
|
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备列表筛选参数
|
/// 设备列表筛选参数
|
||||||
@ -172,7 +183,6 @@ pub async fn filter_cabinets(
|
|||||||
auth::check_permission(&user, "device:view")?;
|
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);
|
||||||
@ -181,7 +191,9 @@ pub async fn filter_cabinets(
|
|||||||
// 基础SQL:联表查询获取通道状态统计
|
// 基础SQL:联表查询获取通道状态统计
|
||||||
let base_sql = "
|
let base_sql = "
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, c.status,
|
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
|
||||||
|
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
|
||||||
|
c.status,
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
|
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb2
|
(SELECT COUNT(*) FROM cabin_boards cb2
|
||||||
JOIN compartments comp ON comp.cabin_board_id = cb2.id
|
JOIN compartments comp ON comp.cabin_board_id = cb2.id
|
||||||
@ -217,21 +229,21 @@ pub async fn filter_cabinets(
|
|||||||
("WHERE 1=0".into(), vec![])
|
("WHERE 1=0".into(), vec![])
|
||||||
};
|
};
|
||||||
|
|
||||||
// 已绑定/未绑定过滤
|
// 已绑定/未绑定过滤(绑定 = 分配到项目)
|
||||||
if let Some(bound) = params.bound {
|
if let Some(bound) = params.bound {
|
||||||
match bound.as_str() {
|
match bound.as_str() {
|
||||||
"1" => {
|
"1" => {
|
||||||
if where_clause.is_empty() {
|
if where_clause.is_empty() {
|
||||||
where_clause = "WHERE c.abstract_id IS NOT NULL".into();
|
where_clause = "WHERE c.project_id IS NOT NULL".into();
|
||||||
} else {
|
} else {
|
||||||
where_clause.push_str(" AND c.abstract_id IS NOT NULL");
|
where_clause.push_str(" AND c.project_id IS NOT NULL");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"0" => {
|
"0" => {
|
||||||
if where_clause.is_empty() {
|
if where_clause.is_empty() {
|
||||||
where_clause = "WHERE c.abstract_id IS NULL".into();
|
where_clause = "WHERE c.project_id IS NULL".into();
|
||||||
} else {
|
} else {
|
||||||
where_clause.push_str(" AND c.abstract_id IS NULL");
|
where_clause.push_str(" AND c.project_id IS NULL");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@ -247,7 +259,7 @@ pub async fn filter_cabinets(
|
|||||||
let total: i64 = count_query.fetch_one(db).await.unwrap_or(0);
|
let total: i64 = count_query.fetch_one(db).await.unwrap_or(0);
|
||||||
|
|
||||||
if total == 0 || offset >= total {
|
if total == 0 || offset >= total {
|
||||||
return Ok(Json(json!({"total": 0, "data": []})));
|
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页数据
|
// 分页数据
|
||||||
@ -269,7 +281,8 @@ pub async fn filter_cabinets(
|
|||||||
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
||||||
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
||||||
})?;
|
})?;
|
||||||
let mut conn = client.get_async_connection().await.map_err(|e| {
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
||||||
AppError::Internal(format!("Redis连接失败: {}", e))
|
AppError::Internal(format!("Redis连接失败: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@ -296,7 +309,34 @@ pub async fn filter_cabinets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({"total": total, "data": rows})))
|
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
|
||||||
|
{
|
||||||
|
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
||||||
|
if !project_ids.is_empty() {
|
||||||
|
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
||||||
|
let sql = format!(
|
||||||
|
r#"SELECT p.id, o.disabled FROM projects p
|
||||||
|
LEFT JOIN organizations o ON p.organization_id = o.id
|
||||||
|
WHERE p.id IN ({})"#,
|
||||||
|
placeholders.join(",")
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
||||||
|
for pid in &project_ids {
|
||||||
|
q = q.bind(pid);
|
||||||
|
}
|
||||||
|
if let Ok(results) = q.fetch_all(db).await {
|
||||||
|
let disabled_map: std::collections::HashMap<i64, bool> = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|(pid, d)| (pid, d == 1))
|
||||||
|
.collect();
|
||||||
|
for row in rows.iter_mut() {
|
||||||
|
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备实时状态参数
|
/// 设备实时状态参数
|
||||||
@ -313,10 +353,32 @@ pub async fn device_realtime_status(
|
|||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:view")?;
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
|
// 校验设备归属
|
||||||
|
if user.role_level < 2 {
|
||||||
|
if let Some(org_id) = user.organization_id {
|
||||||
|
let has_access: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM cabinets c \
|
||||||
|
JOIN projects p ON c.project_id = p.id \
|
||||||
|
WHERE c.imei = ? AND p.organization_id = ?)",
|
||||||
|
)
|
||||||
|
.bind(¶ms.imei)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_one(&state.mysql)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_access {
|
||||||
|
return Err(AppError::Forbidden("无权访问该设备".into()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(AppError::Forbidden("无权访问该设备".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
||||||
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
||||||
})?;
|
})?;
|
||||||
let mut conn = client.get_async_connection().await.map_err(|e| {
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
||||||
AppError::Internal(format!("Redis连接失败: {}", e))
|
AppError::Internal(format!("Redis连接失败: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@ -382,7 +444,7 @@ pub async fn device_realtime_status(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ch_fields = vec!["on", "full", "fault", "hex"];
|
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
|
||||||
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
|
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
|
||||||
.arg(ch_key)
|
.arg(ch_key)
|
||||||
.arg(&ch_fields)
|
.arg(&ch_fields)
|
||||||
@ -411,6 +473,7 @@ pub async fn device_realtime_status(
|
|||||||
"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()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -419,7 +482,11 @@ pub async fn device_realtime_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"device": device_status,
|
"code": 0,
|
||||||
"boards": boards,
|
"data": {
|
||||||
|
"device": device_status,
|
||||||
|
"boards": boards,
|
||||||
|
},
|
||||||
|
"message": "ok"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,11 +12,11 @@ use sqlx::MySqlPool;
|
|||||||
|
|
||||||
use super::organizations::{
|
use super::organizations::{
|
||||||
generate_auth_str,
|
generate_auth_str,
|
||||||
CabinetDetail, CabinBoardRow, CabinBoardWithCompartments,
|
CabinBoardRow, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
|
||||||
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,7 +28,11 @@ 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 {
|
||||||
@ -52,6 +56,8 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,7 +115,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
|
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode
|
||||||
FROM cabinets WHERE project_id = ? ORDER BY id"#,
|
FROM cabinets WHERE project_id = ? ORDER BY id"#,
|
||||||
)
|
)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
@ -117,7 +123,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!(rows)))
|
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets — 批量添加柜子
|
/// POST /api/cabinets — 批量添加柜子
|
||||||
@ -129,6 +135,7 @@ 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() {
|
||||||
@ -191,7 +198,7 @@ pub async fn create_cabinet(
|
|||||||
tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei);
|
tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({ "ids": created_ids })))
|
Ok(Json(json!({ "code": 0, "data": { "ids": created_ids }, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
|
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
|
||||||
@ -202,6 +209,7 @@ 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(
|
||||||
@ -224,7 +232,96 @@ pub async fn update_cabinet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("更新柜子 id={}", id);
|
tracing::info!("更新柜子 id={}", id);
|
||||||
Ok(Json(json!({})))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 柜子状态更新请求体
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetCabinetStatusBody {
|
||||||
|
pub status: i8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用)
|
||||||
|
///
|
||||||
|
/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。
|
||||||
|
pub async fn set_cabinet_status(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<super::AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<SetCabinetStatusBody>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device:edit")?;
|
||||||
|
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||||||
|
|
||||||
|
// 禁/启用操作仅总管理员可执行
|
||||||
|
if user.role_level < 2 {
|
||||||
|
return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = &state.mysql;
|
||||||
|
let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?")
|
||||||
|
.bind(body.status)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = if body.status == 2 { "禁用" } else { "启用" };
|
||||||
|
tracing::info!("{}柜子 id={}", action, id);
|
||||||
|
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 充电工作模式更新请求体(前端契约:字符串 "0" / "1")
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateWorkModeBody {
|
||||||
|
pub work_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/cabinets/:id/work-mode — 设置充电工作模式
|
||||||
|
///
|
||||||
|
/// 0=平台指令模式(默认,需平台下发指令才充电)
|
||||||
|
/// 1=自动充电工作模式(设备自主充电)
|
||||||
|
/// 权限:device:edit(编辑设备),并校验柜子存在 + 组织归属。
|
||||||
|
pub async fn update_work_mode(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<super::AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<UpdateWorkModeBody>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device:edit")?;
|
||||||
|
// 组织隔离:非总管理员只能改本组织项目的柜子
|
||||||
|
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||||||
|
|
||||||
|
// 白名单取值,拒绝一切其它输入(含 "2"、"true" 等)
|
||||||
|
let mode: i8 = match body.work_mode.trim() {
|
||||||
|
"0" => 0,
|
||||||
|
"1" => 1,
|
||||||
|
other => {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"work_mode 只能为 \"0\"(平台指令模式) 或 \"1\"(自动充电模式),收到: {}",
|
||||||
|
other
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = sqlx::query("UPDATE cabinets SET work_mode = ? WHERE id = ?")
|
||||||
|
.bind(mode)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.mysql)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
// 总管理员路径下 verify_cabinet_ownership 直接放行,这里兜底不存在的情况
|
||||||
|
if rows == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("设置充电工作模式 id={} work_mode={}", id, mode);
|
||||||
|
Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
|
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
|
||||||
@ -234,6 +331,7 @@ 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)?;
|
||||||
@ -265,7 +363,7 @@ pub async fn delete_cabinet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("删除柜子 id={}", id);
|
tracing::info!("删除柜子 id={}", id);
|
||||||
Ok(Json(json!({})))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets/:id/bind — 绑定平台抽象ID
|
/// POST /api/cabinets/:id/bind — 绑定平台抽象ID
|
||||||
@ -275,6 +373,7 @@ 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;
|
||||||
|
|
||||||
@ -314,7 +413,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!({ "abstract_id": abstract_id })))
|
Ok(Json(json!({ "code": 0, "data": { "abstract_id": abstract_id }, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组)
|
/// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组)
|
||||||
@ -325,6 +424,7 @@ 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()) {
|
||||||
@ -366,7 +466,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!({})))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
|
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
|
||||||
@ -375,24 +475,55 @@ 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> {
|
||||||
auth::check_permission(&user, "device:edit")?;
|
// 仅总管理员可重置安全码:device:auth_str:reset 不在企业管理员/操作员的权限码表中,
|
||||||
|
// 且 check_permission 对 role_level >= 2 直接放行,因此等价于「仅总管理员可用」
|
||||||
|
auth::check_permission(&user, "device:auth_str:reset")?;
|
||||||
|
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||||||
|
|
||||||
let db = &state.mysql;
|
let 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 = ? WHERE id = ?"#)
|
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ?, auth_str_issued = 0 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!({ "auth_str": auth_str })))
|
Ok(Json(json!({ "code": 0, "data": { "auth_str": auth_str }, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -409,7 +540,7 @@ pub async fn get_cabinet_detail(
|
|||||||
|
|
||||||
let db = &state.mysql;
|
let 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, status
|
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, status, work_mode
|
||||||
FROM cabinets WHERE id = ?"#,
|
FROM cabinets WHERE id = ?"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@ -422,17 +553,87 @@ 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(id)
|
.bind(cabinet_id)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 查询仓控板+仓体,同时构建 Value 以便注入 SOC
|
|
||||||
let mut cabin_boards: Vec<Value> = Vec::new();
|
let mut cabin_boards: Vec<Value> = Vec::new();
|
||||||
let mut comp_ids: Vec<i64> = 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
|
||||||
@ -444,15 +645,22 @@ pub async fn get_cabinet_detail(
|
|||||||
|
|
||||||
for c in &compartments {
|
for c in &compartments {
|
||||||
comp_ids.push(c.id);
|
comp_ids.push(c.id);
|
||||||
|
comp_to_board.push((c.id, (board.board_id, c.channel_id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let comp_values: Vec<Value> = compartments.into_iter().map(|c| {
|
// batt_status / pow_status 为 2 位 hex 字符串,Redis 缺失时保持 null
|
||||||
json!({
|
let comp_values: Vec<Value> = compartments
|
||||||
"id": c.id,
|
.into_iter()
|
||||||
"channel_id": c.channel_id,
|
.map(|c| {
|
||||||
"status": c.status,
|
json!({
|
||||||
|
"id": c.id,
|
||||||
|
"channel_id": c.channel_id,
|
||||||
|
"status": c.status,
|
||||||
|
"batt_status": Value::Null,
|
||||||
|
"pow_status": Value::Null,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
cabin_boards.push(json!({
|
cabin_boards.push(json!({
|
||||||
"id": board.id,
|
"id": board.id,
|
||||||
@ -462,71 +670,135 @@ pub async fn get_cabinet_detail(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 Redis 批量读取实时状态(RSSI/电源/SOC)
|
Ok((cabin_boards, comp_ids, comp_to_board))
|
||||||
let (rssi, pow_fail_dc, pow_fail_ac, mut soc_map) = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
}
|
||||||
if let Ok(mut conn) = client.get_async_connection().await {
|
|
||||||
let key = format!("device:{}", cabinet.imei);
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
let vals: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
|
|
||||||
let rssi: Option<i32> = vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse().ok());
|
|
||||||
let dc = vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1");
|
|
||||||
let ac = vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1");
|
|
||||||
let mut soc_map = std::collections::HashMap::new();
|
|
||||||
for (i, cid) in comp_ids.iter().enumerate() {
|
|
||||||
if let Some(Some(data)) = vals.get(3 + i) {
|
|
||||||
if let Ok(v) = serde_json::from_str::<Value>(data) {
|
|
||||||
let soc = v.get("soc").and_then(|s| s.as_i64());
|
|
||||||
let bid = v.get("battery_id").and_then(|s| s.as_str()).map(|s| s.to_string());
|
|
||||||
soc_map.insert(*cid, (soc, bid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(rssi, dc, ac, soc_map)
|
|
||||||
} else { (None, None, None, std::collections::HashMap::new()) }
|
|
||||||
} else { (None, None, None, std::collections::HashMap::new()) };
|
|
||||||
|
|
||||||
// 注入 SOC 到每个仓体
|
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
|
||||||
for board in &mut cabin_boards {
|
///
|
||||||
if let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) {
|
/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。
|
||||||
for comp in comps {
|
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
||||||
if let Some(id) = comp.get("id").and_then(|i| i.as_i64()) {
|
async fn fetch_cabinet_realtime(
|
||||||
if let Some((soc, bid)) = soc_map.get(&id) {
|
redis_url: &str,
|
||||||
if let Some(s) = soc {
|
imei: &str,
|
||||||
comp["soc"] = json!(s);
|
comp_ids: &[i64],
|
||||||
}
|
comp_to_board: &[(i64, (i8, i8))],
|
||||||
if let Some(b) = bid {
|
) -> CabinetRealtime {
|
||||||
comp["battery_id"] = json!(b);
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
// 通道级字段紧随 SOC 段之后,每通道 3 项
|
||||||
"code": 0,
|
let base = 3 + comp_ids.len();
|
||||||
"data": {
|
for (i, (cid, _)) in comp_to_board.iter().enumerate() {
|
||||||
"id": cabinet.id,
|
let off = base + i * 3;
|
||||||
"project_id": cabinet.project_id,
|
let temp = pick(vals, off).and_then(|v| v.parse::<i64>().ok());
|
||||||
"abstract_id": cabinet.abstract_id,
|
let batt_status = pick(vals, off + 1);
|
||||||
"imei": cabinet.imei,
|
let pow_status = pick(vals, off + 2);
|
||||||
"iccid": cabinet.iccid,
|
rt.ch_map.insert(*cid, (temp, batt_status, pow_status));
|
||||||
"name": cabinet.name,
|
}
|
||||||
"status": cabinet.status,
|
|
||||||
"rssi": rssi,
|
rt
|
||||||
"pow_fail_dc": pow_fail_dc,
|
}
|
||||||
"pow_fail_ac": pow_fail_ac,
|
|
||||||
"address": cabinet.address,
|
/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串
|
||||||
"cabin_boards": cabin_boards,
|
fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) {
|
||||||
},
|
for board in cabin_boards.iter_mut() {
|
||||||
"message": "ok"
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -638,3 +910,4 @@ pub async fn list_compartment_options(
|
|||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
230
software/api-server/src/routes/charge_orders.rs
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
//! 充电订单 API — 列表查询 + 详情
|
||||||
|
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::Json;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
|
|
||||||
|
/// 默认页码(预留 serde default 用)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn default_page() -> i64 { 1 }
|
||||||
|
/// 默认每页大小(预留 serde default 用)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn default_page_size() -> i64 { 20 }
|
||||||
|
|
||||||
|
fn status_text(status: i8) -> &'static str {
|
||||||
|
match status {
|
||||||
|
0 => "充电中",
|
||||||
|
1 => "已完成",
|
||||||
|
2 => "异常",
|
||||||
|
_ => "未知",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_text(reason: Option<&str>) -> &'static str {
|
||||||
|
match reason {
|
||||||
|
Some("normal") => "正常充满",
|
||||||
|
Some("user_stop") => "手动停止",
|
||||||
|
Some("forced") => "强制结束",
|
||||||
|
Some("timeout") => "超时结束",
|
||||||
|
_ => "未知",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChargeOrderQuery {
|
||||||
|
pub cabinet_id: Option<i64>,
|
||||||
|
pub compartment_id: Option<i64>,
|
||||||
|
pub battery_id: Option<String>,
|
||||||
|
pub status: Option<i8>,
|
||||||
|
pub start_time: Option<String>,
|
||||||
|
pub end_time: Option<String>,
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub page_size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ChargeOrderItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub cabinet_id: i64,
|
||||||
|
pub cabinet_name: Option<String>,
|
||||||
|
pub compartment_id: i64,
|
||||||
|
pub battery_id: String,
|
||||||
|
pub start_time: Option<String>,
|
||||||
|
pub end_time: Option<String>,
|
||||||
|
pub start_soc: i64,
|
||||||
|
pub end_soc: Option<i64>,
|
||||||
|
pub energy: i64,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub reason_text: String,
|
||||||
|
pub status: i8,
|
||||||
|
pub status_text: String,
|
||||||
|
pub created_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct ChargeOrderRow {
|
||||||
|
id: i64,
|
||||||
|
cabinet_id: i64,
|
||||||
|
cabinet_name: Option<String>,
|
||||||
|
compartment_id: i64,
|
||||||
|
battery_id: String,
|
||||||
|
start_time: Option<NaiveDateTime>,
|
||||||
|
end_time: Option<NaiveDateTime>,
|
||||||
|
start_soc: i64,
|
||||||
|
end_soc: Option<i64>,
|
||||||
|
energy: i64,
|
||||||
|
reason: Option<String>,
|
||||||
|
status: i8,
|
||||||
|
created_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/charge-orders — 充电订单列表
|
||||||
|
pub async fn list_charge_orders(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<crate::commands::AppState>,
|
||||||
|
Query(params): Query<ChargeOrderQuery>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "charge_record:view")?;
|
||||||
|
|
||||||
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
|
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||||
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
|
let mut query =
|
||||||
|
"SELECT co.*, cab.name as cabinet_name FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1"
|
||||||
|
.to_string();
|
||||||
|
let mut count_query = "SELECT COUNT(*) FROM charge_orders WHERE 1=1".to_string();
|
||||||
|
|
||||||
|
// 按固定顺序追加 `?` 占位符,绑定顺序必须与下方 build_count_query / build_list_query 一致
|
||||||
|
if params.cabinet_id.is_some() {
|
||||||
|
query.push_str(" AND cabinet_id = ?");
|
||||||
|
count_query.push_str(" AND cabinet_id = ?");
|
||||||
|
}
|
||||||
|
if params.compartment_id.is_some() {
|
||||||
|
query.push_str(" AND compartment_id = ?");
|
||||||
|
count_query.push_str(" AND compartment_id = ?");
|
||||||
|
}
|
||||||
|
if params.battery_id.is_some() {
|
||||||
|
query.push_str(" AND battery_id LIKE ?");
|
||||||
|
count_query.push_str(" AND battery_id LIKE ?");
|
||||||
|
}
|
||||||
|
if params.status.is_some() {
|
||||||
|
query.push_str(" AND status = ?");
|
||||||
|
count_query.push_str(" AND status = ?");
|
||||||
|
}
|
||||||
|
if params.start_time.is_some() {
|
||||||
|
query.push_str(" AND start_time >= ?");
|
||||||
|
count_query.push_str(" AND start_time >= ?");
|
||||||
|
}
|
||||||
|
if params.end_time.is_some() {
|
||||||
|
query.push_str(" AND end_time <= ?");
|
||||||
|
count_query.push_str(" AND end_time <= ?");
|
||||||
|
}
|
||||||
|
|
||||||
|
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
|
||||||
|
|
||||||
|
let total: i64 = build_count_query(count_query, ¶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
|
||||||
|
}
|
||||||
@ -249,11 +249,13 @@ 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();
|
||||||
|
|
||||||
@ -328,7 +330,7 @@ async fn generate_charge_records_excel(
|
|||||||
|
|
||||||
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
||||||
|
|
||||||
let (where_clause, bind_values) = build_where_clause(
|
let (mut where_clause, mut bind_values) = build_where_clause(
|
||||||
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -336,6 +338,19 @@ async fn generate_charge_records_excel(
|
|||||||
params.get("end_time").and_then(|v| v.as_str()),
|
params.get("end_time").and_then(|v| v.as_str()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 组织过滤(非总管理员)
|
||||||
|
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let org_id = params.get("org_id").and_then(|v| v.as_i64());
|
||||||
|
if role_level < 2 {
|
||||||
|
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
|
||||||
|
if let Some(oid) = org_id {
|
||||||
|
where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
|
||||||
|
bind_values.push(oid.to_string());
|
||||||
|
} else {
|
||||||
|
where_clause.push_str(&format!("{}1=0", prefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
|
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
|
||||||
|
|
||||||
// 更新进度 50%
|
// 更新进度 50%
|
||||||
@ -351,7 +366,7 @@ async fn generate_charge_records_excel(
|
|||||||
|
|
||||||
let headers = [
|
let headers = [
|
||||||
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
|
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
|
||||||
"充电量(Wh)", "最高温度(0.1°C)", "状态",
|
"充电量(Wh)", "最高温度(K×100)", "状态",
|
||||||
];
|
];
|
||||||
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)?;
|
||||||
|
|||||||
@ -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};
|
||||||
|
|
||||||
/// 电费单价(元/Wh),energy 单位为 Wh,0.80 元/kWh = 0.0008 元/Wh
|
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
|
||||||
const COST_PER_WH: f64 = 0.0008;
|
const COST_PER_WH_CENT: i64 = 80;
|
||||||
|
|
||||||
/// 趋势查询参数
|
/// 趋势查询参数
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -150,7 +150,7 @@ pub async fn get_summary(
|
|||||||
"data": {
|
"data": {
|
||||||
"today_energy": today_energy,
|
"today_energy": today_energy,
|
||||||
"month_energy": month_energy,
|
"month_energy": month_energy,
|
||||||
"estimated_cost": ((month_energy as f64) * COST_PER_WH * 100.0).round() / 100.0,
|
"estimated_cost": (month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0,
|
||||||
"cabinet_count": cabinet_count,
|
"cabinet_count": cabinet_count,
|
||||||
"online_count": online_count,
|
"online_count": online_count,
|
||||||
},
|
},
|
||||||
@ -181,14 +181,17 @@ 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, \
|
||||||
COALESCE(es_today.total_energy, 0) AS today_energy, \
|
CAST(COALESCE(es_today.total_energy, 0) AS SIGNED) AS today_energy, \
|
||||||
COALESCE(es_month.total_energy, 0) AS month_energy \
|
CAST(COALESCE(es_month.month_energy, 0) AS SIGNED) 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 energy_stats es_month \
|
LEFT JOIN ( \
|
||||||
ON c.id = es_month.cabinet_id \
|
SELECT cabinet_id, SUM(total_energy) AS month_energy \
|
||||||
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
FROM energy_stats \
|
||||||
|
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
||||||
|
GROUP BY cabinet_id \
|
||||||
|
) es_month ON c.id = es_month.cabinet_id \
|
||||||
{} \
|
{} \
|
||||||
ORDER BY today_energy DESC, c.id ASC",
|
ORDER BY today_energy DESC, c.id ASC",
|
||||||
org_join
|
org_join
|
||||||
@ -236,14 +239,17 @@ pub async fn get_project_stats(
|
|||||||
"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, \
|
CAST(COALESCE(SUM(es_today.total_energy), 0) AS SIGNED) AS today_energy, \
|
||||||
CAST(COALESCE(SUM(es_month.total_energy), 0) AS SIGNED) AS month_energy \
|
CAST(COALESCE(SUM(es_month.month_energy), 0) AS SIGNED) AS month_energy \
|
||||||
FROM projects p \
|
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 energy_stats es_month \
|
LEFT JOIN ( \
|
||||||
ON c.id = es_month.cabinet_id \
|
SELECT cabinet_id, SUM(total_energy) AS month_energy \
|
||||||
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
FROM energy_stats \
|
||||||
|
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
|
||||||
|
GROUP BY cabinet_id \
|
||||||
|
) es_month ON c.id = es_month.cabinet_id \
|
||||||
{} \
|
{} \
|
||||||
GROUP BY p.id, p.name \
|
GROUP BY p.id, p.name \
|
||||||
ORDER BY today_energy DESC, p.id ASC",
|
ORDER BY today_energy DESC, p.id ASC",
|
||||||
@ -365,12 +371,14 @@ pub async fn export_energy_stats(
|
|||||||
let user_id = user.user_id;
|
let 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();
|
||||||
|
|
||||||
@ -475,13 +483,27 @@ async fn generate_energy_excel(
|
|||||||
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!({}));
|
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
||||||
|
|
||||||
let (where_clause, bind_values) = build_export_clause(
|
let (mut where_clause, mut bind_values) = build_export_clause(
|
||||||
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
||||||
params.get("project_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("start_date").and_then(|v| v.as_str()),
|
||||||
params.get("end_date").and_then(|v| v.as_str()),
|
params.get("end_date").and_then(|v| v.as_str()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 组织过滤(非总管理员)
|
||||||
|
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let org_id = params.get("org_id").and_then(|v| v.as_i64());
|
||||||
|
if role_level < 2 {
|
||||||
|
if let Some(oid) = org_id {
|
||||||
|
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
|
||||||
|
where_clause.push_str(&format!("{}es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
|
||||||
|
bind_values.push(oid.to_string());
|
||||||
|
} else {
|
||||||
|
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
|
||||||
|
where_clause.push_str(&format!("{}1=0", prefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 查询能耗明细
|
// 查询能耗明细
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
|
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
|
||||||
|
|||||||
@ -11,13 +11,15 @@
|
|||||||
//! - POST /api/door/open — 开门
|
//! - POST /api/door/open — 开门
|
||||||
//! - GET /api/cabinets/scan/:abstract_id — 扫码验证
|
//! - GET /api/cabinets/scan/:abstract_id — 扫码验证
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Multipart, Path, Query, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use redis;
|
use redis;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::io::Write;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::commands::{AppState, DeviceCommand};
|
use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::middleware::auth::{self, CurrentUser};
|
use crate::middleware::auth::{self, CurrentUser};
|
||||||
|
|
||||||
@ -31,6 +33,17 @@ pub(crate) struct CompartmentActionInput {
|
|||||||
compartment_id: i64,
|
compartment_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 问题反馈请求体
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct FeedbackInput {
|
||||||
|
/// 反馈内容
|
||||||
|
content: String,
|
||||||
|
/// 联系方式(可选)
|
||||||
|
contact: Option<String>,
|
||||||
|
/// 图片URL列表(可选)
|
||||||
|
images: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 设备列表查询参数
|
/// 设备列表查询参数
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct CabinetListQuery {
|
pub(crate) struct CabinetListQuery {
|
||||||
@ -64,6 +77,10 @@ struct H5CabinetRow {
|
|||||||
status: i8,
|
status: i8,
|
||||||
project_id: Option<i64>,
|
project_id: Option<i64>,
|
||||||
imei: Option<String>,
|
imei: Option<String>,
|
||||||
|
iccid: Option<String>,
|
||||||
|
address: Option<String>,
|
||||||
|
latitude: Option<String>,
|
||||||
|
longitude: Option<String>,
|
||||||
channel_count: i64,
|
channel_count: i64,
|
||||||
idle_count: i64,
|
idle_count: i64,
|
||||||
charging_count: i64,
|
charging_count: i64,
|
||||||
@ -96,10 +113,13 @@ struct ChargeRecordBriefRow {
|
|||||||
energy: Option<String>,
|
energy: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 柜子 IMEI 查询行(用于指令下发)
|
/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发)
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
struct CabinetImeiRow {
|
struct CabinetImeiRow {
|
||||||
|
cabinet_id: i64,
|
||||||
imei: String,
|
imei: String,
|
||||||
|
board_id: i8,
|
||||||
|
channel_id: i8,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -115,7 +135,7 @@ pub async fn get_dashboard(
|
|||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 根据角色确定组织过滤条件
|
// 根据角色确定组织过滤条件
|
||||||
// 超级管理员可通过 org_id 参数切换组织
|
// 总管理员可通过 org_id 参数切换组织
|
||||||
let is_super = user.role_level >= 2 && query.org_id.is_none();
|
let is_super = user.role_level >= 2 && query.org_id.is_none();
|
||||||
let org_id = query.org_id.or(user.organization_id);
|
let org_id = query.org_id.or(user.organization_id);
|
||||||
|
|
||||||
@ -151,8 +171,8 @@ pub async fn get_dashboard(
|
|||||||
// 故障告警(取最近10条故障仓体,返回项目名+柜名)
|
// 故障告警(取最近10条故障仓体,返回项目名+柜名)
|
||||||
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
|
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
|
||||||
|
|
||||||
// 项目摘要
|
// 项目摘要(带组织过滤)
|
||||||
let projects = fetch_project_summaries(pool, &user).await?;
|
let projects = fetch_project_summaries(pool, &user, org_id).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@ -414,12 +434,23 @@ async fn fetch_fault_alerts(
|
|||||||
async fn fetch_project_summaries(
|
async fn fetch_project_summaries(
|
||||||
pool: &sqlx::MySqlPool,
|
pool: &sqlx::MySqlPool,
|
||||||
user: &CurrentUser,
|
user: &CurrentUser,
|
||||||
|
org_id: Option<i64>,
|
||||||
) -> Result<Vec<Value>, AppError> {
|
) -> Result<Vec<Value>, AppError> {
|
||||||
// 获取用户可见的项目
|
// 获取用户可见的项目(优先使用查询参数 org_id)
|
||||||
|
let filter_org = org_id.or(user.organization_id);
|
||||||
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
|
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
|
||||||
sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id")
|
if let Some(oid) = filter_org {
|
||||||
|
sqlx::query_as::<_, ProjectRow>(
|
||||||
|
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
|
||||||
|
)
|
||||||
|
.bind(oid)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
} else if let Some(org_id) = user.organization_id {
|
} else if let Some(org_id) = user.organization_id {
|
||||||
sqlx::query_as::<_, ProjectRow>(
|
sqlx::query_as::<_, ProjectRow>(
|
||||||
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
|
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
|
||||||
@ -502,7 +533,7 @@ pub async fn get_cabinets(
|
|||||||
// 按项目过滤(同时验证组织权限)
|
// 按项目过滤(同时验证组织权限)
|
||||||
if user.role_level >= 2 {
|
if user.role_level >= 2 {
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
sqlx::query_as::<_, H5CabinetRow>(
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
|
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -515,7 +546,7 @@ pub async fn get_cabinets(
|
|||||||
.await?
|
.await?
|
||||||
} else if let Some(org_id) = user.organization_id {
|
} else if let Some(org_id) = user.organization_id {
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
sqlx::query_as::<_, H5CabinetRow>(
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
|
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -536,7 +567,7 @@ pub async fn get_cabinets(
|
|||||||
// 总管理员:按组织过滤或看全部
|
// 总管理员:按组织过滤或看全部
|
||||||
if let Some(oid) = query.org_id {
|
if let Some(oid) = query.org_id {
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
sqlx::query_as::<_, H5CabinetRow>(
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
|
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -551,7 +582,7 @@ pub async fn get_cabinets(
|
|||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
sqlx::query_as::<_, H5CabinetRow>(
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
|
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -565,7 +596,7 @@ pub async fn get_cabinets(
|
|||||||
} else if let Some(org_id) = user.organization_id {
|
} else if let Some(org_id) = user.organization_id {
|
||||||
// 企业管理员只看本组织
|
// 企业管理员只看本组织
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
sqlx::query_as::<_, H5CabinetRow>(
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
|
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -582,23 +613,62 @@ pub async fn get_cabinets(
|
|||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从Redis批量查RSSI
|
// 从Redis批量查RSSI + pow_fail
|
||||||
let mut rssi_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
|
let mut rssi_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
|
||||||
|
let mut dc_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
|
||||||
|
let mut ac_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
|
||||||
|
let mut temp_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
|
||||||
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
||||||
if let Ok(mut conn) = client.get_async_connection().await {
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||||||
let mut pipe = redis::pipe();
|
let mut pipe = redis::pipe();
|
||||||
for r in &rows {
|
for r in &rows {
|
||||||
if let Some(ref imei) = r.imei {
|
if let Some(ref imei) = r.imei {
|
||||||
pipe.cmd("HGET").arg(&format!("device:{imei}")).arg("rssi");
|
let key = format!("device:{imei}");
|
||||||
|
pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !rows.is_empty() {
|
if !rows.is_empty() {
|
||||||
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
|
let results: Vec<Vec<Option<String>>> = match pipe.query_async(&mut conn).await {
|
||||||
for (i, r) in rows.iter().enumerate() {
|
Ok(r) => r,
|
||||||
if r.imei.is_some() {
|
Err(e) => {
|
||||||
let val = results.get(i).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok());
|
tracing::error!("[h5] Redis pipeline查询失败: {}", e);
|
||||||
rssi_map.insert(r.imei.clone().unwrap_or_default(), val);
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
for (i, r) in rows.iter().enumerate() {
|
||||||
|
if let Some(ref imei) = r.imei {
|
||||||
|
if let Some(vals) = results.get(i) {
|
||||||
|
rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||||||
|
dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||||||
|
ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||||||
|
temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询柜子所属组织是否禁用(用于前端徽标)
|
||||||
|
let mut org_disabled_map: std::collections::HashMap<i64, bool> = std::collections::HashMap::new();
|
||||||
|
{
|
||||||
|
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
||||||
|
if !project_ids.is_empty() {
|
||||||
|
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
||||||
|
let sql = format!(
|
||||||
|
r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p
|
||||||
|
LEFT JOIN organizations o ON p.organization_id = o.id
|
||||||
|
WHERE p.id IN ({})"#,
|
||||||
|
placeholders.join(",")
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
||||||
|
for pid in &project_ids {
|
||||||
|
q = q.bind(pid);
|
||||||
|
}
|
||||||
|
if let Ok(rows) = q.fetch_all(pool).await {
|
||||||
|
for (pid, disabled) in rows {
|
||||||
|
org_disabled_map.insert(pid, disabled == 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -607,19 +677,33 @@ pub async fn get_cabinets(
|
|||||||
let list: Vec<Value> = rows
|
let list: Vec<Value> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let rssi = r.imei.as_ref().and_then(|imei| rssi_map.get(imei).copied()).flatten();
|
let imei = r.imei.clone();
|
||||||
|
let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten();
|
||||||
|
let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten();
|
||||||
|
let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten();
|
||||||
|
let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten();
|
||||||
|
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
|
||||||
json!({
|
json!({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"abstract_id": r.abstract_id,
|
"abstract_id": r.abstract_id,
|
||||||
"name": r.name,
|
"name": r.name,
|
||||||
|
"imei": r.imei,
|
||||||
|
"iccid": r.iccid,
|
||||||
"status": r.status,
|
"status": r.status,
|
||||||
"project_id": r.project_id,
|
"project_id": r.project_id,
|
||||||
|
"address": r.address,
|
||||||
|
"latitude": r.latitude,
|
||||||
|
"longitude": r.longitude,
|
||||||
"channel_count": r.channel_count,
|
"channel_count": r.channel_count,
|
||||||
"idle_count": r.idle_count,
|
"idle_count": r.idle_count,
|
||||||
"charging_count": r.charging_count,
|
"charging_count": r.charging_count,
|
||||||
"full_count": r.full_count,
|
"full_count": r.full_count,
|
||||||
"fault_count": r.fault_count,
|
"fault_count": r.fault_count,
|
||||||
"rssi": rssi,
|
"rssi": rssi,
|
||||||
|
"pow_fail_dc": pow_fail_dc,
|
||||||
|
"pow_fail_ac": pow_fail_ac,
|
||||||
|
"temp": temp,
|
||||||
|
"org_disabled": org_disabled,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -640,14 +724,14 @@ pub async fn get_cabinet_by_abstract_id(
|
|||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
let cab: Option<(i64, String, String, Option<String>, Option<String>, i8)> = sqlx::query_as(
|
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
|
||||||
"SELECT id, abstract_id, imei, iccid, name, status FROM cabinets WHERE abstract_id = ?",
|
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
|
||||||
)
|
)
|
||||||
.bind(&abstract_id)
|
.bind(&abstract_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (cab_id, abs_id, imei, iccid, name, status) =
|
let (cab_id, abs_id, _imei, name, status) =
|
||||||
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
||||||
|
|
||||||
// 查询仓控板+仓体
|
// 查询仓控板+仓体
|
||||||
@ -685,8 +769,6 @@ pub async fn get_cabinet_by_abstract_id(
|
|||||||
"data": {
|
"data": {
|
||||||
"id": cab_id,
|
"id": cab_id,
|
||||||
"abstract_id": abs_id,
|
"abstract_id": abs_id,
|
||||||
"imei": imei,
|
|
||||||
"iccid": iccid,
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"status": status,
|
"status": status,
|
||||||
"cabin_boards": cabin_boards,
|
"cabin_boards": cabin_boards,
|
||||||
@ -735,7 +817,8 @@ pub async fn get_compartment_detail(
|
|||||||
|
|
||||||
// 从 Redis 获取实时数据
|
// 从 Redis 获取实时数据
|
||||||
let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
||||||
if let Ok(mut conn) = client.get_async_connection().await {
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||||||
let key = format!("compartment:status:{}", comp_id);
|
let key = format!("compartment:status:{}", comp_id);
|
||||||
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
|
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
|
||||||
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||||
@ -775,10 +858,8 @@ pub async fn get_compartment_detail(
|
|||||||
"cabin_board_id": cabin_board_id,
|
"cabin_board_id": cabin_board_id,
|
||||||
"voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0),
|
"voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
"current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0),
|
"current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
"power": realtime.get("power").and_then(|v| v.as_i64()).unwrap_or(0),
|
|
||||||
"energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0),
|
"energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
"soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0),
|
"soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
"soh": realtime.get("soh").and_then(|v| v.as_i64()).unwrap_or(0),
|
|
||||||
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
|
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
|
||||||
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
|
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
|
||||||
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
|
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
|
||||||
@ -835,13 +916,82 @@ pub async fn scan_cabinet(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// POST /api/h5/charge/start — 开始充电
|
/// POST /api/h5/charge/start — 开始充电
|
||||||
|
///
|
||||||
|
/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`,
|
||||||
|
/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。
|
||||||
|
/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。
|
||||||
pub async fn start_charge(
|
pub async fn start_charge(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CompartmentActionInput>,
|
Json(body): Json<CompartmentActionInput>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "charge:start")?;
|
auth::check_permission(&user, "charge:start")?;
|
||||||
send_compartment_command(&state, body.compartment_id, "on").await
|
|
||||||
|
// 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式
|
||||||
|
// (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
|
||||||
|
let order_row: Result<Option<(i64, String, i8, i8, Option<i8>)>, sqlx::Error> = sqlx::query_as(
|
||||||
|
r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode
|
||||||
|
FROM cabinets cab
|
||||||
|
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
||||||
|
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
||||||
|
WHERE comp.id = ?"#,
|
||||||
|
)
|
||||||
|
.bind(body.compartment_id)
|
||||||
|
.fetch_optional(&state.mysql)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match order_row {
|
||||||
|
// 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单;
|
||||||
|
// **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
|
||||||
|
Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => {
|
||||||
|
// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)
|
||||||
|
match crate::workers::report_worker::load_channel_battery(
|
||||||
|
&state.redis, &imei, board_id, channel_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Some(battery) => {
|
||||||
|
if let Err(e) = crate::workers::report_worker::ensure_charge_order(
|
||||||
|
&state.mysql,
|
||||||
|
&state.redis,
|
||||||
|
&imei,
|
||||||
|
cabinet_id,
|
||||||
|
board_id,
|
||||||
|
channel_id,
|
||||||
|
&battery.battery_id,
|
||||||
|
Some(battery.soc),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"[h5] 下发 on 前建单失败 compartment_id={} err={}",
|
||||||
|
body.compartment_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => tracing::warn!(
|
||||||
|
"[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}",
|
||||||
|
body.compartment_id
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => tracing::info!(
|
||||||
|
"[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}",
|
||||||
|
body.compartment_id
|
||||||
|
),
|
||||||
|
Ok(None) => tracing::warn!(
|
||||||
|
"[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}",
|
||||||
|
body.compartment_id
|
||||||
|
),
|
||||||
|
Err(e) => tracing::error!(
|
||||||
|
"[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}",
|
||||||
|
body.compartment_id,
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
send_compartment_command(&state, &user, body.compartment_id, "on").await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/h5/charge/stop — 停止充电
|
/// POST /api/h5/charge/stop — 停止充电
|
||||||
@ -851,34 +1001,53 @@ pub async fn stop_charge(
|
|||||||
Json(body): Json<CompartmentActionInput>,
|
Json(body): Json<CompartmentActionInput>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "charge:stop")?;
|
auth::check_permission(&user, "charge:stop")?;
|
||||||
send_compartment_command(&state, body.compartment_id, "off").await
|
send_compartment_command(&state, &user, body.compartment_id, "off").await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/h5/door/open — 开门
|
/// POST /api/h5/door/open — 开门(**柜级动作**)
|
||||||
|
///
|
||||||
|
/// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓),
|
||||||
|
/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。
|
||||||
pub async fn open_door(
|
pub async fn open_door(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CompartmentActionInput>,
|
Json(body): Json<CompartmentActionInput>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
auth::check_permission(&user, "device:door:unlock")?;
|
auth::check_permission(&user, "charge:open_door")?;
|
||||||
send_compartment_command(&state, body.compartment_id, "open").await
|
|
||||||
|
let cabinet_id: Option<i64> = sqlx::query_scalar(
|
||||||
|
r#"SELECT cb.cabinet_id FROM compartments comp
|
||||||
|
JOIN cabin_boards cb ON cb.id = comp.cabin_board_id
|
||||||
|
WHERE comp.id = ?"#,
|
||||||
|
)
|
||||||
|
.bind(body.compartment_id)
|
||||||
|
.fetch_optional(&state.mysql)
|
||||||
|
.await?;
|
||||||
|
let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
||||||
|
|
||||||
|
send_cabinet_command(&state, &user, cabinet_id, None, "open").await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 辅助函数
|
// 辅助函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 根据仓体ID查找所属柜子的IMEI,并通过 Redis 下发指令
|
/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。
|
||||||
|
///
|
||||||
|
/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。
|
||||||
|
/// 柜级指令(`open` / `pow_on` / `pow_off` / `reset`)请用 [`send_cabinet_command`]。
|
||||||
async fn send_compartment_command(
|
async fn send_compartment_command(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
user: &CurrentUser,
|
||||||
compartment_id: i64,
|
compartment_id: i64,
|
||||||
action: &str,
|
action: &str,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 通过 compartment → cabin_board → cabinet 链路查找 IMEI 和 sub_device_id
|
// 一次查询拿到 cabinet_id + IMEI + board_id + channel_id
|
||||||
let row: Option<CabinetImeiRow> = sqlx::query_as(
|
let row: Option<CabinetImeiRow> = sqlx::query_as(
|
||||||
r#"SELECT cab.imei FROM cabinets cab
|
r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
|
||||||
|
FROM cabinets cab
|
||||||
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
||||||
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
||||||
WHERE comp.id = ?"#,
|
WHERE comp.id = ?"#,
|
||||||
@ -888,35 +1057,61 @@ async fn send_compartment_command(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
||||||
let imei = &cab_row.imei;
|
|
||||||
|
|
||||||
// 查询 sub_device_id(仓控板 ID)
|
// 检查设备是否被禁用
|
||||||
let sub_row: Option<(i8,)> = sqlx::query_as(
|
let disabled: bool = sqlx::query_scalar(
|
||||||
r#"SELECT cb.board_id FROM cabin_boards cb
|
"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2",
|
||||||
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
|
||||||
WHERE comp.id = ?"#,
|
|
||||||
)
|
)
|
||||||
.bind(compartment_id)
|
.bind(cab_row.cabinet_id)
|
||||||
.fetch_optional(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
if disabled {
|
||||||
|
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let sub_device_id = sub_row.map(|(bid,)| bid as u8);
|
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
||||||
|
let org_disabled: bool = sqlx::query_scalar(
|
||||||
|
r#"SELECT COUNT(*) > 0 FROM cabinets c
|
||||||
|
INNER JOIN projects p ON c.project_id = p.id
|
||||||
|
INNER JOIN organizations o ON p.organization_id = o.id
|
||||||
|
WHERE c.id = ? AND o.disabled = 1"#,
|
||||||
|
)
|
||||||
|
.bind(cab_row.cabinet_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
if org_disabled {
|
||||||
|
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验操作权限(组织隔离)
|
||||||
|
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
|
||||||
|
|
||||||
|
let imei = &cab_row.imei;
|
||||||
|
|
||||||
// 检查是否有设备节点在线
|
// 检查是否有设备节点在线
|
||||||
if !state.nodes.is_device_online(imei).await {
|
if !state.nodes.is_device_online(imei).await {
|
||||||
return Err(AppError::BadRequest("设备不在线".into()));
|
return Err(AppError::BadRequest("设备不在线".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建指令并通过 Redis 下发
|
// 构造协议 id:
|
||||||
|
// 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零)
|
||||||
|
// 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定
|
||||||
|
let id = format!("{:02}-{}", cab_row.board_id, cab_row.channel_id);
|
||||||
|
|
||||||
let cmd = DeviceCommand {
|
let cmd = DeviceCommand {
|
||||||
act: action.to_string(),
|
act: action.to_string(),
|
||||||
msg_id: 0,
|
msg_id: 0,
|
||||||
dev_id: Some(imei.clone()),
|
id: Some(id.clone()),
|
||||||
sub_device_id,
|
|
||||||
params: Some(json!({})),
|
params: Some(json!({})),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json_str = cmd.to_json();
|
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
|
||||||
|
let mut json_val =
|
||||||
|
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
|
||||||
|
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
|
||||||
|
let json_str = json_val.to_string();
|
||||||
let mut conn = state.redis.clone();
|
let mut conn = state.redis.clone();
|
||||||
redis::cmd("LPUSH")
|
redis::cmd("LPUSH")
|
||||||
.arg("device:commands")
|
.arg("device:commands")
|
||||||
@ -926,8 +1121,8 @@ async fn send_compartment_command(
|
|||||||
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[h5] 指令已推入队列 imei={} action={} compartment_id={}",
|
"[h5] 指令已推入队列 id={} action={} compartment_id={}",
|
||||||
imei, action, compartment_id
|
id, action, compartment_id
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@ -937,6 +1132,405 @@ async fn send_compartment_command(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 柜级 / 单仓控板级指令(pow_on / pow_off / reset)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 柜级 / 单仓控板级指令下发。
|
||||||
|
///
|
||||||
|
/// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段)
|
||||||
|
/// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`)
|
||||||
|
///
|
||||||
|
/// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。
|
||||||
|
async fn send_cabinet_command(
|
||||||
|
state: &AppState,
|
||||||
|
user: &CurrentUser,
|
||||||
|
cabinet_id: i64,
|
||||||
|
board_id: Option<i8>,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let pool = &state.mysql;
|
||||||
|
|
||||||
|
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?")
|
||||||
|
.bind(cabinet_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
||||||
|
|
||||||
|
// 设备是否被禁用
|
||||||
|
let disabled: bool =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2")
|
||||||
|
.bind(cabinet_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
if disabled {
|
||||||
|
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
||||||
|
let org_disabled: bool = sqlx::query_scalar(
|
||||||
|
r#"SELECT COUNT(*) > 0 FROM cabinets c
|
||||||
|
INNER JOIN projects p ON c.project_id = p.id
|
||||||
|
INNER JOIN organizations o ON p.organization_id = o.id
|
||||||
|
WHERE c.id = ? AND o.disabled = 1"#,
|
||||||
|
)
|
||||||
|
.bind(cabinet_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
if org_disabled {
|
||||||
|
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组织数据隔离校验
|
||||||
|
verify_cabinet_access(user, cabinet_id, pool).await?;
|
||||||
|
|
||||||
|
// 设备必须在线:平台对不在线的设备一律不投递
|
||||||
|
if !state.nodes.is_device_online(&imei).await {
|
||||||
|
return Err(AppError::BadRequest("设备不在线".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造协议 id:
|
||||||
|
// - `board_id = None` → **柜级**:**不带 id**(省略该字段)
|
||||||
|
// - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01")
|
||||||
|
let id = board_id.map(|b| format!("{:02}", b));
|
||||||
|
|
||||||
|
let cmd = DeviceCommand {
|
||||||
|
act: action.to_string(),
|
||||||
|
msg_id: 0,
|
||||||
|
id: id.clone(),
|
||||||
|
params: Some(json!({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
|
||||||
|
let mut json_val =
|
||||||
|
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
|
||||||
|
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
|
||||||
|
let json_str = json_val.to_string();
|
||||||
|
let mut conn = state.redis.clone();
|
||||||
|
redis::cmd("LPUSH")
|
||||||
|
.arg("device:commands")
|
||||||
|
.arg(&json_str)
|
||||||
|
.query_async::<()>(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
|
||||||
|
id,
|
||||||
|
action,
|
||||||
|
cabinet_id,
|
||||||
|
board_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": { "success": true },
|
||||||
|
"message": "指令已下发"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级)
|
||||||
|
///
|
||||||
|
/// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。
|
||||||
|
/// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。
|
||||||
|
pub async fn cabinet_open(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "charge:open_door")?;
|
||||||
|
send_cabinet_command(&state, &user, id, None, "open").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级)
|
||||||
|
pub async fn cabinet_pow_on(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device:operate")?;
|
||||||
|
send_cabinet_command(&state, &user, id, None, "pow_on").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级)
|
||||||
|
///
|
||||||
|
/// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`;
|
||||||
|
/// 恢复供电须再下发 `pow-on`。
|
||||||
|
pub async fn cabinet_pow_off(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device:operate")?;
|
||||||
|
send_cabinet_command(&state, &user, id, None, "pow_off").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`)
|
||||||
|
pub async fn cabinet_reset(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device:operate")?;
|
||||||
|
send_cabinet_command(&state, &user, id, None, "reset").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/cabin-boards/:id/reset — **重启单块仓控板**
|
||||||
|
/// (协议 `id = IMEI-仓控板号`,两段)
|
||||||
|
pub async fn board_reset(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(board_pk): Path<i64>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device:operate")?;
|
||||||
|
|
||||||
|
let row: Option<(i64, i8)> =
|
||||||
|
sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?")
|
||||||
|
.bind(board_pk)
|
||||||
|
.fetch_optional(&state.mysql)
|
||||||
|
.await?;
|
||||||
|
let (cabinet_id, board_id) =
|
||||||
|
row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?;
|
||||||
|
|
||||||
|
send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交)
|
||||||
|
pub async fn submit_feedback(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<FeedbackInput>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let pool = &state.mysql;
|
||||||
|
|
||||||
|
let content = body.content.trim();
|
||||||
|
if content.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("反馈内容不能为空".into()));
|
||||||
|
}
|
||||||
|
if content.chars().count() > 2000 {
|
||||||
|
return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into()));
|
||||||
|
}
|
||||||
|
let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
|
||||||
|
if let Some(c) = &contact {
|
||||||
|
if c.chars().count() > 200 {
|
||||||
|
return Err(AppError::BadRequest("联系方式过长".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片校验:最多 6 张
|
||||||
|
let images = body.images.clone().unwrap_or_default();
|
||||||
|
let images: Vec<String> = images
|
||||||
|
.iter()
|
||||||
|
.map(|u| u.trim().to_string())
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.collect();
|
||||||
|
if images.len() > 6 {
|
||||||
|
return Err(AppError::BadRequest("最多上传6张图片".into()));
|
||||||
|
}
|
||||||
|
let images_json = if images.is_empty() {
|
||||||
|
String::from("[]")
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(user.user_id)
|
||||||
|
.bind(content)
|
||||||
|
.bind(contact)
|
||||||
|
.bind(&images_json)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[h5] 问题反馈已提交 user_id={} content_len={}",
|
||||||
|
user.user_id, content.chars().count()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": { "success": true },
|
||||||
|
"message": "反馈已提交,感谢您的反馈"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/feedback — 查询反馈列表(分页,管理员使用)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct FeedbackListQuery {
|
||||||
|
page: Option<i64>,
|
||||||
|
page_size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct FeedbackRow {
|
||||||
|
id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
content: String,
|
||||||
|
contact: Option<String>,
|
||||||
|
images: Option<String>,
|
||||||
|
status: i8,
|
||||||
|
created_at: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/h5/feedback — 反馈列表(管理端)
|
||||||
|
///
|
||||||
|
/// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。
|
||||||
|
pub async fn list_feedback(
|
||||||
|
_user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<FeedbackListQuery>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let pool = &state.mysql;
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
|
||||||
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let rows: Vec<FeedbackRow> = sqlx::query_as::<_, FeedbackRow>(
|
||||||
|
r#"SELECT id, user_id, content, contact, images, status, created_at
|
||||||
|
FROM user_feedback
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?"#,
|
||||||
|
)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list: Vec<Value> = rows.into_iter().map(|r| {
|
||||||
|
let images: Vec<String> = r.images
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
json!({
|
||||||
|
"id": r.id,
|
||||||
|
"user_id": r.user_id,
|
||||||
|
"content": r.content,
|
||||||
|
"contact": r.contact,
|
||||||
|
"images": images,
|
||||||
|
"status": r.status,
|
||||||
|
"status_text": if r.status == 0 { "待处理" } else { "已处理" },
|
||||||
|
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": { "list": list, "total": total, "page": page, "page_size": page_size },
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct FeedbackStatusInput {
|
||||||
|
status: i8,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEEDBACK_IMG_DIR: &str = "uploads/feedback";
|
||||||
|
|
||||||
|
pub async fn upload_feedback_image(
|
||||||
|
user: CurrentUser,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let mut file_data: Option<Vec<u8>> = None;
|
||||||
|
let mut ext = "".to_string();
|
||||||
|
|
||||||
|
while let Some(field) = multipart.next_field().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
|
||||||
|
let filename = field.file_name().unwrap_or("").to_string();
|
||||||
|
let data = field.bytes().await
|
||||||
|
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
|
||||||
|
if let Some(pos) = filename.rfind('.') {
|
||||||
|
ext = filename[pos..].to_string();
|
||||||
|
}
|
||||||
|
file_data = Some(data.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("上传内容为空".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.len() > 5 * 1024 * 1024 {
|
||||||
|
return Err(AppError::BadRequest("图片文件不能超过 5MB".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.is_empty() {
|
||||||
|
ext = ".jpg".to_string();
|
||||||
|
}
|
||||||
|
let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||||
|
if !allowed.contains(&ext.as_str()) {
|
||||||
|
return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = std::path::Path::new(FEEDBACK_IMG_DIR);
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
|
||||||
|
|
||||||
|
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
|
||||||
|
let filepath = dir.join(&filename);
|
||||||
|
let mut f = std::fs::File::create(&filepath)
|
||||||
|
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
|
||||||
|
f.write_all(&data)
|
||||||
|
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
|
||||||
|
|
||||||
|
let url = format!("/uploads/feedback/{}", filename);
|
||||||
|
tracing::info!(
|
||||||
|
user_id = user.user_id, path = %url,
|
||||||
|
"反馈图片上传成功"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": { "url": url },
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_feedback_status(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<FeedbackStatusInput>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let pool = &state.mysql;
|
||||||
|
|
||||||
|
if body.status != 0 && body.status != 1 {
|
||||||
|
return Err(AppError::BadRequest("状态值不合法".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?")
|
||||||
|
.bind(body.status)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return Err(AppError::NotFound("反馈记录不存在".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[h5] 反馈状态已更新 operator={} feedback_id={} status={}",
|
||||||
|
user.user_id, id, body.status
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": { "success": true },
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
/// 验证用户是否有权访问指定柜子(组织数据隔离)
|
/// 验证用户是否有权访问指定柜子(组织数据隔离)
|
||||||
///
|
///
|
||||||
/// 总管理员(role_level >= 2)可访问所有柜子;
|
/// 总管理员(role_level >= 2)可访问所有柜子;
|
||||||
|
|||||||
155
software/api-server/src/routes/maps.rs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
//! 腾讯地图 API 代理(服务端签名,避免前端暴露 SK)
|
||||||
|
//!
|
||||||
|
//! 签名算法(MD5):
|
||||||
|
//! 1. 参数值用 RAW(不 URL 编码)拼成 query string
|
||||||
|
//! 2. 签名串 = path + "?" + query_string + secret
|
||||||
|
//! 3. sig = MD5(签名串) 32位小写
|
||||||
|
//! 4. URL 请求中的参数值需 URL 编码
|
||||||
|
//! 注意:签名用 RAW 值,URL 用 URL 编码值,两者不同!
|
||||||
|
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
const TMAP_KEY: &str = "A3YBZ-CIO6G-4F3QO-QEWPM-UQK3F-P7FXT";
|
||||||
|
const TMAP_SECRET: &str = "iIk8Z6CwPKaTgMAppAvtqhVq42CUUvYC";
|
||||||
|
const TMAP_BASE: &str = "https://apis.map.qq.com";
|
||||||
|
|
||||||
|
/// 计算腾讯地图签名(MD5)
|
||||||
|
/// 签名使用 RAW 值(不 URL 编码),URL 请求使用 URL 编码值
|
||||||
|
fn sign(path: &str, params: &BTreeMap<&str, String>) -> String {
|
||||||
|
let qs = params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{}={}", k, v))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
let to_sign = format!("{}?{}{}", path, qs, TMAP_SECRET);
|
||||||
|
let digest = md5::compute(to_sign.as_bytes());
|
||||||
|
format!("{:x}", digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建完整请求 URL(含签名)
|
||||||
|
fn build_url(path: &str, params: BTreeMap<&str, String>) -> String {
|
||||||
|
let sig = sign(path, ¶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,17 +3,22 @@
|
|||||||
//! 认证中间件保护所有 /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;
|
||||||
@ -24,7 +29,10 @@ 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 用于认证中间件)
|
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
|
||||||
pub fn build(state: AppState) -> Router {
|
pub fn build(state: AppState) -> Router {
|
||||||
@ -32,6 +40,8 @@ pub fn build(state: AppState) -> Router {
|
|||||||
// 认证相关
|
// 认证相关
|
||||||
.route("/api/auth/logout", post(auth::logout))
|
.route("/api/auth/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))
|
||||||
// 设备指令下发
|
// 设备指令下发
|
||||||
.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))
|
||||||
@ -53,6 +63,7 @@ pub fn build(state: AppState) -> Router {
|
|||||||
// 组织管理
|
// 组织管理
|
||||||
.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))
|
||||||
@ -60,7 +71,15 @@ pub fn build(state: AppState) -> Router {
|
|||||||
.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))
|
||||||
// 组织树
|
// 组织树
|
||||||
@ -70,6 +89,10 @@ pub fn build(state: AppState) -> Router {
|
|||||||
// 充电记录
|
// 充电记录
|
||||||
.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))
|
||||||
// 下载中心
|
// 下载中心
|
||||||
@ -102,14 +125,33 @@ pub fn build(state: AppState) -> Router {
|
|||||||
.route("/api/charge/stop", post(h5::stop_charge))
|
.route("/api/charge/stop", post(h5::stop_charge))
|
||||||
.route("/api/door/open", post(h5::open_door))
|
.route("/api/door/open", post(h5::open_door))
|
||||||
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
|
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
|
||||||
|
// 问题反馈
|
||||||
|
.route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback))
|
||||||
|
.route("/api/feedback/{id}", put(h5::update_feedback_status))
|
||||||
|
.route("/api/feedback/upload", post(h5::upload_feedback_image))
|
||||||
// 认证中间件层
|
// 认证中间件层
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
||||||
|
|
||||||
Router::<AppState>::new()
|
Router::<AppState>::new()
|
||||||
.route("/api/health", get(health::health_check))
|
.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/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/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id))
|
||||||
|
// 微信回调(免认证)
|
||||||
|
.route("/api/wechat/callback", get(wechat::verify).post(wechat::handle_message))
|
||||||
|
// 腾讯地图代理(免认证,前端需要)
|
||||||
|
.route("/api/maps/suggestion", get(maps::suggestion))
|
||||||
|
.route("/api/maps/geocoder", post(maps::geocoder))
|
||||||
|
.route("/api/maps/place-search", get(maps::place_search))
|
||||||
|
// 头像上传目录(免认证,仅用于公开访问静态文件)
|
||||||
|
.nest_service("/uploads", ServeDir::new("uploads"))
|
||||||
.merge(protected)
|
.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,7 +107,7 @@ pub async fn list_operation_logs(
|
|||||||
target_type: Option<String>,
|
target_type: Option<String>,
|
||||||
target_id: Option<i64>,
|
target_id: Option<i64>,
|
||||||
detail: Option<String>,
|
detail: Option<String>,
|
||||||
created_at: String,
|
created_at: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
|
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
|
||||||
@ -131,7 +131,7 @@ pub async fn list_operation_logs(
|
|||||||
"target_type": r.target_type,
|
"target_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,
|
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@ -54,6 +54,13 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 项目表行
|
/// 项目表行
|
||||||
@ -63,6 +70,12 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 柜子表行
|
/// 柜子表行
|
||||||
@ -96,6 +109,10 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 树节点
|
/// 树节点
|
||||||
@ -111,6 +128,14 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仓控板行
|
/// 仓控板行
|
||||||
@ -131,47 +156,10 @@ 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,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub iccid: Option<String>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub status: i8,
|
|
||||||
pub rssi: Option<i32>,
|
|
||||||
pub pow_fail_dc: Option<bool>,
|
|
||||||
pub pow_fail_ac: Option<bool>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub address: Option<String>,
|
|
||||||
pub cabin_boards: Vec<CabinBoardWithCompartments>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 辅助函数
|
// 辅助函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 生成平台抽象 ID: `10-` + IMEI 的 SHA256 前 4 字节的十六进制
|
|
||||||
pub fn generate_abstract_id(imei: &str) -> String {
|
|
||||||
use sha2::Digest;
|
|
||||||
let hash = sha2::Sha256::digest(imei.as_bytes());
|
|
||||||
let hex_str = hex::encode(&hash[..4]);
|
|
||||||
format!("10-{hex_str}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成 8 位随机字母数字安全码
|
/// 生成 8 位随机字母数字安全码
|
||||||
pub fn generate_auth_str() -> String {
|
pub fn generate_auth_str() -> String {
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@ -193,12 +181,16 @@ 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 FROM organizations ORDER BY id"#,
|
r#"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"#,
|
||||||
)
|
)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(json!(rows)))
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": rows,
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/organizations — 创建组织
|
/// POST /api/organizations — 创建组织
|
||||||
@ -223,7 +215,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!({ "id": id, "name": name })))
|
Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/organizations/:id — 更新组织
|
/// PUT /api/organizations/:id — 更新组织
|
||||||
@ -253,7 +245,48 @@ pub async fn update_organization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("更新组织 id={} name={}", id, name);
|
tracing::info!("更新组织 id={} name={}", id, name);
|
||||||
Ok(Json(json!({ "id": id, "name": name })))
|
Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组织禁用请求体
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct OrgStatusBody {
|
||||||
|
pub disabled: i8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/organizations/:id/status — 禁用/启用组织(仅总管理员)
|
||||||
|
///
|
||||||
|
/// 禁用后该组织及其子组织的柜子全部不可用(指令链路拦截)。
|
||||||
|
pub async fn set_organization_status(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<OrgStatusBody>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
// 仅总管理员(甲方)可禁用组织
|
||||||
|
if user.role_level < 2 {
|
||||||
|
return Err(AppError::Forbidden("仅总管理员可禁用组织".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.disabled != 0 && body.disabled != 1 {
|
||||||
|
return Err(AppError::BadRequest("disabled 必须为 0 或 1".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = &state.mysql;
|
||||||
|
let rows = sqlx::query("UPDATE organizations SET disabled = ? WHERE id = ?")
|
||||||
|
.bind(body.disabled)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("组织 {} 不存在", id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = if body.disabled == 1 { "禁用" } else { "启用" };
|
||||||
|
tracing::info!("{}组织 id={}", action, id);
|
||||||
|
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", action) })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/organizations/:id — 删除组织
|
/// DELETE /api/organizations/:id — 删除组织
|
||||||
@ -265,19 +298,48 @@ 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)
|
||||||
@ -289,5 +351,5 @@ pub async fn delete_organization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("删除组织 id={}", id);
|
tracing::info!("删除组织 id={}", id);
|
||||||
Ok(Json(json!({})))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,26 @@
|
|||||||
|
|
||||||
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::{NameBody, ProjectRow};
|
use super::organizations::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,
|
||||||
@ -18,16 +31,17 @@ 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 FROM projects WHERE organization_id = ? ORDER BY id"#,
|
r#"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects WHERE organization_id = ? ORDER BY id"#,
|
||||||
)
|
)
|
||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(json!(rows)))
|
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/organizations/:org_id/projects — 创建项目
|
/// POST /api/organizations/:org_id/projects — 创建项目
|
||||||
@ -35,9 +49,10 @@ 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<NameBody>,
|
Json(body): Json<ProjectBody>,
|
||||||
) -> 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();
|
||||||
@ -56,16 +71,32 @@ pub async fn create_project(
|
|||||||
return Err(AppError::NotFound(format!("组织 {} 不存在", org_id)));
|
return Err(AppError::NotFound(format!("组织 {} 不存在", org_id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = sqlx::query(r#"INSERT INTO projects (organization_id, name) VALUES (?, ?)"#)
|
let result = sqlx::query(
|
||||||
.bind(org_id)
|
r#"INSERT INTO projects (organization_id, name, address, latitude, longitude) VALUES (?, ?, ?, ?, ?)"#,
|
||||||
.bind(&name)
|
)
|
||||||
.execute(db)
|
.bind(org_id)
|
||||||
.await?;
|
.bind(&name)
|
||||||
|
.bind(&body.address)
|
||||||
|
.bind(&body.latitude)
|
||||||
|
.bind(&body.longitude)
|
||||||
|
.execute(db)
|
||||||
|
.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!({ "id": id, "organization_id": org_id, "name": name })))
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": id,
|
||||||
|
"organization_id": org_id,
|
||||||
|
"name": name,
|
||||||
|
"address": body.address,
|
||||||
|
"latitude": body.latitude,
|
||||||
|
"longitude": body.longitude,
|
||||||
|
},
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/projects/:id — 更新项目
|
/// PUT /api/projects/:id — 更新项目
|
||||||
@ -73,9 +104,10 @@ 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<NameBody>,
|
Json(body): Json<ProjectBody>,
|
||||||
) -> 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();
|
||||||
@ -83,19 +115,34 @@ pub async fn update_project(
|
|||||||
return Err(AppError::BadRequest("项目名称不能为空".into()));
|
return Err(AppError::BadRequest("项目名称不能为空".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = sqlx::query(r#"UPDATE projects SET name = ? WHERE id = ?"#)
|
let rows = sqlx::query(
|
||||||
.bind(&name)
|
r#"UPDATE projects SET name = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?"#,
|
||||||
.bind(id)
|
)
|
||||||
.execute(db)
|
.bind(&name)
|
||||||
.await?
|
.bind(&body.address)
|
||||||
.rows_affected();
|
.bind(&body.latitude)
|
||||||
|
.bind(&body.longitude)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
|
return Err(AppError::NotFound(format!("项目 {} 不存在", id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("更新项目 id={} name={}", id, name);
|
tracing::info!("更新项目 id={} name={} address={:?}", id, name, body.address);
|
||||||
Ok(Json(json!({ "id": id, "name": name })))
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"address": body.address,
|
||||||
|
"latitude": body.latitude,
|
||||||
|
"longitude": body.longitude,
|
||||||
|
},
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/projects/:id — 删除项目
|
/// DELETE /api/projects/:id — 删除项目
|
||||||
@ -105,6 +152,7 @@ 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(
|
||||||
@ -131,5 +179,5 @@ pub async fn delete_project(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("删除项目 id={}", id);
|
tracing::info!("删除项目 id={}", id);
|
||||||
Ok(Json(json!({})))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|||||||
70
software/api-server/src/routes/scope_guard.rs
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
//! 组织级数据隔离校验 — 确保用户只能访问其所属组织的资源。
|
||||||
|
//!
|
||||||
|
//! 总管理员(role_level=2)跳过所有组织归属校验。
|
||||||
|
//! 企业管理员(role_level=1)和操作员(role_level=0)只能访问本组织资源。
|
||||||
|
|
||||||
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::middleware::auth::CurrentUser;
|
||||||
|
|
||||||
|
/// 校验当前用户是否有权访问指定柜子。
|
||||||
|
pub async fn verify_cabinet_ownership(
|
||||||
|
pool: &MySqlPool,
|
||||||
|
user: &CurrentUser,
|
||||||
|
cabinet_id: i64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if user.role_level >= 2 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?;
|
||||||
|
let has_access: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM cabinets c \
|
||||||
|
JOIN projects p ON c.project_id = p.id \
|
||||||
|
WHERE c.id = ? AND p.organization_id = ?)",
|
||||||
|
)
|
||||||
|
.bind(cabinet_id)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::Forbidden("无权访问该资源".into()))?;
|
||||||
|
if !has_access {
|
||||||
|
return Err(AppError::Forbidden("无权访问该资源".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验当前用户是否有权访问指定项目。
|
||||||
|
pub async fn verify_project_ownership(
|
||||||
|
pool: &MySqlPool,
|
||||||
|
user: &CurrentUser,
|
||||||
|
project_id: i64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if user.role_level >= 2 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?;
|
||||||
|
let has_access: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM projects WHERE id = ? AND organization_id = ?)",
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::Forbidden("无权访问该资源".into()))?;
|
||||||
|
if !has_access {
|
||||||
|
return Err(AppError::Forbidden("无权访问该资源".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验当前用户是否有权访问指定组织。
|
||||||
|
pub fn verify_org_ownership(user: &CurrentUser, org_id: i64) -> Result<(), AppError> {
|
||||||
|
if user.role_level >= 2 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if user.organization_id != Some(org_id) {
|
||||||
|
return Err(AppError::Forbidden("无权访问该资源".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -17,7 +17,6 @@ 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)]
|
||||||
@ -74,6 +73,46 @@ pub(crate) struct StatusInput {
|
|||||||
// 辅助函数: 记录操作日志
|
// 辅助函数: 记录操作日志
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 企业管理员操作校验:必须操作本组织的用户且不能操作自己
|
||||||
|
///
|
||||||
|
/// 返回目标用户的当前组织ID(用于后续业务判断)
|
||||||
|
async fn verify_org_admin_scope(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
actor: &CurrentUser,
|
||||||
|
target_user_id: i64,
|
||||||
|
) -> Result<Option<i64>, AppError> {
|
||||||
|
// 总管理员不需要此校验
|
||||||
|
if actor.role_level >= 2 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业管理员必须有组织
|
||||||
|
let actor_org = actor.organization_id
|
||||||
|
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
|
||||||
|
|
||||||
|
if target_user_id == actor.user_id {
|
||||||
|
return Err(AppError::BadRequest("不能操作自己的账户".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询目标用户所属组织
|
||||||
|
let target_org: Option<(Option<i64>,)> = sqlx::query_as(
|
||||||
|
"SELECT organization_id FROM users WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(target_user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (target_org,) = target_org
|
||||||
|
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
|
||||||
|
|
||||||
|
// 目标必须属于同一组织
|
||||||
|
if target_org != Some(actor_org) {
|
||||||
|
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(target_org)
|
||||||
|
}
|
||||||
|
|
||||||
/// 记录一条操作日志到 operation_logs 表
|
/// 记录一条操作日志到 operation_logs 表
|
||||||
async fn log_operation(
|
async fn log_operation(
|
||||||
pool: &sqlx::MySqlPool,
|
pool: &sqlx::MySqlPool,
|
||||||
@ -95,7 +134,7 @@ async fn log_operation(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
tracing::error!("记录操作日志失败: {}", e);
|
tracing::error!(err = %e, "记录操作日志失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,85 +179,53 @@ pub async fn list_users(
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
|
||||||
|
let (where_clause, bind_org) = match (has_keyword, org_filter) {
|
||||||
|
(true, Some(org_id)) => (
|
||||||
|
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
|
||||||
|
Some(org_id),
|
||||||
|
),
|
||||||
|
(true, None) => (
|
||||||
|
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(false, Some(org_id)) => (
|
||||||
|
"WHERE organization_id = ?".to_string(),
|
||||||
|
Some(org_id),
|
||||||
|
),
|
||||||
|
(false, None) => (String::new(), None),
|
||||||
|
};
|
||||||
|
|
||||||
// 查询总数
|
// 查询总数
|
||||||
let total: (i64,) = if has_keyword {
|
let total: (i64,) = {
|
||||||
let mut q = sqlx::query_as(
|
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
|
||||||
r#"SELECT COUNT(*) FROM users WHERE phone LIKE ? OR name LIKE ?"#,
|
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
|
||||||
)
|
if has_keyword {
|
||||||
.bind(&like_pattern)
|
q = q.bind(&like_pattern).bind(&like_pattern);
|
||||||
.bind(&like_pattern);
|
}
|
||||||
if let Some(org_id) = org_filter {
|
if let Some(org_id) = bind_org {
|
||||||
q = sqlx::query_as(
|
q = q.bind(org_id);
|
||||||
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?
|
q.fetch_one(pool).await?
|
||||||
} else if let Some(org_id) = org_filter {
|
|
||||||
sqlx::query_as("SELECT COUNT(*) FROM users WHERE organization_id = ?")
|
|
||||||
.bind(org_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as("SELECT COUNT(*) FROM users")
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查询用户列表
|
// 查询用户列表
|
||||||
let rows: Vec<UserRow> = if has_keyword {
|
let rows: Vec<UserRow> = {
|
||||||
let mut q = sqlx::query_as::<_, UserRow>(
|
let sql = format!(
|
||||||
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
||||||
FROM users u
|
FROM users u {}
|
||||||
WHERE u.phone LIKE ? OR u.name LIKE ?
|
|
||||||
ORDER BY u.id DESC
|
ORDER BY u.id DESC
|
||||||
LIMIT ? OFFSET ?"#,
|
LIMIT ? OFFSET ?"#,
|
||||||
)
|
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE")
|
||||||
.bind(&like_pattern)
|
);
|
||||||
.bind(&like_pattern)
|
let mut q = sqlx::query_as::<_, UserRow>(&sql);
|
||||||
.bind(page_size)
|
if has_keyword {
|
||||||
.bind(offset);
|
q = q.bind(&like_pattern).bind(&like_pattern);
|
||||||
if let Some(org_id) = org_filter {
|
|
||||||
q = sqlx::query_as::<_, UserRow>(
|
|
||||||
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
|
||||||
FROM users u
|
|
||||||
WHERE (u.phone LIKE ? OR u.name LIKE ?) AND u.organization_id = ?
|
|
||||||
ORDER BY u.id DESC
|
|
||||||
LIMIT ? OFFSET ?"#,
|
|
||||||
)
|
|
||||||
.bind(&like_pattern)
|
|
||||||
.bind(&like_pattern)
|
|
||||||
.bind(org_id)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset);
|
|
||||||
}
|
}
|
||||||
q.fetch_all(pool).await?
|
if let Some(org_id) = bind_org {
|
||||||
} else if let Some(org_id) = org_filter {
|
q = q.bind(org_id);
|
||||||
sqlx::query_as::<_, UserRow>(
|
}
|
||||||
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
q.bind(page_size).bind(offset).fetch_all(pool).await?
|
||||||
FROM users u
|
|
||||||
WHERE u.organization_id = ?
|
|
||||||
ORDER BY u.id DESC
|
|
||||||
LIMIT ? OFFSET ?"#,
|
|
||||||
)
|
|
||||||
.bind(org_id)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, UserRow>(
|
|
||||||
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
|
||||||
FROM users u
|
|
||||||
ORDER BY u.id DESC
|
|
||||||
LIMIT ? OFFSET ?"#,
|
|
||||||
)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 收集组织ID并批量查询组织名称
|
// 收集组织ID并批量查询组织名称
|
||||||
@ -236,12 +243,12 @@ pub async fn list_users(
|
|||||||
for id in &org_ids {
|
for id in &org_ids {
|
||||||
q = q.bind(id);
|
q = q.bind(id);
|
||||||
}
|
}
|
||||||
q.fetch_all(pool).await.unwrap_or_default()
|
q.fetch_all(pool).await?
|
||||||
};
|
};
|
||||||
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
|
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
|
||||||
|
|
||||||
// 构建角色名称映射
|
// 构建角色名称映射
|
||||||
let role_names: [&str; 3] = ["普通用户", "企业管理员", "总管理员"];
|
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
|
||||||
|
|
||||||
let list: Vec<Value> = rows
|
let list: Vec<Value> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -274,6 +281,11 @@ pub async fn list_users(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 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>,
|
||||||
@ -292,15 +304,25 @@ pub async fn create_user(
|
|||||||
let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
|
let 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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 企业管理员必须指定所属组织
|
// 确定最终组织归属(按角色和操作者身份)
|
||||||
if role == 1 && input.organization_id.is_none() {
|
let org_id = if current_user.role_level >= 2 {
|
||||||
return Err(AppError::BadRequest("企业管理员必须指定所属组织".into()));
|
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
|
||||||
}
|
if role == 2 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(input.organization_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
|
||||||
|
if role == 2 {
|
||||||
|
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
|
||||||
|
}
|
||||||
|
Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?)
|
||||||
|
};
|
||||||
|
|
||||||
let org_id = input.organization_id;
|
|
||||||
// 校验组织存在
|
// 校验组织存在
|
||||||
if let Some(oid) = org_id {
|
if let Some(oid) = org_id {
|
||||||
let org_exists: (i64,) = sqlx::query_as(
|
let org_exists: (i64,) = sqlx::query_as(
|
||||||
@ -314,7 +336,7 @@ pub async fn create_user(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
|
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
|
||||||
let password = auth::hash_password(&plain_password)?;
|
let password = auth::hash_password(&plain_password)?;
|
||||||
|
|
||||||
// 检查手机号是否已存在
|
// 检查手机号是否已存在
|
||||||
@ -345,7 +367,7 @@ pub async fn create_user(
|
|||||||
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-"));
|
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!("创建用户 id={} phone={} role={}", user_id, phone, role);
|
tracing::info!(user_id, phone = %phone, role, "创建用户");
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@ -382,28 +404,47 @@ 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()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验组织存在
|
// 企业管理员作用域校验:只能编辑本组织用户,不能改成总管理员,组织锁定为本组织
|
||||||
if let Some(oid) = new_org_id
|
let new_org_id: Option<i64> = if current_user.role_level < 2 {
|
||||||
&& Some(oid) != old_org_id {
|
let actor_org = current_user.organization_id
|
||||||
let org_exists: (i64,) = sqlx::query_as(
|
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
|
||||||
"SELECT COUNT(*) FROM organizations WHERE id = ?"
|
if user_id == current_user.user_id {
|
||||||
)
|
return Err(AppError::BadRequest("不能操作自己的账户".into()));
|
||||||
.bind(oid)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
if org_exists.0 == 0 {
|
|
||||||
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// 目标必须属于同一组织
|
||||||
|
if old_org_id != Some(actor_org) {
|
||||||
|
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
|
||||||
|
}
|
||||||
|
// 不能改成总管理员
|
||||||
|
if new_role == 2 {
|
||||||
|
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
|
||||||
|
}
|
||||||
|
Some(actor_org)
|
||||||
|
} else {
|
||||||
|
let org_id = if new_role == 2 {
|
||||||
|
None // 总管理员强制无组织
|
||||||
|
} else {
|
||||||
|
Some(input.organization_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 校验组织存在
|
||||||
|
if let Some(oid) = org_id
|
||||||
|
&& Some(oid) != old_org_id {
|
||||||
|
let org_exists: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM organizations WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(oid)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if org_exists.0 == 0 {
|
||||||
|
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
org_id
|
||||||
|
};
|
||||||
|
|
||||||
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 = ?"
|
||||||
@ -430,7 +471,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!("编辑用户 id={}", user_id);
|
tracing::info!(user_id, "编辑用户");
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
|
||||||
}
|
}
|
||||||
@ -449,7 +490,10 @@ 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 = ?")
|
||||||
@ -467,7 +511,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={}", user_id);
|
tracing::info!(user_id, "重置密码");
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
|
||||||
}
|
}
|
||||||
@ -487,10 +531,8 @@ pub async fn set_user_status(
|
|||||||
|
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 不能禁用自己
|
// 企业管理员作用域校验(含不能操作自己+同组织限制)
|
||||||
if user_id == current_user.user_id {
|
verify_org_admin_scope(pool, ¤t_user, user_id).await?;
|
||||||
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()));
|
||||||
@ -513,7 +555,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={}", status_text, user_id);
|
tracing::info!(user_id, action = %status_text, "{}用户", status_text);
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
|
||||||
}
|
}
|
||||||
@ -532,10 +574,8 @@ pub async fn delete_user(
|
|||||||
|
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 不能删除自己
|
// 企业管理员作用域校验(含不能操作自己+同组织限制)
|
||||||
if user_id == current_user.user_id {
|
verify_org_admin_scope(pool, ¤t_user, user_id).await?;
|
||||||
return Err(AppError::BadRequest("不能删除自己的账户".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询用户信息用于日志
|
// 查询用户信息用于日志
|
||||||
let user_info: Option<(String,)> = sqlx::query_as(
|
let user_info: Option<(String,)> = sqlx::query_as(
|
||||||
@ -560,7 +600,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!("删除用户 id={} phone={}", user_id, phone);
|
tracing::info!(user_id, phone = %phone, "删除用户");
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
|
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
|
||||||
}
|
}
|
||||||
|
|||||||
63
software/api-server/src/routes/wechat.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
//! 微信公众号回调 API
|
||||||
|
//!
|
||||||
|
//! - GET /api/wechat/callback — 服务器地址验证(echostr)
|
||||||
|
//! - POST /api/wechat/callback — 接收推送消息
|
||||||
|
//!
|
||||||
|
//! 配置参数:
|
||||||
|
//! - Token: 7GW16XdnFyak0mE32rc8ZPij(与微信后台一致)
|
||||||
|
//! - EncodingAESKey: kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu
|
||||||
|
//! - 加解密方式: 明文模式
|
||||||
|
//! - 数据格式: XML
|
||||||
|
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 微信验证请求参数
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct WechatVerifyQuery {
|
||||||
|
pub signature: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub echostr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 WeChat Token,优先从环境变量读取
|
||||||
|
fn get_wechat_token() -> String {
|
||||||
|
std::env::var("WECHAT_TOKEN")
|
||||||
|
.unwrap_or_else(|_| "7GW16XdnFyak0mE32rc8ZPij".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证签名:将 token、timestamp、nonce 字典序排序后拼接,做 SHA1 加密
|
||||||
|
/// 与 signature 比较,一致则返回 true
|
||||||
|
fn verify_signature(signature: &str, timestamp: &str, nonce: &str) -> bool {
|
||||||
|
let token = get_wechat_token();
|
||||||
|
let mut parts = vec![token, timestamp.to_string(), nonce.to_string()];
|
||||||
|
parts.sort();
|
||||||
|
let joined = parts.concat();
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
hasher.update(joined.as_bytes());
|
||||||
|
let digest = hex::encode(hasher.finalize());
|
||||||
|
digest == signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/wechat/callback — 微信服务器地址验证
|
||||||
|
pub async fn verify(
|
||||||
|
Query(query): Query<WechatVerifyQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if verify_signature(&query.signature, &query.timestamp, &query.nonce) {
|
||||||
|
(StatusCode::OK, query.echostr)
|
||||||
|
} else {
|
||||||
|
(StatusCode::FORBIDDEN, "signature verification failed".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/wechat/callback — 接收微信推送的消息
|
||||||
|
pub async fn handle_message(body: String) -> impl IntoResponse {
|
||||||
|
info!("微信消息推送: {}", body);
|
||||||
|
// 明文模式,返回 success 即表示收到
|
||||||
|
(StatusCode::OK, "success".to_string())
|
||||||
|
}
|
||||||
288
software/api-server/src/workers/energy.rs
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
//! 订单电量积分(**待确认第 13 项的兜底方案**)
|
||||||
|
//!
|
||||||
|
//! 背景:交付文档把 `energy`(单订单累计充电量 Wh)列入「待确认事项第 13 项」——
|
||||||
|
//! **协议暂不包含该字段**。文档同时写明兜底方案:
|
||||||
|
//! > **若无该数据源,订单电量由平台侧按 `电压 × 电流 × 时间` 积分计算**
|
||||||
|
//!
|
||||||
|
//! 本模块就是那个兜底:
|
||||||
|
//! - 每次 `status_post`(协议固定每 60 秒一次)拿到通道的**电压(mV)/电流(mA)/温度**,
|
||||||
|
//! 按 `Wh = mV × mA × Δt / 3.6e9` 累加到该通道的**订单电量累加器**;
|
||||||
|
//! - 订单结束时读取累加值 → 写入 `charge_orders.energy` 并生成 `charge_records`;
|
||||||
|
//! - 新订单创建时**清零**累加器。
|
||||||
|
//!
|
||||||
|
//! 设计取舍:
|
||||||
|
//! - **累加器放在 Redis 且以"通道"为键**(不是订单)——避免每条 `status_post` 都去查库确认
|
||||||
|
//! "该通道当前有没有活跃订单"(36 通道 × 每秒多柜会造成大量无谓查询)。新订单创建时清零、
|
||||||
|
//! 订单结束时读取并清零,语义等价而开销恒定。
|
||||||
|
//! - 用 `HINCRBY` 累加**毫瓦时(mWh)**而不是瓦时,避免 60 秒小增量被取整成 0;落库时再换算成 Wh。
|
||||||
|
//! - 温度取**历史最大值**(`charge_records.max_temperature` 要的是"本次充电最高温度")。
|
||||||
|
//! 用 **Lua 脚本**把「累加 + 更新最高温 + 记时间戳」做成**一次原子往返**;若环境禁用了
|
||||||
|
//! `EVAL`,降级为普通命令(电量照常累加,仅放弃最高温跟踪),**不影响订单电量**。
|
||||||
|
//! - 单次积分的时间间隔 Δt 取**协议约定的 60 秒**并夹取到 `[0, MAX_DT]`:`status_post` 是固定节奏,
|
||||||
|
//! 但断线/停电后首个采样不应把"离线期间"也算成充电(否则会虚增电量)。
|
||||||
|
|
||||||
|
use redis::aio::ConnectionManager;
|
||||||
|
|
||||||
|
/// 协议约定的上报间隔(秒)——积分用的 Δt。
|
||||||
|
///
|
||||||
|
/// **为什么用固定 60 秒而不是"两次采样的真实间隔"**:
|
||||||
|
/// - 协议 §5.3 规定 `status_post` **固定每 60 秒一次**,正常运行时它就是真实间隔;
|
||||||
|
/// - 用真实间隔需要在每个通道上多读一次 Redis 存上次时间戳(36 通道 × 每分钟 × 每柜),
|
||||||
|
/// 为极小的精度收益付出可观的往返开销;
|
||||||
|
/// - 断线/停电后补报时,固定 60 秒**只会少算**(少算的那段本就没有可信采样),
|
||||||
|
/// 符合"宁可少算、不可虚增"的口径 —— 电量直接关系电费,虚增比少算更糟。
|
||||||
|
pub const ASSUMED_INTERVAL_SECS: i64 = 60;
|
||||||
|
|
||||||
|
/// 换算系数:`mV × mA × s` → **mWh**。
|
||||||
|
///
|
||||||
|
/// 推导:`W = (mV/1000) × (mA/1000)`;`Wh = W × s/3600`;
|
||||||
|
/// 换成 mWh 再乘 1000 → `mWh = mV × mA × s / 3_600_000`。
|
||||||
|
const MV_MA_S_TO_MWH_DIVISOR: i128 = 3_600_000;
|
||||||
|
|
||||||
|
/// 计算一次采样的电量增量(**mWh**)。
|
||||||
|
///
|
||||||
|
/// - `voltage_mv` / `current_ma`:协议换算后的平台单位(见交付文档 §5.3)
|
||||||
|
/// - `dt_secs`:本次采样覆盖的秒数(已由调用方夹取到 `[0, MAX_DT_SECS]`)
|
||||||
|
///
|
||||||
|
/// 电流为 0(未充电)时增量为 0;任何非正输入都返回 0,不做无意义的负数累加。
|
||||||
|
/// 用 `i128` 中间量避免 `mV × mA × s` 溢出(54000 × 8000 × 300 = 1.296e11 > i32,也接近 i64 无需担心,
|
||||||
|
/// 但统一用宽类型更稳妥)。
|
||||||
|
pub fn mwh_delta(voltage_mv: i64, current_ma: i64, dt_secs: i64) -> i64 {
|
||||||
|
if voltage_mv <= 0 || current_ma <= 0 || dt_secs <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let product = voltage_mv as i128 * current_ma as i128 * dt_secs as i128;
|
||||||
|
(product / MV_MA_S_TO_MWH_DIVISOR) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 mWh 换算成落库用的 **Wh**(四舍五入到整数,因为 `charge_orders.energy` 是 INT/BIGINT 的 Wh)。
|
||||||
|
pub fn mwh_to_wh(mwh: i64) -> i64 {
|
||||||
|
if mwh <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
(mwh + 500) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通道订单累加器键:`device:{imei}:board:{板号}:ch:{通道号}:order`
|
||||||
|
///
|
||||||
|
/// 字段:`mwh`(累计毫瓦时)、`maxtemp`(本次充电最高温度 K×100)、`ts`(上次采样秒级时间戳)
|
||||||
|
pub fn order_energy_key(imei: &str, board_id: i64, channel_id: i64) -> String {
|
||||||
|
format!("device:{}:board:{}:ch:{}:order", imei, board_id, channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lua:原子地「累加电量 + 取最高温 + 记时间戳 + 维护"连续无电池"计数」,避免多次往返与竞态。
|
||||||
|
///
|
||||||
|
/// KEYS[1] 累加器键;ARGV[1] mwh 增量;ARGV[2] 本次温度(K×100);ARGV[3] 当前时间戳(秒);
|
||||||
|
/// ARGV[4] `1`=本通道有电池 / `0`=无电池(用于"连续无电池"兜底判定,见 [`consecutive_no_battery`])
|
||||||
|
const ACCUMULATE_LUA: &str = r#"
|
||||||
|
local cur = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0')
|
||||||
|
local t = tonumber(ARGV[2])
|
||||||
|
if t > cur then redis.call('HSET', KEYS[1], 'maxtemp', ARGV[2]) end
|
||||||
|
redis.call('HINCRBY', KEYS[1], 'mwh', ARGV[1])
|
||||||
|
redis.call('HSET', KEYS[1], 'ts', ARGV[3])
|
||||||
|
if ARGV[4] == '1' then redis.call('HSET', KEYS[1], 'nobat', '0')
|
||||||
|
else redis.call('HINCRBY', KEYS[1], 'nobat', 1) end
|
||||||
|
return 1
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// 累加一次采样的电量与最高温(由 `status_post` 的通道写入路径调用)。
|
||||||
|
///
|
||||||
|
/// `battery_present` = 该通道 `batt_status` 的 **bit7(有电池)**,用于维护"连续无电池"计数:
|
||||||
|
/// 待确认第 18 项的兜底方案之一就是「**靠 `status_post` 的 bit7 归零判断电池已被取走**」,
|
||||||
|
/// 由 [`crate::workers::report_worker`] 的周期对账在连续 [`NO_BATTERY_STRIKES`] 次后提前结单。
|
||||||
|
///
|
||||||
|
/// 失败只记 WARN:**电量统计是增值功能,不能因为 Redis 抖动影响状态上报主流程**。
|
||||||
|
pub async fn accumulate(
|
||||||
|
redis: &ConnectionManager,
|
||||||
|
imei: &str,
|
||||||
|
board_id: i64,
|
||||||
|
channel_id: i64,
|
||||||
|
voltage_mv: i64,
|
||||||
|
current_ma: i64,
|
||||||
|
temp_k100: i64,
|
||||||
|
battery_present: bool,
|
||||||
|
) {
|
||||||
|
let delta = mwh_delta(voltage_mv, current_ma, ASSUMED_INTERVAL_SECS);
|
||||||
|
let key = order_energy_key(imei, board_id, channel_id);
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let present = if battery_present { "1" } else { "0" };
|
||||||
|
let mut conn = redis.clone();
|
||||||
|
|
||||||
|
// 优先用 Lua 一次往返完成全部更新
|
||||||
|
let script = redis::Script::new(ACCUMULATE_LUA);
|
||||||
|
match script
|
||||||
|
.key(&key)
|
||||||
|
.arg(delta)
|
||||||
|
.arg(temp_k100.max(0))
|
||||||
|
.arg(now)
|
||||||
|
.arg(present)
|
||||||
|
.invoke_async::<i64>(&mut conn)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// 环境可能禁用 EVAL(或脚本被 RDS 策略拦):降级为普通命令,**电量照常累加**,
|
||||||
|
// 仅放弃"最高温"与"连续无电池"跟踪(缺最高温只是记录不完整,不影响电费口径)。
|
||||||
|
tracing::warn!("[energy] Lua 累加失败,降级为普通命令 key={} err={}", key, e);
|
||||||
|
let mut conn2 = redis.clone();
|
||||||
|
let mut pipe = redis::pipe();
|
||||||
|
pipe.cmd("HINCRBY").arg(&key).arg("mwh").arg(delta).ignore();
|
||||||
|
pipe.cmd("HSET").arg(&key).arg("ts").arg(now).ignore();
|
||||||
|
if battery_present {
|
||||||
|
pipe.cmd("HSET").arg(&key).arg("nobat").arg(0).ignore();
|
||||||
|
} else {
|
||||||
|
pipe.cmd("HINCRBY").arg(&key).arg("nobat").arg(1).ignore();
|
||||||
|
}
|
||||||
|
if let Err(e2) = pipe.query_async::<()>(&mut conn2).await {
|
||||||
|
tracing::warn!("[energy] 电量累加失败 key={} err={}", key, e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「连续无电池」达到该次数即视为电池已被取走(协议 §5.6 状态兜底的抖动保护)。
|
||||||
|
///
|
||||||
|
/// `status_post` 固定每 60 秒一次,故 2 次 ≈ **连续 2 分钟**无电池才判定,
|
||||||
|
/// 避免单次瞬时读数为 0 就误结单(待确认第 16 项:板离线时的表现尚未约定,
|
||||||
|
/// 因此阈值宁高不低,且真正的兜底是 10 小时订单超时)。
|
||||||
|
pub const NO_BATTERY_STRIKES: i64 = 2;
|
||||||
|
|
||||||
|
/// 读取该通道"连续无电池"的采样次数(0 表示最近一次有电池)。
|
||||||
|
pub async fn consecutive_no_battery(
|
||||||
|
redis: &ConnectionManager,
|
||||||
|
imei: &str,
|
||||||
|
board_id: i64,
|
||||||
|
channel_id: i64,
|
||||||
|
) -> i64 {
|
||||||
|
let key = order_energy_key(imei, board_id, channel_id);
|
||||||
|
let mut conn = redis.clone();
|
||||||
|
let value: Option<i64> = redis::cmd("HGET")
|
||||||
|
.arg(&key)
|
||||||
|
.arg("nobat")
|
||||||
|
.query_async(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
value.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取并**清零**通道累加器,返回 `(累计毫瓦时, 本次最高温 K×100)`。
|
||||||
|
///
|
||||||
|
/// 用于订单结束时结算:先取值再删除,保证下一单从 0 开始。
|
||||||
|
/// 读取失败返回 `(0, 0)` 并记 WARN —— 结算拿不到电量时宁可记 0,也不能让订单结束流程失败。
|
||||||
|
pub async fn take_and_reset(
|
||||||
|
redis: &ConnectionManager,
|
||||||
|
imei: &str,
|
||||||
|
board_id: i64,
|
||||||
|
channel_id: i64,
|
||||||
|
) -> (i64, i64) {
|
||||||
|
let key = order_energy_key(imei, board_id, channel_id);
|
||||||
|
|
||||||
|
// 用 Lua 保证"读+删"原子(避免读到之后、删除之前又来一次累加造成跨单串账)
|
||||||
|
const TAKE_LUA: &str = r#"
|
||||||
|
local mwh = tonumber(redis.call('HGET', KEYS[1], 'mwh') or '0')
|
||||||
|
local temp = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0')
|
||||||
|
redis.call('DEL', KEYS[1])
|
||||||
|
return {mwh, temp}
|
||||||
|
"#;
|
||||||
|
let mut conn = redis.clone();
|
||||||
|
let script = redis::Script::new(TAKE_LUA);
|
||||||
|
match script
|
||||||
|
.key(&key)
|
||||||
|
.invoke_async::<(i64, i64)>(&mut conn)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[energy] 读取累加器失败,按 0 结算 key={} err={}", key, e);
|
||||||
|
// 仍尝试清理,避免脏数据带到下一单
|
||||||
|
let mut conn2 = redis.clone();
|
||||||
|
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn2).await;
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清零通道累加器(新订单创建时调用)。
|
||||||
|
pub async fn reset(redis: &ConnectionManager, imei: &str, board_id: i64, channel_id: i64) {
|
||||||
|
let key = order_energy_key(imei, board_id, channel_id);
|
||||||
|
let mut conn = redis.clone();
|
||||||
|
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 换算自检:54.000V × 8.000A 持续 60 秒 = 432W × (1/60)h = **7.2 Wh = 7200 mWh**
|
||||||
|
#[test]
|
||||||
|
fn test_mwh_delta_real_world_case() {
|
||||||
|
assert_eq!(mwh_delta(54_000, 8_000, 60), 7_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未充电(电流 0)不累加 —— 空仓/停止充电时不该产生电量
|
||||||
|
#[test]
|
||||||
|
fn test_mwh_delta_zero_current_no_accumulation() {
|
||||||
|
assert_eq!(mwh_delta(54_000, 0, 60), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非法/异常输入一律返回 0,不做负数累加
|
||||||
|
#[test]
|
||||||
|
fn test_mwh_delta_rejects_non_positive_inputs() {
|
||||||
|
assert_eq!(mwh_delta(0, 8_000, 60), 0);
|
||||||
|
assert_eq!(mwh_delta(-1, 8_000, 60), 0);
|
||||||
|
assert_eq!(mwh_delta(54_000, -1, 60), 0);
|
||||||
|
assert_eq!(mwh_delta(54_000, 8_000, 0), 0);
|
||||||
|
assert_eq!(mwh_delta(54_000, 8_000, -5), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 小电流长时间不因取整丢成 0(这正是用 mWh 而非 Wh 累加的原因)
|
||||||
|
/// 例:54V × 0.1A = 5.4W,60 秒 = 0.09 Wh = 90 mWh(若按 Wh 取整会变成 0)
|
||||||
|
#[test]
|
||||||
|
fn test_mwh_delta_small_current_not_lost_by_rounding() {
|
||||||
|
assert_eq!(mwh_delta(54_000, 100, 60), 90);
|
||||||
|
assert_eq!(mwh_to_wh(90), 0, "不足 0.5Wh 时落库为 0 是可接受的舍入");
|
||||||
|
// 但多次累加后不会丢:10 次 = 900 mWh → 1 Wh
|
||||||
|
assert_eq!(mwh_to_wh(900), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mWh → Wh 四舍五入
|
||||||
|
#[test]
|
||||||
|
fn test_mwh_to_wh_rounding() {
|
||||||
|
assert_eq!(mwh_to_wh(0), 0);
|
||||||
|
assert_eq!(mwh_to_wh(-5), 0);
|
||||||
|
assert_eq!(mwh_to_wh(499), 0);
|
||||||
|
assert_eq!(mwh_to_wh(500), 1);
|
||||||
|
assert_eq!(mwh_to_wh(1_499), 1);
|
||||||
|
assert_eq!(mwh_to_wh(1_500), 2);
|
||||||
|
assert_eq!(mwh_to_wh(7_200), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一轮完整充电的量级检查:54V/8A 充 1 小时应为 432 Wh
|
||||||
|
#[test]
|
||||||
|
fn test_full_hour_charge_magnitude() {
|
||||||
|
let mwh: i64 = (0..60).map(|_| mwh_delta(54_000, 8_000, 60)).sum();
|
||||||
|
assert_eq!(mwh, 432_000);
|
||||||
|
assert_eq!(mwh_to_wh(mwh), 432);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 累加器键格式(跨模块契约:`report_worker` 写入、`order_timeout` 结算读取)
|
||||||
|
#[test]
|
||||||
|
fn test_order_energy_key_format() {
|
||||||
|
assert_eq!(
|
||||||
|
order_energy_key("864720562557021", 1, 3),
|
||||||
|
"device:864720562557021:board:1:ch:3:order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 固定间隔的取向:断线补报时按 60 秒计(少算),而不是按真实空洞(虚增)
|
||||||
|
#[test]
|
||||||
|
fn test_fixed_interval_is_conservative() {
|
||||||
|
let as_fixed = mwh_delta(54_000, 8_000, ASSUMED_INTERVAL_SECS);
|
||||||
|
let as_hour_gap = mwh_delta(54_000, 8_000, 3_600);
|
||||||
|
assert_eq!(as_fixed, 7_200);
|
||||||
|
assert!(
|
||||||
|
as_fixed < as_hour_gap,
|
||||||
|
"用固定 60 秒时必须显著小于按真实空洞计算,避免补报虚增电量"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
software/api-server/src/workers/log_retention.rs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
//! 设备日志保留策略协程
|
||||||
|
//!
|
||||||
|
//! 业务背景:`device_logs` 是**只增不减**的表 —— `status_post` 每台柜子每 60 秒写一条,
|
||||||
|
//! 加上 `bat_in`/`bat_out`/停电/回执等事件,量级随时间线性增长且**原本没有任何清理策略**,
|
||||||
|
//! 长期会把 RDS 撑满(磁盘告警、备份变慢、查询变慢)。
|
||||||
|
//!
|
||||||
|
//! 本协程按**保留天数**定期分批删除过期日志:
|
||||||
|
//! - **分批 `DELETE ... LIMIT`**:避免一条大删除造成长事务、锁表、主从延迟;
|
||||||
|
//! - 单轮有**最大批数上限**:防止积压过多时一轮跑太久;
|
||||||
|
//! - 所有异常只记日志,**绝不让循环退出**(清理是运维保障,不能反过来拖垮服务)。
|
||||||
|
//!
|
||||||
|
//! 依赖索引 `device_logs(created_at)`(由 `db::migrate` 幂等创建)—— 否则每次删除都会全表扫描。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
|
/// 日志保留天数:超过该天数的 `device_logs` 会被清理。
|
||||||
|
///
|
||||||
|
/// 取 90 天的理由:设备日志主要服务于"最近出了什么问题"的排障,
|
||||||
|
/// 3 个月足够回溯;再久的数据排障价值低,但存储成本持续累积。
|
||||||
|
const RETENTION_DAYS: i64 = 90;
|
||||||
|
|
||||||
|
/// 单批删除行数:控制单条 SQL 的影响行数,避免长事务
|
||||||
|
const BATCH_LIMIT: i64 = 5000;
|
||||||
|
|
||||||
|
/// 单轮最多删多少批:积压很多时一轮也不会跑太久,剩下的等下一轮
|
||||||
|
const MAX_BATCHES_PER_ROUND: usize = 20;
|
||||||
|
|
||||||
|
/// 执行间隔:6 小时一次即可(保留策略对时效性不敏感,低频更省资源)
|
||||||
|
const SCAN_INTERVAL_SECS: u64 = 6 * 3600;
|
||||||
|
|
||||||
|
/// 启动设备日志保留协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
|
||||||
|
pub async fn start(pool: MySqlPool) {
|
||||||
|
tracing::info!(
|
||||||
|
"[log_retention] 启动设备日志清理,保留 {} 天,执行间隔 {}s,单批 {} 行",
|
||||||
|
RETENTION_DAYS,
|
||||||
|
SCAN_INTERVAL_SECS,
|
||||||
|
BATCH_LIMIT
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// 单轮失败不退出:记 error 后等下一个周期继续
|
||||||
|
if let Err(e) = purge_once(&pool).await {
|
||||||
|
tracing::error!("[log_retention] 本轮清理失败,等待下轮重试: {}", e);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单轮清理:分批删除 `created_at` 早于 `NOW() - RETENTION_DAYS` 的日志。
|
||||||
|
///
|
||||||
|
/// 返回删掉的总行数(便于日志与测试断言);SQL 失败向上抛,由调用方记录并继续循环。
|
||||||
|
async fn purge_once(pool: &MySqlPool) -> Result<u64, sqlx::Error> {
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
|
||||||
|
for _ in 0..MAX_BATCHES_PER_ROUND {
|
||||||
|
// 阈值交给 MySQL 计算(created_at 也是 MySQL 写的),避免本地时区换算偏差;
|
||||||
|
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
|
||||||
|
let affected = sqlx::query(
|
||||||
|
"DELETE FROM device_logs \
|
||||||
|
WHERE created_at < TIMESTAMPADD(DAY, ?, NOW()) \
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(-RETENTION_DAYS)
|
||||||
|
.bind(BATCH_LIMIT)
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
total += affected;
|
||||||
|
|
||||||
|
// 本批不足一批 → 已删完,提前结束
|
||||||
|
if affected < BATCH_LIMIT as u64 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批间短暂让出,避免长时间占满数据库连接
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if total > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[log_retention] 已清理过期设备日志 {} 行(保留 {} 天)",
|
||||||
|
total,
|
||||||
|
RETENTION_DAYS
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!("[log_retention] 无过期设备日志需要清理");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 保留策略的常量约束:必须是正数、批大小与批数上限在合理区间,
|
||||||
|
/// 避免误改成 0 天(清空全部)或超大值(一次锁表)
|
||||||
|
#[test]
|
||||||
|
fn test_retention_constants_are_sane() {
|
||||||
|
assert!(RETENTION_DAYS > 0, "保留天数必须为正,否则会清空全部日志");
|
||||||
|
assert!(BATCH_LIMIT > 0 && BATCH_LIMIT <= 50_000, "单批行数需在合理区间");
|
||||||
|
assert!(MAX_BATCHES_PER_ROUND > 0, "每轮至少允许一批");
|
||||||
|
assert!(SCAN_INTERVAL_SECS >= 60, "清理间隔不应过于频繁");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
//! 后台协程 — 处理 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;
|
||||||
|
|||||||
92
software/api-server/src/workers/order_timeout.rs
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
//! 订单超时兜底协程
|
||||||
|
//!
|
||||||
|
//! 业务背景:设备可能因断电/断网未上报充电结束,导致 `charge_orders` 卡在
|
||||||
|
//! `status = 0`(充电中)。本协程周期性把**超过 10 小时**仍未结束的订单
|
||||||
|
//! 自动收尾为已完成,避免"幽灵订单"永久占用仓体和统计口径。
|
||||||
|
//!
|
||||||
|
//! 职责边界:本协程只负责「定时扫描 + 落库收尾」,不做停止充电的指令下发
|
||||||
|
//! (设备侧指令由其它流程负责);所有异常只记日志,绝不让循环退出。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use redis::aio::ConnectionManager;
|
||||||
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
|
use crate::workers::report_worker;
|
||||||
|
|
||||||
|
/// 超时阈值:订单持续充电超过该小时数即视为异常,自动结束
|
||||||
|
const TIMEOUT_HOURS: i64 = 10;
|
||||||
|
|
||||||
|
/// 扫描间隔:5 分钟扫一次,兼顾及时性与数据库压力
|
||||||
|
const SCAN_INTERVAL_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// 单轮最多处理订单数,避免一次扫描锁过多行
|
||||||
|
const BATCH_LIMIT: i64 = 200;
|
||||||
|
|
||||||
|
/// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
|
||||||
|
///
|
||||||
|
/// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单
|
||||||
|
/// **同样会写回平台侧积分电量、并生成一条充电记录**,不会在统计口径里凭空消失。
|
||||||
|
pub async fn start(pool: MySqlPool, redis: ConnectionManager) {
|
||||||
|
tracing::info!(
|
||||||
|
"[order_timeout] 启动订单超时兜底,扫描间隔={}s,超时阈值={}h",
|
||||||
|
SCAN_INTERVAL_SECS,
|
||||||
|
TIMEOUT_HOURS
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// 单轮失败不退出:记 error 后等下一个周期继续
|
||||||
|
if let Err(e) = scan_once(&pool, &redis).await {
|
||||||
|
tracing::error!("[order_timeout] 本轮扫描失败,等待下轮重试: {}", e);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单轮扫描:找出 status=0 且 start_time 早于 NOW()-10h 的订单并结单
|
||||||
|
///
|
||||||
|
/// 返回 `Result` 而不是直接 panic,让调用方决定是否继续循环。
|
||||||
|
async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sqlx::Error> {
|
||||||
|
// 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的,
|
||||||
|
// 交给数据库算差值可避免本地时区换算偏差;
|
||||||
|
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
|
||||||
|
let expired: Vec<(i64, i64, Option<chrono::NaiveDateTime>)> = sqlx::query_as(
|
||||||
|
"SELECT id, compartment_id, start_time FROM charge_orders \
|
||||||
|
WHERE status = 0 AND start_time IS NOT NULL \
|
||||||
|
AND start_time < TIMESTAMPADD(HOUR, ?, NOW()) \
|
||||||
|
ORDER BY start_time ASC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(TIMEOUT_HOURS)
|
||||||
|
.bind(BATCH_LIMIT)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if expired.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (order_id, compartment_id, start_time) in expired {
|
||||||
|
// 走统一结算:写回电量(平台侧积分)+ 生成充电记录(status=0 条件保证幂等,
|
||||||
|
// 避免与设备上报的正常结束竞争覆盖)
|
||||||
|
let affected = match report_worker::finalize_timeout_order(pool, redis, compartment_id).await {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
// 单条失败不影响其余订单,下一轮扫描会再次命中
|
||||||
|
tracing::error!("[order_timeout] 结算订单失败 order_id={} err={}", order_id, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if affected > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[order_timeout] 订单超时自动结束 order_id={} compartment_id={} start_time={:?}",
|
||||||
|
order_id,
|
||||||
|
compartment_id,
|
||||||
|
start_time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("[order_timeout] 本轮超时结单处理完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -7,22 +7,24 @@ 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;
|
||||||
|
|
||||||
/// 启动指令响应处理协程
|
/// 启动指令响应处理协程
|
||||||
///
|
///
|
||||||
/// 使用 BRPOP 阻塞等待所有 `device:reply:*` 队列的响应消息。
|
/// 轮询 `device:replies`,把回执按 `msg_id` 派发给等待者。
|
||||||
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<(String, String), redis::RedisError> = redis::cmd("BRPOP")
|
let result: Result<Option<String>, redis::RedisError> =
|
||||||
.arg("device:replies")
|
redis::cmd("RPOP").arg("device:replies").query_async(&mut conn).await;
|
||||||
.arg(0)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((_key, reply_json)) => {
|
Ok(Some(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;
|
||||||
@ -38,8 +40,12 @@ pub async fn start(redis: ConnectionManager, pending: PendingCommands) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 队列为空:短暂让出
|
||||||
|
Ok(None) => {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(REDIS_POLL_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[reply_worker] BRPOP 错误: {}", e);
|
tracing::error!("[reply_worker] RPOP 错误: {}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
software/device-server/.env.example
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# 设备 TCP 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值)
|
||||||
|
# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库
|
||||||
|
|
||||||
|
# Redis 连接(阿里云 Redis,与 api-server 同库)
|
||||||
|
REDIS_URL=redis://:CHANGE_ME@HOST:6379/5
|
||||||
|
|
||||||
|
# TCP 监听端口(测试 3001 / 正式 9001)
|
||||||
|
TCP_PORT=9001
|
||||||
|
|
||||||
|
# 本节点 ID(多节点部署时区分)
|
||||||
|
NODE_ID=device-node-1
|
||||||
|
|
||||||
|
# API 服务地址(节点注册/心跳用)
|
||||||
|
API_SERVER_URL=http://127.0.0.1:9000
|
||||||
|
|
||||||
|
# 心跳间隔(秒)
|
||||||
|
HEARTBEAT_INTERVAL=30
|
||||||
|
|
||||||
|
# 设备在线注册 TTL(秒)
|
||||||
|
DEVICE_REGISTER_TTL=300
|
||||||
@ -1,17 +0,0 @@
|
|||||||
# Redis 连接(生产 db5)
|
|
||||||
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
|
|
||||||
|
|
||||||
# TCP 设备通讯
|
|
||||||
TCP_PORT=9001 # 正式环境TCP
|
|
||||||
|
|
||||||
# 节点ID
|
|
||||||
NODE_ID=node-1
|
|
||||||
|
|
||||||
# API服务地址
|
|
||||||
API_SERVER_URL=http://127.0.0.1:3000
|
|
||||||
|
|
||||||
# 心跳间隔(秒)
|
|
||||||
HEARTBEAT_INTERVAL=60
|
|
||||||
|
|
||||||
# 设备注册TTL(秒)
|
|
||||||
DEVICE_REGISTER_TTL=180
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
# Redis 连接(测试 db6)
|
|
||||||
REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/6
|
|
||||||
|
|
||||||
# TCP 设备通讯
|
|
||||||
TCP_PORT=3001 # 测试环境TCP
|
|
||||||
|
|
||||||
# 节点ID
|
|
||||||
NODE_ID=node-1
|
|
||||||
|
|
||||||
# API服务地址
|
|
||||||
API_SERVER_URL=http://127.0.0.1:3000
|
|
||||||
|
|
||||||
# 心跳间隔(秒)
|
|
||||||
HEARTBEAT_INTERVAL=60
|
|
||||||
|
|
||||||
# 设备注册TTL(秒)
|
|
||||||
DEVICE_REGISTER_TTL=180
|
|
||||||
1
software/device-server/Cargo.lock
generated
@ -540,6 +540,7 @@ dependencies = [
|
|||||||
"redis",
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"socket2 0.5.10",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@ -16,3 +16,4 @@ 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("REGISTER_TTL")
|
register_ttl: std::env::var("DEVICE_REGISTER_TTL")
|
||||||
.unwrap_or_else(|_| "300".to_string())
|
.unwrap_or_else(|_| "300".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.expect("REGISTER_TTL must be a valid number"),
|
.expect("DEVICE_REGISTER_TTL must be a valid number"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,11 @@ 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()
|
||||||
@ -37,8 +42,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();
|
let device_pool = tcp::connection::ConnectionPool::new(redis_conn.clone());
|
||||||
|
|
||||||
// 通过 Redis 注册节点信息
|
// 通过 Redis 注册节点信息
|
||||||
register_node(&redis_conn, &cfg).await;
|
register_node(&redis_conn, &cfg).await;
|
||||||
@ -59,14 +64,15 @@ 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).await;
|
consume_commands(cmd_redis, cmd_pool, cmd_node_id).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).await;
|
tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn, cfg.node_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通过 Redis 注册节点信息(HSET hash 结构,与 heartbeat 保持一致)
|
/// 通过 Redis 注册节点信息(HSET hash 结构,与 heartbeat 保持一致)
|
||||||
@ -128,48 +134,124 @@ async fn heartbeat(redis: &redis::aio::ConnectionManager, node_id: &str, registe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 读取平台指令并发送到设备
|
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 和本节点队列读取指令,
|
||||||
|
/// 查询路由后发送到正确节点。
|
||||||
async fn consume_commands(
|
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] 启动");
|
tracing::info!("[cmd_consumer] 启动 node_id={}", node_id);
|
||||||
|
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")
|
// **非阻塞轮询**取一条待下发指令,先取本节点专属队列、再取统一队列。
|
||||||
.arg("device:commands")
|
//
|
||||||
.arg(0)
|
// 为什么不用 `BRPOP`(2026-09 实测结论):Redis 经 nginx stream 代理访问阿里云 RDS,
|
||||||
.query_async(&mut conn)
|
// 客户端又是多路复用的 `ConnectionManager`,这种组合下 `BRPOP` 的阻塞语义**不可靠**:
|
||||||
.await;
|
// - `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")
|
||||||
|
.query_async::<Option<String>>(&mut conn2)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
match result {
|
match picked {
|
||||||
Ok((_key, cmd_json)) => {
|
Ok(Some(cmd_json)) => {
|
||||||
// 从 JSON 中提取 dev_id 用于路由
|
// 内部路由字段 `_dev_id`(由 api-server 附加):它才是"这条指令发给哪台设备"。
|
||||||
let dev_id = serde_json::from_str::<serde_json::Value>(&cmd_json)
|
// ⚠️ 不能再用协议 `id` 推导 —— 协议定稿后 `id` 只表示**动作目标**
|
||||||
.ok()
|
// (柜级省略 / 一段=单板 / 两段=仓体级),**不含 IMEI**。
|
||||||
.and_then(|v| v.get("dev_id").and_then(|d| d.as_str()).map(String::from));
|
let parsed: Option<serde_json::Value> = serde_json::from_str(&cmd_json).ok();
|
||||||
|
let dev_id = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get(ROUTE_DEV_ID_FIELD).and_then(|d| d.as_str()))
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
let dev_id = match dev_id {
|
let dev_id = match dev_id {
|
||||||
Some(id) if !id.is_empty() => id,
|
Some(s) if !s.is_empty() => s,
|
||||||
_ => {
|
_ => {
|
||||||
tracing::warn!("[cmd_consumer] 指令缺少 dev_id,丢弃");
|
tracing::warn!(
|
||||||
|
"[cmd_consumer] 指令缺少内部路由字段 {},丢弃:{}",
|
||||||
|
ROUTE_DEV_ID_FIELD,
|
||||||
|
cmd_json
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发送到设备
|
// 协议 `id`(动作目标)—— 仅用于日志;柜级指令不带此字段
|
||||||
match pool.send_command(&dev_id, &cmd_json).await {
|
let proto_id = parsed
|
||||||
Ok(()) => {
|
.as_ref()
|
||||||
tracing::debug!("[cmd] 已发送到设备 dev_id={}", dev_id);
|
.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;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
_ => {
|
||||||
tracing::warn!("[cmd] 发送失败 dev_id={} err={}", dev_id, e);
|
// 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景)
|
||||||
|
match pool.send_with_msgid(&dev_id, &cmd_json).await {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[cmd] 发送失败 dev_id={} err={} 已入死信", dev_id, e);
|
||||||
|
// ponytail: 丢入死信队列,不重试(重试语义和次数待业务明确)
|
||||||
|
let mut dl_conn = redis.clone();
|
||||||
|
let _: Result<(), _> = redis::cmd("LPUSH")
|
||||||
|
.arg("device:commands:dead")
|
||||||
|
.arg(&cmd_json)
|
||||||
|
.query_async(&mut dl_conn)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// 两个队列都为空:短暂让出,避免空转打满 CPU(延迟上限 = 本间隔)
|
||||||
|
tracing::trace!("[cmd_consumer] 队列为空,继续等待");
|
||||||
|
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[cmd_consumer] BRPOP 错误: {}", e);
|
tracing::error!("[cmd_consumer] RPOP 错误: {}", e);
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
//! 设备连接池管理。
|
//! 设备连接池管理。
|
||||||
//!
|
//!
|
||||||
//! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。
|
//! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。
|
||||||
@ -7,43 +6,65 @@
|
|||||||
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::{mpsc, RwLock};
|
use tokio::sync::RwLock;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// 敏感指令列表(未认证设备不得执行)
|
||||||
|
const SENSITIVE_ACTS: &[&str] = &["open", "pow_on", "pow_off", "reset", "auth_str"];
|
||||||
|
|
||||||
|
/// 挂起登记(`pending:{dev_id}`)的 TTL(秒)。
|
||||||
|
///
|
||||||
|
/// 取值 120 = 协议 60 秒等待窗口的 2 倍:既保证超时后自动作废,
|
||||||
|
/// 又留出余量避免登记项在回执到达前先过期(api-server 命中时会 `HDEL`)。
|
||||||
|
const PENDING_TTL_SECS: u32 = 120;
|
||||||
|
|
||||||
/// 单台设备的连接状态。
|
/// 单台设备的连接状态。
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct DeviceConnection {
|
pub struct DeviceConnection {
|
||||||
pub dev_id: String,
|
|
||||||
/// 向设备发送指令的通道。
|
/// 向设备发送指令的通道。
|
||||||
pub tx: mpsc::UnboundedSender<String>,
|
pub tx: mpsc::Sender<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 {
|
||||||
pub fn new(dev_id: String, tx: mpsc::UnboundedSender<String>) -> Self {
|
/// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。
|
||||||
|
pub fn new(tx: mpsc::Sender<String>) -> Self {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
Self { dev_id, tx, connected_at: now, last_seen: now, authenticated: false }
|
// 初值 1:协议定稿「两个方向各自独立计数,都每连接从 1 起自增,0 不使用」;
|
||||||
|
// 且 api-server 多处用 `unwrap_or(0)` 兜底,用 0 起会与"缺号"混淆
|
||||||
|
Self { tx, last_seen: now, authenticated: false, next_msg_id: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 标记活跃时间。
|
/// 标记活跃时间。
|
||||||
pub fn touch(&mut self) {
|
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() -> Self {
|
pub fn new(redis: redis::aio::ConnectionManager) -> Self {
|
||||||
Self { inner: Arc::new(RwLock::new(HashMap::new())) }
|
Self { inner: Arc::new(RwLock::new(HashMap::new())), redis }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 注册设备连接。如果已存在则替换旧连接。
|
/// 注册设备连接。如果已存在则替换旧连接。
|
||||||
@ -58,22 +79,89 @@ impl ConnectionPool {
|
|||||||
map.remove(dev_id);
|
map.remove(dev_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 向指定设备发送 JSON 指令。
|
/// 查询该设备连接是否已通过 `login` 认证。
|
||||||
/// 返回 `Ok(())` 表示已投递到通道;设备可能因缓冲区满而断开。
|
/// 连接不存在或尚未认证时返回 `false`。
|
||||||
pub async fn send_command(&self, dev_id: &str, json: &str) -> Result<(), &'static str> {
|
pub async fn is_authenticated(&self, dev_id: &str) -> bool {
|
||||||
let map = self.inner.read().await;
|
|
||||||
match map.get(dev_id) {
|
|
||||||
Some(conn) => conn.tx.send(json.to_string()).map_err(|_| "通道已关闭"),
|
|
||||||
None => Err("设备不在线"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询设备是否在线且已验证。
|
|
||||||
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;
|
||||||
@ -81,33 +169,86 @@ 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) {
|
///
|
||||||
let mut map = self.inner.write().await;
|
/// **为什么由 device-server 登记**:`msg_id` 是本服务分配的,api-server 只有拿到
|
||||||
if let Some(conn) = map.get_mut(dev_id) {
|
/// 「`msg_id` → `act`」映射,才能把设备回执配对回具体的下发指令(§3.5 要求
|
||||||
conn.authenticated = true;
|
/// `msg_id` + `act` 双条件配对);平台对每条指令只等 60 秒,超时即作废。
|
||||||
|
///
|
||||||
|
/// 失败处理:Redis 异常只记 WARN —— 挂起登记是**辅助功能**,
|
||||||
|
/// 绝不能让一条已成功投递到设备的指令因为登记失败而报错(那会误导上层重发)。
|
||||||
|
async fn register_pending(
|
||||||
|
redis: &redis::aio::ConnectionManager,
|
||||||
|
dev_id: &str,
|
||||||
|
msg_id: u32,
|
||||||
|
act: &str,
|
||||||
|
) {
|
||||||
|
if act.is_empty() {
|
||||||
|
tracing::warn!("[pending] 指令缺少 act,跳过挂起登记 dev_id={} msg_id={}", dev_id, msg_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("pending:{}", dev_id);
|
||||||
|
let mut conn = redis.clone();
|
||||||
|
let result: Result<(), _> = redis::cmd("HSET")
|
||||||
|
.arg(&key)
|
||||||
|
.arg(msg_id)
|
||||||
|
.arg(act)
|
||||||
|
.query_async(&mut conn)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => tracing::debug!("[pending] 已登记 dev_id={} msg_id={} act={}", dev_id, msg_id, act),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"[pending] 挂起登记失败 dev_id={} msg_id={} err={}(指令已下发,仅影响配对)",
|
||||||
|
dev_id,
|
||||||
|
msg_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清理超时未活跃的连接(心跳检测)。
|
// ponytail: HSET 不支持同时携带 TTL,需单独 EXPIRE(与 main.rs 的节点注册写法一致)
|
||||||
pub async fn cleanup_timeout(&self, timeout: std::time::Duration) -> Vec<String> {
|
let mut ttl_conn = redis.clone();
|
||||||
let mut map = self.inner.write().await;
|
let ttl_result: Result<(), _> = redis::cmd("EXPIRE")
|
||||||
let now = Instant::now();
|
.arg(&key)
|
||||||
let mut removed = Vec::new();
|
.arg(PENDING_TTL_SECS)
|
||||||
map.retain(|dev_id, conn| {
|
.query_async(&mut ttl_conn)
|
||||||
if now.duration_since(conn.last_seen) > timeout {
|
.await;
|
||||||
removed.push(dev_id.clone());
|
if let Err(e) = ttl_result {
|
||||||
false
|
tracing::warn!("[pending] 挂起登记 TTL 设置失败 dev_id={} err={}", dev_id, e);
|
||||||
} else {
|
}
|
||||||
true
|
}
|
||||||
}
|
|
||||||
});
|
// ---------------------------------------------------------------------------
|
||||||
removed
|
// 单元测试:下行编号与内部字段(协议 §3.4 / §3.5)
|
||||||
}
|
// ---------------------------------------------------------------------------
|
||||||
|
#[cfg(test)]
|
||||||
/// 返回当前在线设备数量。
|
mod tests {
|
||||||
pub async fn online_count(&self) -> usize {
|
use super::*;
|
||||||
self.inner.read().await.len()
|
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,6 +1,8 @@
|
|||||||
//! 设备消息处理逻辑(轻量化)。
|
//! 设备消息处理逻辑(轻量化)。
|
||||||
//!
|
//!
|
||||||
//! 设备服务只处理通信和指令解析,不处理业务逻辑:
|
//! 设备服务只做**协议透传**,不处理业务逻辑:
|
||||||
|
//! - `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` 回复
|
||||||
@ -8,34 +10,57 @@
|
|||||||
use super::connection::ConnectionPool;
|
use super::connection::ConnectionPool;
|
||||||
use super::protocol::DeviceMessage;
|
use super::protocol::DeviceMessage;
|
||||||
|
|
||||||
/// 分发消息:统一转发到 Redis 上报队列。
|
/// 分发消息:补写设备身份后转发到 Redis 上报队列。
|
||||||
///
|
///
|
||||||
/// 设备服务不做任何业务处理,仅负责解析 JSON 并转发。
|
/// `current_dev_id` = 本 TCP 连接绑定的设备身份(由 `auth_str`/`login` 建立)。
|
||||||
|
/// 为什么需要它:新协议里业务上行**不带 `dev_id`**(设备身份由连接确定),
|
||||||
|
/// 而 api-server 依赖 `dev_id` 识别设备,故转发前必须补上。
|
||||||
|
///
|
||||||
|
/// 本服务**不直接回复设备**:应答由 api-server 经 Redis `device:commands` 下发,
|
||||||
|
/// 因此这里没有返回值(历史上曾返回 `Option<ServerResponse>` 但恒为 `None`,
|
||||||
|
/// 那个结构体副本已删除,避免与 api-server 的真实定义漂移)。
|
||||||
pub async fn handle_message(
|
pub async fn handle_message(
|
||||||
msg: DeviceMessage,
|
mut 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(
|
async fn forward_to_redis(redis: &redis::aio::ConnectionManager, msg: &DeviceMessage) {
|
||||||
redis: &redis::aio::ConnectionManager,
|
let json_str = match serde_json::to_string(msg) {
|
||||||
msg: &DeviceMessage,
|
Ok(json) => json,
|
||||||
) {
|
Err(e) => {
|
||||||
if msg.dev_id.is_none() {
|
// 序列化失败说明报文结构异常,记 ERROR 并丢弃(不推送半截数据)
|
||||||
tracing::warn!("[forward] 消息缺少 dev_id, act={}", msg.act);
|
tracing::error!("[forward] 消息序列化失败 act={} err={}", msg.act, e);
|
||||||
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,4 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
//! 设备通讯协议消息类型定义与序列化。
|
//! 设备通讯协议消息类型定义与序列化。
|
||||||
//!
|
//!
|
||||||
//! 设备与平台间采用 JSON 格式通讯,LF (`\n`) 作为消息边界。
|
//! 设备与平台间采用 JSON 格式通讯,LF (`\n`) 作为消息边界。
|
||||||
@ -12,8 +11,15 @@ use serde_json::Value;
|
|||||||
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
|
/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct DeviceMessage {
|
pub struct DeviceMessage {
|
||||||
/// 动作类型:login / auth_str / status_post / pow_fail / off
|
/// 动作类型(上行全集,见交付文档 §4.1):
|
||||||
|
/// auth_str / login / status_post / pow_fail_dc / pow_fail_ac / bat_in / bat_out / on / off,
|
||||||
|
/// 以及下行指令的执行回执 open / pow_on / pow_off / reset
|
||||||
pub act: String,
|
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>,
|
||||||
/// 登录签名时的时间戳(秒级)
|
/// 登录签名时的时间戳(秒级)
|
||||||
@ -22,63 +28,120 @@ 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 {
|
//
|
||||||
/// 1=成功,0=失败
|
// 本服务**只做透传** —— 回复走 Redis `device:commands`(由 api-server 用
|
||||||
pub suc: u8,
|
// `api-server/src/protocol.rs::ServerResponse` 构造),指令同样由 api-server 用
|
||||||
pub msg_id: Option<u32>,
|
// `api-server/src/commands.rs::DeviceCommand` 构造,本服务按 `serde_json::Value`
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
// 读写、只负责"摘内部字段 + 注入 msg_id/登记挂起 + 投递"。
|
||||||
pub err: Option<String>,
|
//
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
// 因此这里**不再保留** `ServerResponse` / `DeviceCommand` 的结构体副本:
|
||||||
pub auth_str: Option<String>,
|
// 有了副本反而会与 api-server 的真实定义漂移(历史上 api-server 里那份
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
// `DeviceMessage` 副本就是这样带着废弃字段误导了很久)。
|
||||||
pub data: Option<Value>,
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
}
|
|
||||||
|
|
||||||
impl ServerResponse {
|
// ---------------------------------------------------------------------------
|
||||||
/// 创建成功响应。
|
// 单元测试:上行报文反序列化(协议 §3.1 / §5.3 / §5.5 / §3.5)
|
||||||
pub fn success(msg_id: Option<u32>) -> Self {
|
// ---------------------------------------------------------------------------
|
||||||
Self { suc: 1, msg_id, err: None, auth_str: None, data: None }
|
#[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)
|
||||||
pub fn error(msg_id: Option<u32>, err: impl Into<String>) -> Self {
|
#[test]
|
||||||
Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None, data: None }
|
fn test_device_message_bat_in_battery_block() {
|
||||||
|
let raw = r#"{"act":"bat_in","msg_id":5,"id":"01-3","battery":{
|
||||||
|
"battery_id":"SN123456","brand_code":"1","cell_count":15,
|
||||||
|
"info":"80825515180320012a","cells":"0e100e15"}}"#;
|
||||||
|
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
||||||
|
let battery = msg.battery.as_ref().unwrap();
|
||||||
|
assert_eq!(battery["battery_id"], "SN123456");
|
||||||
|
assert_eq!(battery["cell_count"], 15);
|
||||||
|
assert_eq!(battery["info"], "80825515180320012a");
|
||||||
|
// `id` 未在结构体声明 → 必须落进 `extra`,否则 api-server 拿不到动作目标
|
||||||
|
let extra = msg.extra.as_ref().unwrap();
|
||||||
|
assert_eq!(extra["id"], "01-3");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
|
/// **关键契约**:设备回执的 `suc` 必须原样落到 `extra`。
|
||||||
pub fn to_json(&self) -> String {
|
/// api-server 用 `msg.get("suc").is_some()` 判定"回执 vs 设备上报"(协议 §3.5),
|
||||||
serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
|
/// 若这里把 `suc` 丢掉,回执会被误当成新请求处理。
|
||||||
}
|
#[test]
|
||||||
}
|
fn test_device_message_receipt_suc_preserved_in_extra() {
|
||||||
|
let raw = r#"{"act":"on","msg_id":1,"id":"01-3","suc":1}"#;
|
||||||
/// 平台下发至设备的指令。
|
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
let extra = msg.extra.as_ref().unwrap();
|
||||||
pub struct DeviceCommand {
|
assert_eq!(extra["suc"], 1);
|
||||||
pub act: String,
|
assert_eq!(extra["id"], "01-3");
|
||||||
pub msg_id: u32,
|
}
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dev_id: Option<String>,
|
/// 有 `suc=0` + `err` 的失败回执:`err` 也必须透传(api-server 要落库,见 A6)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[test]
|
||||||
pub sub_device_id: Option<u8>,
|
fn test_device_message_failed_receipt_error_preserved() {
|
||||||
/// 扩展参数(开门指令、充电参数等)
|
let raw = r#"{"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}"#;
|
||||||
#[serde(flatten)]
|
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
||||||
pub params: Option<Value>,
|
let extra = msg.extra.as_ref().unwrap();
|
||||||
}
|
assert_eq!(extra["suc"], 0);
|
||||||
|
assert_eq!(extra["err"], "door lock not engaged");
|
||||||
impl DeviceCommand {
|
}
|
||||||
/// 序列化为 JSON 字符串(紧凑格式)。
|
|
||||||
pub fn to_json(&self) -> String {
|
/// `auth_str` / `login` 是仅有的两条带 `dev_id` 的上行
|
||||||
serde_json::to_string(self).expect("DeviceCommand 序列化不应失败")
|
#[test]
|
||||||
|
fn test_device_message_auth_str_carries_dev_id() {
|
||||||
|
let raw = r#"{"act":"auth_str","msg_id":1,"dev_id":"864720562557021"}"#;
|
||||||
|
let msg: DeviceMessage = serde_json::from_str(raw).unwrap();
|
||||||
|
assert_eq!(msg.dev_id.as_deref(), Some("864720562557021"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停电上报无附加字段(协议定稿:`pow_fail_dc` / `pow_fail_ac` 均不带 payload)
|
||||||
|
#[test]
|
||||||
|
fn test_device_message_pow_fail_has_no_payload() {
|
||||||
|
for act in ["pow_fail_dc", "pow_fail_ac"] {
|
||||||
|
let raw = format!(r#"{{"act":"{act}","msg_id":4}}"#);
|
||||||
|
let msg: DeviceMessage = serde_json::from_str(&raw).unwrap();
|
||||||
|
assert_eq!(msg.act, act);
|
||||||
|
assert!(msg.cabinet.is_none());
|
||||||
|
assert!(msg.boards.is_none());
|
||||||
|
assert!(msg.battery.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,52 +1,46 @@
|
|||||||
//! 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;
|
||||||
|
|
||||||
/// 心跳超时:5000ms 未收到消息则断开连接。
|
/// 未登录连接**允许**发送的唯一两个 `act`(协议定稿:其余指令必须先 `login` 成功)。
|
||||||
const HEARTBEAT_TIMEOUT: Duration = Duration::from_millis(5000);
|
const UNAUTH_ALLOWED_ACTS: &[&str] = &["auth_str", "login"];
|
||||||
/// 清理超时连接的间隔。
|
|
||||||
const CLEANUP_INTERVAL: Duration = Duration::from_secs(10);
|
/// 连接建立后必须在此秒数内完成 `login` 认证,否则平台主动断开。
|
||||||
|
/// 依据:协议规定"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();
|
||||||
tokio::spawn(handle_connection(stream, peer, pool, redis));
|
let nid = node_id.clone();
|
||||||
|
tokio::spawn(handle_connection(stream, peer, pool, redis, nid));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("accept 失败: {}", e);
|
tracing::error!("accept 失败: {}", e);
|
||||||
@ -61,15 +55,19 @@ 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);
|
||||||
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(4096));
|
// 单行上限 64 KB:status_post 为「每 60 秒全量上报」
|
||||||
|
// (当前 6板×6组 ≈ 8 KB;设计上限 15板×8组 ≈ 25 KB,含电芯数据约 40 KB),留足余量
|
||||||
|
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(65536));
|
||||||
|
|
||||||
// 写通道:HTTP 命令下发通过此通道写入 TCP
|
// 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
let (tx, mut rx) = mpsc::channel::<String>(100);
|
||||||
|
|
||||||
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! {
|
||||||
@ -90,29 +88,47 @@ 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(dev_id.clone(), tx.clone());
|
let conn = DeviceConnection::new(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 成功
|
||||||
let resp = handle_message(msg, &pool, &redis).await;
|
// 未登录连接发来的业务报文一律丢弃(连接尚未与 dev_id 建立可信绑定,
|
||||||
|
// 平台侧也无法路由应答)
|
||||||
if let Some(resp) = resp {
|
if !UNAUTH_ALLOWED_ACTS.contains(&msg.act.as_str()) {
|
||||||
let json = resp.to_json();
|
let authed = match current_dev_id.as_deref() {
|
||||||
if let Err(e) = tx.send(json) {
|
Some(dev_id) => pool.is_authenticated(dev_id).await,
|
||||||
tracing::error!("[{}] 响应发送失败: {}", peer, e);
|
None => false,
|
||||||
break;
|
};
|
||||||
|
if !authed {
|
||||||
|
tracing::warn!(
|
||||||
|
"[安全] 连接未登录,丢弃报文 act={} peer={}",
|
||||||
|
msg.act,
|
||||||
|
peer
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理消息(传入连接绑定的设备身份:新协议业务上行不带 dev_id,
|
||||||
|
// 转发到 Redis 前由 handler 补写,api-server 依赖它识别设备)。
|
||||||
|
// 无返回值:本服务不直接回复设备,应答由 api-server 经 device:commands 下发。
|
||||||
|
handle_message(msg, current_dev_id.as_deref(), &pool, &redis).await;
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
tracing::warn!("[{}] 读取错误: {}", peer, e);
|
tracing::warn!("[{}] 读取错误: {}", peer, e);
|
||||||
@ -124,14 +140,49 @@ async fn handle_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 未登录超时巡检:连接建立后 UNAUTH_TIMEOUT_SECS 秒内未通过 login 即断开
|
||||||
|
// (协议规定:login 成功这条 TCP 才可以保持)
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_secs(UNAUTH_CHECK_INTERVAL_SECS)) => {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
let authed = match current_dev_id.as_deref() {
|
||||||
|
Some(dev_id) => pool.is_authenticated(dev_id).await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
if !authed && connected_at.elapsed().as_secs() >= UNAUTH_TIMEOUT_SECS {
|
||||||
|
tracing::warn!(
|
||||||
|
"[{}] 未在 {} 秒内完成 login,平台主动断开连接",
|
||||||
|
peer,
|
||||||
|
UNAUTH_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
let _ = writer.shutdown().await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 向设备写指令
|
// 向设备写指令
|
||||||
Some(json) = rx.recv() => {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,3 +193,119 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
// babel-preset-taro 更多配置和用法:https://docs.taro.zone/docs/next/babel-config
|
|
||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
['taro', {
|
|
||||||
framework: 'react',
|
|
||||||
ts: true,
|
|
||||||
}],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
env: {
|
|
||||||
NODE_ENV: '"development"',
|
|
||||||
},
|
|
||||||
h5: {
|
|
||||||
devServer: {
|
|
||||||
port: 5174,
|
|
||||||
proxy: {
|
|
||||||
'/h5/api': {
|
|
||||||
target: 'http://10.8.0.252:3000',
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: { '^/h5': '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
const path = require('path')
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
projectName: 'h5-taro',
|
|
||||||
date: '2026-07-03',
|
|
||||||
designWidth: 750,
|
|
||||||
deviceRatio: {
|
|
||||||
640: 2.34 / 2,
|
|
||||||
750: 1,
|
|
||||||
828: 1.81 / 2,
|
|
||||||
375: 2 / 1,
|
|
||||||
},
|
|
||||||
sourceRoot: 'src',
|
|
||||||
outputRoot: 'dist',
|
|
||||||
plugins: [],
|
|
||||||
defineConstants: {},
|
|
||||||
copy: {
|
|
||||||
patterns: [],
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
framework: 'react',
|
|
||||||
// compiler: 'webpack4', (default)
|
|
||||||
sass: {
|
|
||||||
data: '',
|
|
||||||
},
|
|
||||||
mini: {
|
|
||||||
postcss: {
|
|
||||||
pxtransform: { enable: true, config: {} },
|
|
||||||
url: { enable: true, config: { limit: 1024 } },
|
|
||||||
cssModules: {
|
|
||||||
enable: false,
|
|
||||||
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
h5: {
|
|
||||||
publicPath: '/h5/',
|
|
||||||
staticDirectory: 'static',
|
|
||||||
router: {
|
|
||||||
mode: 'browser',
|
|
||||||
basename: '/h5/',
|
|
||||||
},
|
|
||||||
postcss: {
|
|
||||||
autoprefixer: { enable: true, config: {} },
|
|
||||||
cssModules: {
|
|
||||||
enable: false,
|
|
||||||
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = function (merge) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
return merge({}, config, require('./dev'))
|
|
||||||
}
|
|
||||||
return merge({}, config, require('./prod'))
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
env: {
|
|
||||||
NODE_ENV: '"production"',
|
|
||||||
},
|
|
||||||
outputRoot: 'dist',
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "h5-taro",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "安知充 H5 用户端 (Taro)",
|
|
||||||
"scripts": {
|
|
||||||
"dev:h5": "taro build --type h5 --watch",
|
|
||||||
"build:h5": "taro build --type h5",
|
|
||||||
"dev:weapp": "taro build --type weapp --watch",
|
|
||||||
"build:weapp": "taro build --type weapp"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.23.0",
|
|
||||||
"@tarojs/components": "3.6.32",
|
|
||||||
"@tarojs/plugin-framework-react": "^3.6.32",
|
|
||||||
"@tarojs/plugin-platform-h5": "3.6.32",
|
|
||||||
"@tarojs/react": "3.6.32",
|
|
||||||
"@tarojs/runtime": "3.6.32",
|
|
||||||
"@tarojs/taro": "3.6.32",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"zustand": "^4.5.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.23.0",
|
|
||||||
"@tarojs/cli": "3.6.32",
|
|
||||||
"@tarojs/webpack-runner": "3.6.32",
|
|
||||||
"@types/react": "^18.2.0",
|
|
||||||
"babel-preset-taro": "3.6.32",
|
|
||||||
"typescript": "^5.3.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"miniprogramRoot": "./dist",
|
|
||||||
"projectname": "h5-taro",
|
|
||||||
"description": "安知充 H5 用户端",
|
|
||||||
"appid": "touristappid",
|
|
||||||
"setting": {
|
|
||||||
"urlCheck": true,
|
|
||||||
"es6": false,
|
|
||||||
"enhance": false,
|
|
||||||
"postcss": false,
|
|
||||||
"preloadBackgroundData": false,
|
|
||||||
"minified": false,
|
|
||||||
"newFeature": true,
|
|
||||||
"coverView": true,
|
|
||||||
"nodeModules": false,
|
|
||||||
"autoAudits": false,
|
|
||||||
"showShadowRootInWxmlPanel": true,
|
|
||||||
"scopeDataCheck": false,
|
|
||||||
"uglifyFileName": false,
|
|
||||||
"checkInvalidKey": true,
|
|
||||||
"checkSiteMap": true,
|
|
||||||
"uploadWithSourceMap": true,
|
|
||||||
"compileHotReLoad": false,
|
|
||||||
"lazyloadPlaceholderEnable": false,
|
|
||||||
"useMultiFrameRuntime": true,
|
|
||||||
"useApiHook": true,
|
|
||||||
"useApiHostProcess": true
|
|
||||||
},
|
|
||||||
"compileType": "miniprogram"
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import Taro from '@tarojs/taro'
|
|
||||||
|
|
||||||
const BASE_URL = '/h5/api'
|
|
||||||
|
|
||||||
async function request<T>(
|
|
||||||
path: string,
|
|
||||||
options: { method?: string; body?: unknown; params?: Record<string, string> } = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const token = Taro.getStorageSync('h5_token')
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = `${BASE_URL}${path}`
|
|
||||||
if (options.params) {
|
|
||||||
const qs = new URLSearchParams(options.params).toString()
|
|
||||||
url += `?${qs}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await Taro.request({
|
|
||||||
url,
|
|
||||||
method: (options.method as 'GET' | 'POST' | 'PUT' | 'DELETE') || 'GET',
|
|
||||||
header: headers,
|
|
||||||
data: options.body,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ponytail: single error shape, add wrapper if backend uses inconsistent envelopes
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败'
|
|
||||||
throw new Error(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.data as T
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Types ----
|
|
||||||
|
|
||||||
export interface LoginParams {
|
|
||||||
phone: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResult {
|
|
||||||
token: string
|
|
||||||
user: H5User
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface H5User {
|
|
||||||
id: number
|
|
||||||
phone: string
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardData {
|
|
||||||
online_cabinets: number
|
|
||||||
charging_count: number
|
|
||||||
idle_channels: number
|
|
||||||
fault_channels: number
|
|
||||||
today_charge_count: number
|
|
||||||
today_energy_kwh: number
|
|
||||||
today_charge_hours: number
|
|
||||||
alerts: { project: string; cabinet: string; channel: number }[]
|
|
||||||
projects: { id: number; name: string; cabinet_count: number; charging: number; idle: number; fault: number }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CabinetItem {
|
|
||||||
id: number
|
|
||||||
abstract_id: string
|
|
||||||
name?: string
|
|
||||||
status: number // 1=在线 0=离线
|
|
||||||
project_id?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompartmentInfo {
|
|
||||||
id: number
|
|
||||||
channel_id: number
|
|
||||||
status: number // 0=空闲 1=充电中 2=故障 3=离线
|
|
||||||
voltage?: number
|
|
||||||
current?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CabinBoard {
|
|
||||||
id: number
|
|
||||||
board_id: number
|
|
||||||
status: number
|
|
||||||
compartments: CompartmentInfo[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CabinetDetail {
|
|
||||||
id: number
|
|
||||||
abstract_id: string
|
|
||||||
name?: string
|
|
||||||
imei?: string
|
|
||||||
status: number
|
|
||||||
cabin_boards: CabinBoard[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompartmentDetail {
|
|
||||||
id: number
|
|
||||||
channel_id: number
|
|
||||||
status: number
|
|
||||||
voltage?: number
|
|
||||||
current?: number
|
|
||||||
power?: number
|
|
||||||
energy?: number
|
|
||||||
soc?: number
|
|
||||||
soh?: number
|
|
||||||
cell_voltages?: number[]
|
|
||||||
cell_temps?: number[]
|
|
||||||
bms_alerts?: string[]
|
|
||||||
charge_records?: ChargeRecord[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChargeRecord {
|
|
||||||
id: number
|
|
||||||
start_time: string
|
|
||||||
end_time?: string
|
|
||||||
energy_kwh: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- API ----
|
|
||||||
|
|
||||||
export const h5Api = {
|
|
||||||
// Auth
|
|
||||||
login: (params: LoginParams) =>
|
|
||||||
request<{ data: LoginResult }>('/h5/auth/login', { method: 'POST', body: params }),
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
getDashboard: () =>
|
|
||||||
request<{ data: DashboardData }>('/h5/dashboard'),
|
|
||||||
|
|
||||||
// Cabinets
|
|
||||||
getCabinets: (params?: { project_id?: number }) =>
|
|
||||||
request<{ data: CabinetItem[] }>('/h5/cabinets', { params: params as Record<string, string> }),
|
|
||||||
|
|
||||||
// Cabinet detail (by abstract_id for QR scan — public endpoint)
|
|
||||||
getCabinetByAbstract: (abstractId: string) =>
|
|
||||||
request<{ data: CabinetDetail }>(`/h5/cabinets/abstract/${abstractId}`),
|
|
||||||
|
|
||||||
// Cabinet detail by DB id
|
|
||||||
getCabinetDetail: (id: number) =>
|
|
||||||
request<{ data: CabinetDetail }>(`/h5/cabinets/${id}`),
|
|
||||||
|
|
||||||
// Compartment detail
|
|
||||||
getCompartmentDetail: (id: number) =>
|
|
||||||
request<{ data: CompartmentDetail }>(`/h5/compartments/${id}`),
|
|
||||||
|
|
||||||
// Actions — match backend POST /api/h5/charge/start, /stop, /door/open with {compartment_id}
|
|
||||||
startCharge: (compartmentId: number) =>
|
|
||||||
request<{ code: number }>('/h5/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }),
|
|
||||||
|
|
||||||
stopCharge: (compartmentId: number) =>
|
|
||||||
request<{ code: number }>('/h5/charge/stop', { method: 'POST', body: { compartment_id: compartmentId } }),
|
|
||||||
|
|
||||||
openDoor: (compartmentId: number) =>
|
|
||||||
request<{ code: number }>('/h5/door/open', { method: 'POST', body: { compartment_id: compartmentId } }),
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
export default {
|
|
||||||
pages: [
|
|
||||||
'pages/home/index',
|
|
||||||
'pages/devices/index',
|
|
||||||
'pages/me/index',
|
|
||||||
'pages/login/index',
|
|
||||||
'pages/device-detail/index',
|
|
||||||
'pages/compartment-detail/index',
|
|
||||||
],
|
|
||||||
window: {
|
|
||||||
backgroundTextStyle: 'light',
|
|
||||||
navigationBarBackgroundColor: '#ffffff',
|
|
||||||
navigationBarTitleText: '安知充',
|
|
||||||
navigationBarTextStyle: 'black',
|
|
||||||
navigationStyle: 'custom',
|
|
||||||
},
|
|
||||||
tabBar: {
|
|
||||||
color: '#999999',
|
|
||||||
selectedColor: '#2563eb',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderStyle: 'black',
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
pagePath: 'pages/home/index',
|
|
||||||
text: '首页',
|
|
||||||
iconPath: 'assets/home.svg',
|
|
||||||
selectedIconPath: 'assets/home-active.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pagePath: 'pages/devices/index',
|
|
||||||
text: '设备',
|
|
||||||
iconPath: 'assets/device.svg',
|
|
||||||
selectedIconPath: 'assets/device-active.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pagePath: 'pages/me/index',
|
|
||||||
text: '我的',
|
|
||||||
iconPath: 'assets/me.svg',
|
|
||||||
selectedIconPath: 'assets/me-active.svg',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
/* 全局样式 */
|
|
||||||
page {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 28px;
|
|
||||||
color: #333;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 通用重置 */
|
|
||||||
view,
|
|
||||||
text,
|
|
||||||
image,
|
|
||||||
button,
|
|
||||||
input {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 灰色卡片容器 */
|
|
||||||
.card {
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 状态标签 */
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 16px;
|
|
||||||
border-radius: 100px;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.online {
|
|
||||||
background-color: #dcfce7;
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.offline {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.charging {
|
|
||||||
background-color: #dcfce7;
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.idle {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.fault {
|
|
||||||
background-color: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 占位图标(用纯CSS画简单的tab图标) */
|
|
||||||
.tab-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import React, { useEffect } from 'react'
|
|
||||||
import { useDidShow, useDidHide } from '@tarojs/taro'
|
|
||||||
import Taro from '@tarojs/taro'
|
|
||||||
import { useH5AuthStore } from './store/auth'
|
|
||||||
import './app.css'
|
|
||||||
|
|
||||||
function App(props: { children: React.ReactNode }) {
|
|
||||||
const restore = useH5AuthStore((s) => s.restore)
|
|
||||||
const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
restore()
|
|
||||||
}, [restore])
|
|
||||||
|
|
||||||
// Handle QR scan entry: URL rewritten by index.html script
|
|
||||||
useEffect(() => {
|
|
||||||
const abstractId: string | undefined = (window as any).__SCAN_ABSTRACT_ID__
|
|
||||||
if (!abstractId) return
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
import('./api').then(({ h5Api }) => {
|
|
||||||
h5Api.getCabinetByAbstract(abstractId)
|
|
||||||
.then((res) => Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` }))
|
|
||||||
.catch(() => Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` }))
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` })
|
|
||||||
}
|
|
||||||
// ponytail: runs once on mount, auth changes handled by redirect
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isAuthenticated])
|
|
||||||
|
|
||||||
useDidShow(() => {
|
|
||||||
// ponytail: analytics hook, add when needed
|
|
||||||
})
|
|
||||||
|
|
||||||
useDidHide(() => {
|
|
||||||
// ponytail: cleanup hook, add when needed
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{props.children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M12 4C10.9 4 10 4.9 10 6v36c0 1.1.9 2 2 2h24c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H12zm0 6h24v28H12V10z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 191 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M12 4C10.9 4 10 4.9 10 6v36c0 1.1.9 2 2 2h24c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H12zm0 6h24v28H12V10z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 188 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M24 4L4 22h6v18h12V28h4v12h12V22h6L24 4z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 134 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M24 4L4 22h6v18h12V28h4v12h12V22h6L24 4z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 131 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M24 24c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.33 0-16 2.67-16 8v4h32v-4c0-5.33-10.67-8-16-8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 207 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M24 24c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.33 0-16 2.67-16 8v4h32v-4c0-5.33-10.67-8-16-8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 204 B |
@ -1,44 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { View, Text } from '@tarojs/components'
|
|
||||||
import { ChannelStatusBadge } from './StatusBadge'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
channelId: number
|
|
||||||
status: number
|
|
||||||
voltage?: number
|
|
||||||
current?: number
|
|
||||||
onClick?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLORS: Record<number, string> = {
|
|
||||||
0: '#9ca3af', // idle
|
|
||||||
1: '#16a34a', // charging
|
|
||||||
2: '#dc2626', // fault
|
|
||||||
3: '#d1d5db', // offline
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChannelCard({ channelId, status, voltage, current, onClick }: Props) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
border: `2px solid ${COLORS[status] || '#e5e7eb'}`,
|
|
||||||
cursor: onClick ? 'pointer' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
||||||
<Text style={{ fontSize: 24, fontWeight: 600, color: '#111' }}>#{channelId}</Text>
|
|
||||||
<ChannelStatusBadge status={status} />
|
|
||||||
</View>
|
|
||||||
{voltage !== undefined && (
|
|
||||||
<Text style={{ fontSize: 22, color: '#6b7280' }}>{voltage.toFixed(1)}V</Text>
|
|
||||||
)}
|
|
||||||
{current !== undefined && (
|
|
||||||
<Text style={{ fontSize: 22, color: '#6b7280' }}>{current.toFixed(1)}A</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { View, Text } from '@tarojs/components'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean
|
|
||||||
title: string
|
|
||||||
message: string
|
|
||||||
onConfirm: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: Props) {
|
|
||||||
if (!open) return null
|
|
||||||
return (
|
|
||||||
<View style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}>
|
|
||||||
<View style={{ width: '70%', backgroundColor: '#fff', borderRadius: 24, overflow: 'hidden' }}>
|
|
||||||
<View style={{ padding: '32px 24px', textAlign: 'center' }}>
|
|
||||||
<Text style={{ fontSize: 32, fontWeight: 600, color: '#111', marginBottom: 12, display: 'block' }}>{title}</Text>
|
|
||||||
<Text style={{ fontSize: 28, color: '#6b7280' }}>{message}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={{ display: 'flex', borderTop: '1px solid #e5e7eb' }}>
|
|
||||||
<View
|
|
||||||
style={{ flex: 1, padding: '24px 0', textAlign: 'center', borderRight: '1px solid #e5e7eb' }}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
<Text style={{ fontSize: 28, color: '#6b7280' }}>取消</Text>
|
|
||||||
</View>
|
|
||||||
<View style={{ flex: 1, padding: '24px 0', textAlign: 'center' }} onClick={onConfirm}>
|
|
||||||
<Text style={{ fontSize: 28, color: '#2563eb', fontWeight: 500 }}>确定</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { View, Text, Button } from '@tarojs/components'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
message: string
|
|
||||||
onRetry?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ErrorState({ message, onRetry }: Props) {
|
|
||||||
return (
|
|
||||||
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 0' }}>
|
|
||||||
<Text style={{ fontSize: 60, marginBottom: 16 }}>😵</Text>
|
|
||||||
<Text style={{ fontSize: 28, color: '#6b7280', marginBottom: 24 }}>{message}</Text>
|
|
||||||
{onRetry && (
|
|
||||||
<Button
|
|
||||||
style={{ padding: '16px 48px', backgroundColor: '#2563eb', color: '#fff', borderRadius: 12, fontSize: 28, border: 'none' }}
|
|
||||||
onClick={onRetry}
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { View, Text } from '@tarojs/components'
|
|
||||||
|
|
||||||
export default function Loading({ text = '加载中...' }: { text?: string }) {
|
|
||||||
return (
|
|
||||||
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 0' }}>
|
|
||||||
<View style={{ width: 64, height: 64, border: '4px solid #e5e7eb', borderTopColor: '#2563eb', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
|
||||||
<Text style={{ marginTop: 16, fontSize: 28, color: '#9ca3af' }}>{text}</Text>
|
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { View, Text } from '@tarojs/components'
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<number, { label: string; cls: string }> = {
|
|
||||||
0: { label: '空闲', cls: 'idle' },
|
|
||||||
1: { label: '充电中', cls: 'charging' },
|
|
||||||
2: { label: '故障', cls: 'fault' },
|
|
||||||
3: { label: '离线', cls: 'offline' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CabinetStatusBadge({ status }: { status: number }) {
|
|
||||||
const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' }
|
|
||||||
return (
|
|
||||||
<View className={`status-badge ${s.cls}`}>
|
|
||||||
<Text>{s.label}</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChannelStatusBadge({ status }: { status: number }) {
|
|
||||||
const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' }
|
|
||||||
return (
|
|
||||||
<View className={`status-badge ${s.cls}`}>
|
|
||||||
<Text>{s.label}</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
||||||
<title>安知充</title>
|
|
||||||
<script>
|
|
||||||
// 默认路由:根路径跳转到 home 页(tab 页必须在 pages 数组第一位)
|
|
||||||
;(function () {
|
|
||||||
var path = window.location.pathname;
|
|
||||||
// 扫码入口: 提取 URL 中的 abstract_id 并重写路由
|
|
||||||
var match = path.match(/\/h5\/(\d{2}-\d{8})/);
|
|
||||||
if (match) {
|
|
||||||
window.__SCAN_ABSTRACT_ID__ = match[1];
|
|
||||||
window.history.replaceState({}, '', '/h5/pages/home/index');
|
|
||||||
} else if (path === '/h5/' || path === '/h5') {
|
|
||||||
// 纯根路径访问,重定向到 home 页让 Taro 能匹配路由
|
|
||||||
window.history.replaceState({}, '', '/h5/pages/home/index');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
// Taro H5 路由 base
|
|
||||||
window.__TARO_ROUTER_BASE__ = '/h5/';
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||