Compare commits

...

4 Commits

Author SHA1 Message Date
12451
8bc7812733 refactor(api-server): 23 个超 80 行函数拆分为 ≤80 行(纯重构,行为不变)
拆分范围(14 个文件):migrate / users / operation_logs / energy_stats / charge_records /
batteries / device_logs / auth / captcha / main / routes::mod / h5 / cabinet_tree / report_worker

手法:长 SQL 抽模块级 const、拼装与映射与校验抽私有 fn、路由按业务域拆 Router 再 merge、
Excel 导出按"取数/表头/逐行/收尾"分阶段、连续代码块原样搬移+传参。

行为不变的证据:
- 路由 79 → 79 条逐条比对一致(路径+handler+方法链;中间件/CORS/with_state 作用域保持)
- 字符串字面量多重集比对:11 个文件唯一集合完全一致;report_worker 零差异(435 条字面量全等)
- 关键契约计数不变:CAST 14→14、AS SIGNED 4→4、COALESCE 6→6;SQL 文本与 Redis key 零增删
- 未新增 #[allow] / unwrap / expect / panic;captcha 的 RNG 消耗顺序保持(验证码分布不变)

验收:cargo check 0 error / 0 warning;cargo test --bins 39 passed / 0 failed;全仓已无 >80 行函数。
端到端回归:先部署测试环境跑 TCP 协议联调 13/13,且数据库终态与重构前逐字段一致
(orders.energy=22、start_soc=850、end_soc=1000、records.energy=22.00、max_temp=29800、占用状态正确复位)。
2026-09-20 00:01:01 +08:00
12451
999885c38f 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 通过
2026-09-19 23:41:09 +08:00
12451
9829e9dcb1 chore(h5): 移除项目内 vendored 技能库及锁文件
删除 software/h5-uniapp/.agents/skills 与 software/h5-uniapp/agent/skills(各 629 文件,来源 uni-helper/skills 的 Vue/uni-app 技能包),并删除 skills-lock.json。两份技能库内容除 9 个 SKILL.md 的前置元数据格式外完全重复,属仓库内冗余副本;开发期技能改由全局技能目录加载。
2026-09-14 13:33:37 +08:00
12451
b553c4e519 feat: API 统一 + 设备详情补全 + H5 导航优化
- API 路由统一: 移除 /api/h5/,统一 /api/pms/ 入口
- 二进制改名: api-server -> pms-api(与服务名一致)
- nginx: 合并 /api/ + /pms/api/ 为 /api/pms/
- 新增 docs/API.md 接口文档
- 后端: CabinetDetail 增加 rssi/pow_fail/address/soc 等字段
- Redis 管道批量读取仓体实时数据(SOC/电压/电流/功率)
- DB: 所有 TIMESTAMP 列改为 DATETIME(修复类型不匹配)
- 用户管理: 新增创建时间/修改时间列,状态改用 Switch
- 角色管理/用户管理: 操作按钮改为图标
- PC 设备详情: 基本信息补全 RSSI/ICCID/电源/地址
- PC 卡片: 通道统计改用 Lucide 图标,颜色统一
- PC 二维码: canvas 渲染,含编号文字,支持右键下载
- H5: 按 uni-app 规范修正导航(navigateTo 替代 reLaunch)
- H5: 详情页加返回导航条
- H5: 扫码支持摄像头(jsQR)+ ?id= 参数
- H5: 底部 tabBar 在所有页面常显
- H5: 首页统计改为 6 项(设备/通道/空闲/充电/充满/故障)
- H5: 下拉刷新 / 上拉加载
- 产品首页: 添加业务联系
- 文档: 清理过时文件,补充缺失规范章节
2026-07-04 04:03:02 +08:00
234 changed files with 16900 additions and 17440 deletions

19
.gitignore vendored
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

810
AGENTS.md

File diff suppressed because it is too large Load Diff

294
README.md
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

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

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

View File

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

View File

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

View File

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

View File

@ -26,12 +26,23 @@ server {
client_max_body_size 500m;
}
location /pms/api/ {
proxy_pass http://127.0.0.1:3000/api/;
location /api/pms/ {
proxy_pass http://127.0.0.1:9000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 10m;
}
# 头像上传文件
location /uploads/ {
proxy_pass http://127.0.0.1:9000/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 30d;
}
# PMS管理前端 - 不带斜杠时重定向
@ -44,15 +55,4 @@ server {
alias /data/pms/web/;
try_files $uri $uri/ /pms/index.html;
}
# H5用户端 - 不带斜杠时重定向
location = /h5 {
return 301 /h5/;
}
# H5用户端
location /h5/ {
alias /data/pms/h5/;
try_files $uri $uri/ /h5/index.html;
}
}

View File

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

View File

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

View File

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

View File

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

780
docs/API.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

548
docs/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

1
logo/白色横向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

1
logo/白色竖向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

1
logo/纯色横向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

1
logo/纯色竖向.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

4
opencode.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
//! 提供 HTTP API(后台管理 + H5),处理后端业务逻辑。
//! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。
mod captcha;
mod commands;
mod config;
mod db;
@ -15,23 +16,20 @@ mod routes;
mod workers;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
use commands::{AppState, NodeRegistry, PendingCommands};
#[tokio::main]
async fn main() {
// 初始化日志
/// 初始化日志订阅器(默认级别 info,可通过 RUST_LOG 覆盖)
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
}
// 加载配置
let cfg = config::Config::from_env();
cfg.validate();
tracing::info!("配置加载完成");
// 初始化 MySQL 连接池
/// 初始化 MySQL 连接池并执行自动迁移(失败即退出,启动阶段不降级)
async fn init_database(cfg: &config::Config) -> sqlx::MySqlPool {
let db_pool = db::init_pool(&cfg.database_url)
.await
.expect("MySQL 连接失败");
@ -43,43 +41,64 @@ async fn main() {
.expect("数据库迁移失败");
tracing::info!("数据库迁移完成");
// 初始化 Redis 连接
db_pool
}
/// 初始化 Redis 连接管理器(失败即退出)
async fn init_redis(cfg: &config::Config) -> ::redis::aio::ConnectionManager {
let redis_conn = redis::init_connection(&cfg.redis_url)
.await
.expect("Redis 连接失败");
tracing::info!("Redis 连接成功");
redis_conn
}
// 初始化共享状态
let pending_commands = PendingCommands::new();
let nodes = NodeRegistry::new(redis_conn.clone());
let state = AppState {
redis: redis_conn.clone(),
mysql: db_pool.clone(),
pending_commands: pending_commands.clone(),
nodes: nodes.clone(),
redis_url: cfg.redis_url.clone(),
};
/// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
fn load_jwt_secret() -> Arc<str> {
std::env::var("JWT_SECRET")
.expect("JWT_SECRET environment variable is required")
.into()
}
/// 启动全部后台协程:设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留
fn spawn_workers(cfg: &config::Config, state: &AppState) {
// 启动后台协程 — 处理设备上报
let report_redis = redis_conn.clone();
let report_mysql = db_pool.clone();
let report_redis = state.redis.clone();
let report_mysql = state.mysql.clone();
tokio::spawn(async move {
workers::report_worker::start(report_redis, report_mysql).await;
});
// 启动后台协程 — 处理指令响应
let reply_redis = redis_conn.clone();
let reply_redis = state.redis.clone();
let reply_pending = state.pending_commands.clone();
tokio::spawn(async move {
workers::reply_worker::start(reply_redis, pending_commands).await;
workers::reply_worker::start(reply_redis, reply_pending).await;
});
// 启动后台协程 — 节点心跳超时检测
let checker_nodes = nodes.clone();
let checker_nodes = state.nodes.clone();
let heartbeat_timeout = cfg.node_heartbeat_timeout;
tokio::spawn(async move {
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
});
// 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录)
let timeout_mysql = state.mysql.clone();
let timeout_redis = state.redis.clone();
tokio::spawn(async move {
workers::order_timeout::start(timeout_mysql, timeout_redis).await;
});
// 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS)
let retention_mysql = state.mysql.clone();
tokio::spawn(async move {
workers::log_retention::start(retention_mysql).await;
});
}
/// 构建路由并启动 HTTP 服务(阻塞至进程退出)
async fn serve_http(cfg: &config::Config, state: AppState) {
// 构建 HTTP 路由
let app = routes::build(state);
@ -96,3 +115,38 @@ async fn main() {
.await
.expect("服务器运行失败");
}
#[tokio::main]
async fn main() {
// 初始化日志
init_tracing();
// 加载配置
let cfg = config::Config::from_env();
tracing::info!("配置加载完成");
// 初始化 MySQL 连接池 + 自动迁移
let db_pool = init_database(&cfg).await;
// 初始化 Redis 连接
let redis_conn = init_redis(&cfg).await;
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
let jwt_secret = load_jwt_secret();
// 初始化共享状态
let state = AppState {
redis: redis_conn.clone(),
mysql: db_pool.clone(),
pending_commands: PendingCommands::new(),
nodes: NodeRegistry::new(redis_conn),
redis_url: cfg.redis_url.clone(),
jwt_secret,
};
// 启动后台协程(设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留)
spawn_workers(&cfg, &state);
// 构建路由并启动 HTTP 服务
serve_http(&cfg, state).await;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ use axum::extract::{State, Query};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
use crate::error::AppError;
@ -24,6 +25,7 @@ struct CabinetSqlRow {
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
project_address: Option<String>,
status: i8,
board_count: Option<i64>,
channel_count: Option<i64>,
@ -43,6 +45,7 @@ impl From<CabinetSqlRow> for CabinetRow {
iccid: row.iccid,
name: row.name,
address: row.address,
project_address: row.project_address,
status: row.status,
board_count: row.board_count,
channel_count: row.channel_count,
@ -54,10 +57,62 @@ impl From<CabinetSqlRow> for CabinetRow {
pow_fail_dc: None,
pow_fail_ac: None,
is_online: None,
org_disabled: None,
}
}
}
// ---------------------------------------------------------------------------
// SQL 常量
//
// 说明:以下 SQL 均由原函数体**原样搬移**,未改动任何 SELECT 字段列表、
// 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。
// ---------------------------------------------------------------------------
/// 全部组织(组织树用)
const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id";
/// 全部项目(经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题)
const PROJECTS_ALL_SQL: &str = "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id";
/// 各项目下的设备数量
const PROJECT_CABINET_COUNTS_SQL: &str =
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id";
/// 设备列表基础 SQL:联表查询获取通道状态统计(子查询 idle_count/charging_count/... 原样保留)
const CABINET_FILTER_BASE_SQL: &str = "
SELECT
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
c.status,
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
(SELECT COUNT(*) FROM cabin_boards cb2
JOIN compartments comp ON comp.cabin_board_id = cb2.id
WHERE cb2.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM cabin_boards cb3
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
(SELECT COUNT(*) FROM cabin_boards cb4
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
(SELECT COUNT(*) FROM cabin_boards cb5
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
(SELECT COUNT(*) FROM cabin_boards cb6
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
FROM cabinets c
";
/// 设备归属校验(非总管理员):设备必须属于当前用户组织
const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \
JOIN projects p ON c.project_id = p.id \
WHERE c.imei = ? AND p.organization_id = ?)";
// ---------------------------------------------------------------------------
// 组织树
// ---------------------------------------------------------------------------
/// 组织树接口 — 返回多级组织->项目树(含设备数量)
pub async fn organization_tree(
user: CurrentUser,
@ -68,91 +123,107 @@ pub async fn organization_tree(
let db = &state.mysql;
// 获取所有组织
let orgs: Vec<OrganizationRow> = sqlx::query_as(
"SELECT id, name, parent_id FROM organizations ORDER BY id"
)
.fetch_all(db)
.await?;
let orgs: Vec<OrganizationRow> = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?;
// 获取所有项目
let projects: Vec<ProjectRow> = sqlx::query_as(
"SELECT id, organization_id, name FROM projects ORDER BY id"
)
.fetch_all(db)
.await?;
let projects: Vec<ProjectRow> = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?;
// 获取每个项目的设备数量
let project_counts: Vec<(i64, i64)> = sqlx::query_as(
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id"
)
.fetch_all(db)
.await?;
let project_count_map: std::collections::HashMap<i64, i64> = project_counts.into_iter().collect();
let project_count_map = fetch_project_counts(db).await?;
// 递归构建树
fn build_tree(
orgs: &[OrganizationRow],
projects: &[ProjectRow],
project_count_map: &std::collections::HashMap<i64, i64>,
parent_id: Option<i64>,
) -> Vec<TreeNode> {
orgs
.iter()
.filter(|o| o.parent_id == parent_id)
.map(|org| {
// 递归获取子组织
let children = build_tree(orgs, projects, project_count_map, Some(org.id));
// 获取该组织下的项目
let org_projects: Vec<TreeNode> = projects
.iter()
.filter(|p| p.organization_id == Some(org.id))
.map(|p| {
let count = project_count_map.get(&p.id).copied().unwrap_or(0);
TreeNode {
id: p.id,
name: format!("{} ({})", p.name, count),
children: None,
abstract_id: None,
imei: None,
status: None,
}
})
.collect();
// 合并子组织和项目
let mut all_children = children;
all_children.extend(org_projects);
// 计算本组织下的总设备数量
let total_count: i64 = all_children.iter()
.map(|c| {
// 从项目名称中提取数量
if let Some(start) = c.name.rfind('(') {
if let Some(end) = c.name.rfind(')') {
c.name[start+1..end].parse().unwrap_or(0)
} else { 0 }
} else { 0 }
})
.sum();
TreeNode {
id: org.id,
name: format!("{} ({})", org.name, total_count),
children: if all_children.is_empty() { None } else { Some(all_children) },
abstract_id: None,
imei: None,
status: None,
}
})
.collect()
}
let tree = build_tree(&orgs, &projects, &project_count_map, None);
Ok(Json(json!(tree)))
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
}
/// 查询「项目ID -> 设备数量」映射
async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result<HashMap<i64, i64>, AppError> {
let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL)
.fetch_all(db)
.await?;
Ok(project_counts.into_iter().collect())
}
/// 递归构建「组织 -> 项目」树节点,项目名称携带设备数量
fn build_tree(
orgs: &[OrganizationRow],
projects: &[ProjectRow],
project_count_map: &HashMap<i64, i64>,
parent_id: Option<i64>,
) -> Vec<TreeNode> {
orgs.iter()
.filter(|o| o.parent_id == parent_id)
.map(|org| {
// 递归获取子组织
let children = build_tree(orgs, projects, project_count_map, Some(org.id));
// 获取该组织下的项目
let org_projects: Vec<TreeNode> = projects
.iter()
.filter(|p| p.organization_id == Some(org.id))
.map(|p| {
let count = project_count_map.get(&p.id).copied().unwrap_or(0);
TreeNode {
id: p.id,
name: format!("{} ({})", p.name, count),
children: None,
abstract_id: None,
imei: None,
status: None,
disabled: None,
address: p.address.clone(),
latitude: p.latitude.clone(),
longitude: p.longitude.clone(),
}
})
.collect();
// 合并子组织和项目
let mut all_children = children;
all_children.extend(org_projects);
// 计算本组织下的总设备数量
let total_count: i64 = all_children
.iter()
.map(|c| {
// 从项目名称中提取数量
if let Some(start) = c.name.rfind('(') {
if let Some(end) = c.name.rfind(')') {
c.name[start + 1..end].parse().unwrap_or(0)
} else {
0
}
} else {
0
}
})
.sum();
TreeNode {
id: org.id,
name: format!("{} ({})", org.name, total_count),
children: if all_children.is_empty() {
None
} else {
Some(all_children)
},
abstract_id: None,
imei: None,
status: None,
disabled: org.disabled,
address: None,
latitude: None,
longitude: None,
}
})
.collect()
}
// ---------------------------------------------------------------------------
// 设备列表筛选
// ---------------------------------------------------------------------------
/// 设备列表筛选参数
#[derive(Deserialize)]
pub struct CabinetFilterParams {
@ -172,36 +243,42 @@ pub async fn filter_cabinets(
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
let redis = &state.redis;
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
let offset = (page - 1) * page_size;
// 基础SQL:联表查询获取通道状态统计
let base_sql = "
SELECT
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, c.status,
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
(SELECT COUNT(*) FROM cabin_boards cb2
JOIN compartments comp ON comp.cabin_board_id = cb2.id
WHERE cb2.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM cabin_boards cb3
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
(SELECT COUNT(*) FROM cabin_boards cb4
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
(SELECT COUNT(*) FROM cabin_boards cb5
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
(SELECT COUNT(*) FROM cabin_boards cb6
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
FROM cabinets c
";
// 组织/项目过滤 + 已绑定/未绑定过滤
let (where_clause, binds) = build_filter_where(&params, &user);
let (mut where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
// 总数量
let total = fetch_filter_total(db, &where_clause, &binds).await;
if total == 0 || offset >= total {
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
}
// 分页数据
let sql_rows = fetch_filter_page(db, &where_clause, &binds, page_size, offset).await?;
// 转换为CabinetRow
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
attach_realtime_status(&state, &mut rows).await?;
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
attach_org_disabled(db, &mut rows).await;
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
}
/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致)
fn build_filter_where(
params: &CabinetFilterParams,
user: &CurrentUser,
) -> (String, Vec<String>) {
let (where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
} else if let Some(org_id) = params.organization_id {
("JOIN projects p ON c.project_id = p.id
@ -217,94 +294,145 @@ pub async fn filter_cabinets(
("WHERE 1=0".into(), vec![])
};
// 已绑定/未绑定过滤
if let Some(bound) = params.bound {
match bound.as_str() {
"1" => {
if where_clause.is_empty() {
where_clause = "WHERE c.abstract_id IS NOT NULL".into();
} else {
where_clause.push_str(" AND c.abstract_id IS NOT NULL");
}
}
"0" => {
if where_clause.is_empty() {
where_clause = "WHERE c.abstract_id IS NULL".into();
} else {
where_clause.push_str(" AND c.abstract_id IS NULL");
}
}
_ => {}
}
}
// 已绑定/未绑定过滤(绑定 = 分配到项目)
let where_clause = match params.bound.as_deref() {
Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"),
Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"),
_ => where_clause,
};
// 总数量
(where_clause, binds)
}
/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接
fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String {
if where_clause.is_empty() {
where_new.to_string()
} else {
let mut clause = where_clause;
clause.push_str(and_tail);
clause
}
}
/// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致)
async fn fetch_filter_total(db: &sqlx::MySqlPool, where_clause: &str, binds: &[String]) -> i64 {
let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for b in &binds {
for b in binds {
count_query = count_query.bind(b);
}
let total: i64 = count_query.fetch_one(db).await.unwrap_or(0);
count_query.fetch_one(db).await.unwrap_or(0)
}
if total == 0 || offset >= total {
return Ok(Json(json!({"total": 0, "data": []})));
}
// 分页数据
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", base_sql, where_clause);
/// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定)
async fn fetch_filter_page(
db: &sqlx::MySqlPool,
where_clause: &str,
binds: &[String],
page_size: i64,
offset: i64,
) -> Result<Vec<CabinetSqlRow>, AppError> {
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", CABINET_FILTER_BASE_SQL, where_clause);
let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql);
for b in &binds {
for b in binds {
data_query = data_query.bind(b);
}
let sql_rows: Vec<CabinetSqlRow> = data_query
let rows: Vec<CabinetSqlRow> = data_query
.bind(page_size)
.bind(offset)
.fetch_all(db).await?;
.fetch_all(db)
.await?;
Ok(rows)
}
// 转换为CabinetRow
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
/// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返)
async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Result<(), AppError> {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = open_redis_conn(state).await?;
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
{
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
AppError::Internal(format!("Redis客户端创建失败: {}", e))
})?;
let mut conn = client.get_async_connection().await.map_err(|e| {
AppError::Internal(format!("Redis连接失败: {}", e))
})?;
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
let mut pipe = redis::pipe();
for row in &rows {
let key = format!("device:{}", row.imei);
pipe.cmd("HGET").arg(&key).arg("online");
pipe.cmd("HGET").arg(&key).arg("rssi");
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
}
// 一次发送,获取所有结果
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
// 每4个一组解析
for (i, row) in rows.iter_mut().enumerate() {
let base = i * 4;
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
row.rssi = results.get(base + 1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok());
row.pow_fail_dc = results.get(base + 2).and_then(|v| v.as_ref()).map(|v| v == "1");
row.pow_fail_ac = results.get(base + 3).and_then(|v| v.as_ref()).map(|v| v == "1");
}
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
let mut pipe = redis::pipe();
for row in rows.iter() {
let key = format!("device:{}", row.imei);
pipe.cmd("HGET").arg(&key).arg("online");
pipe.cmd("HGET").arg(&key).arg("rssi");
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
}
Ok(Json(json!({"total": total, "data": rows})))
// 一次发送,获取所有结果
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
// 每4个一组解析
for (i, row) in rows.iter_mut().enumerate() {
let base = i * 4;
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
row.rssi = results
.get(base + 1)
.and_then(|v| v.as_ref())
.and_then(|v| v.parse::<i32>().ok());
row.pow_fail_dc = results
.get(base + 2)
.and_then(|v| v.as_ref())
.map(|v| v == "1");
row.pow_fail_ac = results
.get(base + 3)
.and_then(|v| v.as_ref())
.map(|v| v == "1");
}
Ok(())
}
/// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标)
async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) {
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
if project_ids.is_empty() {
return;
}
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
r#"SELECT p.id, o.disabled FROM projects p
LEFT JOIN organizations o ON p.organization_id = o.id
WHERE p.id IN ({})"#,
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
for pid in &project_ids {
q = q.bind(pid);
}
if let Ok(results) = q.fetch_all(db).await {
let disabled_map: HashMap<i64, bool> =
results.into_iter().map(|(pid, d)| (pid, d == 1)).collect();
for row in rows.iter_mut() {
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
}
}
}
// ---------------------------------------------------------------------------
// 设备实时状态
// ---------------------------------------------------------------------------
/// 设备实时状态参数
#[derive(Deserialize)]
pub struct DeviceStatusParams {
pub imei: String,
}
/// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误)
async fn open_redis_conn(state: &AppState) -> Result<redis::aio::MultiplexedConnection, AppError> {
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
AppError::Internal(format!("Redis客户端创建失败: {}", e))
})?;
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
client.get_multiplexed_async_connection().await.map_err(|e| {
AppError::Internal(format!("Redis连接失败: {}", e))
})
}
/// 设备实时状态接口 — 返回板级+通道级状态
pub async fn device_realtime_status(
user: CurrentUser,
@ -313,36 +441,82 @@ pub async fn device_realtime_status(
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
AppError::Internal(format!("Redis客户端创建失败: {}", e))
})?;
let mut conn = client.get_async_connection().await.map_err(|e| {
AppError::Internal(format!("Redis连接失败: {}", e))
})?;
// 校验设备归属
ensure_device_access(&state, &user, &params.imei).await?;
let device_key = format!("device:{}", params.imei);
let mut conn = open_redis_conn(&state).await?;
let device_status = load_device_status(&mut conn, &params.imei).await;
let boards = load_boards_status(&mut conn, &params.imei).await;
Ok(Json(json!({
"code": 0,
"data": {
"device": device_status,
"boards": boards,
},
"message": "ok"
})))
}
/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织
async fn ensure_device_access(
state: &AppState,
user: &CurrentUser,
imei: &str,
) -> Result<(), AppError> {
if user.role_level < 2 {
if let Some(org_id) = user.organization_id {
let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL)
.bind(imei)
.bind(org_id)
.fetch_one(&state.mysql)
.await
.unwrap_or(false);
if !has_access {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
} else {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
}
Ok(())
}
/// 读取设备级字段(online / rssi / pow_fail_dc / pow_fail_ac)
async fn load_device_status(
conn: &mut redis::aio::MultiplexedConnection,
imei: &str,
) -> Value {
let device_key = format!("device:{}", imei);
// 获取设备级字段
let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"];
let values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(&device_key)
.arg(&fields)
.query_async(&mut conn)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let device_status = json!({
json!({
"online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
"rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()),
"pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
"pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
});
})
}
/// 扫描并组装全部仓控板状态(板级 + 通道级)
async fn load_boards_status(
conn: &mut redis::aio::MultiplexedConnection,
imei: &str,
) -> Value {
// 扫描板级key: device:{imei}:board:*
let board_pattern = format!("device:{}:board:*", params.imei);
let board_pattern = format!("device:{}:board:*", imei);
let board_keys: Vec<String> = redis::cmd("KEYS")
.arg(&board_pattern)
.query_async(&mut conn)
.query_async(&mut *conn)
.await
.unwrap_or_default();
@ -354,72 +528,98 @@ pub async fn device_realtime_status(
continue;
}
// 获取板级字段
let board_fields = vec!["status_hex"];
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(board_key)
.arg(&board_fields)
.query_async(&mut conn)
.await
.unwrap_or_default();
let mut board_data = json!({
"status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
});
// 扫描通道级key: device:{imei}:board:{idx}:ch:*
let ch_pattern = format!("{}:ch:*", board_key);
let ch_keys: Vec<String> = redis::cmd("KEYS")
.arg(&ch_pattern)
.query_async(&mut conn)
.await
.unwrap_or_default();
let mut channels = json!({});
for ch_key in &ch_keys {
let ch_idx = ch_key.split(":").last().unwrap_or("");
if ch_idx.is_empty() {
continue;
}
let ch_fields = vec!["on", "full", "fault", "hex"];
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(ch_key)
.arg(&ch_fields)
.query_async(&mut conn)
.await
.unwrap_or_default();
// 状态映射:fault > full > on > idle
let fault = ch_values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
let full = ch_values.get(1).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
let on = ch_values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
let status = if fault {
"fault"
} else if full {
"full"
} else if on {
"charging"
} else {
"idle"
};
channels[ch_idx] = json!({
"status": status,
"on": on,
"full": full,
"fault": fault,
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
});
}
board_data["channels"] = channels;
boards[board_idx] = board_data;
boards[board_idx] = load_single_board(conn, board_key).await;
}
Ok(Json(json!({
"device": device_status,
"boards": boards,
})))
boards
}
/// 读取单块仓控板的板级字段,并扫描其下的通道级 key
async fn load_single_board(
conn: &mut redis::aio::MultiplexedConnection,
board_key: &str,
) -> Value {
// 获取板级字段
let board_fields = vec!["status_hex"];
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(board_key)
.arg(&board_fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let mut board_data = json!({
"status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
});
// 扫描通道级key: device:{imei}:board:{idx}:ch:*
let ch_pattern = format!("{}:ch:*", board_key);
let ch_keys: Vec<String> = redis::cmd("KEYS")
.arg(&ch_pattern)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let mut channels = json!({});
for ch_key in &ch_keys {
let ch_idx = ch_key.split(":").last().unwrap_or("");
if ch_idx.is_empty() {
continue;
}
channels[ch_idx] = load_channel_status(conn, ch_key).await;
}
board_data["channels"] = channels;
board_data
}
/// 读取单个通道状态(状态映射优先级:fault > full > on > idle)
async fn load_channel_status(
conn: &mut redis::aio::MultiplexedConnection,
ch_key: &str,
) -> Value {
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(ch_key)
.arg(&ch_fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
// 状态映射:fault > full > on > idle
let fault = ch_values
.get(0)
.and_then(|v| v.as_ref())
.map(|v| v == "1")
.unwrap_or(false);
let full = ch_values
.get(1)
.and_then(|v| v.as_ref())
.map(|v| v == "1")
.unwrap_or(false);
let on = ch_values
.get(2)
.and_then(|v| v.as_ref())
.map(|v| v == "1")
.unwrap_or(false);
let status = if fault {
"fault"
} else if full {
"full"
} else if on {
"charging"
} else {
"idle"
};
json!({
"status": status,
"on": on,
"full": full,
"fault": fault,
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()),
})
}

View File

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

View File

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

View File

@ -43,8 +43,8 @@ pub struct ChargeRecordItem {
pub channel_id: i8,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub energy: Option<f64>,
pub max_temperature: Option<f64>,
pub energy: Option<i64>,
pub max_temperature: Option<i64>,
pub status: i8,
pub status_text: String,
}
@ -62,7 +62,7 @@ fn status_text(status: i8) -> &'static str {
}
}
/// 数据库行映射 — DECIMAL 列通过 CAST 转为 f64
/// 数据库行映射
#[derive(Debug, sqlx::FromRow)]
struct ChargeRecordRow {
id: i64,
@ -74,8 +74,8 @@ struct ChargeRecordRow {
channel_id: Option<i8>,
start_time: Option<NaiveDateTime>,
end_time: Option<NaiveDateTime>,
energy: Option<f64>,
max_temperature: Option<f64>,
energy: Option<i64>,
max_temperature: Option<i64>,
status: i8,
}
@ -134,12 +134,12 @@ async fn fetch_charge_rows(
};
let sql = format!(
"SELECT cr.id, cr.cabinet_id, c.name AS cabinet_name, \
cr.compartment_id, cb.id AS cabin_board_id, cb.board_id, comp.channel_id, \
cr.start_time, cr.end_time, \
CAST(cr.energy AS DOUBLE) AS energy, \
CAST(cr.max_temperature AS DOUBLE) AS max_temperature, \
cr.status \
"SELECT cr.id, cr.cabinet_id, c.name AS cabinet_name, \
cr.compartment_id, cb.id AS cabin_board_id, cb.board_id, comp.channel_id, \
cr.start_time, cr.end_time, \
cr.energy, \
cr.max_temperature, \
cr.status \
FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
@ -155,6 +155,61 @@ async fn fetch_charge_rows(
query.fetch_all(pool).await
}
/// 追加组织数据隔离到 WHERE 子句与绑定值
fn append_org_scope(user: &CurrentUser, where_clause: &mut String, bind_values: &mut Vec<String>) {
let (org_cond, org_binds) = auth::org_condition(user, "cr");
if !org_cond.is_empty() {
if where_clause.is_empty() {
*where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
} else {
where_clause.push_str(&org_cond);
}
bind_values.extend(org_binds);
}
}
/// 统计符合条件的充电记录总数(JOIN 链路需与数据查询保持一致)
async fn count_charge_records(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<i64, AppError> {
let count_sql = format!(
"SELECT COUNT(*) FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in bind_values {
count_query = count_query.bind(val);
}
Ok(count_query.fetch_one(pool).await?)
}
/// 将充电记录行映射为响应项列表
fn charge_rows_to_items(rows: Vec<ChargeRecordRow>) -> Vec<ChargeRecordItem> {
rows.into_iter()
.map(|r| ChargeRecordItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
cabin_board_id: r.cabin_board_id.unwrap_or(0),
board_id: r.board_id.unwrap_or(0),
compartment_id: r.compartment_id,
channel_id: r.channel_id.unwrap_or(0),
start_time: r.start_time.map(|t| t.to_string()),
end_time: r.end_time.map(|t| t.to_string()),
energy: r.energy,
max_temperature: r.max_temperature,
status: r.status,
status_text: status_text(r.status).to_string(),
})
.collect()
}
/// GET /api/charge-records — 充电记录分页列表
pub async fn list_charge_records(
user: CurrentUser,
@ -173,30 +228,10 @@ pub async fn list_charge_records(
);
// 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "cr");
if !org_cond.is_empty() {
if where_clause.is_empty() {
where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
} else {
where_clause.push_str(&org_cond);
}
bind_values.extend(org_binds);
}
append_org_scope(&user, &mut where_clause, &mut bind_values);
// 查询总数(需要与数据查询保持一致的 JOIN 链路)
let count_sql = format!(
"SELECT COUNT(*) FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(&state.mysql).await?;
let total = count_charge_records(&state.mysql, &where_clause, &bind_values).await?;
// 查询列表
let rows = fetch_charge_rows(
@ -208,24 +243,7 @@ pub async fn list_charge_records(
)
.await?;
let list: Vec<ChargeRecordItem> = rows
.into_iter()
.map(|r| ChargeRecordItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
cabin_board_id: r.cabin_board_id.unwrap_or(0),
board_id: r.board_id.unwrap_or(0),
compartment_id: r.compartment_id,
channel_id: r.channel_id.unwrap_or(0),
start_time: r.start_time.map(|t| t.to_string()),
end_time: r.end_time.map(|t| t.to_string()),
energy: r.energy,
max_temperature: r.max_temperature,
status: r.status,
status_text: status_text(r.status).to_string(),
})
.collect();
let list = charge_rows_to_items(rows);
Ok(Json(json!({
"code": 0,
@ -249,11 +267,13 @@ pub async fn export_charge_records(
let user_id = user.user_id;
let file_name = generate_export_filename(&params);
// 序列化查询参数
// 序列化查询参数(含组织过滤信息)
let query_json = serde_json::to_string(&json!({
"cabinet_id": params.cabinet_id,
"start_time": params.start_time,
"end_time": params.end_time,
"org_id": user.organization_id,
"role_level": user.role_level,
}))
.unwrap_or_default();
@ -305,30 +325,28 @@ fn generate_export_filename(params: &ChargeRecordExportQuery) -> String {
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
}
/// 后台异步生成 Excel 文件
async fn generate_charge_records_excel(
pool: sqlx::MySqlPool,
/// 读取导出任务的文件名与查询参数
///
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
async fn load_export_task(
pool: &sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(&pool)
.fetch_optional(pool)
.await?;
let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
Ok((file_name, params))
}
let (where_clause, bind_values) = build_where_clause(
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
fn build_export_where(params: &Value) -> (String, Vec<String>) {
let (mut where_clause, mut bind_values) = build_where_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
None,
None,
@ -336,22 +354,33 @@ async fn generate_charge_records_excel(
params.get("end_time").and_then(|v| v.as_str()),
);
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
// 组织过滤(非总管理员)
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
let org_id = params.get("org_id").and_then(|v| v.as_i64());
if role_level < 2 {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
if let Some(oid) = org_id {
where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
bind_values.push(oid.to_string());
} else {
where_clause.push_str(&format!("{}1=0", prefix));
}
}
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
(where_clause, bind_values)
}
// 生成 Excel
/// 构建导出工作簿(表头 + 数据行)
fn build_charge_workbook(
rows: &[ChargeRecordRow],
) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("充电记录")?;
let headers = [
"ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
"充电量(kWh)", "最高温度(°C)", "状态",
"充电量(Wh)", "最高温度(K×100)", "状态",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
@ -371,11 +400,59 @@ async fn generate_charge_records_excel(
row, 5,
record.end_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
)?;
sheet.write_number(row, 6, record.energy.unwrap_or(0.0))?;
sheet.write_number(row, 7, record.max_temperature.unwrap_or(0.0))?;
sheet.write_number(row, 6, record.energy.unwrap_or(0) as f64)?;
sheet.write_number(row, 7, record.max_temperature.unwrap_or(0) as f64)?;
sheet.write_string(row, 8, status_text(record.status))?;
}
Ok(workbook)
}
/// 将导出任务标记为完成并记录文件路径
async fn mark_task_completed(
pool: &sqlx::MySqlPool,
task_id: i64,
file_path: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(file_path)
.bind(task_id)
.execute(pool)
.await?;
Ok(())
}
/// 后台异步生成 Excel 文件
async fn generate_charge_records_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let (file_name, params) = load_export_task(&pool, task_id).await?;
// 组织过滤(非总管理员)+ 查询数据
let (where_clause, bind_values) = build_export_where(&params);
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = build_charge_workbook(&rows)?;
// 保存文件
let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?;
@ -383,14 +460,7 @@ async fn generate_charge_records_excel(
workbook.save(&file_path)?;
// 更新任务状态为完成
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
mark_task_completed(&pool, task_id, &file_path).await?;
tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
Ok(())

View File

@ -59,16 +59,11 @@ struct DeviceLogRow {
created_at: Option<NaiveDateTime>,
}
/// GET /api/device-logs — 设备日志分页列表
pub async fn list_device_logs(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DeviceLogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device_log:view")?;
let offset = (params.page - 1) * params.page_size;
// 构建 WHERE 条件
/// 构建 WHERE 条件与绑定值(含组织数据隔离)
fn build_device_log_filters(
user: &CurrentUser,
params: &DeviceLogQuery,
) -> (String, Vec<String>) {
let mut conditions = Vec::new();
let mut bind_values: Vec<String> = Vec::new();
@ -90,7 +85,7 @@ pub async fn list_device_logs(
}
// 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "dl");
let (org_cond, org_binds) = auth::org_condition(user, "dl");
if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND "));
bind_values.extend(org_binds);
@ -101,19 +96,34 @@ pub async fn list_device_logs(
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(where_clause, bind_values)
}
// 查询总数
/// 统计符合条件的设备日志总数
async fn count_device_logs(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<i64, AppError> {
let count_sql = format!(
"SELECT COUNT(*) FROM device_logs dl {}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
for val in bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(&state.mysql).await?;
Ok(count_query.fetch_one(pool).await?)
}
// 查询列表
/// 查询设备日志行(关联柜名,分页)
async fn fetch_device_log_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
page_size: i64,
offset: i64,
) -> Result<Vec<DeviceLogRow>, AppError> {
let list_sql = format!(
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
dl.log_type, dl.content, dl.created_at \
@ -124,15 +134,17 @@ pub async fn list_device_logs(
);
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
for val in &bind_values {
for val in bind_values {
list_query = list_query.bind(val);
}
list_query = list_query.bind(params.page_size).bind(offset);
list_query = list_query.bind(page_size).bind(offset);
let rows = list_query.fetch_all(&state.mysql).await?;
Ok(list_query.fetch_all(pool).await?)
}
let list: Vec<DeviceLogItem> = rows
.into_iter()
/// 将设备日志行映射为响应项
fn device_logs_to_items(rows: Vec<DeviceLogRow>) -> Vec<DeviceLogItem> {
rows.into_iter()
.map(|r| DeviceLogItem {
id: r.id,
cabinet_id: r.cabinet_id,
@ -142,7 +154,35 @@ pub async fn list_device_logs(
content: r.content,
created_at: r.created_at.map(|t| t.to_string()),
})
.collect();
.collect()
}
/// GET /api/device-logs — 设备日志分页列表
pub async fn list_device_logs(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DeviceLogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device_log:view")?;
let offset = (params.page - 1) * params.page_size;
// 构建 WHERE 条件
let (where_clause, bind_values) = build_device_log_filters(&user, &params);
// 查询总数
let total = count_device_logs(&state.mysql, &where_clause, &bind_values).await?;
// 查询列表
let rows = fetch_device_log_rows(
&state.mysql,
&where_clause,
&bind_values,
params.page_size,
offset,
)
.await?;
let list = device_logs_to_items(rows);
Ok(Json(json!({
"code": 0,

View File

@ -1,7 +1,7 @@
//! 能耗管理 API — 总览、排行、趋势、导出
//!
//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。
//! 电费估算按 ¥0.80/kWh 计算(可配置)。
//! 电费估算按 ¥0.80/kWh 计算(可配置),energy 单位为 Wh。
use axum::extract::{Query, State};
use axum::Json;
@ -11,8 +11,8 @@ use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 电费单价(元/kWh)
const COST_PER_KWH: f64 = 0.80;
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
const COST_PER_WH_CENT: i64 = 80;
/// 趋势查询参数
#[derive(Debug, Deserialize)]
@ -40,8 +40,8 @@ struct CabinetRankRow {
id: i64,
abstract_id: String,
name: Option<String>,
today_energy: Option<f64>,
month_energy: Option<f64>,
today_energy: Option<i64>,
month_energy: Option<i64>,
}
/// 项目统计行
@ -50,15 +50,15 @@ struct ProjectStatRow {
id: i64,
name: String,
cabinet_count: i64,
today_energy: Option<f64>,
month_energy: Option<f64>,
today_energy: Option<i64>,
month_energy: Option<i64>,
}
/// 趋势数据行
#[derive(Debug, sqlx::FromRow)]
struct TrendRow {
period: String, // 日期或周/月标识
energy: Option<f64>,
energy: Option<i64>,
charge_count: Option<i64>,
}
@ -69,7 +69,7 @@ struct EnergyDetailRow {
cabinet_id: i64,
cabinet_name: Option<String>,
project_name: Option<String>,
total_energy: Option<f64>,
total_energy: Option<i64>,
charge_count: Option<i64>,
}
@ -100,22 +100,22 @@ pub async fn get_summary(
// 今日总能耗
let today_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es WHERE stat_date = CURDATE(){}",
"SELECT CAST(COALESCE(SUM(total_energy), 0) AS SIGNED) FROM energy_stats es WHERE stat_date = CURDATE(){}",
org_filter
);
let mut today_q = sqlx::query_scalar::<_, f64>(&today_sql);
let mut today_q = sqlx::query_scalar::<_, i64>(&today_sql);
for v in &org_binds { today_q = today_q.bind(v); }
let today_energy: f64 = today_q.fetch_one(pool).await?;
let today_energy: i64 = today_q.fetch_one(pool).await?;
// 本月总能耗
let month_sql = format!(
"SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es \
"SELECT CAST(COALESCE(SUM(total_energy), 0) AS SIGNED) FROM energy_stats es \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}",
org_filter
);
let mut month_q = sqlx::query_scalar::<_, f64>(&month_sql);
let mut month_q = sqlx::query_scalar::<_, i64>(&month_sql);
for v in &org_binds { month_q = month_q.bind(v); }
let month_energy: f64 = month_q.fetch_one(pool).await?;
let month_energy: i64 = month_q.fetch_one(pool).await?;
// 柜子总数(含组织过滤)
let cabinet_count: i64 = if user.role_level >= 2 {
@ -145,15 +145,12 @@ pub async fn get_summary(
0
};
let today = today_energy;
let month = month_energy;
Ok(Json(json!({
"code": 0,
"data": {
"today_energy": (today * 100.0).round() / 100.0,
"month_energy": (month * 100.0).round() / 100.0,
"estimated_cost": ((month * COST_PER_KWH) * 100.0).round() / 100.0,
"today_energy": today_energy,
"month_energy": month_energy,
"estimated_cost": (month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0,
"cabinet_count": cabinet_count,
"online_count": online_count,
},
@ -184,14 +181,17 @@ pub async fn get_cabinet_ranking(
let sql = format!(
"SELECT c.id, c.abstract_id, c.name, \
COALESCE(es_today.total_energy, 0) AS today_energy, \
COALESCE(es_month.total_energy, 0) AS month_energy \
CAST(COALESCE(es_today.total_energy, 0) AS SIGNED) AS today_energy, \
CAST(COALESCE(es_month.month_energy, 0) AS SIGNED) AS month_energy \
FROM cabinets c \
LEFT JOIN energy_stats es_today \
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
LEFT JOIN energy_stats es_month \
ON c.id = es_month.cabinet_id \
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
LEFT JOIN ( \
SELECT cabinet_id, SUM(total_energy) AS month_energy \
FROM energy_stats \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
GROUP BY cabinet_id \
) es_month ON c.id = es_month.cabinet_id \
{} \
ORDER BY today_energy DESC, c.id ASC",
org_join
@ -205,8 +205,8 @@ pub async fn get_cabinet_ranking(
"cabinet_id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"today_energy": r.today_energy.unwrap_or(0),
"month_energy": r.month_energy.unwrap_or(0),
})
}).collect();
@ -238,15 +238,18 @@ pub async fn get_project_stats(
let sql = format!(
"SELECT p.id, p.name, \
COUNT(DISTINCT c.id) AS cabinet_count, \
COALESCE(SUM(es_today.total_energy), 0) AS today_energy, \
COALESCE(SUM(es_month.total_energy), 0) AS month_energy \
CAST(COALESCE(SUM(es_today.total_energy), 0) AS SIGNED) AS today_energy, \
CAST(COALESCE(SUM(es_month.month_energy), 0) AS SIGNED) AS month_energy \
FROM projects p \
LEFT JOIN cabinets c ON p.id = c.project_id \
LEFT JOIN energy_stats es_today \
ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
LEFT JOIN energy_stats es_month \
ON c.id = es_month.cabinet_id \
AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
LEFT JOIN ( \
SELECT cabinet_id, SUM(total_energy) AS month_energy \
FROM energy_stats \
WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
GROUP BY cabinet_id \
) es_month ON c.id = es_month.cabinet_id \
{} \
GROUP BY p.id, p.name \
ORDER BY today_energy DESC, p.id ASC",
@ -261,8 +264,8 @@ pub async fn get_project_stats(
"project_id": r.id,
"name": r.name,
"cabinet_count": r.cabinet_count,
"today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"today_energy": r.today_energy.unwrap_or(0),
"month_energy": r.month_energy.unwrap_or(0),
})
}).collect();
@ -301,7 +304,7 @@ pub async fn get_trend(
"week" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
CAST(SUM(total_energy) AS SIGNED) AS energy, CAST(SUM(charge_count) AS SIGNED) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
@ -314,7 +317,7 @@ pub async fn get_trend(
"month" => {
let sql = format!(
"SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \
SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
CAST(SUM(total_energy) AS SIGNED) AS energy, CAST(SUM(charge_count) AS SIGNED) AS charge_count \
FROM energy_stats es \
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY period ORDER BY period ASC",
@ -343,7 +346,7 @@ pub async fn get_trend(
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"period": r.period,
"energy": (r.energy.unwrap_or(0.0) * 100.0).round() / 100.0,
"energy": r.energy.unwrap_or(0),
"charge_count": r.charge_count.unwrap_or(0),
})
}).collect();
@ -368,12 +371,14 @@ pub async fn export_energy_stats(
let user_id = user.user_id;
let file_name = generate_export_filename(&params);
// 序列化查询参数
// 序列化查询参数(含组织过滤信息)
let query_json = serde_json::to_string(&json!({
"cabinet_id": params.cabinet_id,
"project_id": params.project_id,
"start_date": params.start_date,
"end_date": params.end_date,
"org_id": user.organization_id,
"role_level": user.role_level,
}))
.unwrap_or_default();
@ -456,6 +461,122 @@ fn build_export_clause(
(clause, bind_values)
}
/// 读取导出任务的文件名与查询参数
///
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
async fn load_export_task(
pool: &sqlx::MySqlPool,
task_id: i64,
) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(pool)
.await?;
let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
Ok((file_name, params))
}
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
fn build_export_where(params: &Value) -> (String, Vec<String>) {
let (mut where_clause, mut bind_values) = build_export_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
params.get("project_id").and_then(|v| v.as_i64()),
params.get("start_date").and_then(|v| v.as_str()),
params.get("end_date").and_then(|v| v.as_str()),
);
// 组织过滤(非总管理员)
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
let org_id = params.get("org_id").and_then(|v| v.as_i64());
if role_level < 2 {
if let Some(oid) = org_id {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
where_clause.push_str(&format!("{}es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
bind_values.push(oid.to_string());
} else {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
where_clause.push_str(&format!("{}1=0", prefix));
}
}
(where_clause, bind_values)
}
/// 查询能耗明细行
async fn fetch_energy_detail_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<Vec<EnergyDetailRow>, sqlx::Error> {
let sql = format!(
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
p.name AS project_name, \
es.total_energy, \
es.charge_count \
FROM energy_stats es \
LEFT JOIN cabinets c ON es.cabinet_id = c.id \
LEFT JOIN projects p ON c.project_id = p.id \
{} ORDER BY es.stat_date DESC, es.cabinet_id ASC",
where_clause
);
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
for val in bind_values {
query = query.bind(val);
}
query.fetch_all(pool).await
}
/// 构建导出工作簿(表头 + 数据行)
fn build_energy_workbook(
rows: &[EnergyDetailRow],
) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("能耗统计")?;
let headers = [
"日期", "柜子ID", "柜子名称", "所属项目", "能耗(Wh)", "充电次数",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
}
for (row_idx, record) in rows.iter().enumerate() {
let row = (row_idx + 1) as u32;
sheet.write_string(row, 0, record.stat_date.to_string())?;
sheet.write_number(row, 1, record.cabinet_id as f64)?;
sheet.write_string(row, 2, record.cabinet_name.as_deref().unwrap_or("-"))?;
sheet.write_string(row, 3, record.project_name.as_deref().unwrap_or("-"))?;
sheet.write_number(row, 4, record.total_energy.unwrap_or(0) as f64)?;
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
}
Ok(workbook)
}
/// 将导出任务标记为完成并记录文件路径
async fn mark_task_completed(
pool: &sqlx::MySqlPool,
task_id: i64,
file_path: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(file_path)
.bind(task_id)
.execute(pool)
.await?;
Ok(())
}
/// 后台异步生成能耗 Excel 文件
async fn generate_energy_excel(
pool: sqlx::MySqlPool,
@ -468,41 +589,11 @@ async fn generate_energy_excel(
.await?;
// 获取查询参数
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(&pool)
.await?;
let (file_name, params) = load_export_task(&pool, task_id).await?;
let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
let (where_clause, bind_values) = build_export_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
params.get("project_id").and_then(|v| v.as_i64()),
params.get("start_date").and_then(|v| v.as_str()),
params.get("end_date").and_then(|v| v.as_str()),
);
// 查询能耗明细
let sql = format!(
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
p.name AS project_name, \
CAST(es.total_energy AS DOUBLE) AS total_energy, \
es.charge_count \
FROM energy_stats es \
LEFT JOIN cabinets c ON es.cabinet_id = c.id \
LEFT JOIN projects p ON c.project_id = p.id \
{} ORDER BY es.stat_date DESC, es.cabinet_id ASC",
where_clause
);
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
for val in &bind_values {
query = query.bind(val);
}
let rows = query.fetch_all(&pool).await?;
// 组织过滤(非总管理员)+ 查询能耗明细
let (where_clause, bind_values) = build_export_where(&params);
let rows = fetch_energy_detail_rows(&pool, &where_clause, &bind_values).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
@ -511,26 +602,7 @@ async fn generate_energy_excel(
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("能耗统计")?;
let headers = [
"日期", "柜子ID", "柜子名称", "所属项目", "能耗(kWh)", "充电次数",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
}
for (row_idx, record) in rows.iter().enumerate() {
let row = (row_idx + 1) as u32;
sheet.write_string(row, 0, record.stat_date.to_string())?;
sheet.write_number(row, 1, record.cabinet_id as f64)?;
sheet.write_string(row, 2, record.cabinet_name.as_deref().unwrap_or("-"))?;
sheet.write_string(row, 3, record.project_name.as_deref().unwrap_or("-"))?;
sheet.write_number(row, 4, record.total_energy.unwrap_or(0.0))?;
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
}
let mut workbook = build_energy_workbook(&rows)?;
// 保存文件
let file_dir = "./exports";
@ -539,14 +611,7 @@ async fn generate_energy_excel(
workbook.save(&file_path)?;
// 更新任务状态为完成
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
mark_task_completed(&pool, task_id, &file_path).await?;
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
Ok(())

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -3,17 +3,22 @@
//! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。
//! 权限校验在各路由处理器内部通过 `check_permission` 完成。
use axum::http::Method;
use axum::middleware;
use axum::routing::{get, post, put};
use axum::Router;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use crate::commands::AppState;
use crate::middleware::auth::auth_middleware;
mod auth;
mod batteries;
mod cabinets;
mod cabinet_tree;
mod charge_records;
mod charge_orders;
mod device_logs;
mod downloads;
mod energy_stats;
@ -24,94 +29,180 @@ mod organizations;
mod projects;
mod roles;
mod users;
mod scope_guard;
mod users_perm;
mod wechat;
mod maps;
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
pub fn build(state: AppState) -> Router {
let protected = Router::<AppState>::new()
// 认证相关
/// 认证与账户相关路由(需登录)
fn auth_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
// 设备指令下发
.route("/api/auth/avatar", put(auth::update_avatar))
.route("/api/auth/avatar/upload", post(auth::upload_avatar))
}
/// 设备指令下发 + 设备服务节点管理路由
fn device_node_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/devices/{dev_id}/command", post(crate::commands::send_command))
.route("/api/devices/stats", get(crate::commands::device_stats))
// 节点管理
.route("/api/nodes/register", post(crate::nodes::register_node))
.route("/api/nodes/{node_id}/heartbeat", post(crate::nodes::node_heartbeat))
.route("/api/nodes/{node_id}/deregister", post(crate::nodes::deregister_node))
.route("/api/nodes", get(crate::nodes::list_nodes))
// 角色管理
}
/// 角色、权限码与用户权限分配路由
fn rbac_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/roles", get(roles::list_roles).post(roles::create_role))
.route("/api/roles/{id}", put(roles::update_role).delete(roles::delete_role))
.route("/api/roles/{id}/permissions", put(roles::set_role_permissions))
// 权限码列表
.route("/api/permissions", get(roles::list_permissions))
// 用户权限管理
.route("/api/users/{id}/permissions", get(users_perm::get_user_permissions))
.route("/api/users/{id}/roles", put(users_perm::set_user_roles))
.route("/api/users/{id}/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes))
// 组织管理
}
/// 组织与项目路由
fn org_project_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization))
.route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization))
// 项目管理
.route("/api/organizations/{id}/status", put(organizations::set_organization_status))
.route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project))
.route("/api/projects/{id}", put(projects::update_project).delete(projects::delete_project))
// 设备管理
}
/// 设备(充电柜)管理、柜级控制、组织树与级联下拉路由
fn cabinet_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets))
.route("/api/cabinets", post(cabinets::create_cabinet))
.route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail))
.route("/api/cabinets/{id}/status", put(cabinets::set_cabinet_status))
.route("/api/cabinets/{id}/work-mode", put(cabinets::update_work_mode))
.route("/api/cabinets/{id}/regenerate-auth", post(cabinets::regenerate_auth))
// 柜级设备控制:开门解锁 / 接触器通断 / 重启整柜;仓控板单独重启
.route("/api/cabinets/{id}/open", post(h5::cabinet_open))
.route("/api/cabinets/{id}/pow-on", post(h5::cabinet_pow_on))
.route("/api/cabinets/{id}/pow-off", post(h5::cabinet_pow_off))
.route("/api/cabinets/{id}/reset", post(h5::cabinet_reset))
.route("/api/cabin-boards/{id}/reset", post(h5::board_reset))
.route("/api/cabinets/{id}/bind", post(cabinets::bind_cabinet))
.route("/api/cabinets/{id}/replace-imei", post(cabinets::replace_imei))
// 组织树
.route("/api/organization-tree", get(cabinet_tree::organization_tree))
.route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets))
.route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status))
// 充电记录
.route("/api/charge-records", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_charge_records))
// 设备日志
.route("/api/device-logs", get(device_logs::list_device_logs))
// 下载中心
.route("/api/downloads", get(downloads::list_downloads))
.route("/api/downloads/{id}", get(downloads::get_download))
.route("/api/downloads/{id}/file", get(downloads::download_file))
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
// 设备级联下拉
.route("/api/cabinets/options", get(cabinets::list_cabinet_options))
.route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options))
.route("/api/compartments/options", get(cabinets::list_compartment_options))
// 能耗管理
}
/// 充电记录 / 充电订单 / 电池台账 / 设备日志路由
fn record_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/charge-records", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_charge_records))
.route("/api/charge-orders", get(charge_orders::list_charge_orders))
.route("/api/batteries", get(batteries::list_batteries))
.route("/api/device-logs", get(device_logs::list_device_logs))
}
/// 下载中心路由
fn download_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/downloads", get(downloads::list_downloads))
.route("/api/downloads/{id}", get(downloads::get_download))
.route("/api/downloads/{id}/file", get(downloads::download_file))
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
}
/// 能耗管理路由
fn energy_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/energy-stats/summary", get(energy_stats::get_summary))
.route("/api/energy-stats/cabinets", get(energy_stats::get_cabinet_ranking))
.route("/api/energy-stats/projects", get(energy_stats::get_project_stats))
.route("/api/energy-stats/trend", get(energy_stats::get_trend))
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
// 用户管理
}
/// 用户管理与操作日志路由
fn user_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/users", get(users::list_users).post(users::create_user))
.route("/api/users/{id}", put(users::update_user).delete(users::delete_user))
.route("/api/users/{id}/reset-password", put(users::reset_password))
.route("/api/users/{id}/status", put(users::set_user_status))
// 操作日志
.route("/api/operation-logs", get(operation_logs::list_operation_logs))
// H5 用户端接口(需认证)
.route("/api/h5/dashboard", get(h5::get_dashboard))
.route("/api/h5/projects", get(h5::get_projects))
.route("/api/h5/cabinets", get(h5::get_cabinets))
.route("/api/h5/cabinets/{id}", get(h5::get_cabinet_detail))
.route("/api/h5/compartments/{id}", get(h5::get_compartment_detail))
.route("/api/h5/charge/start", post(h5::start_charge))
.route("/api/h5/charge/stop", post(h5::stop_charge))
.route("/api/h5/door/open", post(h5::open_door))
// 认证中间件层
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
}
/// H5 用户端接口路由(与 PC 共用同一套 API 路由)+ 问题反馈
fn h5_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/dashboard", get(h5::get_dashboard))
.route("/api/cabinets", get(h5::get_cabinets))
.route("/api/compartments/{id}", get(h5::get_compartment_detail))
.route("/api/charge/start", post(h5::start_charge))
.route("/api/charge/stop", post(h5::stop_charge))
.route("/api/door/open", post(h5::open_door))
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
.route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback))
.route("/api/feedback/{id}", put(h5::update_feedback_status))
.route("/api/feedback/upload", post(h5::upload_feedback_image))
}
/// 合并全部受保护路由(不含认证中间件层,便于单测校验路由表无冲突)
fn protected_router() -> Router<AppState> {
Router::<AppState>::new()
.merge(auth_routes())
.merge(device_node_routes())
.merge(rbac_routes())
.merge(org_project_routes())
.merge(cabinet_routes())
.merge(record_routes())
.merge(download_routes())
.merge(energy_routes())
.merge(user_routes())
.merge(h5_routes())
}
/// 免认证的公开路由(健康检查、登录、微信回调、地图代理、静态头像目录)
fn public_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/health", get(health::health_check))
.route("/api/auth/challenge", get(auth::get_challenge))
.route("/api/auth/login", post(auth::login))
.route("/api/h5/auth/login", post(h5::h5_login))
// 微信小程序手机号快速登录(免认证)
.route("/api/auth/wx-login", post(auth::wx_login))
// 扫码入口(免认证)
.route("/api/h5/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"))
}
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
pub fn build(state: AppState) -> Router {
// 认证中间件层:仅作用于受保护路由
let protected = protected_router()
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
public_routes()
.merge(protected)
.layer(CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE]))
.with_state(state)
}

View File

@ -24,22 +24,23 @@ pub(crate) struct LogQuery {
page_size: Option<u32>,
}
/// GET /api/operation-logs — 操作日志列表(分页)
/// 操作日志数据库行映射
#[derive(sqlx::FromRow)]
struct LogRow {
id: i64,
user_id: i64,
user_name: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<i64>,
detail: Option<String>,
created_at: chrono::NaiveDateTime,
}
/// 构建 WHERE 子句与绑定值
///
/// 支持 user_id / action / start_time / end_time 过滤。
pub async fn list_operation_logs(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<LogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
/// 动态拼装过滤条件(使用 ? 占位符防止 SQL 注入),并追加组织数据隔离。
fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec<String>) {
let mut conditions: Vec<String> = Vec::new();
let mut bind_values: Vec<String> = Vec::new();
@ -67,7 +68,7 @@ pub async fn list_operation_logs(
}
// 追加组织过滤
let (org_cond, org_binds) = auth::org_condition_for_logs(&user, "o");
let (org_cond, org_binds) = auth::org_condition_for_logs(user, "o");
if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND ").to_string());
bind_values.extend(org_binds);
@ -78,16 +79,31 @@ pub async fn list_operation_logs(
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(where_clause, bind_values)
}
// 查询总数
/// 统计符合条件的操作日志总数
async fn count_operation_logs(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<i64, AppError> {
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
for val in bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(pool).await?;
Ok(count_query.fetch_one(pool).await?)
}
// 查询列表(关联用户姓名)
/// 查询操作日志行(关联用户姓名,分页)
async fn fetch_operation_log_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
page_size: u32,
offset: u32,
) -> Result<Vec<LogRow>, AppError> {
let list_sql = format!(
r#"SELECT o.id, o.user_id, u.name AS user_name, o.action, o.target_type, o.target_id, o.detail, o.created_at
FROM operation_logs o
@ -98,30 +114,20 @@ pub async fn list_operation_logs(
where_clause
);
#[derive(sqlx::FromRow)]
struct LogRow {
id: i64,
user_id: i64,
user_name: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<i64>,
detail: Option<String>,
created_at: String,
}
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
for val in &bind_values {
for val in bind_values {
list_query = list_query.bind(val);
}
let rows = list_query
Ok(list_query
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?;
.await?)
}
let list: Vec<Value> = rows
.into_iter()
/// 将操作日志行映射为响应 JSON 列表
fn operation_logs_to_json(rows: Vec<LogRow>) -> Vec<Value> {
rows.into_iter()
.map(|r| {
json!({
"id": r.id,
@ -131,10 +137,37 @@ pub async fn list_operation_logs(
"target_type": r.target_type,
"target_id": r.target_id,
"detail": r.detail,
"created_at": r.created_at,
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
})
})
.collect();
.collect()
}
/// GET /api/operation-logs — 操作日志列表(分页)
///
/// 支持 user_id / action / start_time / end_time 过滤。
pub async fn list_operation_logs(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<LogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
let (where_clause, bind_values) = build_log_filters(&user, &query);
// 查询总数
let total = count_operation_logs(pool, &where_clause, &bind_values).await?;
// 查询列表(关联用户姓名)
let rows =
fetch_operation_log_rows(pool, &where_clause, &bind_values, page_size, offset).await?;
let list = operation_logs_to_json(rows);
Ok(Json(json!({
"code": 0,

View File

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

View File

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

View File

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

View File

@ -17,7 +17,6 @@ use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState;
use crate::config::DEFAULT_PASSWORD;
/// 用户列表查询参数
#[derive(Debug, Deserialize)]
@ -37,6 +36,7 @@ struct UserRow {
organization_id: Option<i64>,
status: i32,
created_at: NaiveDateTime,
updated_at: NaiveDateTime,
}
/// 创建用户请求体
@ -73,6 +73,46 @@ pub(crate) struct StatusInput {
// 辅助函数: 记录操作日志
// ---------------------------------------------------------------------------
/// 企业管理员操作校验:必须操作本组织的用户且不能操作自己
///
/// 返回目标用户的当前组织ID(用于后续业务判断)
async fn verify_org_admin_scope(
pool: &sqlx::MySqlPool,
actor: &CurrentUser,
target_user_id: i64,
) -> Result<Option<i64>, AppError> {
// 总管理员不需要此校验
if actor.role_level >= 2 {
return Ok(None);
}
// 企业管理员必须有组织
let actor_org = actor.organization_id
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
if target_user_id == actor.user_id {
return Err(AppError::BadRequest("不能操作自己的账户".into()));
}
// 查询目标用户所属组织
let target_org: Option<(Option<i64>,)> = sqlx::query_as(
"SELECT organization_id FROM users WHERE id = ?"
)
.bind(target_user_id)
.fetch_optional(pool)
.await?;
let (target_org,) = target_org
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
// 目标必须属于同一组织
if target_org != Some(actor_org) {
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
}
Ok(target_org)
}
/// 记录一条操作日志到 operation_logs 表
async fn log_operation(
pool: &sqlx::MySqlPool,
@ -94,7 +134,7 @@ async fn log_operation(
.await;
if let Err(e) = result {
tracing::error!("记录操作日志失败: {}", e);
tracing::error!(err = %e, "记录操作日志失败");
}
}
@ -102,6 +142,126 @@ async fn log_operation(
// 用户列表
// ---------------------------------------------------------------------------
/// 构建用户列表的 WHERE 子句与需要绑定的组织ID
///
/// 返回 (where_clause, bind_org):where_clause 用于 COUNT 与列表查询,
/// bind_org 为需要追加绑定的组织过滤值(无过滤则为 None)。
fn build_user_list_where(has_keyword: bool, org_filter: Option<i64>) -> (String, Option<i64>) {
match (has_keyword, org_filter) {
(true, Some(org_id)) => (
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
Some(org_id),
),
(true, None) => (
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
None,
),
(false, Some(org_id)) => (
"WHERE organization_id = ?".to_string(),
Some(org_id),
),
(false, None) => (String::new(), None),
}
}
/// 统计符合条件的用户总数
async fn count_users(
pool: &sqlx::MySqlPool,
where_clause: &str,
has_keyword: bool,
like_pattern: &str,
bind_org: Option<i64>,
) -> Result<i64, AppError> {
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
if has_keyword {
q = q.bind(like_pattern).bind(like_pattern);
}
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
let total: (i64,) = q.fetch_one(pool).await?;
Ok(total.0)
}
/// 查询当前页的用户行(分页 + 排序)
async fn fetch_user_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
has_keyword: bool,
like_pattern: &str,
bind_org: Option<i64>,
page_size: u32,
offset: u32,
) -> Result<Vec<UserRow>, AppError> {
let sql = format!(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
FROM users u {}
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE")
);
let mut q = sqlx::query_as::<_, UserRow>(&sql);
if has_keyword {
q = q.bind(like_pattern).bind(like_pattern);
}
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
Ok(q.bind(page_size).bind(offset).fetch_all(pool).await?)
}
/// 批量查询用户所属组织的名称映射(id → name)
async fn load_org_name_map(
pool: &sqlx::MySqlPool,
rows: &[UserRow],
) -> Result<std::collections::HashMap<i64, String>, AppError> {
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
if org_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
// 使用 IN 查询, 构造占位符
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT id, name FROM organizations WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
for id in &org_ids {
q = q.bind(id);
}
let org_names: Vec<(i64, String)> = q.fetch_all(pool).await?;
Ok(org_names.into_iter().collect())
}
/// 将用户行映射为响应 JSON 列表
fn users_to_json(
rows: Vec<UserRow>,
org_map: &std::collections::HashMap<i64, String>,
) -> Vec<Value> {
// 构建角色名称映射
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
rows.into_iter()
.map(|r| {
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
json!({
"id": r.id,
"phone": r.phone,
"name": r.name,
"role": r.role,
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
"organization_id": r.organization_id,
"organization_name": org_name,
"status": r.status,
"created_at": r.created_at.to_string(),
"updated_at": r.updated_at.to_string(),
})
})
.collect()
}
/// GET /api/users — 用户列表(分页)
///
/// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。
@ -139,130 +299,31 @@ pub async fn list_users(
})));
}
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
let (where_clause, bind_org) = build_user_list_where(has_keyword, org_filter);
// 查询总数
let total: (i64,) = if has_keyword {
let mut q = sqlx::query_as(
r#"SELECT COUNT(*) FROM users WHERE phone LIKE ? OR name LIKE ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern);
if let Some(org_id) = org_filter {
q = sqlx::query_as(
r#"SELECT COUNT(*) FROM users WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern)
.bind(org_id);
}
q.fetch_one(pool).await?
} else if let Some(org_id) = org_filter {
sqlx::query_as("SELECT COUNT(*) FROM users WHERE organization_id = ?")
.bind(org_id)
.fetch_one(pool)
.await?
} else {
sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(pool)
.await?
};
let total = count_users(pool, &where_clause, has_keyword, &like_pattern, bind_org).await?;
// 查询用户列表
let rows: Vec<UserRow> = if has_keyword {
let mut q = sqlx::query_as::<_, UserRow>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at
FROM users u
WHERE u.phone LIKE ? OR u.name LIKE ?
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern)
.bind(page_size)
.bind(offset);
if let Some(org_id) = org_filter {
q = sqlx::query_as::<_, UserRow>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at
FROM users u
WHERE (u.phone LIKE ? OR u.name LIKE ?) AND u.organization_id = ?
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(&like_pattern)
.bind(&like_pattern)
.bind(org_id)
.bind(page_size)
.bind(offset);
}
q.fetch_all(pool).await?
} else if let Some(org_id) = org_filter {
sqlx::query_as::<_, UserRow>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at
FROM users u
WHERE u.organization_id = ?
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(org_id)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
} else {
sqlx::query_as::<_, UserRow>(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at
FROM users u
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
};
let rows = fetch_user_rows(
pool,
&where_clause,
has_keyword,
&like_pattern,
bind_org,
page_size,
offset,
)
.await?;
// 收集组织ID并批量查询组织名称
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
let org_names = if org_ids.is_empty() {
vec![]
} else {
// 使用 IN 查询, 构造占位符
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT id, name FROM organizations WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
for id in &org_ids {
q = q.bind(id);
}
q.fetch_all(pool).await.unwrap_or_default()
};
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
// 构建角色名称映射
let role_names: [&str; 3] = ["普通用户", "企业管理员", "总管理员"];
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
json!({
"id": r.id,
"phone": r.phone,
"name": r.name,
"role": r.role,
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
"organization_id": r.organization_id,
"organization_name": org_name,
"status": r.status,
"created_at": r.created_at.to_string(),
})
})
.collect();
let org_map = load_org_name_map(pool, &rows).await?;
let list = users_to_json(rows, &org_map);
Ok(Json(json!({
"code": 0,
"data": { "list": list, "total": total.0 },
"data": { "list": list, "total": total },
"message": "ok"
})))
}
@ -271,7 +332,70 @@ pub async fn list_users(
// 创建用户
// ---------------------------------------------------------------------------
/// 校验手机号格式(11 位数字)
fn validate_phone(phone: &str) -> Result<(), AppError> {
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
}
Ok(())
}
/// 校验组织存在(不存在返回参数错误)
async fn ensure_organization_exists(pool: &sqlx::MySqlPool, oid: i64) -> Result<(), AppError> {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
Ok(())
}
/// 校验手机号未被占用
async fn ensure_phone_available(pool: &sqlx::MySqlPool, phone: &str) -> Result<(), AppError> {
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM users WHERE phone = ?"
)
.bind(phone)
.fetch_one(pool)
.await?;
if exists.0 > 0 {
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
}
Ok(())
}
/// 按操作者身份与目标角色确定创建用户时的组织归属
fn resolve_create_org_id(
current_user: &CurrentUser,
role: i32,
input_org_id: Option<i64>,
) -> Result<Option<i64>, AppError> {
if current_user.role_level >= 2 {
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
if role == 2 {
Ok(None)
} else {
Ok(Some(input_org_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?))
}
} else {
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
if role == 2 {
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
}
Ok(Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?))
}
}
/// POST /api/users — 创建用户
///
/// 权限模型:
/// - 总管理管理员(role_level>=2):可创建任意角色;角色0/1必须指定组织,角色2强制无组织
/// - 企业管理员(role_level=1):仅可创建操作员/企业管理员,组织强制为本组织
/// - 操作员(role_level=0):无 user:create 权限,被拦截
pub async fn create_user(
current_user: CurrentUser,
State(state): State<AppState>,
@ -283,48 +407,27 @@ pub async fn create_user(
// 校验手机号
let phone = input.phone.trim();
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
}
validate_phone(phone)?;
let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
let role = input.role.unwrap_or(0);
if !(0..=2).contains(&role) {
return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into()));
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
}
// 企业管理员必须指定所属组织
if role == 1 && input.organization_id.is_none() {
return Err(AppError::BadRequest("企业管理员必须指定所属组织".into()));
}
// 确定最终组织归属(按角色和操作者身份)
let org_id = resolve_create_org_id(&current_user, role, input.organization_id)?;
let org_id = input.organization_id;
// 校验组织存在
if let Some(oid) = org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
ensure_organization_exists(pool, oid).await?;
}
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 exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM users WHERE phone = ?"
)
.bind(phone)
.fetch_one(pool)
.await?;
if exists.0 > 0 {
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
}
ensure_phone_available(pool, phone).await?;
let result = sqlx::query(
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
@ -343,7 +446,7 @@ pub async fn create_user(
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-"));
log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await;
tracing::info!("创建用户 id={} phone={} role={}", user_id, phone, role);
tracing::info!(user_id, phone = %phone, role, "创建用户");
Ok(Json(json!({
"code": 0,
@ -356,6 +459,50 @@ pub async fn create_user(
// 编辑用户
// ---------------------------------------------------------------------------
/// 按操作者身份与目标角色确定编辑后的组织归属
///
/// 企业管理员(role_level<2)只能编辑本组织用户、不能改自己、不能授予总管理员;
/// 总管理员可自由指定,角色2强制无组织。
async fn resolve_update_org_id(
pool: &sqlx::MySqlPool,
current_user: &CurrentUser,
user_id: i64,
old_org_id: Option<i64>,
new_role: i32,
input_org_id: Option<i64>,
) -> Result<Option<i64>, AppError> {
if current_user.role_level < 2 {
// 企业管理员作用域校验:组织锁定为本组织
let actor_org = current_user.organization_id
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能操作自己的账户".into()));
}
// 目标必须属于同一组织
if old_org_id != Some(actor_org) {
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
}
// 不能改成总管理员
if new_role == 2 {
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
}
Ok(Some(actor_org))
} else {
let org_id = if new_role == 2 {
None // 总管理员强制无组织
} else {
Some(input_org_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
};
// 校验组织存在(组织未变更时无需重复校验)
if let Some(oid) = org_id
&& Some(oid) != old_org_id {
ensure_organization_exists(pool, oid).await?;
}
Ok(org_id)
}
}
/// PUT /api/users/:id — 编辑用户
pub async fn update_user(
current_user: CurrentUser,
@ -380,28 +527,19 @@ pub async fn update_user(
let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
let new_role = input.role.unwrap_or(old_role);
if !(0..=2).contains(&new_role) {
return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into()));
}
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()));
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
}
// 校验组织存在
if let Some(oid) = new_org_id
&& Some(oid) != old_org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
}
// 组织归属与作用域校验
let new_org_id = resolve_update_org_id(
pool,
&current_user,
user_id,
old_org_id,
new_role,
input.organization_id,
)
.await?;
let rows = sqlx::query(
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
@ -428,7 +566,7 @@ pub async fn update_user(
);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("编辑用户 id={}", user_id);
tracing::info!(user_id, "编辑用户");
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
}
@ -447,7 +585,10 @@ pub async fn reset_password(
auth::check_permission(&current_user, "user:edit")?;
let pool = &state.mysql;
let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
// 企业管理员只能重置本组织用户密码
verify_org_admin_scope(pool, &current_user, user_id).await?;
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
let new_password = auth::hash_password(&plain_password)?;
let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?")
@ -465,7 +606,7 @@ pub async fn reset_password(
let detail = format!("重置密码: 用户ID={}", user_id);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("重置密码 user_id={}", user_id);
tracing::info!(user_id, "重置密码");
Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" })))
}
@ -485,10 +626,8 @@ pub async fn set_user_status(
let pool = &state.mysql;
// 不能禁用自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能修改自己的状态".into()));
}
// 企业管理员作用域校验(含不能操作自己+同组织限制)
verify_org_admin_scope(pool, &current_user, user_id).await?;
if ![0, 1].contains(&input.status) {
return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into()));
@ -511,7 +650,7 @@ pub async fn set_user_status(
let detail = format!("{}用户: 用户ID={}", status_text, user_id);
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!("{}用户 user_id={}", status_text, user_id);
tracing::info!(user_id, action = %status_text, "{}用户", status_text);
Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) })))
}
@ -530,10 +669,8 @@ pub async fn delete_user(
let pool = &state.mysql;
// 不能删除自己
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能删除自己的账户".into()));
}
// 企业管理员作用域校验(含不能操作自己+同组织限制)
verify_org_admin_scope(pool, &current_user, user_id).await?;
// 查询用户信息用于日志
let user_info: Option<(String,)> = sqlx::query_as(
@ -558,7 +695,7 @@ pub async fn delete_user(
let detail = format!("删除用户: ID={}, 手机号={}", user_id, phone);
log_operation(pool, current_user.user_id, "delete", "user", Some(user_id), &detail).await;
tracing::info!("删除用户 id={} phone={}", user_id, phone);
tracing::info!(user_id, phone = %phone, "删除用户");
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 191 B

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